diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cba54a80..95b1e07e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ jobs: release: runs-on: macos-15 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Generate DocC Static Site run: | swift package --allow-writing-to-directory ./docs \ @@ -22,4 +22,4 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./docs - \ No newline at end of file + diff --git a/.github/workflows/test-spm-codecov.yml b/.github/workflows/test-spm-codecov.yml index dd90724d..5cc6a665 100644 --- a/.github/workflows/test-spm-codecov.yml +++ b/.github/workflows/test-spm-codecov.yml @@ -13,14 +13,14 @@ jobs: runs-on: macos-15 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: true - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.0' + xcode-version: 'latest-stable' - name: Run tests run: swift test --enable-code-coverage @@ -29,7 +29,7 @@ jobs: run: xcrun llvm-cov export -format="lcov" .build/debug/${{ inputs.test_bundle }}.xctest/Contents/MacOS/${{ inputs.test_bundle }} -instr-profile .build/debug/codecov/default.profdata > coverage_report.lcov - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage_report.lcov diff --git a/.gitignore b/.gitignore index 0023a534..a37a67bb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.gitnexus diff --git a/Package.resolved b/Package.resolved index 3db326ac..7c6285f4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,42 @@ { - "originHash" : "390a5d64b3f7682868743fd9aa7e7c06b8f083a95128e0276de66f5845228098", + "originHash" : "a28a01c55ab3914990f786c8c0ea8f7808d7809cc1893bac212b8c893e3f75b5", "pins" : [ + { + "identity" : "noora", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/Noora", + "state" : { + "revision" : "70c6d1477a982f3e4cd46ed2d122e04e9d0a0b59", + "version" : "0.56.0" + } + }, + { + "identity" : "path", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/path", + "state" : { + "revision" : "7c74ac435e03a927c3a73134c48b61e60221abcb", + "version" : "0.3.8" + } + }, + { + "identity" : "rainbow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Rainbow", + "state" : { + "revision" : "cdf146ae671b2624917648b61c908d1244b98ca1", + "version" : "4.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", @@ -28,6 +64,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" + } + }, { "identity" : "swift-parsing", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ea74d6ab..9e3c55ba 100644 --- a/Package.swift +++ b/Package.swift @@ -6,14 +6,18 @@ let package = Package( name: "FischerCore", platforms: [ .iOS(.v13), - .macOS(.v10_15) + .macOS(.v13) ], products: [ .library( name: "FischerCore", - targets: ["FischerCore"]) + targets: ["FischerCore"]), + .executable( + name: "fischer-cli", + targets: ["fischer-cli"]) ], dependencies: [ + .package(url: "https://github.com/tuist/Noora", .upToNextMajor(from: "0.15.0")), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.14.1") ], @@ -24,6 +28,13 @@ let package = Package( .product(name: "Parsing", package: "swift-parsing") ] ), + .executableTarget( + name: "fischer-cli", + dependencies: [ + "FischerCore", + .product(name: "Noora", package: "Noora") + ] + ), .testTarget( name: "FischerCoreTests", dependencies: [ diff --git a/README.md b/README.md index b3bfdedb..fabed0c8 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,21 @@ [![codecov](https://codecov.io/gh/NSStudent/fischer-core/branch/develop/graph/badge.svg?token=XHQP3Y1EHD)](https://codecov.io/gh/NSStudent/fischer-core) `FischerCore` is a Swift library that encapsulates the core logic and data structures necessary for building chess games. -Named in honor of the legendary chess grandmaster **Bobby Fischer**, this library provides a comprehensive foundation for creating, managing, and enforcing the rules of chess +Named in honor of the legendary chess grandmaster **Bobby Fischer**, this library provides a comprehensive foundation for creating, managing, and enforcing the rules of chess. It also powers the MindChess project as its chess logic core: https://nsstudent.dev/MindChessLanding/ +## What's Included + +- `FischerCore`: a Swift library target with board, bitboard, move, game, FEN, SAN, UCI, and PGN support. +- `fischer-cli`: an interactive terminal executable for exploring the engine without writing an app. +- Rule-enforced move execution, including castling, en passant, promotion, check detection, legal move generation, undo, and redo. +- FEN parsing and serialization for `Board`, `Position`, and `Game`. +- Helpers for physical-board and app workflows, including FEN placement extraction, board-placement move resolution, UCI execution, history navigation, structured SAN history, stable renderable piece identity, and game outcome lookup. +- PGN parsing into structured game models, column elements, and move trees. +- Rich PGN comment support, including text, arrows, highlighted squares, clock time, elapsed move time, and engine evaluation annotations. +- SAN and UCI helpers for converting and executing moves. +- Core value types are `Sendable` where their stored state is value-safe, so they can move cleanly through Swift concurrency boundaries. + ## Swift Package Manager You can use `FischerCore` in your project by adding it as a dependency in your `Package.swift` file: @@ -29,12 +41,346 @@ Then, add `"FischerCore"` to your target's dependencies: ) ``` +The package exposes both a library product and an executable product: + +```swift +.product(name: "FischerCore", package: "fischer-core") +``` + +```sh +swift run fischer-cli +``` + ## Documentation The API reference and detailed documentation for `FischerCore` is available at: 👉 [https://nsstudent.dev/fischer-core/documentation/fischercore/](https://nsstudent.dev/fischer-core/documentation/fischercore/) +You can also generate the DocC documentation locally: + +```sh +swift package generate-documentation --target FischerCore +``` + +To preview the documentation in Xcode, open the package and choose **Product > Build Documentation**. + +## Core Usage + +Create a game from the standard starting position: + +```swift +import FischerCore + +var game = Game() + +try game.execute(san: "e4") +try game.execute(san: "e5") +try game.execute(san: "Nf3") + +print(game.position.fen()) +print(try game.sanRepresentation()) +``` + +Create a game from FEN: + +```swift +let game = try Game(with: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1") + +print(game.board.ascii()) +print(game.availableMoves()) +``` + +Work directly with moves: + +```swift +var game = Game() +let move = Square.e2 >>> Square.e4 + +if game.isLegal(move: move) { + try game.execute(move: move) +} +``` + +Undo and redo moves: + +```swift +var game = Game() + +try game.execute(san: "d4") +try game.execute(san: "d5") + +let undone = game.undoMove() +let redone = game.redoMove() +``` + +Generate legal moves for a position: + +```swift +let moves = game.availableMoves() +let knightMoves = game.movesBitboardForPiece(at: .g1) +``` + +## Board, Position, and FEN + +`Board` represents piece placement, while `Position` represents the full chess state: + +- Piece placement +- Side to move +- Castling rights +- En passant target +- Halfmove clock +- Fullmove number + +```swift +let position = Position(fen: "8/8/8/8/8/8/8/4K3 w - - 0 1") +let fen = position?.fen() +``` + +Useful board helpers include: + +- `board.ascii()` for terminal-friendly board rendering. +- `board.fen()` for FEN placement serialization. +- `board.bitboard(for:)` for fast piece/color queries. +- `board.attackers(to:color:)` and `board.attackersToKing(for:)` for attack detection. +- `board.pinned(for:)` for pinned-piece lookup. +- `FEN.placement(from:)` to extract the piece-placement field from either a full FEN string or a placement-only string. + +```swift +let placement = FEN.placement( + from: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1" +) +// "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" +``` + +## App Workflow Helpers + +FischerCore includes convenience APIs for common app integrations, especially physical-board, training, replay, and SwiftUI-style views. + +Resolve a board-placement change into the legal move that produced it: + +```swift +var game = Game() +let nextPlacement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + +if let resolved = game.resolvedMove(toPlacement: nextPlacement) { + try game.execute(resolvedMove: resolved) +} +``` + +The placement resolver also handles promotions by trying every legal promotion piece and returning the matching `ResolvedMove`: + +```swift +let game = try Game(with: "8/k6P/8/8/8/8/K6p/8 w - - 0 1") +let nextPlacement = "7N/k7/8/8/8/8/K6p/8" + +let resolved = game.resolvedMove(toPlacement: nextPlacement) +print(resolved?.promotion as Any) // Optional(.knight) +``` + +Run UCI moves directly when integrating with engines, logs, or network protocols: + +```swift +var game = Game() + +try game.execute(uci: "e2e4") +try game.execute(uci: "e7e8q") +``` + +Navigate through played and undone moves without manually looping over `undoMove()` and `redoMove()`: + +```swift +game.jumpToMove(8) +game.rewindToStart() +game.fastForward() +``` + +Use `playedSANMoves()` when a UI needs structured move-list rows instead of one SAN string: + +```swift +let rows = try game.playedSANMoves() + +for row in rows { + print(row.moveNumber, row.color, row.san, row.move, row.promotion as Any) +} +``` + +Render board pieces with stable identities for SwiftUI diffing and animations: + +```swift +ForEach(game.boardPieces) { boardPiece in + PieceView(piece: boardPiece.piece) + .position(position(for: boardPiece.square)) +} +``` + +Look up the identity for a specific square when a custom renderer needs direct access: + +```swift +let pieceID = game.pieceID(at: .e4) +``` + +Check the finished result directly: + +```swift +if let outcome = game.outcome { + print(outcome) +} +``` + +## SAN and UCI + +FischerCore supports both SAN and UCI-oriented workflows. + +Execute SAN directly: + +```swift +var game = Game() +try game.execute(san: "e4") +try game.execute(san: "Nf6") +``` + +Convert UCI to SAN in the current game state: + +```swift +var game = Game() + +let sanMove = try game.sanMove(from: "e2e4") +try game.execute(move: sanMove) +``` + +Convert a sequence of UCI moves: + +```swift +let sans = try Game().sanMoveList(from: ["e2e4", "e7e5", "g1f3"]) +``` + +Parse UCI move values: + +```swift +let value = try UCIMoveValueParser().parse("e7e8q") +print(value.start) +print(value.end) +print(value.promotion) +``` + +## PGN + +Parse PGN text into structured games: + +```swift +let pgn = """ +[Event "Example"] +[Result "*"] + +1. e4 e5 2. Nf3 Nc6 * +""" + +let games = try PGNReader().parse(pgn) +let firstGame = games.first +``` + +Parse into the classic `PGNGame` model: + +```swift +let game = try PGNGameParser().parse(pgn) +print(game.tags) +print(game.elements) +print(game.result) +``` + +Load a parsed PGN game into a rule-enforced `Game`: + +```swift +let pgnGame = try PGNGameParser().parse(pgn) +let gameAtStart = try Game(loading: pgnGame) +let gameAtEnd = try Game(loading: pgnGame, moveToEnd: true) +``` + +Convert a sequence of FEN positions into a PGN game: + +```swift +let pgnGame = try PGNGame(fenPositions: [ + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1" +]) +``` + +PGN comments preserve structured annotations: + +- Text comments +- Colored arrows (`[%cal ...]`) +- Colored square highlights (`[%csl ...]`) +- Clock time (`[%clk ...]`) +- Elapsed move time (`[%emt ...]`) +- Engine evaluations (`[%eval ...]`) + +## Command Line + +The FischerCore command line interface can be used to explore and test the package from a terminal. +It is an interactive Noora-powered prompt, so you select commands from a menu instead of typing command aliases manually. + +```sh +swift run fischer-cli +``` + +On startup, the CLI prints the initial board. Each loop then asks you to select one of these commands: + +| Command | Description | +| --- | --- | +| `board` | Print the current position as an ASCII chess board. | +| `move` | Prompt for one or more SAN moves separated by spaces, then execute them in order. | +| `position` | Print the current position as a full FEN string. | +| `reset` | Reset to the starting position, or enter a custom FEN position. | +| `clear` | Clear the visible terminal area. | +| `help` | Print the available command list. | +| `quit` | End the FischerCore CLI session. | + +Example move input when the CLI asks for SAN notation: + +```text +e4 e5 Nf3 Nc6 Bb5 +``` + +Example custom FEN input when using `reset`: + +```text +rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1 +``` + +CLI behavior notes: + +- Empty reset input restores the standard starting position. +- Invalid FEN input keeps the current game and prints an error. +- Invalid SAN input prints an error for that move. +- Successful moves report the SAN move and the resolved start/end squares. + +## Development + +Run the test suite: + +```sh +swift test +``` + +Build the library and CLI: + +```sh +swift build +``` + +Generate DocC documentation: + +```sh +swift package generate-documentation --target FischerCore +``` + +The package currently depends on: + +- [swift-parsing](https://github.com/pointfreeco/swift-parsing) for parser composition. +- [Noora](https://github.com/tuist/Noora) for the interactive CLI prompts. +- [swift-docc-plugin](https://github.com/apple/swift-docc-plugin) for local documentation generation. + ## References This library is based on the code of other well-crafted chess engines and bitboard libraries in Swift. Notably, it builds upon the work in [Sage by @nvzqz](https://github.com/nvzqz/Sage/tree/develop), adapting and updating it to be compatible with the current state of the Swift language and modern development practices. @@ -47,17 +393,26 @@ Special thanks to [Point-Free](https://www.pointfree.co/) for their fantastic [s The following features are planned to improve the functionality and completeness of `FischerCore`: -- [x] `BasicPGNParser` +- [x] `BasicPGNParser` - [x] Tests with some [TWIC](https://theweekinchess.com/twic) pgn files +- [x] Interactive CLI with board, move, position, reset, clear, help, and quit commands +- [x] FEN-to-PGN reconstruction from position sequences +- [x] UCI-to-SAN conversion helpers +- [x] App workflow helpers for physical-board FEN placement resolution, UCI execution, structured SAN history, move-history navigation, and outcome lookup +- [x] `Sendable` conformances for value-safe core model types - [ ] Add performance benchmarks for parsers and move generation. - [ ] Improve FEN parsing using a unified parser approach. -### ✅ What's Implemented +### What's Implemented The following core features are already available: - `Bitboard & tables`: Dense bitboard representation with precomputed attack masks (king, knight, pawn, lines) for fast move queries and between/line lookup tables. -- `Game`: Rule-enforced move execution (castling, en passant, promotion), move history with undo, outcome detection, threefold/50-move counters, and FEN-based initialization. -- `Moves`: `SANMove` ↔ `Move` bridge so PGN moves can be executed inside `Game`, plus tokenized positions for quick state comparison. -- `PGN parsing`: Full game parsing into `PGN`, `PGNGame`, and `PGNElement` with tags, variations, NAG evaluations, and rich comments. +- `Game`: Rule-enforced move execution (castling, en passant, promotion), move history with undo/redo, outcome detection, threefold/50-move counters, and FEN-based initialization. +- `App helpers`: Resolve a target board placement into a legal move, execute resolved moves or UCI strings, jump through move history, and expose structured SAN rows for move-list UIs. +- `Moves`: `SANMove` <-> `Move` bridge so PGN moves can be executed inside `Game`. +- `UI identity`: Stable `Game.BoardPiece` and `Game.PieceID` values for rendering pieces without relying on string tokens. +- `UCI`: UCI move value parsing and UCI-to-SAN conversion against the current game state. +- `PGN parsing`: Full game parsing into `PGN`, `PGNGame`, `PGNElement`, and `MoveTreePGN` with tags, variations, NAG evaluations, and rich comments. +- `PGN reconstruction`: Build SAN move text and `PGNGame` values from FEN position sequences. - `Comment types`: Text, arrows, highlighted squares, clock time (`[%clk ...]`), elapsed move time (`[%emt ...]`), and engine evaluation (`[%eval ...]`) comments are parsed and preserved. diff --git a/Sources/FischerCore/Bitboard.swift b/Sources/FischerCore/Bitboard.swift index e3eba681..c20fd642 100644 --- a/Sources/FischerCore/Bitboard.swift +++ b/Sources/FischerCore/Bitboard.swift @@ -11,7 +11,7 @@ import Foundation /// Bitboards are commonly used in chess engines to represent piece positions and calculate move generation. /// /// - Note: Square 0 is the least-significant bit (a1), and Square 63 is the most-significant bit (h8). -public struct Bitboard: Equatable, Hashable { +public struct Bitboard: Equatable, Hashable, Sendable { private(set) var rawValue: UInt64 = 0 @@ -24,7 +24,7 @@ public struct Bitboard: Equatable, Hashable { /// An iterator over the squares set in a `Bitboard`. /// /// Iterates by popping least significant bits until the bitboard is empty. - public struct Iterator: IteratorProtocol { + public struct Iterator: IteratorProtocol, Sendable { fileprivate var bitboard: Bitboard @@ -341,7 +341,7 @@ extension Bitboard { public static let edges: Bitboard = 0xff818181818181ff - public enum ShiftDirection { + public enum ShiftDirection: Sendable { case north case south case east diff --git a/Sources/FischerCore/Board.swift b/Sources/FischerCore/Board.swift index 374d82cd..708cb890 100644 --- a/Sources/FischerCore/Board.swift +++ b/Sources/FischerCore/Board.swift @@ -8,9 +8,9 @@ import Foundation /// /// - Note: The bitboards are indexed by `Piece.bitValue`, where `0...5` represent white pieces /// and `6...11` black pieces. -public struct Board: Equatable { +public struct Board: Equatable, Sendable { - public enum Side { + public enum Side: Sendable { case kingside case queenside @@ -23,7 +23,7 @@ public struct Board: Equatable { } } - public struct Space: Equatable { + public struct Space: Equatable, Sendable { public var piece: Piece? public var file: File public var rank: Rank @@ -52,7 +52,7 @@ public struct Board: Equatable { return Space(piece: self[square], square: square) } - public struct Iterator: IteratorProtocol { + public struct Iterator: IteratorProtocol, Sendable { let _board: Board diff --git a/Sources/FischerCore/CastlingRights.swift b/Sources/FischerCore/CastlingRights.swift index c708c2d9..6efb8332 100644 --- a/Sources/FischerCore/CastlingRights.swift +++ b/Sources/FischerCore/CastlingRights.swift @@ -8,8 +8,8 @@ import Foundation /// - `whiteQueenside`: White may castle queenside (O-O-O). /// - `blackKingside`: Black may castle kingside (O-O). /// - `blackQueenside`: Black may castle queenside (O-O-O). -public struct CastlingRights: Equatable { - public enum Right: String, CaseIterable, CustomStringConvertible, Equatable { +public struct CastlingRights: Equatable, Sendable { + public enum Right: String, CaseIterable, CustomStringConvertible, Equatable, Sendable { case whiteKingside case whiteQueenside case blackKingside @@ -110,7 +110,7 @@ public struct CastlingRights: Equatable { } } - public struct Iterator: IteratorProtocol { + public struct Iterator: IteratorProtocol, Sendable { fileprivate var base: SetIterator diff --git a/Sources/FischerCore/FEN.swift b/Sources/FischerCore/FEN.swift new file mode 100644 index 00000000..49d2259e --- /dev/null +++ b/Sources/FischerCore/FEN.swift @@ -0,0 +1,16 @@ +// +// FEN.swift +// FischerCore +// + +/// Helpers for working with Forsyth-Edwards Notation strings. +public enum FEN { + /// Returns the piece-placement field from either a full FEN string or an + /// already-trimmed placement string. + public static func placement(from fenOrPlacement: String) -> String { + fenOrPlacement + .split(separator: " ", maxSplits: 1) + .first + .map(String.init) ?? fenOrPlacement + } +} diff --git a/Sources/FischerCore/File.swift b/Sources/FischerCore/File.swift index e9797290..a0f9b5b1 100644 --- a/Sources/FischerCore/File.swift +++ b/Sources/FischerCore/File.swift @@ -6,9 +6,9 @@ import Foundation /// and initialization from characters or strings. It is used in board representation and SAN parsing. /// /// Files are 1-based internally (`.a = 1`) but expose a zero-based `index` for array-like access. -public enum File: Int, Equatable { +public enum File: Int, Equatable, Sendable { - public enum Direction { + public enum Direction: Sendable { case left case right } diff --git a/Sources/FischerCore/Game/Extensions/Game+Convenience.swift b/Sources/FischerCore/Game/Extensions/Game+Convenience.swift new file mode 100644 index 00000000..69e16f64 --- /dev/null +++ b/Sources/FischerCore/Game/Extensions/Game+Convenience.swift @@ -0,0 +1,247 @@ +// +// Game+Convenience.swift +// FischerCore +// + +/// A legal move resolved against a concrete game state, including the promotion +/// choice needed to reproduce the resulting position. +public struct ResolvedMove: Equatable, Sendable { + /// The legal move to execute in the game state where it was resolved. + public let move: Move + + /// The promotion piece required by `move`, or `nil` when the move is not a + /// promotion. + public let promotion: PromotionPiece? + + /// Creates a resolved move. + /// + /// - Parameters: + /// - move: The legal move to execute. + /// - promotion: The promotion piece to apply when the move promotes a + /// pawn. + public init(move: Move, promotion: PromotionPiece? = nil) { + self.move = move + self.promotion = promotion + } +} + +/// A structured SAN entry for one move that has already been played. +public struct PlayedSANMove: Equatable, Identifiable, Sendable { + /// The stable identifier for this row, equal to `index`. + public var id: Int { index } + + /// The zero-based index of the move in `Game.moveHistory`. + public let index: Int + + /// The human chess move number for this move. + public let moveNumber: Int + + /// The side that played the move. + public let color: PlayerColor + + /// The SAN text for the move in its original game context. + public let san: String + + /// The underlying coordinate move. + public let move: Move + + /// The promotion piece used by the move, or `nil` when the move did not + /// promote a pawn. + public let promotion: PromotionPiece? + + /// Whether the SAN move ended the game by checkmate. + public let isCheckmate: Bool + + /// Creates a structured SAN entry for a played move. + /// + /// - Parameters: + /// - index: The zero-based index in `Game.moveHistory`. + /// - moveNumber: The human chess move number. + /// - color: The side that played the move. + /// - san: The SAN text for the move. + /// - move: The underlying coordinate move. + /// - promotion: The promotion piece, if any. + /// - isCheckmate: Whether the move ended the game by checkmate. + public init( + index: Int, + moveNumber: Int, + color: PlayerColor, + san: String, + move: Move, + promotion: PromotionPiece?, + isCheckmate: Bool + ) { + self.index = index + self.moveNumber = moveNumber + self.color = color + self.san = san + self.move = move + self.promotion = promotion + self.isCheckmate = isCheckmate + } +} + +public extension Game { + /// The game result if the side to move has no legal moves. + var outcome: Outcome? { + guard isFinished else { return nil } + return kingIsChecked ? .win(playerTurn.inverse()) : .draw + } + + /// Returns true when the move is a pawn move to the promotion rank. + func requiresPromotion(move: Move) -> Bool { + guard let piece = board[move.start] else { return false } + return piece.kind.isPawn && move.end.rank == Rank(endFor: piece.color) + } + + /// Finds the legal move that transforms the current board placement into + /// `placement`. The input may be either a full FEN string or the placement + /// field only. + func resolvedMove(toPlacement placement: String) -> ResolvedMove? { + let targetPlacement = FEN.placement(from: placement) + guard Board(fen: targetPlacement) != nil else { return nil } + guard targetPlacement != position.board.fen() else { return nil } + + for move in availableMoves() { + if requiresPromotion(move: move) { + for promotion in PromotionPiece.allCases { + var next = self + try? next.execute(move: move, promotion: promotion) + if next.position.board.fen() == targetPlacement { + return ResolvedMove(move: move, promotion: promotion) + } + } + } else { + var next = self + try? next.execute(move: move) + if next.position.board.fen() == targetPlacement { + return ResolvedMove(move: move) + } + } + } + return nil + } + + /// Executes a previously resolved move. + /// + /// Use this when a move has already been resolved with + /// `resolvedMove(toPlacement:)` or another workflow that produces a + /// `ResolvedMove`. + /// + /// - Parameters: + /// - resolvedMove: The move and optional promotion piece to execute. + /// - considerHalfmoves: Whether to apply halfmove-clock validation. + mutating func execute(resolvedMove: ResolvedMove, considerHalfmoves: Bool = true) throws { + if let promotion = resolvedMove.promotion { + try execute(move: resolvedMove.move, considerHalfmoves: considerHalfmoves, promotion: promotion) + } else { + try execute(move: resolvedMove.move, considerHalfmoves: considerHalfmoves) + } + } + + /// Parses and executes a UCI move in the current game state. + /// + /// The method supports standard UCI moves such as `"e2e4"` and promotion + /// moves such as `"h7h8q"`. Null moves and malformed UCI strings throw an + /// error. + /// + /// - Parameters: + /// - uci: The UCI move string to execute. + /// - considerHalfmoves: Whether to apply halfmove-clock validation. + mutating func execute(uci: String, considerHalfmoves: Bool = true) throws { + let parser = UCIMoveParser() + let parsedMove: UCIMove + do { + parsedMove = try parser.parse(uci) + } catch { + throw FischerCoreError.illegalMove + } + + switch parsedMove { + case .nullMove: + throw FischerCoreError.illegalMove + case let .move(value): + try execute( + resolvedMove: ResolvedMove(move: value.asMove(), promotion: value.promotion), + considerHalfmoves: considerHalfmoves + ) + } + } + + /// Undoes moves until the game reaches its initial position. + mutating func rewindToStart() { + while undoMove() != nil {} + } + + /// Redoes moves until no undone move remains. + /// + /// - Parameter considerHalfmoves: Whether to apply halfmove-clock + /// validation while replaying moves. + mutating func fastForward(considerHalfmoves: Bool = false) { + while redoMove(considerHalfmoves: considerHalfmoves) {} + } + + /// Moves the game history cursor to a specific move index. + /// + /// Values below zero rewind to the start. Values past the available redo + /// history fast-forward as far as possible. + /// + /// - Parameters: + /// - index: The target number of played moves to keep in `moveHistory`. + /// - considerHalfmoves: Whether to apply halfmove-clock validation while + /// replaying moves. + mutating func jumpToMove(_ index: Int, considerHalfmoves: Bool = false) { + let target = max(0, index) + if target < moveHistory.count { + while moveHistory.count > target { + _ = undoMove() + } + } else if target > moveHistory.count { + while moveHistory.count < target { + guard redoMove(considerHalfmoves: considerHalfmoves) else { break } + } + } + } + + /// Returns the played moves as structured SAN entries, preserving move + /// number, color, move index, UCI-resolved move, promotion, and mate flag. + func playedSANMoves() throws -> [PlayedSANMove] { + var replay = try Game(with: initialFen) + let extraIndex = replay.playerTurn == .white ? 0 : 1 + let initialFullmove = Int(replay.initalFullmoves) + + return try moveHistory.enumerated().map { index, element in + let sanMove = try replay.sanMove(from: element.asUCIMove()) + let color = replay.playerTurn + let playedMove = PlayedSANMove( + index: index, + moveNumber: initialFullmove + ((index + extraIndex) / 2), + color: color, + san: sanMove.description, + move: element.move, + promotion: element.promotionPiece, + isCheckmate: sanMove.isCheckmate + ) + + if let promotion = element.promotionPiece { + try replay.execute(move: element.move, promotion: promotion) + } else { + try replay.execute(move: element.move) + } + return playedMove + } + } +} + +private extension SANMove { + var isCheckmate: Bool { + switch self { + case let .san(move): + return move.isCheckmate + case let .kingsideCastling(_, isCheckMate): + return isCheckMate + case let .queensideCastling(_, isCheckMate): + return isCheckMate + } + } +} diff --git a/Sources/FischerCore/Game/Game.swift b/Sources/FischerCore/Game/Game.swift index 9fa230f6..008ef7cf 100644 --- a/Sources/FischerCore/Game/Game.swift +++ b/Sources/FischerCore/Game/Game.swift @@ -10,27 +10,76 @@ import Foundation /// - Detection of game outcome and king safety /// /// It can be used for analysis, game playback, and engine integration. -public struct Game: Equatable { - public struct GameToken: Equatable { - public var token: [String] = [String](repeating: UUID().uuidString, count: 64) - private var piecesCount: [Int] = [Int](repeating: 0, count: 12) - init(board: Board) { - for square in Square.allCases { - if let piece = board[square] { - piecesCount[piece.bitValue] += 1 - token[square.rawValue] = "\(piece.fenName)\(piecesCount[piece.bitValue])" - } else { - token[square.rawValue] = UUID().uuidString - } +public struct Game: Equatable, Sendable { + /// A stable identity for one rendered chess piece. + /// + /// Piece identities are intended for UI diffing and animation. They are not + /// part of chess-state equality and should not be used to compare legal + /// positions. + public struct PieceID: Hashable, Sendable, CustomStringConvertible { + /// The underlying compact identifier. + public let rawValue: Int + + /// A textual representation suitable for legacy string-based UI IDs. + public var description: String { + "piece-\(rawValue)" + } + + /// Creates a piece identity from a raw integer value. + /// + /// - Parameter rawValue: The compact integer identity. + public init(rawValue: Int) { + self.rawValue = rawValue + } + } + + /// A chess piece paired with its current square and stable UI identity. + public struct BoardPiece: Identifiable, Equatable, Sendable { + /// The stable identity for this piece. + public let id: PieceID + + /// The piece occupying `square`. + public let piece: Piece + + /// The square currently occupied by `piece`. + public let square: Square + + /// Creates a renderable board piece. + /// + /// - Parameters: + /// - id: The stable identity for the piece. + /// - piece: The chess piece. + /// - square: The square occupied by the piece. + public init(id: PieceID, piece: Piece, square: Square) { + self.id = id + self.piece = piece + self.square = square + } + } + + /// Legacy string tokens for pieces on the board. + /// + /// Use `boardPieces` or `pieceID(at:)` for new UI code. + @available(*, deprecated, message: "Use boardPieces or pieceID(at:) instead.") + public struct GameToken: Equatable, Sendable { + /// String tokens indexed by `Square.rawValue`. + public var token: [String] + + init(pieceIDs: [PieceID?]) { + self.token = pieceIDs.enumerated().map { index, pieceID in + pieceID?.description ?? "empty-\(index)" } } - - mutating func update(with startSquare: Square, oldSquare: Square, capturePice: Piece? = nil) { - (token[oldSquare.rawValue], token[startSquare.rawValue]) = (UUID().uuidString, token[oldSquare.rawValue]) + + /// Creates legacy string tokens. + /// + /// - Parameter token: String tokens indexed by `Square.rawValue`. + public init(token: [String]) { + self.token = token } } - public enum ExecutionError: Error { + public enum ExecutionError: Error, Sendable { case missingPiece(Square) case illegalMove(Move, PlayerColor, Board) case invalidPromotion(Piece.Kind) @@ -94,8 +143,25 @@ public struct Game: Equatable { public var isFinished: Bool { return availableMoves().isEmpty } - - public var token: GameToken + + /// Renderable pieces with stable identities for UI diffing and animation. + public var boardPieces: [BoardPiece] { + Square.allCases.compactMap { square in + guard let piece = board[square], + let id = pieceIDs[square.rawValue] else { return nil } + return BoardPiece(id: id, piece: piece, square: square) + } + } + + /// Legacy string tokens for occupied and empty squares. + /// + /// Use `boardPieces` or `pieceID(at:)` for new UI code. + @available(*, deprecated, message: "Use boardPieces or pieceID(at:) instead.") + public var token: GameToken { + GameToken(pieceIDs: pieceIDs) + } + + private var pieceIDs: [PieceID?] /// Create a game from another. private init(game: Game) { @@ -112,7 +178,7 @@ public struct Game: Equatable { self.fullmoves = game.fullmoves self.initalFullmoves = game.initalFullmoves self.enPassantTarget = game.enPassantTarget - self.token = GameToken(board: self.board) + self.pieceIDs = game.pieceIDs self.initialFen = game.initialFen } @@ -132,7 +198,7 @@ public struct Game: Equatable { self.halfmoves = 0 self.fullmoves = 1 self.initalFullmoves = 1 - self.token = GameToken(board: self.board) + self.pieceIDs = Self.pieceIDs(for: self.board) let position = Position(board: board, playerTurn: playerTurn, castlingRights: castlingRights, @@ -164,9 +230,49 @@ public struct Game: Equatable { self.halfmoves = position.halfmoves self.fullmoves = position.fullmoves self.initalFullmoves = position.fullmoves - self.token = GameToken(board: self.board) + self.pieceIDs = Self.pieceIDs(for: self.board) self.initialFen = position.fen() } + + /// Returns the stable identity for the piece at `square`. + /// + /// - Parameter square: The square to inspect. + /// - Returns: A piece identity when the square is occupied, otherwise + /// `nil`. + public func pieceID(at square: Square) -> PieceID? { + pieceIDs[square.rawValue] + } + + public static func == (lhs: Game, rhs: Game) -> Bool { + lhs.undoHistory == rhs.undoHistory + && lhs.moveHistory == rhs.moveHistory + && lhs.board == rhs.board + && lhs.playerTurn == rhs.playerTurn + && lhs.castlingRights == rhs.castlingRights + && lhs.whitePlayer == rhs.whitePlayer + && lhs.blackPlayer == rhs.blackPlayer + && lhs.initialFen == rhs.initialFen + && lhs.variant == rhs.variant + && lhs.attackersToKing == rhs.attackersToKing + && lhs.fullmoves == rhs.fullmoves + && lhs.initalFullmoves == rhs.initalFullmoves + && lhs.halfmoves == rhs.halfmoves + && lhs.enPassantTarget == rhs.enPassantTarget + } + + private static func pieceIDs(for board: Board) -> [PieceID?] { + var nextID = 0 + return Square.allCases.map { square in + guard board[square] != nil else { return nil } + defer { nextID += 1 } + return PieceID(rawValue: nextID) + } + } + + private mutating func movePieceID(from oldSquare: Square, to newSquare: Square) { + pieceIDs[newSquare.rawValue] = pieceIDs[oldSquare.rawValue] + pieceIDs[oldSquare.rawValue] = nil + } } // MARK: - Execute movement @@ -226,7 +332,7 @@ extension Game { let rook = Piece(rook: playerTurn) board[rook][old] = false board[rook][new] = true - token.update(with: new, oldSquare: old) + movePieceID(from: old, to: new) } } if let capture = capture, capture.kind.isRook { @@ -240,6 +346,7 @@ extension Game { } } + let capturedPieceID = capture == nil ? nil : pieceIDs[captureSquare.rawValue] moveHistory.append( MoveHistoryElement( move: move, @@ -249,11 +356,13 @@ extension Game { kingAttackers: attackersToKing, halfmoves: halfmoves, rights: rights, - promotionPiece: promoted + promotionPiece: promoted, + capturedPieceID: capturedPieceID ) ) if let capture = capture { board[capture][captureSquare] = false + pieceIDs[captureSquare.rawValue] = nil } if capture == nil && !piece.kind.isPawn { halfmoves += 1 @@ -264,7 +373,7 @@ extension Game { board[endPiece][move.end] = true playerTurn.invert() let pieceMoved = board[move.end]! - token.update(with: move.end, oldSquare: move.start) + movePieceID(from: move.start, to: move.end) if pieceMoved.kind.isPawn && abs(move.rankChange) == 2 { enPassantTarget = Square(file: move.start.file, rank: pieceMoved.color.isWhite() ? 3 : 6) } else { @@ -382,14 +491,15 @@ extension Game { guard let moveHistoryElement = moveHistory.popLast() else { return nil } - let (move, piece, capture, enPassantTarget, attackers, halfmoves, rights) = ( + let (move, piece, capture, enPassantTarget, attackers, halfmoves, rights, capturedPieceID) = ( moveHistoryElement.move, moveHistoryElement.piece, moveHistoryElement.capture, moveHistoryElement.enPassantTarget, moveHistoryElement.kingAttackers, moveHistoryElement.halfmoves, - moveHistoryElement.rights + moveHistoryElement.rights, + moveHistoryElement.capturedPieceID ) var captureSquare = move.end var promotionKind: PromotionPiece? @@ -405,7 +515,7 @@ extension Game { let rook = Piece(rook: playerTurn.inverse()) board[rook][old] = true board[rook][new] = false - token.update(with: old, oldSquare: new) + movePieceID(from: new, to: old) } if let capture = capture { board[capture][captureSquare] = true @@ -413,7 +523,10 @@ extension Game { undoHistory.append(UndoMoveElement(move: move, promotion: promotionKind, kingAttackers: attackers)) board[piece][move.end] = false board[piece][move.start] = true - token.update(with: move.start, oldSquare: move.end) + movePieceID(from: move.end, to: move.start) + if capture != nil { + pieceIDs[captureSquare.rawValue] = capturedPieceID + } playerTurn.invert() self.enPassantTarget = enPassantTarget self.attackersToKing = attackers @@ -428,9 +541,3 @@ extension Game { - - - - - - diff --git a/Sources/FischerCore/Game/MoveHistoryElement.swift b/Sources/FischerCore/Game/MoveHistoryElement.swift index 4813099f..e01cff51 100644 --- a/Sources/FischerCore/Game/MoveHistoryElement.swift +++ b/Sources/FischerCore/Game/MoveHistoryElement.swift @@ -7,7 +7,7 @@ import Foundation -public struct MoveHistoryElement: Equatable { +public struct MoveHistoryElement: Equatable, Sendable { public let move: Move public let piece: Piece public let capture: Piece? @@ -16,6 +16,7 @@ public struct MoveHistoryElement: Equatable { public let halfmoves: UInt public let rights: CastlingRights public let promotionPiece: PromotionPiece? + let capturedPieceID: Game.PieceID? public init( move: Move, @@ -35,6 +36,29 @@ public struct MoveHistoryElement: Equatable { self.halfmoves = halfmoves self.rights = rights self.promotionPiece = promotionPiece + self.capturedPieceID = nil + } + + init( + move: Move, + piece: Piece, + capture: Piece?, + enPassantTarget: Square?, + kingAttackers: Bitboard, + halfmoves: UInt, + rights: CastlingRights, + promotionPiece: PromotionPiece? = nil, + capturedPieceID: Game.PieceID? + ) { + self.move = move + self.piece = piece + self.capture = capture + self.enPassantTarget = enPassantTarget + self.kingAttackers = kingAttackers + self.halfmoves = halfmoves + self.rights = rights + self.promotionPiece = promotionPiece + self.capturedPieceID = capturedPieceID } } diff --git a/Sources/FischerCore/Game/UndoMoveElement.swift b/Sources/FischerCore/Game/UndoMoveElement.swift index cc89ca8e..41307a69 100644 --- a/Sources/FischerCore/Game/UndoMoveElement.swift +++ b/Sources/FischerCore/Game/UndoMoveElement.swift @@ -7,7 +7,7 @@ import Foundation -struct UndoMoveElement: Equatable { +struct UndoMoveElement: Equatable, Sendable { let move: Move let promotion: PromotionPiece? let kingAttackers: Bitboard diff --git a/Sources/FischerCore/Move/Move.swift b/Sources/FischerCore/Move/Move.swift index 5f075088..7228e783 100644 --- a/Sources/FischerCore/Move/Move.swift +++ b/Sources/FischerCore/Move/Move.swift @@ -1,6 +1,6 @@ import Foundation -public struct Move: Hashable { +public struct Move: Hashable, Sendable { public var start: Square public var end: Square } diff --git a/Sources/FischerCore/Move/MoveTree.swift b/Sources/FischerCore/Move/MoveTree.swift new file mode 100644 index 00000000..ee8bb866 --- /dev/null +++ b/Sources/FischerCore/Move/MoveTree.swift @@ -0,0 +1,115 @@ +// +// MoveTree.swift +// FischerCore +// +// Created by Omar Megdadi on 21/3/26. +// + +/// A move tree where each node represents a SAN ply in the main line. +/// +/// The tree models a linked main line through `next`, while `variants` +/// stores alternative continuations that branch from the same position as +/// the current move. +public final class MoveTree: Equatable { + /// The turn number of the SAN move represented by the node. + public let turn: UInt + + /// The side to move for the SAN move represented by the node. + public let color: PlayerColor + + /// The SAN move represented by the current node. + public let node: SANMove + + /// The next move in the main line. + public let next: MoveTree? + + /// All alternative branches attached to this move. + public let variants: [MoveTree] + + /// Convenience access to the first variation, when only one is needed. + public var variant: MoveTree? { + variants.first + } + + /// Alias that makes the node payload explicit at call sites. + public var sanMove: SANMove { + node + } + + public init( + turn: UInt, + color: PlayerColor, + node: SANMove, + next: MoveTree? = nil, + variants: [MoveTree] = [] + ) { + self.turn = turn + self.color = color + self.node = node + self.next = next + self.variants = variants + } + + public static func == (lhs: MoveTree, rhs: MoveTree) -> Bool { + lhs.turn == rhs.turn + && lhs.color == rhs.color + && lhs.node == rhs.node + && lhs.next == rhs.next + && lhs.variants == rhs.variants + } +} + +public extension PGNGame { + /// Builds a move tree using the parsed PGN plies as the main line and + /// attaching PGN variations to the move where they are declared. + var moveTree: MoveTree? { + MoveTree.buildLine(from: elements) + } +} + +extension MoveTree { + static func buildLine(from elements: [PGNElement]) -> MoveTree? { + buildLine(from: elements[...]) + } + + static func buildLine(from elements: ArraySlice) -> MoveTree? { + guard let current = elements.first else { + return nil + } + + if let whiteMove = current.whiteMove { + let next: MoveTree? + if let blackMove = current.blackMove { + next = MoveTree( + turn: current.turn, + color: .black, + node: blackMove, + next: buildLine(from: elements.dropFirst()), + variants: current.postBlackVariation?.compactMap(buildLine(from:)) ?? [] + ) + } else { + next = buildLine(from: elements.dropFirst()) + } + + return MoveTree( + turn: current.turn, + color: .white, + node: whiteMove, + next: next, + variants: current.postWhiteVariation?.compactMap(buildLine(from:)) ?? [] + ) + } + + if let blackMove = current.blackMove { + return MoveTree( + turn: current.turn, + color: .black, + node: blackMove, + next: buildLine(from: elements.dropFirst()), + variants: current.postBlackVariation?.compactMap(buildLine(from:)) ?? [] + ) + } + + return buildLine(from: elements.dropFirst()) + } +} diff --git a/Sources/FischerCore/Outcome.swift b/Sources/FischerCore/Outcome.swift index aadcc60d..7d350bbc 100644 --- a/Sources/FischerCore/Outcome.swift +++ b/Sources/FischerCore/Outcome.swift @@ -7,7 +7,7 @@ import Foundation -public enum Outcome: Hashable, CustomStringConvertible, Equatable { +public enum Outcome: Hashable, CustomStringConvertible, Equatable, Sendable { case win(PlayerColor) case draw diff --git a/Sources/FischerCore/PGN/Model/MoveTreePGN.swift b/Sources/FischerCore/PGN/Model/MoveTreePGN.swift new file mode 100644 index 00000000..5bfe815c --- /dev/null +++ b/Sources/FischerCore/PGN/Model/MoveTreePGN.swift @@ -0,0 +1,59 @@ +// +// MoveTreePGN.swift +// FischerCore +// +// Created by Omar Megdadi on 21/3/26. +// + +/// A PGN parsed directly into metadata plus a move tree. +public struct MoveTreePGN: Equatable { + public var tags: [PGNTag: String] + public var initialComment: [PGNComment]? + public var tree: MoveTree? + public var result: PGNOutcome? + + public init( + tags: [PGNTag: String], + initialComment: [PGNComment]? = nil, + tree: MoveTree? = nil, + result: PGNOutcome? = nil + ) { + self.tags = tags + self.initialComment = initialComment + self.tree = tree + self.result = result + } + + init( + tags: [PGNTag: String], + initialComment: [PGNComment]?, + elements: [PGNElement], + result: PGNOutcome? + ) { + self.init( + tags: tags, + initialComment: initialComment, + tree: MoveTree.buildLine(from: elements), + result: result + ) + } + + public func fen() -> String? { + tags[.fen] + } + + public func initialBoard() -> Board { + fen().flatMap(Board.init(fen:)) ?? Board() + } +} + +public extension PGNGame { + var moveTreePGN: MoveTreePGN { + MoveTreePGN( + tags: tags, + initialComment: initialComment, + tree: moveTree, + result: result + ) + } +} diff --git a/Sources/FischerCore/PGN/Model/NAG.swift b/Sources/FischerCore/PGN/Model/NAG.swift index 2e01cdea..cbc848bd 100644 --- a/Sources/FischerCore/PGN/Model/NAG.swift +++ b/Sources/FischerCore/PGN/Model/NAG.swift @@ -14,7 +14,7 @@ /// and help engines and humans alike understand the quality of a move. /// /// - Reference: [PGN Standard - NAG codes](https://en.wikipedia.org/wiki/Numeric_Annotation_Glyphs) -public enum NAG: Int, CaseIterable, Equatable { +public enum NAG: Int, CaseIterable, Equatable, Sendable { case nullAnnotation = 0 case goodMove case poorMove diff --git a/Sources/FischerCore/PGN/Model/PGN.swift b/Sources/FischerCore/PGN/Model/PGN.swift index bb02ce80..940eb6c9 100644 --- a/Sources/FischerCore/PGN/Model/PGN.swift +++ b/Sources/FischerCore/PGN/Model/PGN.swift @@ -6,7 +6,7 @@ // // Reference https://github.com/fsmosca/PGN-Standard/blob/master/PGN-Standard.txt -public struct PGN { +public struct PGN: Sendable { public var games: [PGNGame] } diff --git a/Sources/FischerCore/PGN/Model/PGNArrow.swift b/Sources/FischerCore/PGN/Model/PGNArrow.swift index c3123d98..1f890baf 100644 --- a/Sources/FischerCore/PGN/Model/PGNArrow.swift +++ b/Sources/FischerCore/PGN/Model/PGNArrow.swift @@ -9,7 +9,7 @@ /// /// These arrows are used in visual tools (e.g., Lichess studies) to indicate move suggestions, /// threats, or analysis lines. Each arrow has a color and a direction from one square to another. -public struct PGNArrow: Equatable { +public struct PGNArrow: Equatable, Sendable { /// The color of the arrow, typically used to convey meaning (e.g., red for threats). public var color: PGNColor diff --git a/Sources/FischerCore/PGN/Model/PGNColor.swift b/Sources/FischerCore/PGN/Model/PGNColor.swift index 437cb3b3..fbabd76b 100644 --- a/Sources/FischerCore/PGN/Model/PGNColor.swift +++ b/Sources/FischerCore/PGN/Model/PGNColor.swift @@ -16,7 +16,7 @@ /// - Y: Yellow /// - M: Magenta /// - C: Cyan -public enum PGNColor: String, CaseIterable { +public enum PGNColor: String, CaseIterable, Sendable { /// Red color annotation. case red = "R" diff --git a/Sources/FischerCore/PGN/Model/PGNComment.swift b/Sources/FischerCore/PGN/Model/PGNComment.swift index 349bcd8f..43621cf0 100644 --- a/Sources/FischerCore/PGN/Model/PGNComment.swift +++ b/Sources/FischerCore/PGN/Model/PGNComment.swift @@ -10,7 +10,7 @@ /// PGN comments can include plain text, arrows (to highlight movement or threats), /// and highlighted squares. This enum may be extended in the future to support /// engine evaluations and timing information. -public enum PGNComment: Equatable { +public enum PGNComment: Equatable, Sendable { /// A plain text comment enclosed in curly braces `{}` in PGN. case text(String) diff --git a/Sources/FischerCore/PGN/Model/PGNElement.swift b/Sources/FischerCore/PGN/Model/PGNElement.swift index 1000453a..352bfb0a 100644 --- a/Sources/FischerCore/PGN/Model/PGNElement.swift +++ b/Sources/FischerCore/PGN/Model/PGNElement.swift @@ -11,7 +11,7 @@ /// A `PGNElement` captures the state of a game at a given turn number and allows for /// inclusion of optional annotations and nested variations, making it suitable for /// rich PGN parsing and serialization. -public struct PGNElement { +public struct PGNElement: Sendable { /// The turn number of this PGN element. public let turn: UInt @@ -101,7 +101,7 @@ extension PGNElement: CustomStringConvertible { } } -public struct PGNColumnElement { +public struct PGNColumnElement: Sendable { /// The turn number of this PGN element. public let turn: UInt diff --git a/Sources/FischerCore/PGN/Model/PGNGame+FENPositions.swift b/Sources/FischerCore/PGN/Model/PGNGame+FENPositions.swift new file mode 100644 index 00000000..47444cf9 --- /dev/null +++ b/Sources/FischerCore/PGN/Model/PGNGame+FENPositions.swift @@ -0,0 +1,109 @@ +// +// PGNGame+FENPositions.swift +// FischerCore +// + +public enum PGNGameFENPositionsError: Error, CustomStringConvertible, Sendable { + case malformedPosition(index: Int, fen: String) + case invalidMove(index: Int, from: String, to: String) + case moveExecutionFailed(index: Int, move: Move, promotion: PromotionPiece?, reason: String) + case pgnParsingFailed(moveText: String, reason: String) + + public var description: String { + switch self { + case let .malformedPosition(index, fen): + return "Malformed FEN position at index \(index): \(fen)" + case let .invalidMove(index, from, to): + return "Invalid move at index \(index): no legal move transforms \(from) into \(to)" + case let .moveExecutionFailed(index, move, promotion, reason): + let promotionText = promotion.map { " promoting to \($0.rawValue)" } ?? "" + return "Failed to execute move at index \(index): \(move)\(promotionText). \(reason)" + case let .pgnParsingFailed(moveText, reason): + return "Failed to parse generated PGN moves '\(moveText)'. \(reason)" + } + } +} + +public extension PGNGame { + init(fenPositions positions: [String]) throws { + let moveText = try Self.sanRepresentation(fenPositions: positions) + let pgnString = """ + [Event "OTB Game"] + [Result "*"] + + \(moveText.isEmpty ? "*" : "\(moveText) *") + """ + + do { + self = try PGNGameParser().parse(pgnString) + } catch { + throw PGNGameFENPositionsError.pgnParsingFailed(moveText: moveText, reason: String(describing: error)) + } + } + + static func sanRepresentation(fenPositions positions: [String]) throws -> String { + var game = Game() + for (index, rawPosition) in positions.enumerated() { + let placement = rawPosition.fenPlacement + guard Board(fen: placement) != nil else { + throw PGNGameFENPositionsError.malformedPosition(index: index, fen: rawPosition) + } + guard placement != game.position.board.fen() else { continue } + guard let result = physicalMove(to: placement, in: game) else { + if index < positions.index(before: positions.endIndex) { + continue + } + throw PGNGameFENPositionsError.invalidMove( + index: index, + from: game.position.board.fen(), + to: placement + ) + } + + do { + if let promotion = result.promotion { + try game.execute(move: result.move, promotion: promotion) + } else { + try game.execute(move: result.move) + } + } catch { + throw PGNGameFENPositionsError.moveExecutionFailed(index: index, move: result.move, promotion: result.promotion, reason: String(describing: error)) + } + } + return try game.sanRepresentation() + } +} + +private extension PGNGame { + static func physicalMove(to placement: String, in game: Game) -> (move: Move, promotion: PromotionPiece?)? { + for move in game.availableMoves() { + if isPawnPromotion(move: move, in: game) { + for piece in PromotionPiece.allCases { + var next = game + try? next.execute(move: move, promotion: piece) + if next.position.board.fen() == placement { + return (move, piece) + } + } + } else { + var next = game + try? next.execute(move: move) + if next.position.board.fen() == placement { + return (move, nil) + } + } + } + return nil + } + + static func isPawnPromotion(move: Move, in game: Game) -> Bool { + guard let piece = game.board[move.start] else { return false } + return piece.kind.isPawn && move.end.rank == Rank(endFor: piece.color) + } +} + +private extension String { + var fenPlacement: String { + split(separator: " ", maxSplits: 1).first.map(String.init) ?? self + } +} diff --git a/Sources/FischerCore/PGN/Model/PGNGame.swift b/Sources/FischerCore/PGN/Model/PGNGame.swift index 3c666b1f..13a5316c 100644 --- a/Sources/FischerCore/PGN/Model/PGNGame.swift +++ b/Sources/FischerCore/PGN/Model/PGNGame.swift @@ -9,7 +9,7 @@ /// /// `PGNGame` encapsulates the full information of a chess game including tags, comments, /// moves and game outcome. -public struct PGNGame: Equatable { +public struct PGNGame: Equatable, Sendable { /// The collection of PGN tags (e.g., Event, Site, Date, etc.) describing metadata about the game. public var tags: [PGNTag: String] @@ -85,7 +85,16 @@ public extension Game { } mutating func execute(move: SANMove, considerHalfmoves: Bool = true) throws { - try execute(move: try transform(sanMove: move, considerHalfmoves: considerHalfmoves), considerHalfmoves: considerHalfmoves) + let transformedMove = try transform(sanMove: move, considerHalfmoves: considerHalfmoves) + if case let .san(defaultMove) = move, let promotion = defaultMove.promotionTo { + try execute(move: transformedMove, considerHalfmoves: considerHalfmoves, promotion: promotion) + } else { + try execute(move: transformedMove, considerHalfmoves: considerHalfmoves) + } + } + + mutating func execute(san: String, considerHalfmoves: Bool = true) throws { + try execute(move: SANMove(san: san), considerHalfmoves: considerHalfmoves) } init(loading pgnGame: PGNGame, moveToEnd: Bool = false, considerHalfmoves: Bool = true) throws { diff --git a/Sources/FischerCore/PGN/Model/PGNOutcome.swift b/Sources/FischerCore/PGN/Model/PGNOutcome.swift index 39227969..31a8e43b 100644 --- a/Sources/FischerCore/PGN/Model/PGNOutcome.swift +++ b/Sources/FischerCore/PGN/Model/PGNOutcome.swift @@ -14,7 +14,7 @@ /// - `*`: Undefined or ongoing game. /// /// Use this enum to interpret or assign result values when parsing or writing PGN data. -public enum PGNOutcome: String, CaseIterable, Equatable { +public enum PGNOutcome: String, CaseIterable, Equatable, Sendable { /// White wins the game. case win = "1-0" diff --git a/Sources/FischerCore/PGN/Model/PGNSquare.swift b/Sources/FischerCore/PGN/Model/PGNSquare.swift index a097f240..24bcac59 100644 --- a/Sources/FischerCore/PGN/Model/PGNSquare.swift +++ b/Sources/FischerCore/PGN/Model/PGNSquare.swift @@ -11,7 +11,7 @@ /// with a `PGNColor` (indicating the player's side, either white or black). /// It is useful when interpreting or processing PGN data that distinguishes /// moves or annotations based on color. -public struct PGNSquare: Equatable { +public struct PGNSquare: Equatable, Sendable { /// The color associated with the square (white or black). public var color: PGNColor diff --git a/Sources/FischerCore/PGN/Model/PGNTag.swift b/Sources/FischerCore/PGN/Model/PGNTag.swift index 24e48a96..0548f623 100644 --- a/Sources/FischerCore/PGN/Model/PGNTag.swift +++ b/Sources/FischerCore/PGN/Model/PGNTag.swift @@ -7,7 +7,7 @@ /// Represents the standardized PGN tags used in chess game notation. /// PGN (Portable Game Notation) tags store metadata about chess games. -public enum PGNTag: Equatable, Hashable, RawRepresentable, Codable, CaseIterable { +public enum PGNTag: Equatable, Hashable, RawRepresentable, Codable, CaseIterable, Sendable { // MARK: - Known Tags /// Name of the event or tournament. case event diff --git a/Sources/FischerCore/PGN/Model/PromotionPiece.swift b/Sources/FischerCore/PGN/Model/PromotionPiece.swift index 7715a6d3..5b4a9cad 100644 --- a/Sources/FischerCore/PGN/Model/PromotionPiece.swift +++ b/Sources/FischerCore/PGN/Model/PromotionPiece.swift @@ -13,7 +13,7 @@ /// - R: Rook /// - B: Bishop /// - N: Knight -public enum PromotionPiece: String, CaseIterable { +public enum PromotionPiece: String, CaseIterable, Sendable { case knight = "N" case bishop = "B" case rook = "R" diff --git a/Sources/FischerCore/PGN/Model/SANMove.swift b/Sources/FischerCore/PGN/Model/SANMove.swift index dbb350b9..5b55eff4 100644 --- a/Sources/FischerCore/PGN/Model/SANMove.swift +++ b/Sources/FischerCore/PGN/Model/SANMove.swift @@ -9,12 +9,12 @@ /// /// SANMove supports normal piece moves, captures, promotions, check/checkmate flags, /// and also castling moves (kingside and queenside). -public enum SANMove: Equatable { +public enum SANMove: Equatable, Sendable { /// Specifies how the origin of a move is disambiguated in SAN. /// /// When two identical pieces can move to the same square, disambiguation is needed. /// This enum helps represent the disambiguation component: by file, rank, or full square. - public enum FromPosition: Equatable { + public enum FromPosition: Equatable, Sendable { case file(File) case rank(Rank) case square(Square) @@ -24,7 +24,7 @@ public enum SANMove: Equatable { /// /// Includes the piece, origin (if disambiguated), capture flag, destination, /// promotion (if any), and whether the move results in check or checkmate. - public struct SANDefaultMove: Equatable { + public struct SANDefaultMove: Equatable, Sendable { /// The type of piece making the move. public let piece: Piece.Kind /// The disambiguation of the origin square, if required. diff --git a/Sources/FischerCore/PGN/Parsers/MoveTreePGNParser.swift b/Sources/FischerCore/PGN/Parsers/MoveTreePGNParser.swift new file mode 100644 index 00000000..d99aead6 --- /dev/null +++ b/Sources/FischerCore/PGN/Parsers/MoveTreePGNParser.swift @@ -0,0 +1,34 @@ +// +// MoveTreePGNParser.swift +// FischerCore +// +// Created by Omar Megdadi on 21/3/26. +// + +import Parsing + +public struct MoveTreePGNParser: Parser { + public init() {} + + public var body: some Parser { + Parse(MoveTreePGN.init(tags:initialComment:elements:result:)) { + TagParser() + Whitespace(1...) + Optionally { + CommentListParser() + } + Many { + PGNElementBasicParser() + } separator: { + Whitespace() + } terminator: { + Whitespace() + } + Optionally { + Whitespace() + PGNOutcome.parser() + } + Whitespace() + } + } +} diff --git a/Sources/FischerCore/PGN/Parsers/MoveTreePGNReader.swift b/Sources/FischerCore/PGN/Parsers/MoveTreePGNReader.swift new file mode 100644 index 00000000..6b864d7b --- /dev/null +++ b/Sources/FischerCore/PGN/Parsers/MoveTreePGNReader.swift @@ -0,0 +1,479 @@ +// +// MoveTreePGNReader.swift +// FischerCore +// +// Created by Omar Megdadi on 21/3/26. +// + +import Foundation +import Parsing + +public struct MoveTreePGNReader { + public enum ReaderError: Error, Equatable { + case unterminatedTag + case unterminatedVariation + case unexpectedVariation + } + + private var input: Substring + + public init(_ pgn: String) { + self.input = pgn[...] + } + + public mutating func readGame() throws -> MoveTreePGN? { + skipLeadingNoise() + + guard !input.isEmpty else { + return nil + } + + let tags = try parseTags() + skipLeadingNoise() + + let initialComment = try parseCommentList() + + let (turn, color) = initialPlyState(from: tags) + let line = try parseLine(untilVariationEnd: false, turn: turn, color: color) + + return MoveTreePGN( + tags: tags, + initialComment: initialComment, + tree: line.head?.freeze(), + result: line.result + ) + } + + public mutating func readGames() throws -> [MoveTreePGN] { + var games: [MoveTreePGN] = [] + while let game = try readGame() { + games.append(game) + } + return games + } +} + +private extension MoveTreePGNReader { + final class NodeBuilder { + let turn: UInt + let color: PlayerColor + let move: SANMove + var next: NodeBuilder? + var variants: [NodeBuilder] + + init( + turn: UInt, + color: PlayerColor, + move: SANMove, + next: NodeBuilder? = nil, + variants: [NodeBuilder] = [] + ) { + self.turn = turn + self.color = color + self.move = move + self.next = next + self.variants = variants + } + + func freeze() -> MoveTree { + MoveTree( + turn: turn, + color: color, + node: move, + next: next?.freeze(), + variants: variants.map { $0.freeze() } + ) + } + } + + struct ParsedLine { + let head: NodeBuilder? + let result: PGNOutcome? + } + + mutating func parseLine( + untilVariationEnd: Bool, + turn: UInt, + color: PlayerColor + ) throws -> ParsedLine { + var currentTurn = turn + var currentColor = color + var head: NodeBuilder? + var tail: NodeBuilder? + var result: PGNOutcome? + + while true { + skipMovetextNoise() + + guard let character = input.first else { + if untilVariationEnd { + throw ReaderError.unterminatedVariation + } + break + } + + if untilVariationEnd, character == ")" { + input.removeFirst() + break + } + + if !untilVariationEnd, character == "[" { + break + } + + if let outcome = parseOutcome() { + result = outcome + break + } + + if character == "(" { + input.removeFirst() + + guard let tail else { + throw ReaderError.unexpectedVariation + } + + let variation = try parseLine( + untilVariationEnd: true, + turn: tail.turn, + color: tail.color + ) + if let branch = variation.head { + tail.variants.append(branch) + } + continue + } + + if character == "{" { + _ = try parseCommentList() + continue + } + + if character == ";" || character == "%" { + skipLineComment() + continue + } + + if parseMoveNumber(turn: ¤tTurn, color: ¤tColor) { + continue + } + + if parseNAG() { + continue + } + + if let move = try parseMove() { + let node = NodeBuilder( + turn: currentTurn, + color: currentColor, + move: move + ) + + if let tail { + tail.next = node + } else { + head = node + } + tail = node + + switch currentColor { + case .white: + currentColor = .black + case .black: + currentColor = .white + currentTurn += 1 + } + continue + } + + skipToken() + } + + return ParsedLine(head: head, result: result) + } + + mutating func parseTags() throws -> [PGNTag: String] { + var tags: [PGNTag: String] = [:] + + while true { + skipLeadingNoise() + + guard input.first == "[" else { + return tags + } + + input.removeFirst() + skipInlineWhitespace() + + let name = String(consume(while: { !$0.isWhitespace && $0 != "\"" && $0 != "]" })) + skipInlineWhitespace() + + guard input.first == "\"" else { + throw ReaderError.unterminatedTag + } + input.removeFirst() + + var value = "" + var escaped = false + var terminated = false + + while let character = input.first { + input.removeFirst() + + if escaped { + if character == "\"" || character == "\\" { + value.append(character) + } else { + value.append("\\") + value.append(character) + } + escaped = false + continue + } + + if character == "\\" { + escaped = true + continue + } + + if character == "\"" { + terminated = true + break + } + + if character == "\n" { + throw ReaderError.unterminatedTag + } + + value.append(character) + } + + guard terminated else { + throw ReaderError.unterminatedTag + } + + skipInlineWhitespace() + + guard input.first == "]" else { + throw ReaderError.unterminatedTag + } + input.removeFirst() + + let key = PGNTag(rawValue: name) ?? .custom(name) + tags[key] = value + } + } + + mutating func parseCommentList() throws -> [PGNComment] { + var remainder = input + let comments = try CommentListParser().parse(&remainder) + input = remainder + return comments + } + + mutating func parseMove() throws -> SANMove? { + if let castling = parseZeroCastling() { + return castling + } + + var remainder = input + guard let move = try? SanMoveParser().parse(&remainder), + isTokenBoundary(remainder.first) else { + return nil + } + input = remainder + return move + } + + mutating func parseNAG() -> Bool { + guard let character = input.first, + character == "!" || character == "?" || character == "$" else { + return false + } + + var remainder = input + guard (try? NAGParser().parse(&remainder)) != nil else { + return false + } + + guard remainder.startIndex != input.startIndex else { + return false + } + + input = remainder + return true + } + + mutating func parseMoveNumber(turn: inout UInt, color: inout PlayerColor) -> Bool { + var remainder = input + let digits = remainder.prefix(while: \.isWholeNumber) + guard !digits.isEmpty, + let parsedTurn = UInt(digits) else { + return false + } + + remainder.removeFirst(digits.count) + + var dotCount = 0 + while remainder.first == "." { + remainder.removeFirst() + dotCount += 1 + } + + guard dotCount > 0 else { + return false + } + + turn = parsedTurn + color = dotCount >= 3 ? .black : .white + input = remainder + return true + } + + mutating func parseOutcome() -> PGNOutcome? { + for pattern in ["1/2-1/2", "1-0", "0-1", "*"] { + guard input.hasPrefix(pattern), + let outcome = PGNOutcome(rawValue: pattern) else { + continue + } + + let remainder = input.dropFirst(pattern.count) + guard isTokenBoundary(remainder.first) else { + continue + } + + input = remainder + return outcome + } + + return nil + } + + mutating func parseZeroCastling() -> SANMove? { + let castlePrefixes = [ + ("0-0-0#", SANMove.queensideCastling(isCheck: false, isCheckMate: true)), + ("0-0-0+", SANMove.queensideCastling(isCheck: true, isCheckMate: false)), + ("0-0-0", SANMove.queensideCastling(isCheck: false, isCheckMate: false)), + ("0-0#", SANMove.kingsideCastling(isCheck: false, isCheckMate: true)), + ("0-0+", SANMove.kingsideCastling(isCheck: true, isCheckMate: false)), + ("0-0", SANMove.kingsideCastling(isCheck: false, isCheckMate: false)) + ] + + for (prefix, move) in castlePrefixes where input.hasPrefix(prefix) { + let remainder = input.dropFirst(prefix.count) + guard isTokenBoundary(remainder.first) else { + continue + } + input = remainder + return move + } + + return nil + } + + func initialPlyState(from tags: [PGNTag: String]) -> (UInt, PlayerColor) { + guard let fen = tags[.fen], + let position = Position(fen: fen) else { + return (1, .white) + } + + return (position.fullmoves, position.playerTurn) + } + + mutating func skipLeadingNoise() { + if input.first == "\u{FEFF}" { + input.removeFirst() + } + + while true { + let count = input.count + skipWhitespace() + + if input.first == "%" || input.first == ";" { + skipLineComment() + continue + } + + if input.count == count { + return + } + } + } + + mutating func skipMovetextNoise() { + while true { + let count = input.count + skipWhitespace() + + if input.first == ";" || input.first == "%" { + skipLineComment() + continue + } + + if input.count == count { + return + } + } + } + + mutating func skipWhitespace() { + while let character = input.first, character.isWhitespace { + input.removeFirst() + } + } + + mutating func skipInlineWhitespace() { + while let character = input.first, + character == " " || character == "\t" || character == "\r" { + input.removeFirst() + } + } + + mutating func skipLineComment() { + while let character = input.first { + input.removeFirst() + if character == "\n" { + return + } + } + } + + mutating func skipToken() { + guard let character = input.first else { return } + + if isTokenBoundary(Optional(character)) { + input.removeFirst() + return + } + + while let character = input.first, !isTokenBoundary(character) { + input.removeFirst() + } + } + + mutating func consume(while predicate: (Character) -> Bool) -> Substring { + let prefix = input.prefix(while: predicate) + input.removeFirst(prefix.count) + return prefix + } + + func isTokenBoundary(_ character: Character?) -> Bool { + guard let character else { return true } + return isTokenBoundary(character) + } + + func isTokenBoundary(_ character: Character) -> Bool { + character.isWhitespace + || character == "{" + || character == "}" + || character == "(" + || character == ")" + || character == "!" + || character == "?" + || character == "$" + || character == ";" + || character == "." + || character == "*" + || character == "[" + } +} diff --git a/Sources/FischerCore/PGN/Parsers/PGNReader.swift b/Sources/FischerCore/PGN/Parsers/PGNReader.swift new file mode 100644 index 00000000..056723ff --- /dev/null +++ b/Sources/FischerCore/PGN/Parsers/PGNReader.swift @@ -0,0 +1,16 @@ +// +// PGNReader.swift +// FischerCore +// +// Created by Codex on 22/3/26. +// + +/// Reads a PGN document into an array of ``MoveTreePGN`` values. +public struct PGNReader { + public init() {} + + public func parse(_ pgn: String) throws -> [MoveTreePGN] { + var reader = MoveTreePGNReader(pgn) + return try reader.readGames() + } +} diff --git a/Sources/FischerCore/PGN/Parsers/SANParsers/SanMoveParser.swift b/Sources/FischerCore/PGN/Parsers/SANParsers/SanMoveParser.swift index fbfd70a3..08800424 100644 --- a/Sources/FischerCore/PGN/Parsers/SANParsers/SanMoveParser.swift +++ b/Sources/FischerCore/PGN/Parsers/SANParsers/SanMoveParser.swift @@ -21,3 +21,9 @@ struct SanMoveParser: Parser { } } } + +public extension SANMove { + init(san: String) throws { + self = try SanMoveParser().parse(san) + } +} diff --git a/Sources/FischerCore/Piece.swift b/Sources/FischerCore/Piece.swift index ac2c7233..f4a53703 100644 --- a/Sources/FischerCore/Piece.swift +++ b/Sources/FischerCore/Piece.swift @@ -1,7 +1,7 @@ import Foundation -public struct Piece { - public enum Kind: Int, CaseIterable, Equatable { +public struct Piece: Sendable { + public enum Kind: Int, CaseIterable, Equatable, Sendable { case pawn case knight case bishop @@ -74,6 +74,11 @@ public struct Piece { case .king: return color.isBlack() ? "â™”" : "♚" } } + + public init(kind: Kind, color: PlayerColor) { + self.kind = kind + self.color = color + } } extension Piece { diff --git a/Sources/FischerCore/PlayerColor.swift b/Sources/FischerCore/PlayerColor.swift index 207af30d..c3ef1f26 100644 --- a/Sources/FischerCore/PlayerColor.swift +++ b/Sources/FischerCore/PlayerColor.swift @@ -1,6 +1,6 @@ import Foundation -public enum PlayerColor: String, CaseIterable, Equatable, Codable { +public enum PlayerColor: String, CaseIterable, Equatable, Codable, Sendable { case white case black } @@ -35,4 +35,3 @@ extension PlayerColor { } } - diff --git a/Sources/FischerCore/Position.swift b/Sources/FischerCore/Position.swift index 94dd1834..06c040b1 100644 --- a/Sources/FischerCore/Position.swift +++ b/Sources/FischerCore/Position.swift @@ -7,7 +7,7 @@ import Foundation -public struct Position: Equatable, CustomStringConvertible { +public struct Position: Equatable, CustomStringConvertible, Sendable { public var board: Board public var playerTurn: PlayerColor public var castlingRights: CastlingRights diff --git a/Sources/FischerCore/PositionError.swift b/Sources/FischerCore/PositionError.swift index 8ce448ef..9da66bc1 100644 --- a/Sources/FischerCore/PositionError.swift +++ b/Sources/FischerCore/PositionError.swift @@ -7,7 +7,7 @@ import Foundation -public enum PositionError: Error, Equatable { +public enum PositionError: Error, Equatable, Sendable { case wrongKingCount(PlayerColor) case missingKing(CastlingRights.Right) case missingRook(CastlingRights.Right) diff --git a/Sources/FischerCore/Rank.swift b/Sources/FischerCore/Rank.swift index aa967547..4bc862d7 100644 --- a/Sources/FischerCore/Rank.swift +++ b/Sources/FischerCore/Rank.swift @@ -1,7 +1,7 @@ import Foundation -public enum Rank: Int, Equatable { - public enum Direction { +public enum Rank: Int, Equatable, Sendable { + public enum Direction: Sendable { case up case down } diff --git a/Sources/FischerCore/Square.swift b/Sources/FischerCore/Square.swift index e8ed2bab..53c13c02 100644 --- a/Sources/FischerCore/Square.swift +++ b/Sources/FischerCore/Square.swift @@ -1,7 +1,7 @@ import Foundation -public enum Square: Int, CaseIterable, Identifiable, Equatable { - public enum Color { +public enum Square: Int, CaseIterable, Identifiable, Equatable, Sendable { + public enum Color: Sendable { case light case dark } @@ -177,4 +177,3 @@ extension Square { return Square.Color.light } } - diff --git a/Sources/FischerCore/UCI/UCIMove.swift b/Sources/FischerCore/UCI/UCIMove.swift index 7b10f069..f1184d05 100644 --- a/Sources/FischerCore/UCI/UCIMove.swift +++ b/Sources/FischerCore/UCI/UCIMove.swift @@ -6,7 +6,7 @@ // -enum UCIMove: Equatable { +enum UCIMove: Equatable, Sendable { case nullMove case move(UCIMoveValue) -} \ No newline at end of file +} diff --git a/Sources/FischerCore/UCI/UCIMoveValue.swift b/Sources/FischerCore/UCI/UCIMoveValue.swift index c6a56aba..c6646953 100644 --- a/Sources/FischerCore/UCI/UCIMoveValue.swift +++ b/Sources/FischerCore/UCI/UCIMoveValue.swift @@ -6,7 +6,7 @@ // -public struct UCIMoveValue: Equatable { +public struct UCIMoveValue: Equatable, Sendable { public let start: Square public let end: Square public let promotion: PromotionPiece? @@ -17,4 +17,3 @@ public extension UCIMoveValue { .init(start: start, end: end) } } - diff --git a/Sources/FischerCore/Variant.swift b/Sources/FischerCore/Variant.swift index 046dd8ba..14ee364c 100644 --- a/Sources/FischerCore/Variant.swift +++ b/Sources/FischerCore/Variant.swift @@ -1,6 +1,6 @@ import Foundation -public enum Variant: Equatable { +public enum Variant: Equatable, Sendable { case standard case chess960 diff --git a/Sources/fischer-cli/CLI.swift b/Sources/fischer-cli/CLI.swift new file mode 100644 index 00000000..ece3ee1b --- /dev/null +++ b/Sources/fischer-cli/CLI.swift @@ -0,0 +1,99 @@ +// +// CLI.swift +// FischerCore +// + +import FischerCore +import Foundation +import Noora + +final class CLI { + private var game: Game + private let noora: Noora + + init(game: Game = Game(), noora: Noora = Noora()) { + self.game = game + self.noora = noora + } + + func startUp() { + noora.success("Starting FischerCore CLI") + write("\n") + write("\(game.board.ascii())\n\n") + } + + /// Returns false when the session should end. + @discardableResult + func run() -> Bool { + let option: CommandOption = noora.singleChoicePrompt( + title: "FischerCore", + question: "Select a command" + ) + + switch option { + case .quit: + noora.success("Goodbye") + return false + + case .board: + write("\(game.board.ascii())\n") + + case .clear: + print("\u{001B}[2J\u{001B}[H", terminator: "") + + case .help: + for opt in CommandOption.allCases { + write(" \(opt)\n") + } + write("\n") + + case .position: + write("\(game.position.fen())\n") + + case .reset: + let fen = noora.textPrompt( + prompt: "FEN position (leave empty to reset to starting position)", + description: "Forsyth–Edwards Notation encodes a full chess position as a compact string." + ) + if fen.isEmpty { + game = Game() + noora.success("Board reset") + } else { + do { + game = try Game(with: fen) + noora.success("Board reset") + } catch { + noora.error("Invalid FEN") + } + } + write("\(game.board.ascii())\n") + + case .move: + let input = noora.textPrompt( + prompt: "Move(s) in SAN notation", + description: "Standard Algebraic Notation — separate multiple moves with spaces (e.g. e4 e5 Nf3)." + ) + let sans = input.split(separator: " ").map(String.init).filter { !$0.isEmpty } + guard !sans.isEmpty else { + noora.error("No moves entered") + break + } + for san in sans { + do { + let sanMove = try SANMove(san: san) + let move = try game.transform(sanMove: sanMove) + try game.execute(move: sanMove) + noora.success("Moved \(san) from \(move.start) to \(move.end)") + } catch { + noora.error("Invalid move \(san)") + } + } + } + + return true + } + + private func write(_ string: String) { + print(string, terminator: "") + } +} diff --git a/Sources/fischer-cli/Command.swift b/Sources/fischer-cli/Command.swift new file mode 100644 index 00000000..428c079a --- /dev/null +++ b/Sources/fischer-cli/Command.swift @@ -0,0 +1,36 @@ +// +// Command.swift +// FischerCore +// + +enum Command: Equatable { + case quit + case board + case clear + case help + case position + case reset(fen: String? = nil) + case move(sans: [String]) +} + +enum CommandOption: CaseIterable, CustomStringConvertible, Equatable { + case board + case move + case position + case reset + case clear + case help + case quit + + var description: String { + switch self { + case .board: return "board — Print the current position as a visual chess board" + case .move: return "move — Execute moves using standard algebraic notation (e.g. e4 Nf3)" + case .position: return "position — Print the current position as FEN" + case .reset: return "reset — Reset the board, optionally to a custom FEN position" + case .clear: return "clear — Clear the visible area of the console" + case .help: return "help — Print this help message" + case .quit: return "quit — End FischerCore session" + } + } +} diff --git a/Sources/fischer-cli/main.swift b/Sources/fischer-cli/main.swift new file mode 100644 index 00000000..bfb0634b --- /dev/null +++ b/Sources/fischer-cli/main.swift @@ -0,0 +1,9 @@ +// +// main.swift +// FischerCore +// + +let cli = CLI() +cli.startUp() + +while cli.run() {} diff --git a/Tests/FischerCoreTests/GameConvenienceTests.swift b/Tests/FischerCoreTests/GameConvenienceTests.swift new file mode 100644 index 00000000..f4e332da --- /dev/null +++ b/Tests/FischerCoreTests/GameConvenienceTests.swift @@ -0,0 +1,175 @@ +// +// GameConvenienceTests.swift +// FischerCore +// + +import Testing +@testable import FischerCore + +final class GameConvenienceTests { + @Test("FEN placement extracts placement from full FEN or placement") + func fenPlacement() { + #expect( + FEN.placement(from: "8/8/8/8/8/8/8/4K3 w - - 0 1") + == "8/8/8/8/8/8/8/4K3" + ) + #expect(FEN.placement(from: "8/8/8/8/8/8/8/4K3") == "8/8/8/8/8/8/8/4K3") + } + + @Test("Game resolves physical board placement into a normal move") + func resolvesPhysicalPlacementIntoNormalMove() throws { + var next = Game() + try next.execute(move: .e2 >>> .e4) + + let resolved = Game().resolvedMove(toPlacement: next.position.board.fen()) + + #expect(resolved == ResolvedMove(move: .e2 >>> .e4)) + } + + @Test("Game resolves physical board placement into a promotion move") + func resolvesPhysicalPlacementIntoPromotionMove() throws { + let game = try Game(with: "8/k6P/8/8/8/8/K6p/8 w - - 0 1") + var next = game + try next.execute(move: .h7 >>> .h8, promotion: .knight) + + let resolved = game.resolvedMove(toPlacement: next.position.board.fen()) + + #expect(resolved == ResolvedMove(move: .h7 >>> .h8, promotion: .knight)) + #expect(game.requiresPromotion(move: .h7 >>> .h8)) + } + + @Test("Game executes UCI moves including promotion") + func executeUCIMove() throws { + var game = try Game(with: "8/k6P/8/8/8/8/K6p/8 w - - 0 1") + + try game.execute(uci: "h7h8n") + + #expect(game.board[.h8] == Piece(knight: .white)) + #expect(throws: (any Error).self) { + try game.execute(uci: "0000") + } + } + + @Test("Game exposes outcome when finished") + func outcome() throws { + var game = Game() + + try game.execute(move: .f2 >>> .f3) + try game.execute(move: .e7 >>> .e5) + try game.execute(move: .g2 >>> .g4) + try game.execute(move: .d8 >>> .h4) + + #expect(game.outcome == .win(.black)) + } + + @Test("Game jumps through undo and redo history") + func jumpToMove() throws { + var game = Game() + try game.execute(move: .e2 >>> .e4) + try game.execute(move: .e7 >>> .e5) + try game.execute(move: .g1 >>> .f3) + + game.jumpToMove(1) + #expect(game.moveHistory.map(\.move) == [.e2 >>> .e4]) + + game.fastForward() + #expect(game.moveHistory.map(\.move) == [.e2 >>> .e4, .e7 >>> .e5, .g1 >>> .f3]) + + game.rewindToStart() + #expect(game.moveHistory.isEmpty) + } + + @Test("Game returns structured SAN moves") + func playedSANMoves() throws { + var game = try Game(with: "rnbqkbnr/pppppppp/8/8/8/1P6/P1PPPPPP/RNBQKBNR b KQkq - 0 1") + + try game.execute(move: .g7 >>> .g6) + try game.execute(move: .c1 >>> .b2) + try game.execute(move: .f8 >>> .g7) + + let moves = try game.playedSANMoves() + + #expect(moves.map(\.moveNumber) == [1, 2, 2]) + #expect(moves.map(\.color) == [.black, .white, .black]) + #expect(moves.map(\.san) == ["g6", "Bb2", "Bg7"]) + #expect(moves.map(\.move) == [.g7 >>> .g6, .c1 >>> .b2, .f8 >>> .g7]) + } + + @Test("Game exposes stable board pieces for UI identity") + func boardPiecesExposeStableIdentity() throws { + var game = Game() + let initialID = try #require(game.pieceID(at: .e2)) + + try game.execute(move: .e2 >>> .e4) + + #expect(game.pieceID(at: .e2) == nil) + #expect(game.pieceID(at: .e4) == initialID) + #expect(game.boardPieces.contains(Game.BoardPiece(id: initialID, piece: Piece(pawn: .white), square: .e4))) + } + + @Test("Game restores board piece identity across capture undo and redo") + func boardPieceIdentitySurvivesCaptureUndoRedo() throws { + var game = try Game(with: "8/8/8/3p4/4P3/8/8/4K2k w - - 0 1") + let movingID = try #require(game.pieceID(at: .e4)) + let capturedID = try #require(game.pieceID(at: .d5)) + + try game.execute(move: .e4 >>> .d5) + #expect(game.pieceID(at: .d5) == movingID) + + game.undoMove() + #expect(game.pieceID(at: .e4) == movingID) + #expect(game.pieceID(at: .d5) == capturedID) + + let didRedo = game.redoMove() + #expect(didRedo) + #expect(game.pieceID(at: .d5) == movingID) + } + + @Test("Game moves rook identity when castling") + func boardPieceIdentityMovesRookWhenCastling() throws { + var game = try Game(with: "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1") + let kingID = try #require(game.pieceID(at: .e1)) + let rookID = try #require(game.pieceID(at: .h1)) + + try game.execute(move: .e1 >>> .g1) + + #expect(game.pieceID(at: .g1) == kingID) + #expect(game.pieceID(at: .f1) == rookID) + + game.undoMove() + #expect(game.pieceID(at: .e1) == kingID) + #expect(game.pieceID(at: .h1) == rookID) + } + + @Test("Game removes and restores captured identity for en passant") + func boardPieceIdentityHandlesEnPassant() throws { + var game = try Game(with: "8/8/8/3pP3/8/8/8/4K2k w - d6 0 1") + let movingID = try #require(game.pieceID(at: .e5)) + let capturedID = try #require(game.pieceID(at: .d5)) + + try game.execute(move: .e5 >>> .d6) + + #expect(game.pieceID(at: .d6) == movingID) + #expect(game.pieceID(at: .d5) == nil) + + game.undoMove() + #expect(game.pieceID(at: .e5) == movingID) + #expect(game.pieceID(at: .d5) == capturedID) + } + + @Test("Game keeps pawn identity when promoting") + func boardPieceIdentitySurvivesPromotion() throws { + var game = try Game(with: "8/k6P/8/8/8/8/K6p/8 w - - 0 1") + let pawnID = try #require(game.pieceID(at: .h7)) + + try game.execute(move: .h7 >>> .h8, promotion: .knight) + + #expect(game.pieceID(at: .h8) == pawnID) + #expect(game.boardPieces.contains(Game.BoardPiece(id: pawnID, piece: Piece(knight: .white), square: .h8))) + } + + @Test("Game equality is stable for equivalent initial games") + func gameEqualityIsStableForEquivalentInitialGames() { + #expect(Game() == Game()) + } +} diff --git a/Tests/FischerCoreTests/MoveTreePGNReaderTests.swift b/Tests/FischerCoreTests/MoveTreePGNReaderTests.swift new file mode 100644 index 00000000..9daaa956 --- /dev/null +++ b/Tests/FischerCoreTests/MoveTreePGNReaderTests.swift @@ -0,0 +1,190 @@ +import Testing +@testable import FischerCore + +final class MoveTreePGNReaderTests { + @Test("Reader matches direct parser on PGN with variations") + func readerMatchesParserOnVariationRichPGN() async throws { + let input = + """ + [Event "Fighting against London: Bishop Kicking!"] + [Site "https://lichess.org/study/Q7v33IGE/7W2bZKD5"] + [Result "*"] + [Variant "Standard"] + [ECO "A45"] + [Opening "Indian Defense"] + [Annotator "https://lichess.org/@/delta_horsey"] + [StudyName "Fighting against London"] + [ChapterName "Bishop Kicking!"] + [UTCDate "2025.03.24"] + [UTCTime "00:41:42"] + + 1. d4 Nf6 2. Bf4 b6!? (2... d5 3. e3 c5 4. c3 Nc6 5. Nd2 e6 6. Ngf3 Bd6 7. Bg3 O-O 8. Bd3) 3. e3 Bb7 4. Nf3 Nh5! 5. Bg5 (5. Bg3 Nxg3 6. hxg3 g6 7. c4 Bg7 8. Nc3 O-O 9. Bd3 e6 $36 10. Be4?! d5! 11. cxd5 exd5 12. Bd3 c5 $15) 5... h6 6. Bh4 g5 7. Nfd2 (7. Ne5 Nf6 8. Bg3 d6 9. Nc4 Ne4!? $13) (7. Bg3 Nxg3 8. hxg3 Bg7 9. Nbd2 e6 10. c3 d5 11. a4 a6! $10) 7... Nf4! 8. exf4 gxh4 9. Nf3 (9. Qh5?! e6 10. Nc3 Qf6! 11. Qe5 Qxe5+ 12. dxe5 Nc6 13. O-O-O O-O-O 14. Rg1 d6 $36) (9. h3? e6 10. Nc3 Qf6! $17) 9... e6 10. Nbd2 c5! 11. dxc5 bxc5 12. g3 Be7 13. Bg2 Nc6 $13 * + """ + + let parsed = try MoveTreePGNParser().parse(input) + var reader = MoveTreePGNReader(input) + let firstRead = try reader.readGame() + let read = try #require(firstRead) + + #expect(read == parsed) + #expect(try reader.readGame() == nil) + } + + @Test("Reader parses black-to-move PGN") + func readerParsesBlackToMovePGN() async throws { + let input = + """ + [Event "[NEW] CRUSH the French!: 4...Nd7"] + [Site "https://lichess.org/study/Rb4AyiUo/DsuBAZD0"] + [Result "*"] + [Variant "Standard"] + [ECO "?"] + [Opening "?"] + [Annotator "https://lichess.org/@/Bosburp"] + [StudyName "[NEW] CRUSH the French!"] + [ChapterName "4...Nd7"] + [FEN "rnbqkbnr/ppp2ppp/4p3/8/4N3/5N2/PPPP1PPP/R1BQKB1R b KQkq - 0 4"] + [SetUp "1"] + [UTCDate "2025.03.31"] + [UTCTime "10:47:15"] + + 4... Nd7 5. d4 Ngf6 6. Nxf6+ Nxf6 7. g3 Be7 (7... c5 8. Bg2) (7... b6 8. Bg2 Bb7 9. O-O Be7 10. Qe2 O-O 11. Rd1) 8. Bg2 O-O 9. O-O * + """ + + var reader = MoveTreePGNReader(input) + let firstRead = try reader.readGame() + let pgn = try #require(firstRead) + let tree = try #require(pgn.tree) + + #expect(tree.turn == 4) + #expect(tree.color == .black) + #expect(pgn.result == .undefined) + #expect(tree.mainLineTurnAndColors == [ + "4.black", + "5.white", + "5.black", + "6.white", + "6.black", + "7.white", + "7.black", + "8.white", + "8.black", + "9.white" + ]) + } + + @Test("Reader reads multiple games sequentially") + func readerReadsMultipleGames() async throws { + let input = + """ + [Event "Game One"] + [Result "*"] + + 1. e4 e5 * + + [Event "Game Two"] + [Result "1-0"] + + 1. d4 d5 1-0 + """ + + var reader = MoveTreePGNReader(input) + let firstRead = try reader.readGame() + let secondRead = try reader.readGame() + let first = try #require(firstRead) + let second = try #require(secondRead) + + #expect(first.tags[.event] == "Game One") + #expect(first.tree?.mainLineDescriptions == ["e4", "e5"]) + #expect(first.result == .undefined) + + #expect(second.tags[.event] == "Game Two") + #expect(second.tree?.mainLineDescriptions == ["d4", "d5"]) + #expect(second.result == .win) + + #expect(try reader.readGame() == nil) + } + + @Test("Reader returns nil for empty PGN noise") + func readerReturnsNilForEmptyPGNNoise() async throws { + let input = + """ + \u{FEFF} + % Generated by exporter + ; No games in this document + + """ + + var reader = MoveTreePGNReader(input) + + #expect(try reader.readGame() == nil) + #expect(try reader.readGames().isEmpty) + } + + @Test("Reader parses metadata comments and zero castling") + func readerParsesMetadataCommentsAndZeroCastling() async throws { + let input = + #""" + [Event "Reader coverage"] + [CustomTag "escaped \"quote\" and \\ slash"] + [Result "0-1"] + + {Initial note} + 1. e4 e5 + ; Line comments are ignored + 2. Nf3 Nc6 + 3. Bc4 0-0 0-1 + """# + + var reader = MoveTreePGNReader(input) + let pgn = try #require(try reader.readGame()) + + #expect(pgn.tags[.event] == "Reader coverage") + #expect(pgn.tags[.custom("CustomTag")] == #"escaped "quote" and \ slash"#) + #expect(pgn.initialComment == [.text("Initial note")]) + #expect(pgn.result == .loss) + #expect(pgn.tree?.mainLineDescriptions == ["e4", "e5", "Nf3", "Nc6", "Bc4", "O-O"]) + } + + @Test("Reader reports malformed tags and variations") + func readerReportsMalformedTagsAndVariations() async throws { + #expect(throws: MoveTreePGNReader.ReaderError.unterminatedTag) { + var reader = MoveTreePGNReader(#"[Event "Broken"#) + _ = try reader.readGame() + } + + #expect(throws: MoveTreePGNReader.ReaderError.unexpectedVariation) { + var reader = MoveTreePGNReader( + """ + [Event "Unexpected variation"] + [Result "*"] + + (1. e4) * + """ + ) + _ = try reader.readGame() + } + + #expect(throws: MoveTreePGNReader.ReaderError.unterminatedVariation) { + var reader = MoveTreePGNReader( + """ + [Event "Unterminated variation"] + [Result "*"] + + 1. e4 (1. d4 + """ + ) + _ = try reader.readGame() + } + } +} + +private extension MoveTree { + var mainLineDescriptions: [String] { + [node.description] + (next?.mainLineDescriptions ?? []) + } + + var mainLineTurnAndColors: [String] { + ["\(turn).\(color.rawValue)"] + (next?.mainLineTurnAndColors ?? []) + } +} diff --git a/Tests/FischerCoreTests/MoveTreeTests.swift b/Tests/FischerCoreTests/MoveTreeTests.swift new file mode 100644 index 00000000..cbd5de16 --- /dev/null +++ b/Tests/FischerCoreTests/MoveTreeTests.swift @@ -0,0 +1,423 @@ +import Testing +@testable import FischerCore + +final class MoveTreeTests { + @Test("Build move tree main line") + func buildMainLineTree() async throws { + let input = + """ + [Event "MoveTree main line"] + [Site "Fischer-Core Test Collection"] + [Result "*"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 * + """ + + let game = try PGNGameParser().parse(input) + let tree = try #require(game.moveTree) + + #expect(tree.mainLineDescriptions == ["e4", "e5", "Nf3", "Nc6", "Bb5", "a6"]) + #expect(tree.mainLineTurnAndColors == [ + "1.white", + "1.black", + "2.white", + "2.black", + "3.white", + "3.black" + ]) + #expect(tree.variants.isEmpty) + } + + @Test("Attach black variation to move node") + func attachBlackVariation() async throws { + let input = + """ + [Event "HKnight64's Study: ruy lopez berlin"] + [Site "https://lichess.org/study/dnszSFnZ/2Z88wfoL"] + [Result "*"] + [Variant "Standard"] + [ECO "C67"] + [Opening "Ruy Lopez: Berlin Defense, Rio Gambit Accepted"] + [Annotator "https://lichess.org/@/HKnight64"] + [StudyName "HKnight64's Study"] + [ChapterName "ruy lopez berlin"] + [UTCDate "2025.02.10"] + [UTCTime "11:05:29"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 Nf6 4. O-O Nxe4 5. Re1 Nf6 (5... Nd6 6. Nxe5 Be7 7. Nxc6 dxc6 8. Bf1 Bf5 9. c3 O-O 10. d4 Re8 11. Nd2 Nb5) * + """ + + let game = try PGNGameParser().parse(input) + let tree = try #require(game.moveTree) + let mainLineLastMove = try #require(tree.mainLineLastNode) + let variation = try #require(mainLineLastMove.variant) + + #expect(mainLineLastMove.turn == 5) + #expect(mainLineLastMove.color == .black) + #expect(mainLineLastMove.node.description == "Nf6") + #expect(variation.mainLineDescriptions == ["Nd6", "Nxe5", "Be7", "Nxc6", "dxc6", "Bf1", "Bf5", "c3", "O-O", "d4", "Re8", "Nd2", "Nb5"]) + #expect(variation.mainLineTurnAndColors == [ + "5.black", + "6.white", + "6.black", + "7.white", + "7.black", + "8.white", + "8.black", + "9.white", + "9.black", + "10.white", + "10.black", + "11.white", + "11.black" + ]) + } + + @Test("Attach multiple variations to the same move node") + func attachMultipleVariations() async throws { + let input = + """ + [Event "MoveTree multiple variations"] + [Site "Fischer-Core Test Collection"] + [Result "*"] + + 1. e4 (1. d4 d5) (1. Nf3 Nf6) e5 * + """ + + let game = try PGNGameParser().parse(input) + let tree = try #require(game.moveTree) + + #expect(tree.node.description == "e4") + #expect(tree.turn == 1) + #expect(tree.color == .white) + #expect(tree.next?.node.description == "e5") + #expect(tree.next?.turn == 1) + #expect(tree.next?.color == .black) + #expect(tree.variants.count == 2) + #expect(tree.variants.map(\.mainLineDescriptions) == [["d4", "d5"], ["Nf3", "Nf6"]]) + #expect(tree.variants.map(\.mainLineTurnAndColors) == [ + ["1.white", "1.black"], + ["1.white", "1.black"] + ]) + } + + @Test("Build move tree from PGN with several variations") + func buildTreeFromPGNWithSeveralVariations() async throws { + let input = + """ + [Event "Fighting against London: Bishop Kicking!"] + [Site "https://lichess.org/study/Q7v33IGE/7W2bZKD5"] + [Result "*"] + [Variant "Standard"] + [ECO "A45"] + [Opening "Indian Defense"] + [Annotator "https://lichess.org/@/delta_horsey"] + [StudyName "Fighting against London"] + [ChapterName "Bishop Kicking!"] + [UTCDate "2025.03.24"] + [UTCTime "00:41:42"] + + 1. d4 Nf6 2. Bf4 b6!? (2... d5 3. e3 c5 4. c3 Nc6 5. Nd2 e6 6. Ngf3 Bd6 7. Bg3 O-O 8. Bd3) 3. e3 Bb7 4. Nf3 Nh5! 5. Bg5 (5. Bg3 Nxg3 6. hxg3 g6 7. c4 Bg7 8. Nc3 O-O 9. Bd3 e6 $36 10. Be4?! d5! 11. cxd5 exd5 12. Bd3 c5 $15) 5... h6 6. Bh4 g5 7. Nfd2 (7. Ne5 Nf6 8. Bg3 d6 9. Nc4 Ne4!? $13) (7. Bg3 Nxg3 8. hxg3 Bg7 9. Nbd2 e6 10. c3 d5 11. a4 a6! $10) 7... Nf4! 8. exf4 gxh4 9. Nf3 (9. Qh5?! e6 10. Nc3 Qf6! 11. Qe5 Qxe5+ 12. dxe5 Nc6 13. O-O-O O-O-O 14. Rg1 d6 $36) (9. h3? e6 10. Nc3 Qf6! $17) 9... e6 10. Nbd2 c5! 11. dxc5 bxc5 12. g3 Be7 13. Bg2 Nc6 $13 * + """ + + let game = try PGNGameParser().parse(input) + let tree = try #require(game.moveTree) + let mainLine = tree.mainLineNodes + + #expect(mainLine.count == 26) + #expect(tree.maxDepth == 29) + #expect(mainLine[0].turn == 1) + #expect(mainLine[0].color == .white) + #expect(mainLine[1].turn == 1) + #expect(mainLine[1].color == .black) + + #expect(mainLine[3].node.description == "b6") + #expect(mainLine[3].turn == 2) + #expect(mainLine[3].color == .black) + #expect(mainLine[3].variants.count == 1) + #expect(mainLine[3].variants.first?.mainLineDescriptions == ["d5", "e3", "c5", "c3", "Nc6", "Nd2", "e6", "Ngf3", "Bd6", "Bg3", "O-O", "Bd3"]) + #expect(mainLine[3].variants.first?.mainLineTurnAndColors == [ + "2.black", + "3.white", + "3.black", + "4.white", + "4.black", + "5.white", + "5.black", + "6.white", + "6.black", + "7.white", + "7.black", + "8.white" + ]) + + #expect(mainLine[8].node.description == "Bg5") + #expect(mainLine[8].turn == 5) + #expect(mainLine[8].color == .white) + #expect(mainLine[8].variants.count == 1) + + #expect(mainLine[12].node.description == "Nfd2") + #expect(mainLine[12].turn == 7) + #expect(mainLine[12].color == .white) + #expect(mainLine[12].variants.count == 2) + #expect(mainLine[12].variants.map(\.mainLineDescriptions) == [ + ["Ne5", "Nf6", "Bg3", "d6", "Nc4", "Ne4"], + ["Bg3", "Nxg3", "hxg3", "Bg7", "Nbd2", "e6", "c3", "d5", "a4", "a6"] + ]) + #expect(mainLine[12].variants.map(\.mainLineTurnAndColors) == [ + ["7.white", "7.black", "8.white", "8.black", "9.white", "9.black"], + ["7.white", "7.black", "8.white", "8.black", "9.white", "9.black", "10.white", "10.black", "11.white", "11.black"] + ]) + + #expect(mainLine[16].node.description == "Nf3") + #expect(mainLine[16].turn == 9) + #expect(mainLine[16].color == .white) + #expect(mainLine[16].variants.count == 2) + #expect(mainLine[16].variants.map(\.maxDepth) == [12, 4]) + #expect(mainLine[16].variants.map(\.mainLineTurnAndColors) == [ + ["9.white", "9.black", "10.white", "10.black", "11.white", "11.black", "12.white", "12.black", "13.white", "13.black", "14.white", "14.black"], + ["9.white", "9.black", "10.white", "10.black"] + ]) + } + + @Test("Build move tree from black-to-move PGN") + func buildTreeFromBlackToMovePGN() async throws { + let input = + """ + [Event "[NEW] CRUSH the French!: 4...Nd7"] + [Site "https://lichess.org/study/Rb4AyiUo/DsuBAZD0"] + [Result "*"] + [Variant "Standard"] + [ECO "?"] + [Opening "?"] + [Annotator "https://lichess.org/@/Bosburp"] + [StudyName "[NEW] CRUSH the French!"] + [ChapterName "4...Nd7"] + [FEN "rnbqkbnr/ppp2ppp/4p3/8/4N3/5N2/PPPP1PPP/R1BQKB1R b KQkq - 0 4"] + [SetUp "1"] + [UTCDate "2025.03.31"] + [UTCTime "10:47:15"] + + 4... Nd7 5. d4 Ngf6 6. Nxf6+ Nxf6 7. g3 Be7 (7... c5 8. Bg2) (7... b6 8. Bg2 Bb7 9. O-O Be7 10. Qe2 O-O 11. Rd1) 8. Bg2 O-O 9. O-O * + """ + + let game = try PGNGameParser().parse(input) + let tree = try #require(game.moveTree) + let mainLine = tree.mainLineNodes + + #expect(mainLine.count == 10) + #expect(tree.turn == 4) + #expect(tree.color == .black) + #expect(tree.node.description == "Nd7") + #expect(tree.mainLineDescriptions == ["Nd7", "d4", "Ngf6", "Nxf6+", "Nxf6", "g3", "Be7", "Bg2", "O-O", "O-O"]) + #expect(tree.mainLineTurnAndColors == [ + "4.black", + "5.white", + "5.black", + "6.white", + "6.black", + "7.white", + "7.black", + "8.white", + "8.black", + "9.white" + ]) + #expect(tree.maxDepth == 15) + + #expect(mainLine[6].node.description == "Be7") + #expect(mainLine[6].turn == 7) + #expect(mainLine[6].color == .black) + #expect(mainLine[6].variants.count == 2) + #expect(mainLine[6].variants.map(\.mainLineDescriptions) == [ + ["c5", "Bg2"], + ["b6", "Bg2", "Bb7", "O-O", "Be7", "Qe2", "O-O", "Rd1"] + ]) + #expect(mainLine[6].variants.map(\.mainLineTurnAndColors) == [ + ["7.black", "8.white"], + ["7.black", "8.white", "8.black", "9.white", "9.black", "10.white", "10.black", "11.white"] + ]) + } + + @Test("Parse move tree directly from PGN text") + func parseMoveTreeDirectlyFromPGNText() async throws { + let input = + """ + [Event "Fighting against London: Bishop Kicking!"] + [Site "https://lichess.org/study/Q7v33IGE/7W2bZKD5"] + [Result "*"] + [Variant "Standard"] + [ECO "A45"] + [Opening "Indian Defense"] + [Annotator "https://lichess.org/@/delta_horsey"] + [StudyName "Fighting against London"] + [ChapterName "Bishop Kicking!"] + [UTCDate "2025.03.24"] + [UTCTime "00:41:42"] + + 1. d4 Nf6 2. Bf4 b6!? (2... d5 3. e3 c5 4. c3 Nc6 5. Nd2 e6 6. Ngf3 Bd6 7. Bg3 O-O 8. Bd3) 3. e3 Bb7 4. Nf3 Nh5! 5. Bg5 (5. Bg3 Nxg3 6. hxg3 g6 7. c4 Bg7 8. Nc3 O-O 9. Bd3 e6 $36 10. Be4?! d5! 11. cxd5 exd5 12. Bd3 c5 $15) 5... h6 6. Bh4 g5 7. Nfd2 (7. Ne5 Nf6 8. Bg3 d6 9. Nc4 Ne4!? $13) (7. Bg3 Nxg3 8. hxg3 Bg7 9. Nbd2 e6 10. c3 d5 11. a4 a6! $10) 7... Nf4! 8. exf4 gxh4 9. Nf3 (9. Qh5?! e6 10. Nc3 Qf6! 11. Qe5 Qxe5+ 12. dxe5 Nc6 13. O-O-O O-O-O 14. Rg1 d6 $36) (9. h3? e6 10. Nc3 Qf6! $17) 9... e6 10. Nbd2 c5! 11. dxc5 bxc5 12. g3 Be7 13. Bg2 Nc6 $13 * + """ + + let expectedGame = try PGNGameParser().parse(input) + let directPGN = try MoveTreePGNParser().parse(input) + + #expect(directPGN.tags == expectedGame.tags) + #expect(directPGN.initialComment == expectedGame.initialComment) + #expect(directPGN.result == expectedGame.result) + #expect(directPGN.tree == expectedGame.moveTree) + } + + @Test("Parse black-to-move tree directly from PGN text") + func parseBlackToMoveTreeDirectlyFromPGNText() async throws { + let input = + """ + [Event "[NEW] CRUSH the French!: 4...Nd7"] + [Site "https://lichess.org/study/Rb4AyiUo/DsuBAZD0"] + [Result "*"] + [Variant "Standard"] + [ECO "?"] + [Opening "?"] + [Annotator "https://lichess.org/@/Bosburp"] + [StudyName "[NEW] CRUSH the French!"] + [ChapterName "4...Nd7"] + [FEN "rnbqkbnr/ppp2ppp/4p3/8/4N3/5N2/PPPP1PPP/R1BQKB1R b KQkq - 0 4"] + [SetUp "1"] + [UTCDate "2025.03.31"] + [UTCTime "10:47:15"] + + 4... Nd7 5. d4 Ngf6 6. Nxf6+ Nxf6 7. g3 Be7 (7... c5 8. Bg2) (7... b6 8. Bg2 Bb7 9. O-O Be7 10. Qe2 O-O 11. Rd1) 8. Bg2 O-O 9. O-O * + """ + + let pgn = try MoveTreePGNParser().parse(input) + let tree = try #require(pgn.tree) + + #expect(pgn.tags[.fen] == "rnbqkbnr/ppp2ppp/4p3/8/4N3/5N2/PPPP1PPP/R1BQKB1R b KQkq - 0 4") + #expect(pgn.result == .undefined) + #expect(tree.turn == 4) + #expect(tree.color == .black) + #expect(tree.mainLineDescriptions == ["Nd7", "d4", "Ngf6", "Nxf6+", "Nxf6", "g3", "Be7", "Bg2", "O-O", "O-O"]) + #expect(tree.mainLineTurnAndColors == [ + "4.black", + "5.white", + "5.black", + "6.white", + "6.black", + "7.white", + "7.black", + "8.white", + "8.black", + "9.white" + ]) + } + + @Test("MoveTreePGN exposes FEN board and converted tree") + func moveTreePGNExposesFENBoardAndConvertedTree() async throws { + let fen = "8/8/8/8/8/8/8/K6k w - - 0 1" + let input = + """ + [Event "MoveTreePGN model"] + [FEN "\(fen)"] + [SetUp "1"] + [Result "1/2-1/2"] + + {Initial setup} + 1. Ka2 Kg2 1/2-1/2 + """ + + let game = try PGNGameParser().parse(input) + let pgn = game.moveTreePGN + + #expect(pgn.tags[.event] == "MoveTreePGN model") + #expect(pgn.fen() == fen) + #expect(pgn.initialBoard().fen() == "8/8/8/8/8/8/8/K6k") + #expect(pgn.initialComment == [.text("Initial setup")]) + #expect(pgn.result == .draw) + #expect(pgn.tree == game.moveTree) + } + + @Test("MoveTreePGN falls back to default board") + func moveTreePGNFallsBackToDefaultBoard() async throws { + let pgnWithoutFEN = MoveTreePGN(tags: [:]) + let pgnWithInvalidFEN = MoveTreePGN(tags: [.fen: "invalid fen"]) + + #expect(pgnWithoutFEN.fen() == nil) + #expect(pgnWithoutFEN.initialBoard() == Board()) + #expect(pgnWithInvalidFEN.fen() == "invalid fen") + #expect(pgnWithInvalidFEN.initialBoard() == Board()) + } + + @Test("MoveTree skips empty elements and preserves black-only nodes") + func moveTreeSkipsEmptyElementsAndPreservesBlackOnlyNodes() async throws { + let blackMove = SANMove.san( + SANMove.SANDefaultMove( + kind: .pawn, + isCapture: false, + toSquare: .e5, + promotion: nil, + isCheck: false, + isCheckmate: false + ) + ) + let elements = [ + PGNElement( + turn: 1, + previousWhiteCommentList: nil, + whiteMove: nil, + whiteEvaluation: nil, + postWhiteCommentList: nil, + postWhiteVariation: nil, + previousBlackCommentList: nil, + blackMove: nil, + blackEvaluation: nil, + postBlackCommentList: nil, + postBlackVariation: nil + ), + PGNElement( + turn: 1, + previousWhiteCommentList: nil, + whiteMove: nil, + whiteEvaluation: nil, + postWhiteCommentList: nil, + postWhiteVariation: nil, + previousBlackCommentList: nil, + blackMove: blackMove, + blackEvaluation: nil, + postBlackCommentList: nil, + postBlackVariation: nil + ) + ] + + #expect(MoveTree.buildLine(from: [PGNElement]()) == nil) + + let tree = try #require(MoveTree.buildLine(from: elements)) + #expect(tree.turn == 1) + #expect(tree.color == .black) + #expect(tree.node == blackMove) + #expect(tree.next == nil) + } +} + +private extension MoveTree { + var mainLineDescriptions: [String] { + [node.description] + (next?.mainLineDescriptions ?? []) + } + + var mainLineTurnAndColors: [String] { + ["\(turn).\(color.rawValue)"] + (next?.mainLineTurnAndColors ?? []) + } + + var mainLineNodes: [MoveTree] { + [self] + (next?.mainLineNodes ?? []) + } + + var mainLineLastNode: MoveTree? { + next?.mainLineLastNode ?? self + } + + var maxDepth: Int { + let childDepth = max( + next?.maxDepth ?? 0, + variants.map(\.maxDepth).max() ?? 0 + ) + return 1 + childDepth + } +} diff --git a/Tests/FischerCoreTests/PGN/PGNGameFENPositionsTests.swift b/Tests/FischerCoreTests/PGN/PGNGameFENPositionsTests.swift new file mode 100644 index 00000000..ea03bcbb --- /dev/null +++ b/Tests/FischerCoreTests/PGN/PGNGameFENPositionsTests.swift @@ -0,0 +1,171 @@ +// +// PGNGameFENPositionsTests.swift +// FischerCore +// + +import Testing +@testable import FischerCore + +final class PGNGameFENPositionsTests { + @Test("PGN game initializes from empty FEN positions") + func pgnGameInitializesFromEmptyFenPositions() throws { + let pgnGame = try PGNGame(fenPositions: []) + let loadedGame = try Game(loading: pgnGame, moveToEnd: true) + + #expect(try PGNGame.sanRepresentation(fenPositions: []) == "") + #expect(loadedGame.position.board.fen() == Game().position.board.fen()) + } + + @Test("PGN game initializes from physical board FEN positions") + func pgnGameInitializesFromPhysicalBoardFenPositions() throws { + let positions = [ + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + "rnbqkbnr/pppppppp/8/8/8/8/PPPP1PPP/RNBQKBNR", + "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR", + "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR", + "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKB1R", + "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R", + "r1bqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R", + "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R", + "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQK2R", + "r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R", + "r1bqk1nr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R", + "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQK2R", + "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ3R", + "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ2KR", + "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ2K1", + "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1", + "r1bqk2r/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1", + "r1bqk2r/pppp1ppp/2n2n2/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1", + "r1bqk2r/pppp1ppp/2n2n2/2b1p3/2B1P3/3P1N2/PPP2PPP/RNBQ1RK1", + "r1bq3r/pppp1ppp/2n2n2/2b1p3/2B1P3/3P1N2/PPP2PPP/RNBQ1RK1", + "r1bq2kr/pppp1ppp/2n2n2/2b1p3/2B1P3/3P1N2/PPP2PPP/RNBQ1RK1", + "r1bq2k1/pppp1ppp/2n2n2/2b1p3/2B1P3/3P1N2/PPP2PPP/RNBQ1RK1", + "r1bq1rk1/pppp1ppp/2n2n2/2b1p3/2B1P3/3P1N2/PPP2PPP/RNBQ1RK1", + ] + + let pgnGame = try PGNGame(fenPositions: positions) + let loadedGame = try Game(loading: pgnGame, moveToEnd: true) + + #expect( + try PGNGame.sanRepresentation(fenPositions: positions) + == "1.e4 e5 2.Nf3 Nc6 3.Bc4 Bc5 4.O-O Nf6 5.d3 O-O" + ) + #expect(loadedGame.position.board.fen() == positions.last!) + } + + @Test("PGN game builds SAN representation for promotion FEN positions") + func pgnGameBuildsSanRepresentationForPromotionFenPositions() throws { + let knightPromotionPositions = try fenPositions(after: [ + "a2a4", + "h7h5", + "a4a5", + "h5h4", + "a5a6", + "h4h3", + "a6b7", + "h3g2", + "b7a8N", + ]) + let queenPromotionPositions = try fenPositions(after: [ + "a2a4", + "h7h5", + "a4a5", + "h5h4", + "a5a6", + "h4h3", + "a6b7", + "h3g2", + "b7a8Q", + ]) + + #expect( + try PGNGame.sanRepresentation(fenPositions: knightPromotionPositions) + == "1.a4 h5 2.a5 h4 3.a6 h3 4.axb7 hxg2 5.bxa8=N" + ) + #expect( + try PGNGame.sanRepresentation(fenPositions: queenPromotionPositions) + == "1.a4 h5 2.a5 h4 3.a6 h3 4.axb7 hxg2 5.bxa8=Q" + ) + } + + @Test("PGN game FEN positions throws typed error for malformed positions") + func pgnGameFenPositionsThrowsForMalformedPosition() { + #expect(throws: PGNGameFENPositionsError.self) { + _ = try PGNGame(fenPositions: ["not-a-fen"]) + } + } + + @Test("PGN game FEN positions throws typed error for invalid transitions") + func pgnGameFenPositionsThrowsForInvalidTransition() { + do { + _ = try PGNGame(fenPositions: ["8/8/8/8/8/8/8/8"]) + Issue.record("Expected invalid transition error") + } catch let error as PGNGameFENPositionsError { + guard case let .invalidMove(index, _, to) = error else { + Issue.record("Expected invalidMove, got \(error)") + return + } + #expect(index == 0) + #expect(to == "8/8/8/8/8/8/8/8") + } catch { + Issue.record("Expected PGNGameFENPositionsError, got \(error)") + } + } + + @Test("PGN game FEN position errors describe each failure") + func pgnGameFenPositionErrorsDescribeEachFailure() { + let move = Move(start: .a2, end: .a4) + + #expect( + PGNGameFENPositionsError.invalidMove(index: 1, from: "start", to: "end").description + == "Invalid move at index 1: no legal move transforms start into end" + ) + #expect( + PGNGameFENPositionsError.moveExecutionFailed( + index: 2, + move: move, + promotion: nil, + reason: "illegal" + ).description + == "Failed to execute move at index 2: a2 >>> a4. illegal" + ) + #expect( + PGNGameFENPositionsError.moveExecutionFailed( + index: 3, + move: move, + promotion: .queen, + reason: "illegal" + ).description + == "Failed to execute move at index 3: a2 >>> a4 promoting to Q. illegal" + ) + #expect( + PGNGameFENPositionsError.pgnParsingFailed(moveText: "1.e4", reason: "bad").description + == "Failed to parse generated PGN moves '1.e4'. bad" + ) + } + + private func fenPositions(after uciMoves: [String]) throws -> [String] { + var game = Game() + var positions = [game.position.board.fen()] + + for uciMove in uciMoves { + let from = String(uciMove.prefix(2)) + let to = String(uciMove.dropFirst(2).prefix(2)) + guard let start = Square(from), let end = Square(to) else { + Issue.record("Invalid UCI move \(uciMove)") + continue + } + let promotion = uciMove.last.flatMap { PromotionPiece(rawValue: String($0)) } + + if let promotion { + try game.execute(move: start >>> end, promotion: promotion) + } else { + try game.execute(move: start >>> end) + } + positions.append(game.position.board.fen()) + } + + return positions + } +} diff --git a/Tests/FischerCoreTests/PGN/PGNParserTests.swift b/Tests/FischerCoreTests/PGN/PGNParserTests.swift index 6eb2fd90..10996525 100644 --- a/Tests/FischerCoreTests/PGN/PGNParserTests.swift +++ b/Tests/FischerCoreTests/PGN/PGNParserTests.swift @@ -435,29 +435,29 @@ class PGNParserTests { #expect(result.games.count == 10) } - @Test("TWIC file PGN parser", .disabled("We only check in local")) + @Test("TWIC file PGN parser") func parseTWICFileFromResources() async throws { let fileURL = try #require( - Bundle.module.url(forResource: "twic1618", withExtension: "pgn") + Bundle.module.url(forResource: "twic1618-short", withExtension: "pgn") ) let fileContents = try String(contentsOf: fileURL, encoding: .utf8) let parser = PGNParser() let result = try parser.parse(fileContents) - #expect(result.games.count == 5347) + #expect(result.games.count == 159) } @Test("TWIC file PGN parser") func parseTWICFileToBasicPGN() async throws { let fileURL = try #require( - Bundle.module.url(forResource: "twic1618", withExtension: "pgn") + Bundle.module.url(forResource: "twic1618-short", withExtension: "pgn") ) let fileContents = try String(contentsOf: fileURL, encoding: .utf8) let parser = BasicPGNParser() let result = try parser.parse(fileContents) - #expect(result.count == 5347) + #expect(result.count == 159) } } diff --git a/Tests/FischerCoreTests/PGN/PGNReaderTests.swift b/Tests/FischerCoreTests/PGN/PGNReaderTests.swift new file mode 100644 index 00000000..55062850 --- /dev/null +++ b/Tests/FischerCoreTests/PGN/PGNReaderTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing +@testable import FischerCore + +final class PGNReaderTests { + @Test("TWIC file MoveTree PGN reader") + func parseTWICFileToMoveTreePGN() async throws { + let fileContents = try loadTWICFile() + let result = try PGNReader().parse(fileContents) + + #expect(result.count == 159) + } + + @Test("TWIC file MoveTree PGN reader matches PGN parser") + func parseTWICFileToMoveTreePGNMatchingClassicParser() async throws { + let fileContents = try loadTWICFile() + + let parserResult = try PGNParser().parse(fileContents) + let readerResult = try PGNReader().parse(fileContents) + + #expect(readerResult.count == 159) + #expect(readerResult == parserResult.games.map(\.moveTreePGN)) + } + + @Test("TWIC benchmark for PGN parsers") + func benchmarkTWICParsers() async throws { + let fileContents = try loadTWICFile() + + let classic = try benchmark(label: "PGNParser") { + try PGNParser().parse(fileContents).games.count + } + let basic = try benchmark(label: "BasicPGNParser") { + try BasicPGNParser().parse(fileContents).count + } + let moveTree = try benchmark(label: "PGNReader") { + try PGNReader().parse(fileContents).count + } + + #expect(classic.count == 159) + #expect(basic.count == 159) + #expect(moveTree.count == 159) + + print( + """ + TWIC benchmark + - \(classic.summary) + - \(basic.summary) + - \(moveTree.summary) + """ + ) + } +} + +private extension PGNReaderTests { + func loadTWICFile() throws -> String { + let fileURL = try #require( + Bundle.module.url(forResource: "twic1618-short", withExtension: "pgn") + ) + + return try String(contentsOf: fileURL, encoding: .utf8) + } + + func benchmark( + label: String, + iterations: Int = 3, + operation: () throws -> Int + ) throws -> BenchmarkResult { + var samples: [TimeInterval] = [] + var lastCount = 0 + + for _ in 0..