diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 68d889e..576260c 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -4,9 +4,11 @@ on: pull_request jobs: tests: name: Unit-Tests - runs-on: macos-13 + runs-on: macos-15 steps: - - name: Checkout repository - uses: actions/checkout@v2 - - name: Run unit tests - run: swift test --enable-code-coverage + - name: Checkout repository + uses: actions/checkout@v4 + - name: Print Swift version + run: swift --version + - name: Run unit tests + run: swift test --enable-code-coverage diff --git a/.gitignore b/.gitignore index bb460e7..2a57a82 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ xcuserdata/ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +build +docs \ No newline at end of file diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..03554cf --- /dev/null +++ b/.swiftformat @@ -0,0 +1,9 @@ +--swiftversion 6.0 +--disable blankLinesAroundMark +--disable preferForLoop +--disable wrapMultilineStatementBraces +--commas inline +--ifdef outdent +--importgrouping testable-last +--indent 2 +--propertytypes explicit diff --git a/.swiftlint.yml b/.swiftlint.yml index d1844de..9df01c9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,26 @@ disabled_rules: + - comment_spacing + - cyclomatic_complexity + - force_cast + - force_try + - function_body_length - identifier_name - - trailing_comma + - file_length + - leading_whitespace + - static_over_final_class + - type_name + - unused_enumerated -cyclomatic_complexity: 50 -function_body_length: 80 +line_length: + warning: 110 + +nesting: + function_level: 1 + type_level: 3 + +trailing_whitespace: + severity: error + +excluded: + - .build + - Package.swift diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..1a8991c --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,98 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Build", + "type": "shell", + "command": "swift build", + "problemMatcher": [], + "presentation": { + "echo": false, + "showReuseMessage": false, + "clear": true + } + }, + { + "label": "Build all", + "type": "shell", + "command": "swift build --build-tests", + "problemMatcher": [], + "presentation": { + "echo": false, + "showReuseMessage": false, + "clear": true + } + }, + { + "label": "Test", + "type": "shell", + "command": "swift test", + "problemMatcher": [], + "presentation": { + "echo": false, + "showReuseMessage": false, + "clear": true + } + }, + { + "label": "Format all files", + "type": "shell", + "command": "swiftformat -v .", + "problemMatcher": [], + "presentation": { + "echo": false, + "showReuseMessage": false, + "clear": true + } + }, + { + "label": "Lint all files", + "type": "shell", + "command": "swiftlint .", + "problemMatcher": [], + "presentation": { + "echo": false, + "showReuseMessage": false, + } + }, + { + "label": "Format and lint all files", + "problemMatcher": [], + "dependsOn": [ + "Format all files", + "Lint all files" + ], + "dependsOrder": "sequence" + }, + { + "label": "Format current file", + "type": "shell", + "command": "swiftformat -v ${file}", + "problemMatcher": [], + "presentation": { + "echo": false, + "showReuseMessage": false, + "clear": true + } + }, + { + "label": "Lint current file", + "type": "shell", + "command": "swiftlint ${file}", + "problemMatcher": [], + "presentation": { + "echo": false, + "showReuseMessage": false + } + }, + { + "label": "Format and lint current file", + "problemMatcher": [], + "dependsOn": [ + "Format current file", + "Lint current file" + ], + "dependsOrder": "sequence" + } + ] +} diff --git a/Package.swift b/Package.swift index 38bc12d..4509d38 100644 --- a/Package.swift +++ b/Package.swift @@ -1,19 +1,26 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "ChessCore", + platforms: [ + .iOS(.v13), + .macOS(.v10_15) + ], products: [ .library( name: "ChessCore", - targets: ["ChessCore"]), + targets: ["ChessCore"] + ) ], targets: [ .target( name: "ChessCore", - dependencies: []), + dependencies: [] + ), .testTarget( name: "ChessCoreTests", - dependencies: ["ChessCore"]), + dependencies: ["ChessCore"] + ) ] ) diff --git a/README.md b/README.md index 39befd4..5b7054d 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,13 @@ -# Chess +# ChessCore -Chess Swift package +A Swift chess engine. -## Example +## Requirements -### Interactive command line program -```swift -var game = Game() -print(game) +- Swift 6+ +- iOS 13+ / macOS 10.15+ -while !game.isGameOver { - print("\n?", terminator: " ") - guard let line = readLine() else { continue } - do { - try game.move(line) - print(game) - } catch { - print(error.localizedDescription) - } -} -``` +## Examples -#### Output -``` -White to move. - - 8 r n b q k b n r - 7 p p p p p p p p - 6 - 5 - 4 - 3 - 2 P P P P P P P P - 1 R N B Q K B N R - a b c d e f g h - -? f3 -1. f3 - -Black to move. - - 8 r n b q k b n r - 7 p p p p p p p p - 6 - 5 - 4 - 3 P - 2 P P P P P P P - 1 R N B Q K B N R - a b c d e f g h - -? e6 -1. f3 e6 - -White to move. - - 8 r n b q k b n r - 7 p p p p p p p - 6 p - 5 - 4 - 3 P - 2 P P P P P P P - 1 R N B Q K B N R - a b c d e f g h - -? g4 -1. f3 e6 -2. g4 - -Black to move. - - 8 r n b q k b n r - 7 p p p p p p p - 6 p - 5 - 4 P - 3 P - 2 P P P P P P - 1 R N B Q K B N R - a b c d e f g h - -? Qh4# -1. f3 e6 -2. g4 Qh4# - -Black wins. - - 8 r n b k b n r - 7 p p p p p p p - 6 p - 5 - 4 P q - 3 P - 2 P P P P P P - 1 R N B Q K B N R - a b c d e f g h -``` +- [Chess](https://github.com/iliaskarim/Chess) — command-line demo +- [ChessUI](https://github.com/iliaskarim/chessui) — SwiftUI interface diff --git a/Sources/ChessCore/Board.swift b/Sources/ChessCore/Board.swift new file mode 100644 index 0000000..725d60a --- /dev/null +++ b/Sources/ChessCore/Board.swift @@ -0,0 +1,508 @@ +private extension Move { + static func ~= (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.castling(lhsCastling), .castling(rhsCastling)): + lhsCastling == rhsCastling + + case let (.translation(lhsTranslation), .translation(rhsTranslation)): + lhsTranslation ~= rhsTranslation + + default: + false + } + } + + func transforms(for piece: Piece, from square: Square) -> [Board.Transform] { + switch self { + case let .translation(translation): + [{ board in + let targetSquare = translation.targetSquare + + var pieces = board.pieces.filter { key, _ in key != square } + .merging([ + targetSquare: .init( + color: piece.color, + figure: translation.promotion ?? piece.figure + ) + ]) { _, new in new } + + if piece.figure == .pawn, + targetSquare == board.enPassantSquare + piece.color.forwardUnitVector { + pieces = pieces.filter { key, _ in key != board.enPassantSquare } + } + + return .init( + pieces: pieces, + enPassantSquare: piece.figure == .pawn && abs(targetSquare.rank.rawValue - square.rank.rawValue) == 2 ? + targetSquare + : nil + ) + }] + + case let .castling(castling): + // First transform: place the king on rook target square. Legality is + // checked after each step so the king cannot castle through attack. + [{ board in + .init(pieces: board.pieces.filter { key, _ in key != square } + .merging([ + castling.rookTargetSquare(for: piece.color): piece + ]) { _, new in new }) + }, { board in + .init(pieces: board.pieces.filter { key, _ in key != castling.rookSquare(for: piece.color) } + .merging([ + castling.kingTargetSquare(for: piece.color): piece, + castling.rookTargetSquare(for: piece.color): .init(color: piece.color, figure: .rook) + ]) { _, new in new }) + }] + } + } +} + +private extension Move.Castling { + func kingTargetSquare(for color: Piece.Color) -> Square { + .init(file: self == .kingside ? .g : .c, rank: color.backRank) + } + + func requiredEmptySquares(for color: Piece.Color) -> [Square] { + (self == .kingside ? [.f, .g] : [.b, .c, .d]).map { file in + .init(file: file, rank: color.backRank) + } + } + + func rookSquare(for color: Piece.Color) -> Square { + .init(file: self == .kingside ? .h : .a, rank: color.backRank) + } + + func rookTargetSquare(for color: Piece.Color) -> Square { + .init(file: self == .kingside ? .f : .d, rank: color.backRank) + } +} + +private extension Move.Translation { + static func ~= (lhs: Self, rhs: Self) -> Bool { + if let lhsFile = lhs.disambiguationFile, let rhsFile = rhs.disambiguationFile, lhsFile != rhsFile { + return false + } + + if let lhsRank = lhs.disambiguationRank, let rhsRank = rhs.disambiguationRank, lhsRank != rhsRank { + return false + } + + return lhs.figure == rhs.figure + && lhs.isCapture == rhs.isCapture + && lhs.targetSquare == rhs.targetSquare + && lhs.promotion == rhs.promotion + } +} + +private extension Piece { + var files: [Square.File] { + switch figure { + case .king: + [.e] + + case .queen: + [.d] + + case .rook: + [.a, .h] + + case .bishop: + [.c, .f] + + case .knight: + [.b, .g] + + case .pawn: + Square.File.allCases + } + } + + var rank: Square.Rank { + switch figure { + case .pawn: + color.pawnRank + + default: + color.backRank + } + } + + func paths(from square: Square, isCapture: Bool) -> [[Square]] { + switch (figure, isCapture, square.rank) { + case (.king, _, _): + square.singleStepPaths(for: .cardinalUnitVectors + .diagonalUnitVectors) + + case (.queen, _, _): + square.linearPaths(for: .cardinalUnitVectors + .diagonalUnitVectors) + + case (.rook, _, _): + square.linearPaths(for: .cardinalUnitVectors) + + case (.bishop, _, _): + square.linearPaths(for: .diagonalUnitVectors) + + case (.knight, _, _): + square.singleStepPaths(for: [-2, -1, 1, 2].flatMap { files in + [-2, -1, 1, 2].filter { ranks in + abs(files) != abs(ranks) + } + .map { ranks in + .init(files: files, ranks: ranks) + } + }) + + case (.pawn, false, color.pawnRank): + [[color.pawnSinglePushTargetRank, color.pawnDoublePushTargetRank].map { rank in + .init(file: square.file, rank: rank) + }] + + case (.pawn, false, _): + square.singleStepPaths(for: [color.forwardUnitVector]) + + case (.pawn, true, _): + square.singleStepPaths(for: [-1, 1].map { files in + .init(files: files, ranks: color.forwardUnitVector.ranks) + }) + } + } + + func promotions(for targetSquare: Square) -> [Piece.Figure?] { + (figure == .pawn && targetSquare.rank == color.opposite.backRank) ? + [.queen, .rook, .bishop, .knight] + : [nil] + } +} + +private extension Square { + func linearPaths(for vectors: [Board.Vector]) -> [[Self]] { + vectors.compactMap(linearPath) + } + + func singleStepPaths(for vectors: [Board.Vector]) -> [[Self]] { + vectors.compactMap { vector in + (self + vector).map { square in + [square] + } + } + } + + private func linearPath(for vector: Board.Vector) -> [Self] { + (self + vector).map { square in + [square] + square.linearPath(for: vector) + } ?? [] + } +} + +private extension Square? { + static func + (lhs: Self, rhs: Board.Vector) -> Self { + guard let lhs, + let file = Wrapped.File(rawValue: lhs.file.rawValue + rhs.files), + let rank = Wrapped.Rank(rawValue: lhs.rank.rawValue + rhs.ranks) else { + return nil + } + + return .init(file: file, rank: rank) + } +} + +/// An 8x8 chessboard where each square may hold a piece. +/// +/// A board represents a position and provides move generation plus status +/// evaluation for checks, checkmates, and stalemates. +public struct Board { + /// A board's status. + /// + /// A position is in check, is checkmate, or is stalemate. + public enum Status { + /// The side to move is in check. + case check + + /// The side to move is checkmated. + case checkmate + + /// The side to move has no legal moves and is not in check. + case stalemate + } + + struct Vector { + let files: Int + + let ranks: Int + } + + fileprivate typealias Transform = (Board) -> (Board) + + /// Standard chess starting position. + /// + /// White pieces start on ranks 1 and 2. Black pieces start on ranks 7 and 8. + public static var board: Board { + .init(pieces: .init(uniqueKeysWithValues: Piece.Color.allCases.flatMap { color in + Piece.Figure.allCases.map { figure in + Piece(color: color, figure: figure) + } + } + .flatMap { piece in + piece.files.map { file in + (.init(file: file, rank: piece.rank), piece) + } + })) + } + + /// Retrieves the piece located at a specific square on the board. + /// + /// - Parameters: + /// - square: The square on the board whose piece to retrieve. + /// - Returns: The piece at the given square, or `nil` if the square is empty. + public subscript(square: Square) -> Piece? { + pieces[square] + } + + /// Board status. + public var status: Status? { + switch (isInCheck, isNoMovePossible) { + case (true, false): + .check + + case (true, true): + .checkmate + + case (false, true): + .stalemate + + default: + nil + } + } + + weak var dataSource: BoardDataSource? + + fileprivate let enPassantSquare: Square? + + fileprivate let pieces: [Square: Piece] + + private var isInCheck: Bool { + pieces.contains { square, piece in + translations(for: piece, from: square, isCapture: true).contains { translation in + pieces[translation.targetSquare] == .init(color: toMove, figure: .king) + } + } + } + + private var isNoMovePossible: Bool { + !pieces.contains { square, piece in + !legalMoves(for: piece, from: square).isEmpty + } + } + + private var toMove: Piece.Color { + dataSource?.toMove ?? .white + } + + /// Retrieves the moves that can be made from a specific square on the board. + /// + /// - Parameters: + /// - square: The square on the board from which to retrieve possible moves. + /// - Returns: A dictionary whose keys are each destination square and whose + /// values are the moves that end on that square. + public func moves(from square: Square) -> [Square: [Move]] { + self[square].map { piece in + .init(grouping: legalMoves(for: piece, from: square)) { move in + switch move { + case let .translation(translation): + translation.targetSquare + + case let .castling(castling): + castling.kingTargetSquare(for: toMove) + } + } + } ?? [:] + } + + func applying(move: Move) throws -> Self { + let matchingMoves = flatMap { square, piece in + moves(for: piece, from: square, isCapture: move.isCapture) + .filter { candidate in + candidate ~= move + } + .map { candidate in + (candidate, piece, square) + } + } + + guard matchingMoves.count < 2 else { + throw MoveError.ambiguousMove(candidates: matchingMoves.map(\.0)) + } + + guard let (_, piece, square) = matchingMoves.first, + let transformedBoard = applying(move: move, for: piece, from: square) else { + throw MoveError.illegalMove + } + + return transformedBoard + } + + func applying(move: Move, for piece: Piece, from square: Square) -> Self? { + move.transforms(for: piece, from: square).reduce(self) { board, transform in + var transformedBoard = board.map(transform) + transformedBoard?.dataSource = dataSource + guard let transformedBoard, !transformedBoard.isInCheck else { + return nil + } + + return transformedBoard + } + } + + init(pieces: [Square: Piece], enPassantSquare: Square? = nil) { + self.pieces = pieces + self.enPassantSquare = enPassantSquare + } + + private func hasPieceMoved(_ piece: Piece, from square: Square) -> Bool { + dataSource?.hasPieceMoved(piece, from: square) ?? false + } + + private func legalMoves(for piece: Piece, from square: Square) -> [Move] { + ( + moves(for: piece, from: square, isCapture: false) + + moves(for: piece, from: square, isCapture: true) + ) + .filter { move in + applying(move: move, for: piece, from: square) != nil + } + } + + private func moves(for piece: Piece, from square: Square, isCapture: Bool) -> [Move] { + guard piece.color == toMove else { + return [] + } + + let moves = translations(for: piece, from: square, isCapture: isCapture).map(Move.translation) + + guard piece == .init(color: toMove, figure: .king), + square == .init(file: .e, rank: toMove.backRank), + !hasPieceMoved(piece, from: square), + !isCapture, + !isInCheck else { + return moves + } + + return moves + Move.Castling.allCases.filter { castling in + let rookSquare = castling.rookSquare(for: toMove) + let rook = Piece(color: toMove, figure: .rook) + return pieces[rookSquare] == rook + && !castling.requiredEmptySquares(for: toMove).contains(where: pieces.keys.contains) + && !hasPieceMoved(rook, from: rookSquare) + } + .map(Move.castling) + } + + private func translations(for piece: Piece, from square: Square, isCapture: Bool) -> [Move.Translation] { + piece.paths(from: square, isCapture: isCapture).flatMap { path in + let obstruction = path.enumerated().first { _, square in + pieces.keys.contains(square) + } + + guard isCapture else { + return path.prefix(upTo: obstruction?.0 ?? path.endIndex) + } + + guard let enPassantSquare, + let first = path.first, + piece.figure == .pawn, + first == enPassantSquare + piece.color.forwardUnitVector else { + guard let obstruction, pieces[obstruction.1]!.color != piece.color else { + return [] + } + + return path[obstruction.0 ..< obstruction.0 + 1] + } + + return [first] + } + .flatMap { targetSquare in + piece.promotions(for: targetSquare).map { promotion in + .init( + figure: piece.figure, + targetSquare: targetSquare, + isCapture: isCapture, + promotion: promotion, + disambiguationFile: square.file, + disambiguationRank: square.rank + ) + } + } + } +} + +extension Board: Collection { + public typealias Index = [Square: Piece].Index + + public subscript(position: Index) -> (key: Square, value: Piece) { + pieces[position] + } + + public var endIndex: Index { + pieces.endIndex + } + + public var startIndex: Index { + pieces.startIndex + } + + public func index(after i: Index) -> Index { + pieces.index(after: i) + } +} + +extension Board: CustomDebugStringConvertible { + public var debugDescription: String { + Square.Rank.allCases.reversed().map { rank in + "\(rank) ".appending(Square.File.allCases.map { file in + pieces[.init(file: file, rank: rank)]?.debugDescription ?? " " + } + .joined(separator: " ")) + } + .joined(separator: "\n") + .appending("\n \(Square.File.allCases.map(\.description).joined(separator: " "))") + } +} + +extension Board: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (Square, Piece)...) { + self.init(pieces: .init(uniqueKeysWithValues: elements)) + } +} + +extension Board.Status: CustomStringConvertible { + public var description: String { + switch self { + case .check: + .check + + case .checkmate: + .checkmate + + case .stalemate: + .stalemate + } + } +} + +private extension [Board.Vector] { + static let cardinalUnitVectors: Self = [-1, 0, 1].flatMap { files in + [-1, 0, 1].filter { ranks in + abs(files) != abs(ranks) + } + .map { ranks in + .init(files: files, ranks: ranks) + } + } + + static let diagonalUnitVectors: Self = [-1, 1].flatMap { files in + [-1, 1].map { ranks in + .init(files: files, ranks: ranks) + } + } +} diff --git a/Sources/ChessCore/Errors/GameStateError.swift b/Sources/ChessCore/Errors/GameStateError.swift new file mode 100644 index 0000000..ef35509 --- /dev/null +++ b/Sources/ChessCore/Errors/GameStateError.swift @@ -0,0 +1,5 @@ +/// Errors related to game state validation. +public enum GameStateError: Error, Equatable { + /// The operation cannot be performed because the game is over. + case gameOver +} diff --git a/Sources/ChessCore/Errors/MoveError.swift b/Sources/ChessCore/Errors/MoveError.swift new file mode 100644 index 0000000..68cc702 --- /dev/null +++ b/Sources/ChessCore/Errors/MoveError.swift @@ -0,0 +1,8 @@ +/// Errors related to move validation. +public enum MoveError: Error, Equatable { + /// More than one legal move matches the requested move. + case ambiguousMove(candidates: [Move]) + + /// No legal move matches the requested move. + case illegalMove +} diff --git a/Sources/ChessCore/Extensions/String+Display.swift b/Sources/ChessCore/Extensions/String+Display.swift new file mode 100644 index 0000000..5de576e --- /dev/null +++ b/Sources/ChessCore/Extensions/String+Display.swift @@ -0,0 +1,17 @@ +extension String { + static let agreement = "agreement" + + static let check = "Check" + + static let checkmate = "Checkmate" + + static let drawBy = "Draw by" + + static let fiftyMoveRule = "fifty-move rule" + + static let stalemate = "Stalemate" + + static let toMove = "to move" + + static let wins = "wins" +} diff --git a/Sources/ChessCore/Extensions/String+Notation.swift b/Sources/ChessCore/Extensions/String+Notation.swift new file mode 100644 index 0000000..1efad35 --- /dev/null +++ b/Sources/ChessCore/Extensions/String+Notation.swift @@ -0,0 +1,22 @@ +public extension String { + /// Standard result notation indicating a Black win. + static let blackVictoryNotation = "0-1" + + /// SAN marker used in move notation for captures. + static let captureNotation = "x" + + /// SAN notation for kingside castling. + static let castlingKingsideNotation = "O-O" + + /// SAN notation for queenside castling. + static let castlingQueensideNotation = "O-O-O" + + /// Standard result notation indicating a drawn game. + static let drawNotation = "1/2-1/2" + + /// SAN marker used in move notation for promotions. + static let promotionNotation = "=" + + /// Standard result notation indicating a White win. + static let whiteVictoryNotation = "1-0" +} diff --git a/Sources/ChessCore/Game.swift b/Sources/ChessCore/Game.swift new file mode 100644 index 0000000..14b697f --- /dev/null +++ b/Sources/ChessCore/Game.swift @@ -0,0 +1,199 @@ +import Combine +import Foundation + +private extension Board.Status { + var punctuation: Turn.Punctuation? { + switch self { + case .check: + .check + + case .checkmate: + .checkmate + + default: + nil + } + } +} + +private extension Move { + var isPawnMove: Bool { + switch self { + case let .translation(translation): + translation.figure == .pawn + + case .castling: + false + } + } +} + +/// A chess game. +/// +/// A game owns the current board position and applies moves while tracking game +/// status such as whose turn it is and whether the game has ended. +public class Game: BoardDataSource, ObservableObject { + /// A game's status. + /// + /// A game is either in progress, won, or drawn. + public enum Status: Equatable { + /// A draw result. + /// + /// Games are drawn by agreement, the fifty-move rule, or stalemate. + public enum Draw { + /// The players agreed to a draw. + case byAgreement + + /// Fifty consecutive moves were played without a pawn move or capture. + case byFiftyMoveRule + + /// The side to move has no legal moves and is not in check. + case byStalemate + } + + /// A player has won the game, with the winning color and whether the win + /// was by resignation or checkmate. + case winner(Piece.Color, isByResignation: Bool) + + /// The game ended in a draw. + case draw(Draw) + + /// The game is in progress, with the side to move. + case toMove(Piece.Color) + } + + /// Game board. + @Published public private(set) var board: Board + + /// Game status. + public var status: Status { + switch (turns.last, board.status) { + case let (.end(.some(victor)), _): + .winner(victor, isByResignation: true) + + case (.end(nil), _): + .draw(.byAgreement) + + case (_, .checkmate): + .winner(toMove.opposite, isByResignation: false) + + case (_, .stalemate): + .draw(.byStalemate) + + default: + if turns.count < 100 || turns.suffix(100).contains(where: { turn in + switch turn { + case let .move(move, _): + move.isPawnMove || move.isCapture + + default: + false + } + }) { + .toMove(toMove) + } else { + .draw(.byFiftyMoveRule) + } + } + } + + var toMove: Piece.Color { + turns.count.isMultiple(of: 2) ? .white : .black + } + + private var turns = [Turn]() + + /// Ends the game. + /// + /// - Parameters: + /// - victor: The winning color, if any. Pass `nil` for a draw. + /// - Throws: `GameStateError.gameOver` if the game is already over. + public func endGame(victor: Piece.Color?) throws { + guard case .toMove = status else { + throw GameStateError.gameOver + } + + turns += [.end(victor: victor)] + } + + /// Makes a move. + /// + /// - Parameters: + /// - move: The move. + /// - Throws: `GameStateError.gameOver` if the game is already over, + /// `MoveError.ambiguousMove(candidates:)` if multiple moves match the + /// input, or `MoveError.illegalMove` if no legal matching move exists. + public func play(_ move: Move) throws { + guard case .toMove = status else { + throw GameStateError.gameOver + } + + board = try board.applying(move: move) + turns += [.move(move, punctuation: nil)] + + // Calculate the punctuation after adding the move to turns played. + turns[turns.indices.last!] = .move(move, punctuation: board.status?.punctuation) + } + + func hasPieceMoved(_ piece: Piece, from square: Square) -> Bool { + board[square] != piece || turns.contains { turn in + switch turn { + case let .move(.translation(translation), _): + translation.targetSquare == square + + default: + false + } + } + } + + /// Creates a game from a board. + /// + /// - Parameters: + /// - board: The game board. + public init(board: Board = .board) { + self.board = board + self.board.dataSource = self + } +} + +extension Game: CustomDebugStringConvertible { + public var debugDescription: String { + stride(from: 0, to: turns.count, by: 2).map { i in + "\(i / 2 + 1). \(turns[i])".appending(turns.count > i + 1 ? " \(turns[i + 1])" : "") + } + .joined(separator: "\n") + .appending(turns.isEmpty ? "" : "\n\n") + .appending("\(status)\n\n\(board)") + } +} + +extension Game.Status: CustomStringConvertible { + public var description: String { + switch self { + case let .winner(color, _): + "\(color) \(String.wins)." + + case let .draw(draw): + "\(draw)" + + case let .toMove(color): + "\(color) \(String.toMove)." + } + } +} + +extension Game.Status.Draw: CustomStringConvertible { + public var description: String { + switch self { + case .byAgreement: + "\(String.drawBy) \(String.agreement)." + + case .byFiftyMoveRule: + "\(String.drawBy) \(String.fiftyMoveRule)." + + case .byStalemate: + "\(String.drawBy) \(String.stalemate.lowercased())." + } + } +} diff --git a/Sources/ChessCore/Models/Board.swift b/Sources/ChessCore/Models/Board.swift deleted file mode 100644 index 6fac5f2..0000000 --- a/Sources/ChessCore/Models/Board.swift +++ /dev/null @@ -1,23 +0,0 @@ - -// MARK: - Board - -/// A model representing a chess board. -/// -/// Chess boards consist of black and white figures arranged on an eight-by-eight grid. -public struct Board { - typealias Mutation = (originSquare: Square, targetSquare: Square, promotion: Piece.Figure?) - - let enPassant: Square? - - var moves = [Notation]() - - let pieces: [Square: Piece] - - let squaresTouched: [Square] - - init(pieces: [Square : Piece], enPassant: Square? = nil, squaresTouched: [Square] = []) { - self.pieces = pieces - self.enPassant = enPassant - self.squaresTouched = squaresTouched - } -} diff --git a/Sources/ChessCore/Models/Errors/IllegalMove.swift b/Sources/ChessCore/Models/Errors/IllegalMove.swift deleted file mode 100644 index 3f47a59..0000000 --- a/Sources/ChessCore/Models/Errors/IllegalMove.swift +++ /dev/null @@ -1,16 +0,0 @@ - -enum IllegalMove: Error { - enum CannotCastle { - case inCheck - case obstructed - case pieceMoved(figure: Piece.Figure) - case pieceOutOfPosition(figure: Piece.Figure) - } - - case cannotCastle(_: CannotCastle) - case cannotMoveIntoCheck - case cannotPromoteToFigure - case figureCannotPromote - case figureMustPromote - case mustReachEndOfBoardToPromote -} diff --git a/Sources/ChessCore/Models/Errors/InvalidNotation.swift b/Sources/ChessCore/Models/Errors/InvalidNotation.swift deleted file mode 100644 index c434c92..0000000 --- a/Sources/ChessCore/Models/Errors/InvalidNotation.swift +++ /dev/null @@ -1,14 +0,0 @@ - -enum InvalidNotation: Error { - enum BadPunctuation { - case isCheck - case isCheckmate - case isNotCheck - case isNotCheckmate - } - - case ambiguous - case badMove - case badPunctuation(_: BadPunctuation) - case unparseable(notation: String) -} diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift deleted file mode 100644 index 739226b..0000000 --- a/Sources/ChessCore/Models/Game.swift +++ /dev/null @@ -1,518 +0,0 @@ -import Foundation - -// MARK: - -public extension Board { - static var board: Board { - let allPieces = Piece.Color.allCases.flatMap { color in - Piece.Figure.allCases.map { figure in - Piece(color: color, figure: figure) - } - } - - return Board(pieces: allPieces.reduce(into: .init()) { pieces, piece in - let files: [Square.File] - switch piece.figure { - case .bishop: - files = [.c, .f] - - case .king: - files = [.e] - - case .knight: - files = [.b, .g] - - case .pawn: - files = Square.File.allCases - - case .queen: - files = [.d] - - case .rook: - files = [.a, .h] - } - - pieces = files.reduce(into: pieces) { pieces, file in - let rank: Square.Rank - if case piece.figure = .pawn { - rank = piece.color == .black ? .seven : .two - } else { - rank = piece.color == .black ? .eight : .one - } - pieces[.init(file: file, rank: rank)] = piece - } - }) - } -} - -private extension Board { - var isCheckmate: Bool { - isCheck(color: moveColor) && isNoMovePossible - } - - var isNoMovePossible: Bool { - !pieces.filter { _, piece in - piece.color == moveColor - }.flatMap { square, _ in - (moves(from: square, isCapture: false) + moves(from: square, isCapture: true)).map { targetSquare in - Mutation(originSquare: square, targetSquare: targetSquare, promotion: nil) - } - }.contains { mutation in - mutatedBoard(mutations: [mutation]) != nil - } - } - - var moveColor: Piece.Color { - moves.count.isMultiple(of: 2) ? .white : .black - } - - func isCheck(color: Piece.Color) -> Bool { - pieces.flatMap { square, _ in - moves(from: square, isCapture: true) - }.contains { targetSquare in - pieces[targetSquare] == .init(color: color, figure: .king) - } - } - - func moves(from originSquare: Square, isCapture: Bool) -> [Square] { - let piece = pieces[originSquare]! - let paths = isCapture ? piece.capturePaths(from: originSquare) : piece.movePaths(from: originSquare) - return paths.flatMap { path in - let obstruction = path.enumerated().first { _, square in - pieces[square] != nil - } - - // Non-capture moves can move a piece up to the first obstruction in its path or the end of the path if its unobstructed. - guard isCapture else { - return path.prefix(upTo: obstruction?.0 ?? path.endIndex) - } - - guard let obstruction, pieces[obstruction.1]!.color != piece.color else { - guard let enPassant, piece.figure == .pawn, path.first == enPassant + piece.forwardUnitVector else { - return [] - } - - // En passant captures are the only captures where the captured piece is not in the capture path. - return [path.first!] - } - - // All other captures take the first opposing piece in the path. - return path[obstruction.0 ..< obstruction.0 + 1] - } - } - - func mutatedBoard(mutations: [Mutation]) -> Self? { - mutations.reduce(self) { board, mutation in - guard let board else { - return nil - } - - // Replace pieces. - let piece = board.pieces[mutation.originSquare]! - var pieces = board.pieces - pieces[mutation.originSquare] = nil - if let enPassant = board.enPassant, piece.figure == .pawn, mutation.targetSquare == enPassant + piece.forwardUnitVector { - pieces[enPassant] = nil - } - pieces[mutation.targetSquare] = Piece(color: piece.color, figure: mutation.promotion ?? piece.figure) - - // Check for pawns that can be captured en passant. - let enPassant: Square? - if piece.figure == .pawn, abs(mutation.originSquare.rank.rawValue - mutation.targetSquare.rank.rawValue) == 2 { - enPassant = mutation.targetSquare - } else { - enPassant = nil - } - - // Update touched squares. - let squaresTouched = squaresTouched + (squaresTouched.contains(mutation.targetSquare) ? [] : [mutation.targetSquare]) - let mutatedBoard = Board(pieces: pieces, enPassant: enPassant, squaresTouched: squaresTouched) - - // Forbid moving into check. - guard !mutatedBoard.isCheck(color: moveColor) else { - return nil - } - - return mutatedBoard - } - } -} - -extension Board: CustomStringConvertible { - public var description: String { - let state: String - if case let .end(victor) = moves.last { - switch victor { - case let .some(color): - state = "\(color.description.capitalized) \(String.wins)" - case nil: - state = .drawGame - } - } else if isCheckmate { - state = "\((moveColor == .black ? Piece.Color.white : Piece.Color.black).description.capitalized) \(String.wins)" - } else if isNoMovePossible { - state = .drawGame - } else { - state = "\(moveColor.description.capitalized) \(String.toMove)" - } - - let grid = Square.Rank.allCases.reversed().map { rank in - " \(rank) ".appending(Square.File.allCases.map { file in - pieces[Square(file: file, rank: rank)]?.description ?? " " - }.joined(separator: " ")) - }.joined(separator: "\n") - .appending("\n ") - .appending(Square.File.allCases.map(\.description).joined(separator: " ")) - - return "\(state).\n\n\(grid)" - } -} - -// MARK: - -extension Notation: CustomStringConvertible { - var description: String { - switch self { - case let .end(victor): - guard let victor else { - return .draw - } - return victor == .black ? .blackVictory : .whiteVictory - - case let .gameplay(gameplay): - return "\(gameplay)" - } - } -} - -extension Notation.Gameplay: CustomStringConvertible { - var description: String { - "\(play)\(punctuation?.description ?? "")" - } -} - -extension Notation.Gameplay.Play: CustomStringConvertible { - var description: String { - switch self { - case let .castle(castle): - switch castle { - case .long: - return .castleLong - - case .short: - return .castleShort - } - - case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): - let disambiguation = "\(disambiguationFile?.description ?? "")\(disambiguationRank?.description ?? "")" - let promotion = promotion.map(\.description).map(String.promotion.appending) ?? "" - return "\(figure)\(disambiguation)\(isCapture ? .capture : "")\(targetSquare)\(promotion)" - } - } -} - -extension Notation.Gameplay.Punctuation: CustomStringConvertible { - var description: String { - rawValue - } -} - -// MARK: - -private extension Piece { - var forwardUnitVector: Vector { - Vector(ranks: color == .black ? -1 : 1) - } - - func capturePaths(from square: Square) -> [[Square]] { - guard .pawn == figure else { - return movePaths(from: square) - } - - return [Vector(files: -1, ranks: forwardUnitVector.ranks), - Vector(files: 1, ranks: forwardUnitVector.ranks)].compactMap { vector in - (square + vector).map { targetSquare in - [targetSquare] - } - } - } - - func movePaths(from square: Square) -> [[Square]] { - switch figure { - case .bishop: - return Vector.diagonalUnitVectors.compactMap(square.allSquaresInDirection) - - case .king: - return Vector.unitVectors.compactMap { vector in - (square + vector).map { targetSquare in - [targetSquare] - } - } - - case .knight: - return [Vector(files: -2, ranks: -1), - Vector(files: -2, ranks: 1), - Vector(files: -1, ranks: -2), - Vector(files: -1, ranks: 2), - Vector(files: 1, ranks: -2), - Vector(files: 1, ranks: 2), - Vector(files: 2, ranks: -1), - Vector(files: 2, ranks: 1)].compactMap { vector in - (square + vector).map { targetSquare in - [targetSquare] - } - } - - case .pawn: - let oneSquareForward = (square + forwardUnitVector)! - let isOnStartRank = square.rank == .two || square.rank == .seven - return [[oneSquareForward, isOnStartRank ? oneSquareForward + forwardUnitVector : nil].compactMap { targetSquare in - targetSquare - }] - - case .queen: - return Vector.unitVectors.compactMap(square.allSquaresInDirection) - - case .rook: - return Vector.cardinalUnitVectors.compactMap(square.allSquaresInDirection) - } - } -} - -extension Piece: CustomStringConvertible { - public var description: String { - let description = figure == .pawn ? "P" : figure.rawValue - return color == .black ? description.lowercased() : description - } -} - -extension Piece.Color: CustomStringConvertible { - public var description: String { - rawValue - } -} - -extension Piece.Figure: CustomStringConvertible { - public var description: String { - rawValue - } -} - -// MARK: - -private extension Square { - static func + (lhs: Self, rhs: Vector) -> Self? { - guard let file = File(rawValue: lhs.file.rawValue + rhs.files), let rank = Rank(rawValue: lhs.rank.rawValue + rhs.ranks) else { - return nil - } - return .init(file: file, rank: rank) - } - - func allSquaresInDirection(_ direction: Vector) -> [Self] { - (self + direction).map { squareInDirection in - [squareInDirection] + squareInDirection.allSquaresInDirection(direction) - } ?? [] - } -} - -extension Square: CustomStringConvertible { - public var description: String { - "\(file)\(rank)" - } -} - -extension Square.File: CustomStringConvertible { - var description: String { - String(Character(UnicodeScalar(rawValue + 96)!)) - } -} - -extension Square.Rank: CustomStringConvertible { - var description: String { - String(rawValue) - } -} - -// MARK: - -extension Vector { - static let cardinalUnitVectors: [Vector] = [ - .init(files: -1, ranks: 0), - .init(files: 0, ranks: -1), - .init(files: 0, ranks: 1), - .init(files: 1, ranks: 0) - ] - - static let diagonalUnitVectors: [Vector] = [ - .init(files: -1, ranks: -1), - .init(files: -1, ranks: 1), - .init(files: 1, ranks: -1), - .init(files: 1, ranks: 1) - ] - - static let unitVectors = cardinalUnitVectors + diagonalUnitVectors -} - -// MARK: - Game - -/// A model representing a chess game. -/// -/// Chess is a board game played between two players. -public struct Game { - /// Game state - public var isGameOver: Bool { - guard case .end = board.moves.last else { - return board.isNoMovePossible - } - return true - } - - /// Game board - private(set) var board: Board - - /// Move - /// - Parameter notation: Notation - public mutating func move(_ notationString: String) throws { - guard let notation = Notation(string: notationString) else { - throw InvalidNotation.unparseable(notation: notationString) - } - - guard case let .gameplay(gameplay) = notation else { - board.moves += [notation] - return - } - - let mutations: [Board.Mutation] - - switch gameplay.play { - case let .castle(castle): - guard !board.isCheck(color: board.moveColor) else { - throw IllegalMove.cannotCastle(.inCheck) - } - - let rank: Square.Rank = board.moveColor == .black ? .eight : .one - guard !(castle == .long ? [.b, .c, .d] : [.f, .g]).map({ file in - Square(file: file, rank: rank) - }).contains(where: board.pieces.keys.contains) else { - throw IllegalMove.cannotCastle(.obstructed) - } - - let kingOriginSquare = Square(file: .e, rank: rank) - let rookOriginSquare = Square(file: castle == .long ? .a : .h, rank: rank) - for (figure, square) in [Piece.Figure.king: kingOriginSquare, Piece.Figure.rook: rookOriginSquare] { - guard board.pieces[square] == .init(color: board.moveColor, figure: figure) else { - throw IllegalMove.cannotCastle(.pieceOutOfPosition(figure: figure)) - } - - guard !board.squaresTouched.contains(square) else { - throw IllegalMove.cannotCastle(.pieceMoved(figure: figure)) - } - } - - let rookTargetSquare = Square(file: castle == .long ? .d : .f, rank: rank) - mutations = [ - (originSquare: kingOriginSquare, targetSquare: rookTargetSquare, promotion: nil), - (originSquare: rookTargetSquare, targetSquare: .init(file: castle == .long ?.c : .g, rank: rank), promotion: nil), - (originSquare: rookOriginSquare, targetSquare: rookTargetSquare, promotion: nil) - ] - - case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): - let eligibleSquares: [Square] = board.pieces.compactMap { square, piece in - guard piece == .init(color: board.moveColor, figure: figure) else { - return nil - } - - if let disambiguationFile, square.file != disambiguationFile { - return nil - } - - if let disambiguationRank, square.rank != disambiguationRank { - return nil - } - - return board.moves(from: square, isCapture: isCapture).contains(targetSquare) ? square : nil - } - - guard let originSquare = eligibleSquares.first else { - throw InvalidNotation.badMove - } - - guard eligibleSquares.count == 1 else { - throw InvalidNotation.ambiguous - } - - // Pawns must be promoted when they reach the end of the board. - let promotionRank: Square.Rank = board.moveColor == .black ? .one : .eight - guard promotion != nil || figure != .pawn || targetSquare.rank != promotionRank else { - throw IllegalMove.figureMustPromote - } - - if let promotion { - // Only pawns can be promoted - guard figure == .pawn else { - throw IllegalMove.figureCannotPromote - } - - // only when they reach the end of the board - guard targetSquare.rank == promotionRank else { - throw IllegalMove.mustReachEndOfBoardToPromote - } - - // and they must be promoted to bishop, knight, rook or queen. - guard ![Piece.Figure.king, Piece.Figure.pawn].contains(promotion) else { - throw IllegalMove.cannotPromoteToFigure - } - } - - mutations = [(originSquare: originSquare, targetSquare: targetSquare, promotion: promotion)] - } - - guard var mutatedBoard = board.mutatedBoard(mutations: mutations) else { - throw IllegalMove.cannotMoveIntoCheck - } - - mutatedBoard.moves = self.board.moves + [notation] - - // Compute game state. - let isCheck = mutatedBoard.isCheck(color: mutatedBoard.moveColor) - let isCheckmate = mutatedBoard.isCheckmate - - // Validate punctuation parsed from input notation. - switch gameplay.punctuation { - case .check: - guard isCheck else { - throw InvalidNotation.badPunctuation(.isNotCheck) - } - guard !isCheckmate else { - throw InvalidNotation.badPunctuation(.isCheckmate) - } - - case .checkmate: - guard isCheckmate else { - throw InvalidNotation.badPunctuation(.isNotCheckmate) - } - - case .none: - guard !isCheckmate else { - throw InvalidNotation.badPunctuation(.isCheckmate) - } - guard !isCheck else { - throw InvalidNotation.badPunctuation(.isCheck) - } - } - - self.board = mutatedBoard - } - - /// Designated initializer - public init(board: Board = .board) { - self.board = board - } -} - -extension Game: CustomStringConvertible { - public var description: String { - let moves = board.moves.isEmpty ? "" : stride(from: 0, to: board.moves.count, by: 2).map { i in - "\(i / 2 + 1). " - .appending(board.moves[i].description) - .appending(board.moves.count > i + 1 ? " \(board.moves[i + 1])" : "") - }.joined(separator: "\n") - - return "\(moves)\n\n\(board)" - } -} diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift deleted file mode 100644 index 1a01967..0000000 --- a/Sources/ChessCore/Models/Notation.swift +++ /dev/null @@ -1,128 +0,0 @@ - -// MARK: - Notation - -enum Notation { - struct Gameplay { - enum Play { - enum Castle { - case long - case short - } - - case castle(castle: Castle) - case translation(disambiguationFile: Square.File?, disambiguationRank: Square.Rank?, figure: Piece.Figure, - isCapture: Bool, promotion: Piece.Figure?, targetSquare: Square) - } - - enum Punctuation: String { - case check = "+" - case checkmate = "#" - - fileprivate init?(_ character: Character) { - self.init(rawValue: String(character)) - } - } - - let play: Play - let punctuation: Punctuation? - - init(play: Play, punctuation: Punctuation?) { - self.play = play - self.punctuation = punctuation - } - - fileprivate init?(_ string: String) { - var string = string - - punctuation = string.last.map(Punctuation.init) ?? nil - if punctuation != nil { - string = String(string.dropLast()) - } - - switch string { - case .castleLong: - play = .castle(castle: .long) - - case .castleShort: - play = .castle(castle: .short) - - default: - let figure = (string.first.map(Piece.Figure.init) ?? nil) ?? .pawn - if figure != .pawn { - string = String(string.dropFirst()) - } - - let isCapture = string.contains(String.capture) - string = string.replacingOccurrences(of: String.capture, with: "") - - let promotion = string.last.map(Piece.Figure.init) ?? nil - if promotion != nil { - string = String(string.dropLast()) - guard string.last == Character(String.promotion) else { - return nil - } - string = String(string.dropLast()) - } - - let disambiguationFile: Square.File? - let disambiguationRank: Square.Rank? - if string.count == 4 { - guard let file = string.first.map(Square.File.init) ?? nil else { - return nil - } - disambiguationFile = file - string = String(string.dropFirst()) - - guard let rank = string.first.map(Square.Rank.init) ?? nil else { - return nil - } - disambiguationRank = rank - string = String(string.dropFirst()) - } else if string.count == 3 { - if let file = string.first.map(Square.File.init) ?? nil { - disambiguationFile = file - disambiguationRank = nil - } else if let rank = string.first.map(Square.Rank.init) ?? nil { - disambiguationFile = nil - disambiguationRank = rank - } else { - return nil - } - string = String(string.dropFirst()) - } else { - disambiguationFile = nil - disambiguationRank = nil - } - - guard let targetSquare = Square(String(string)) else { - return nil - } - - play = .translation(disambiguationFile: disambiguationFile, disambiguationRank: disambiguationRank, figure: figure, - isCapture: isCapture, promotion: promotion, targetSquare: targetSquare) - } - } - } - - case end(victor: Piece.Color?) - case gameplay(_ gameplay: Gameplay) - - init?(string: String) { - switch string { - case .whiteVictory: - self = .end(victor: .white) - - case .blackVictory: - self = .end(victor: .black) - - case .draw: - self = .end(victor: nil) - - default: - guard let gameplay = Gameplay(string) else { - return nil - } - self = .gameplay(gameplay) - } - } -} diff --git a/Sources/ChessCore/Models/Piece.swift b/Sources/ChessCore/Models/Piece.swift deleted file mode 100644 index 8750815..0000000 --- a/Sources/ChessCore/Models/Piece.swift +++ /dev/null @@ -1,46 +0,0 @@ - -/// A model representing a chess piece. -public struct Piece: Equatable { - /// Color - public enum Color: String, CaseIterable { - case white - case black - } - - /// Figure - public enum Figure: String, CaseIterable { - case bishop = "B" - case king = "K" - case knight = "N" - case pawn = "" - case queen = "Q" - case rook = "R" - - init?(_ character: Character) { - self.init(rawValue: String(character)) - } - } - - /// Color - let color: Color - - /// Figure - let figure: Figure - - public init(color: Color, figure: Figure) { - self.color = color - self.figure = figure - } -} - -extension Piece.Color { - var opposite: Self { - switch self { - case .white: - return .black - - case .black: - return .white - } - } -} diff --git a/Sources/ChessCore/Models/Square.swift b/Sources/ChessCore/Models/Square.swift deleted file mode 100644 index d04ecb4..0000000 --- a/Sources/ChessCore/Models/Square.swift +++ /dev/null @@ -1,47 +0,0 @@ - -// MARK: - Square - -/// A model representing a square on a chess board. -public struct Square: Hashable { - /// File - enum File: Int, CaseIterable { - case a = 1, b, c, d, e, f, g, h - - internal init?(_ character: Character) { - guard let ascii = character.asciiValue else { - return nil - } - self.init(rawValue: Int(ascii) - 96) - } - } - - /// Rank - enum Rank: Int, CaseIterable { - case one = 1, two, three, four, five, six, seven, eight - - internal init?(_ character: Character) { - guard let int = Int(String(character)) else { - return nil - } - self.init(rawValue: int) - } - } - - internal let file: File - - internal let rank: Rank - - /// Designated initializer - init(file: File, rank: Rank) { - self.file = file - self.rank = rank - } - - /// Convenience initializer - public init?(_ string: String) { - guard let file = string.first.map(File.init) ?? nil, let rank = string.last.map(Rank.init) ?? nil, string.count == 2 else { - return nil - } - self.init(file: file, rank: rank) - } -} diff --git a/Sources/ChessCore/Models/Vector.swift b/Sources/ChessCore/Models/Vector.swift deleted file mode 100644 index e6a9fa9..0000000 --- a/Sources/ChessCore/Models/Vector.swift +++ /dev/null @@ -1,16 +0,0 @@ - -// MARK: - Vector - -struct Vector { - /// File translation - let files: Int - - /// Rank translation - let ranks: Int - - /// Designated initializer - init(files: Int = 0, ranks: Int = 0) { - self.files = files - self.ranks = ranks - } -} diff --git a/Sources/ChessCore/Move.swift b/Sources/ChessCore/Move.swift new file mode 100644 index 0000000..5078936 --- /dev/null +++ b/Sources/ChessCore/Move.swift @@ -0,0 +1,126 @@ +/// A chess move. +/// +/// Each move is either a ``Castling`` (castling move) or a ``Translation`` +/// (non-castling move). Castling moves record whether castling is kingside or +/// queenside. Non-castling moves record the moving figure, the destination +/// square, whether the move captures, an optional promotion figure, and +/// optional algebraic disambiguation. +public enum Move: Hashable, Sendable { + /// A castling move. + /// + /// Castling is either kingside or queenside. + public enum Castling: CaseIterable, Sendable { + /// Kingside (O-O) castling. + case kingside + + /// Queenside (O-O-O) castling. + case queenside + } + + /// A non-castling move. + /// + /// Details include the moving figure, the square the figure moves to, whether + /// the move captures, an optional promotion figure, and optional algebraic + /// disambiguation. + public struct Translation: Hashable, Sendable { + /// Moving figure. + public let figure: Piece.Figure + + /// Square the figure moves to. + public let targetSquare: Square + + /// Whether the move captures. + public let isCapture: Bool + + /// Figure to promote to, if any. + public let promotion: Piece.Figure? + + /// Disambiguation file, if any. + public let disambiguationFile: Square.File? + + /// Disambiguation rank, if any. + public let disambiguationRank: Square.Rank? + + /// Creates a translation describing a non-castling move. + /// + /// - Parameters: + /// - figure: The moving figure. + /// - targetSquare: The square the figure moves to. + /// - isCapture: Whether the move captures. Defaults to `false`. + /// - promotion: The figure to promote to, if any. Defaults to `nil`. + /// - disambiguationFile: The disambiguation file, if any. Defaults to `nil`. + /// - disambiguationRank: The disambiguation rank, if any. Defaults to `nil`. + public init( + figure: Piece.Figure, + targetSquare: Square, + isCapture: Bool = false, + promotion: Piece.Figure? = nil, + disambiguationFile: Square.File? = nil, + disambiguationRank: Square.Rank? = nil + ) { + self.figure = figure + self.isCapture = isCapture + self.promotion = promotion + self.disambiguationFile = disambiguationFile + self.disambiguationRank = disambiguationRank + self.targetSquare = targetSquare + } + } + + /// The move is castling. + case castling(_ castling: Castling) + + /// The move is a non-castling move. + case translation(_ translation: Translation) + + var isCapture: Bool { + switch self { + case let .translation(translation): + translation.isCapture + + case .castling: + false + } + } +} + +extension Move: CustomStringConvertible { + public var description: String { + switch self { + case let .castling(castling): + "\(castling)" + + case let .translation(translation): + "\(translation)" + } + } +} + +extension Move.Castling: CustomStringConvertible { + public var description: String { + switch self { + case .kingside: + .castlingKingsideNotation + + case .queenside: + .castlingQueensideNotation + } + } +} + +extension Move.Translation: CustomStringConvertible { + public var description: String { + figure.rawValue.appending(disambiguationDescription) + .appending(isCapture ? .captureNotation : "") + .appending(targetSquare.description) + .appending(promotionDescription) + } + + private var disambiguationDescription: String { + "\(disambiguationFile?.description ?? "")\(disambiguationRank?.description ?? "")" + } + + private var promotionDescription: String { + promotion.map(\.rawValue).map(String.promotionNotation.appending) ?? "" + } +} diff --git a/Sources/ChessCore/Piece.swift b/Sources/ChessCore/Piece.swift new file mode 100644 index 0000000..b4e379f --- /dev/null +++ b/Sources/ChessCore/Piece.swift @@ -0,0 +1,87 @@ +/// A chess piece. +/// +/// Each piece is identified by its ``Color`` (side) and ``Figure`` (kind). +public struct Piece: Equatable, Sendable { + /// A piece's color. + /// + /// A color identifies which side a piece belongs to. + public enum Color: String, CaseIterable, Sendable { + case white, black + + /// Opposing piece color. + public var opposite: Self { + self == .white ? .black : .white + } + + var backRank: Square.Rank { + self == .white ? .one : .eight + } + + var forwardUnitVector: Board.Vector { + .init(files: 0, ranks: self == .white ? 1 : -1) + } + + var pawnDoublePushTargetRank: Square.Rank { + .init(rawValue: pawnSinglePushTargetRank.rawValue + forwardUnitVector.ranks)! + } + + var pawnSinglePushTargetRank: Square.Rank { + .init(rawValue: pawnRank.rawValue + forwardUnitVector.ranks)! + } + + var pawnRank: Square.Rank { + .init(rawValue: backRank.rawValue + forwardUnitVector.ranks)! + } + } + + /// A piece's figure. + /// + /// A figure identifies the kind of chess piece. + public enum Figure: String, CaseIterable, Sendable { + case king = "K" + + case queen = "Q" + + case rook = "R" + + case bishop = "B" + + case knight = "N" + + case pawn = "" + } + + /// Piece color. + public let color: Color + + /// Piece figure. + public let figure: Figure + + /// Creates a piece from a color and figure. + /// + /// - Parameters: + /// - color: The piece's color. + /// - figure: The piece's figure. + public init(color: Color, figure: Figure) { + self.color = color + self.figure = figure + } +} + +extension Piece: CustomDebugStringConvertible { + public var debugDescription: String { + color == .white ? figure.debugDescription : figure.debugDescription.lowercased() + } +} + +extension Piece.Color: CustomStringConvertible { + public var description: String { + rawValue.capitalized + } +} + +extension Piece.Figure: CustomDebugStringConvertible { + public var debugDescription: String { + self == .pawn ? "P" : rawValue + } +} diff --git a/Sources/ChessCore/Protocols/BoardDataSource.swift b/Sources/ChessCore/Protocols/BoardDataSource.swift new file mode 100644 index 0000000..e026f40 --- /dev/null +++ b/Sources/ChessCore/Protocols/BoardDataSource.swift @@ -0,0 +1,5 @@ +protocol BoardDataSource: AnyObject { + var toMove: Piece.Color { get } + + func hasPieceMoved(_ piece: Piece, from square: Square) -> Bool +} diff --git a/Sources/ChessCore/Square.swift b/Sources/ChessCore/Square.swift new file mode 100644 index 0000000..38c445e --- /dev/null +++ b/Sources/ChessCore/Square.swift @@ -0,0 +1,56 @@ +/// A square on a chessboard. +/// +/// Each square is identified by its ``File`` (column) and ``Rank`` (row). +public struct Square: Hashable, Sendable { + /// A square's file. + /// + /// A file identifies a square's column on the board, labeled from `a` to `h`. + public enum File: Int, CaseIterable, Sendable { + case a = 1 + + case b, c, d, e, f, g, h + } + + /// A square's rank. + /// + /// A rank identifies a square's row on the board, numbered from `1` to `8`. + public enum Rank: Int, CaseIterable, Sendable { + case one = 1 + + case two, three, four, five, six, seven, eight + } + + /// Square file. + public let file: File + + /// Square rank. + public let rank: Rank + + /// Creates a square from a file and rank. + /// + /// - Parameters: + /// - file: The square's file. + /// - rank: The square's rank. + public init(file: File, rank: Rank) { + self.file = file + self.rank = rank + } +} + +extension Square: CustomStringConvertible { + public var description: String { + "\(file)\(rank)" + } +} + +extension Square.File: CustomStringConvertible { + public var description: String { + String(Character(UnicodeScalar(rawValue + 96)!)) + } +} + +extension Square.Rank: CustomStringConvertible { + public var description: String { + String(rawValue) + } +} diff --git a/Sources/ChessCore/String.swift b/Sources/ChessCore/String.swift deleted file mode 100644 index 291e38f..0000000 --- a/Sources/ChessCore/String.swift +++ /dev/null @@ -1,14 +0,0 @@ - -extension String { - static let blackVictory = "0-1" - static let capture = "x" - static let castleLong = "O-O-O" - static let castleShort = "O-O" - static let draw = "1/2-1/2" - static let drawGame = "Draw game" - static let promotion = "=" - static let stalemate = "Stalemate" - static let toMove = "to move" - static let whiteVictory = "1-0" - static let wins = "wins" -} diff --git a/Sources/ChessCore/Turn.swift b/Sources/ChessCore/Turn.swift new file mode 100644 index 0000000..f3663db --- /dev/null +++ b/Sources/ChessCore/Turn.swift @@ -0,0 +1,38 @@ +enum Turn { + enum Punctuation: String { + case check = "+" + + case checkmate = "#" + } + + case end(victor: Piece.Color?) + + case move(_ move: Move, punctuation: Punctuation?) +} + +extension Turn: CustomStringConvertible { + var description: String { + switch self { + case let .end(victor): + switch victor { + case .black: + .blackVictoryNotation + + case .white: + .whiteVictoryNotation + + case .none: + .drawNotation + } + + case let .move(move, punctuation): + "\(move)\(punctuation?.description ?? "")" + } + } +} + +extension Turn.Punctuation: CustomStringConvertible { + var description: String { + rawValue + } +} diff --git a/Tests/ChessCoreTests/BoardTests.swift b/Tests/ChessCoreTests/BoardTests.swift new file mode 100644 index 0000000..bbf65ec --- /dev/null +++ b/Tests/ChessCoreTests/BoardTests.swift @@ -0,0 +1,331 @@ +import Testing +@testable import ChessCore + +struct BoardTests { + private class MockBoardDataSource: BoardDataSource { + let toMove: Piece.Color + + func hasPieceMoved(_ _: Piece, from _: Square) -> Bool { + false + } + + init(toMove: Piece.Color = .white) { + self.toMove = toMove + } + } + + @Test + func dataSourceIsWeaklyHeld() { + var board = Board.board + var dataSource: BoardDataSource? = MockBoardDataSource() + + board.dataSource = dataSource + #expect(board.dataSource != nil) + + dataSource = nil + #expect(board.dataSource == nil) + } + + // MARK: - Castling + + @Test + func blackCastlingMoves() { + var board: Board = [ + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .a, rank: .eight): .init(color: .black, figure: .rook), + .init(file: .h, rank: .eight): .init(color: .black, figure: .rook) + ] + let dataSource = MockBoardDataSource(toMove: .black) + board.dataSource = dataSource + + let kingsideCastle = Move.castling(.kingside) + let queensideCastle = Move.castling(.queenside) + let kd8 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .d, rank: .eight), + disambiguationFile: .e, + disambiguationRank: .eight + )) + let kf8 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .f, rank: .eight), + disambiguationFile: .e, + disambiguationRank: .eight + )) + let kd7 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .d, rank: .seven), + disambiguationFile: .e, + disambiguationRank: .eight + )) + let ke7 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .e, rank: .seven), + disambiguationFile: .e, + disambiguationRank: .eight + )) + let kf7 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .f, rank: .seven), + disambiguationFile: .e, + disambiguationRank: .eight + )) + + let actualMoves = board.moves(from: .init(file: .e, rank: .eight)) + #expect(actualMoves.count == 7) + + let actualMovesToG8 = actualMoves[.init(file: .g, rank: .eight)] + #expect(actualMovesToG8 == [kingsideCastle]) + + let actualMovesToC8 = actualMoves[.init(file: .c, rank: .eight)] + #expect(actualMovesToC8 == [queensideCastle]) + + let actualMovesToD8 = actualMoves[.init(file: .d, rank: .eight)] + #expect(actualMovesToD8 == [kd8]) + + let actualMovesToF8 = actualMoves[.init(file: .f, rank: .eight)] + #expect(actualMovesToF8 == [kf8]) + + let actualMovesToD7 = actualMoves[.init(file: .d, rank: .seven)] + #expect(actualMovesToD7 == [kd7]) + + let actualMovesToE7 = actualMoves[.init(file: .e, rank: .seven)] + #expect(actualMovesToE7 == [ke7]) + + let actualMovesToF7 = actualMoves[.init(file: .f, rank: .seven)] + #expect(actualMovesToF7 == [kf7]) + } + + @Test + func whiteCastlingMoves() { + let board: Board = [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook) + ] + + let kingsideCastle = Move.castling(.kingside) + let queensideCastle = Move.castling(.queenside) + let kd1 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .d, rank: .one), + disambiguationFile: .e, + disambiguationRank: .one + )) + let kf1 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .f, rank: .one), + disambiguationFile: .e, + disambiguationRank: .one + )) + let kd2 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .d, rank: .two), + disambiguationFile: .e, + disambiguationRank: .one + )) + let ke2 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .e, rank: .two), + disambiguationFile: .e, + disambiguationRank: .one + )) + let kf2 = Move.translation(.init( + figure: .king, + targetSquare: .init(file: .f, rank: .two), + disambiguationFile: .e, + disambiguationRank: .one + )) + + let actualMoves = board.moves(from: .init(file: .e, rank: .one)) + #expect(actualMoves.count == 7) + + let actualMovesToG1 = actualMoves[.init(file: .g, rank: .one)] + #expect(actualMovesToG1 == [kingsideCastle]) + + let actualMovesToC1 = actualMoves[.init(file: .c, rank: .one)] + #expect(actualMovesToC1 == [queensideCastle]) + + let actualMovesToD1 = actualMoves[.init(file: .d, rank: .one)] + #expect(actualMovesToD1 == [kd1]) + + let actualMovesToF1 = actualMoves[.init(file: .f, rank: .one)] + #expect(actualMovesToF1 == [kf1]) + + let actualMovesToD2 = actualMoves[.init(file: .d, rank: .two)] + #expect(actualMovesToD2 == [kd2]) + + let actualMovesToE2 = actualMoves[.init(file: .e, rank: .two)] + #expect(actualMovesToE2 == [ke2]) + + let actualMovesToF2 = actualMoves[.init(file: .f, rank: .two)] + #expect(actualMovesToF2 == [kf2]) + } + + // MARK: - Initial Position + + @Test + func initialBoardSetup() throws { + let board = Board.board + #expect(board.count == 32) + + let backRank: [Square.File: Piece.Figure] = [ + .a: .rook, .b: .knight, .c: .bishop, .d: .queen, + .e: .king, .f: .bishop, .g: .knight, .h: .rook + ] + + for file in Square.File.allCases { + #expect(try board[.init(file: file, rank: .one)] == .init( + color: .white, + figure: #require(backRank[file]) + )) + + #expect(board[.init(file: file, rank: .two)] == .init( + color: .white, + figure: .pawn + )) + + for rank in [Square.Rank.three, .four, .five, .six] { + #expect(board[.init(file: file, rank: rank)] == nil) + } + + #expect(board[.init(file: file, rank: .seven)] == .init( + color: .black, + figure: .pawn + )) + + #expect(try board[.init(file: file, rank: .eight)] == .init( + color: .black, + figure: #require(backRank[file]) + )) + } + } + + @Test + func initialKnightMovesFromB1() { + let na3 = Move.translation(.init( + figure: .knight, + targetSquare: .init(file: .a, rank: .three), + disambiguationFile: .b, + disambiguationRank: .one + )) + + let nc3 = Move.translation(.init( + figure: .knight, + targetSquare: .init(file: .c, rank: .three), + disambiguationFile: .b, + disambiguationRank: .one + )) + + let actualMoves = Board.board.moves(from: .init(file: .b, rank: .one)) + #expect(actualMoves.count == 2) + + let actualMovesToA3 = actualMoves[.init(file: .a, rank: .three)] + #expect(actualMovesToA3 == [na3]) + + let actualMovesToC3 = actualMoves[.init(file: .c, rank: .three)] + #expect(actualMovesToC3 == [nc3]) + } + + @Test + func initialKnightMovesFromG1() { + let nf3 = Move.translation(.init( + figure: .knight, + targetSquare: .init(file: .f, rank: .three), + disambiguationFile: .g, + disambiguationRank: .one + )) + + let nh3 = Move.translation(.init( + figure: .knight, + targetSquare: .init(file: .h, rank: .three), + disambiguationFile: .g, + disambiguationRank: .one + )) + + let actualMoves = Board.board.moves(from: .init(file: .g, rank: .one)) + #expect(actualMoves.count == 2) + + let actualMovesToF3 = actualMoves[.init(file: .f, rank: .three)] + #expect(actualMovesToF3 == [nf3]) + + let actualMovesToH3 = actualMoves[.init(file: .h, rank: .three)] + #expect(actualMovesToH3 == [nh3]) + } + + @Test + func initialPawnMoves() { + for file in Square.File.allCases { + let expectedMoveToRankThree = Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: file, rank: .three), + disambiguationFile: file, + disambiguationRank: .two + )) + + let expectedMoveToRankFour = Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: file, rank: .four), + disambiguationFile: file, + disambiguationRank: .two + )) + + let actualMoves = Board.board.moves(from: .init(file: file, rank: .two)) + #expect(actualMoves.count == 2) + + let actualMovesToRankThree = actualMoves[.init(file: file, rank: .three)] + #expect(actualMovesToRankThree == [expectedMoveToRankThree]) + + let actualMovesToRankFour = actualMoves[.init(file: file, rank: .four)] + #expect(actualMovesToRankFour == [expectedMoveToRankFour]) + } + } + + // MARK: - Promotion + + @Test + func blackPromotionMoves() { + var board: Board = [ + .init(file: .e, rank: .two): .init(color: .black, figure: .pawn) + ] + let dataSource = MockBoardDataSource(toMove: .black) + board.dataSource = dataSource + + let expectedMovesToE1 = [Piece.Figure.queen, .rook, .bishop, .knight].map { promotion in + Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: .e, rank: .one), + promotion: promotion, + disambiguationFile: .e, + disambiguationRank: .two + )) + } + + let actualMoves = board.moves(from: .init(file: .e, rank: .two)) + let actualMovesToE1 = actualMoves[.init(file: .e, rank: .one)] + + #expect(Set(actualMovesToE1 ?? []) == Set(expectedMovesToE1)) + } + + @Test + func whitePromotionMoves() { + let board: Board = [ + .init(file: .e, rank: .seven): .init(color: .white, figure: .pawn) + ] + + let expectedMovesToE8 = [Piece.Figure.queen, .rook, .bishop, .knight].map { promotion in + Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: .e, rank: .eight), + promotion: promotion, + disambiguationFile: .e, + disambiguationRank: .seven + )) + } + + let actualMoves = board.moves(from: .init(file: .e, rank: .seven)) + let actualMovesToE8 = actualMoves[.init(file: .e, rank: .eight)] + + #expect(Set(actualMovesToE8 ?? []) == Set(expectedMovesToE8)) + } +} diff --git a/Tests/ChessCoreTests/ChessTests.swift b/Tests/ChessCoreTests/ChessTests.swift deleted file mode 100644 index c3a980e..0000000 --- a/Tests/ChessCoreTests/ChessTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -import XCTest -@testable import ChessCore - -final class ChessTests: XCTestCase { - func testFirstMoves() throws { - for move in ["a3", "a4", "b3", "b4", "c3", "c4", "d3", "d4", "e3", "e4", "f3", "f4", "g3", "g4", "h3", "h4"] { - var game = Game() - try game.move(move) - print(game) - } - - for move in ["Na3", "Nc3", "Nf3", "Nh3"] { - var game = Game() - try game.move(move) - print(game) - } - } - - func testScholarsMate() throws { - var game = Game() - try game.move("e4") - try game.move("e5") - try game.move("Qh5") - try game.move("Nc6") - try game.move("Bc4") - try game.move("Nf6") - try game.move("Qxf7#") - print(game) - } - - func testStalemate() throws { - var game = Game(board: .init(pieces: [ - .init("e5")!: .init(color: .white, figure: .king), - .init("e8")!: .init(color: .black, figure: .king), - .init("e7")!: .init(color: .white, figure: .pawn) - ])) - try game.move("Ke6") - print(game) - XCTAssertTrue(game.isGameOver) - } -} diff --git a/Tests/ChessCoreTests/GameTests.swift b/Tests/ChessCoreTests/GameTests.swift new file mode 100644 index 0000000..ea23690 --- /dev/null +++ b/Tests/ChessCoreTests/GameTests.swift @@ -0,0 +1,538 @@ +import Testing +@testable import ChessCore + +struct GameTests { + // MARK: - Castling + + @Test + func castlingIsIllegalAfterKingIsMoved() throws { + let game = Game(board: [ + .init(file: .e, rank: .two): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let ke1 = Move.translation(.init(figure: .king, targetSquare: .init(file: .e, rank: .one))) + try game.play(ke1) + + let kd8 = Move.translation(.init(figure: .king, targetSquare: .init(file: .d, rank: .eight))) + try game.play(kd8) + + let kingsideCastle = Move.castling(.kingside) + #expect(throws: MoveError.illegalMove) { + try game.play(kingsideCastle) + } + + let queensideCastle = Move.castling(.queenside) + #expect(throws: MoveError.illegalMove) { + try game.play(queensideCastle) + } + } + + @Test + func castlingIsIllegalWhenKingIsInCheck() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .e, rank: .seven): .init(color: .black, figure: .rook) + ]) + + let kingsideCastle = Move.castling(.kingside) + #expect(throws: MoveError.illegalMove) { + try game.play(kingsideCastle) + } + + let queensideCastle = Move.castling(.queenside) + #expect(throws: MoveError.illegalMove) { + try game.play(queensideCastle) + } + } + + @Test + func castlingLegalityIsSideSpecificWhenBFileIsBlocked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .b, rank: .one): .init(color: .white, figure: .knight), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let queensideCastle = Move.castling(.queenside) + #expect(throws: MoveError.illegalMove) { + try game.play(queensideCastle) + } + + let kingsideCastle = Move.castling(.kingside) + try game.play(kingsideCastle) + + #expect(game.board[.init(file: .g, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .f, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .h, rank: .one)] == nil) + } + + @Test + func castlingLegalityIsSideSpecificWhenCFileIsAttacked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .c, rank: .eight): .init(color: .black, figure: .rook) + ]) + + let queensideCastle = Move.castling(.queenside) + #expect(throws: MoveError.illegalMove) { + try game.play(queensideCastle) + } + + let kingsideCastle = Move.castling(.kingside) + try game.play(kingsideCastle) + + #expect(game.board[.init(file: .g, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .f, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .h, rank: .one)] == nil) + } + + @Test + func castlingLegalityIsSideSpecificWhenCFileIsBlocked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .c, rank: .one): .init(color: .white, figure: .bishop), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let queensideCastle = Move.castling(.queenside) + #expect(throws: MoveError.illegalMove) { + try game.play(queensideCastle) + } + + let kingsideCastle = Move.castling(.kingside) + try game.play(kingsideCastle) + + #expect(game.board[.init(file: .g, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .f, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .h, rank: .one)] == nil) + } + + @Test + func castlingLegalityIsSideSpecificWhenDFileIsAttacked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .d, rank: .eight): .init(color: .black, figure: .rook) + ]) + + let queensideCastle = Move.castling(.queenside) + #expect(throws: MoveError.illegalMove) { + try game.play(queensideCastle) + } + + let kingsideCastle = Move.castling(.kingside) + try game.play(kingsideCastle) + + #expect(game.board[.init(file: .g, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .f, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .h, rank: .one)] == nil) + } + + @Test + func castlingLegalityIsSideSpecificWhenDFileIsBlocked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .d, rank: .one): .init(color: .white, figure: .queen), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let queensideCastle = Move.castling(.queenside) + #expect(throws: MoveError.illegalMove) { + try game.play(queensideCastle) + } + + let kingsideCastle = Move.castling(.kingside) + try game.play(kingsideCastle) + + #expect(game.board[.init(file: .g, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .f, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .h, rank: .one)] == nil) + } + + @Test + func castlingLegalityIsSideSpecificWhenFFileIsAttacked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .f, rank: .eight): .init(color: .black, figure: .rook) + ]) + + let kingsideCastle = Move.castling(.kingside) + #expect(throws: MoveError.illegalMove) { + try game.play(kingsideCastle) + } + + let queensideCastle = Move.castling(.queenside) + try game.play(queensideCastle) + + #expect(game.board[.init(file: .c, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .d, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .a, rank: .one)] == nil) + } + + @Test + func castlingLegalityIsSideSpecificWhenFFileIsBlocked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .f, rank: .one): .init(color: .white, figure: .bishop), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let kingsideCastle = Move.castling(.kingside) + #expect(throws: MoveError.illegalMove) { + try game.play(kingsideCastle) + } + + let queensideCastle = Move.castling(.queenside) + try game.play(queensideCastle) + + #expect(game.board[.init(file: .c, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .d, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .a, rank: .one)] == nil) + } + + @Test + func castlingLegalityIsSideSpecificWhenGFileIsAttacked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .g, rank: .eight): .init(color: .black, figure: .rook) + ]) + + let kingsideCastle = Move.castling(.kingside) + #expect(throws: MoveError.illegalMove) { + try game.play(kingsideCastle) + } + + let queensideCastle = Move.castling(.queenside) + try game.play(queensideCastle) + + #expect(game.board[.init(file: .c, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .d, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .a, rank: .one)] == nil) + } + + @Test + func castlingLegalityIsSideSpecificWhenGFileIsBlocked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .g, rank: .one): .init(color: .white, figure: .knight), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let kingsideCastle = Move.castling(.kingside) + #expect(throws: MoveError.illegalMove) { + try game.play(kingsideCastle) + } + + let queensideCastle = Move.castling(.queenside) + try game.play(queensideCastle) + + #expect(game.board[.init(file: .c, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .d, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .a, rank: .one)] == nil) + } + + @Test + func castlingRightsAreRookSpecificAfterKingsideRookIsMoved() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .h, rank: .two): .init(color: .white, figure: .rook), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let rh1 = Move.translation(.init(figure: .rook, targetSquare: .init(file: .h, rank: .one))) + try game.play(rh1) + + let kd8 = Move.translation(.init(figure: .king, targetSquare: .init(file: .d, rank: .eight))) + try game.play(kd8) + + let kingsideCastle = Move.castling(.kingside) + #expect(throws: MoveError.illegalMove) { + try game.play(kingsideCastle) + } + + let queensideCastle = Move.castling(.queenside) + try game.play(queensideCastle) + + #expect(game.board[.init(file: .c, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .d, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .a, rank: .one)] == nil) + } + + @Test + func castlingRightsAreRookSpecificAfterQueensideRookIsMoved() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .two): .init(color: .white, figure: .rook), + .init(file: .h, rank: .one): .init(color: .white, figure: .rook), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let ra1 = Move.translation(.init(figure: .rook, targetSquare: .init(file: .a, rank: .one))) + try game.play(ra1) + + let kd8 = Move.translation(.init(figure: .king, targetSquare: .init(file: .d, rank: .eight))) + try game.play(kd8) + + let queensideCastle = Move.castling(.queenside) + #expect(throws: MoveError.illegalMove) { + try game.play(queensideCastle) + } + + let kingsideCastle = Move.castling(.kingside) + try game.play(kingsideCastle) + + #expect(game.board[.init(file: .g, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .f, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .h, rank: .one)] == nil) + } + + @Test + func queensideCastlingIsLegalWhenBFileIsAttacked() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .a, rank: .one): .init(color: .white, figure: .rook), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .b, rank: .eight): .init(color: .black, figure: .rook) + ]) + + let queensideCastle = Move.castling(.queenside) + try game.play(queensideCastle) + + #expect(game.board[.init(file: .c, rank: .one)] == .init(color: .white, figure: .king)) + #expect(game.board[.init(file: .d, rank: .one)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .e, rank: .one)] == nil) + #expect(game.board[.init(file: .a, rank: .one)] == nil) + } + + // MARK: - En Passant + + @Test + func canCaptureEnPassantAfterDoubleStepPawnMove() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .d, rank: .two): .init(color: .white, figure: .pawn), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .e, rank: .four): .init(color: .black, figure: .pawn) + ]) + + let d4 = Move.translation(.init(figure: .pawn, targetSquare: .init(file: .d, rank: .four))) + try game.play(d4) + + let exd3 = Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: .d, rank: .three), + isCapture: true + )) + try game.play(exd3) + + #expect(game.board[.init(file: .d, rank: .three)] == .init(color: .black, figure: .pawn)) + #expect(game.board[.init(file: .d, rank: .four)] == nil) + } + + @Test + func cannotCaptureEnPassantAfterAnotherMove() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .d, rank: .two): .init(color: .white, figure: .pawn), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .e, rank: .five): .init(color: .black, figure: .pawn) + ]) + + let d4 = Move.translation(.init(figure: .pawn, targetSquare: .init(file: .d, rank: .four))) + try game.play(d4) + + let e4 = Move.translation(.init(figure: .pawn, targetSquare: .init(file: .e, rank: .four))) + try game.play(e4) + + let kd1 = Move.translation(.init(figure: .king, targetSquare: .init(file: .d, rank: .one))) + try game.play(kd1) + + let exd3 = Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: .d, rank: .three), + isCapture: true + )) + + #expect(throws: MoveError.illegalMove) { + try game.play(exd3) + } + } + + @Test + func cannotCaptureEnPassantAfterSingleStepPawnMove() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .d, rank: .three): .init(color: .white, figure: .pawn), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .e, rank: .four): .init(color: .black, figure: .pawn) + ]) + + let d4 = Move.translation(.init(figure: .pawn, targetSquare: .init(file: .d, rank: .four))) + try game.play(d4) + + let exd3 = Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: .d, rank: .three), + isCapture: true + )) + + #expect(throws: MoveError.illegalMove) { + try game.play(exd3) + } + } + + // MARK: - Game End + + @Test + func fiftyMoveRule() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .b, rank: .one): .init(color: .white, figure: .knight), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .g, rank: .eight): .init(color: .black, figure: .knight) + ]) + + let na3 = Move.translation(.init(figure: .knight, targetSquare: .init(file: .a, rank: .three))) + let nh6 = Move.translation(.init(figure: .knight, targetSquare: .init(file: .h, rank: .six))) + let nb1 = Move.translation(.init(figure: .knight, targetSquare: .init(file: .b, rank: .one))) + let ng8 = Move.translation(.init(figure: .knight, targetSquare: .init(file: .g, rank: .eight))) + + let moves = [na3, nh6, nb1, ng8] + for ply in 0 ..< 100 { + try game.play(moves[ply % moves.count]) + } + + #expect(game.status == .draw(.byFiftyMoveRule)) + } + + @Test + func scholarsMate() throws { + let game = Game() + + // 1. + let e4 = Move.translation(.init(figure: .pawn, targetSquare: .init(file: .e, rank: .four))) + try game.play(e4) + + let e5 = Move.translation(.init(figure: .pawn, targetSquare: .init(file: .e, rank: .five))) + try game.play(e5) + + // 2. + let qh5 = Move.translation(.init(figure: .queen, targetSquare: .init(file: .h, rank: .five))) + try game.play(qh5) + + let nc6 = Move.translation(.init(figure: .knight, targetSquare: .init(file: .c, rank: .six))) + try game.play(nc6) + + // 3. + let bc4 = Move.translation(.init(figure: .bishop, targetSquare: .init(file: .c, rank: .four))) + try game.play(bc4) + + let nf6 = Move.translation(.init(figure: .knight, targetSquare: .init(file: .f, rank: .six))) + try game.play(nf6) + + // 4. + let qxf7 = Move.translation(.init( + figure: .queen, + targetSquare: .init(file: .f, rank: .seven), + isCapture: true + )) + try game.play(qxf7) + + #expect(game.status == .winner(.white, isByResignation: false)) + } + + @Test + func stalemate() throws { + let game = Game(board: [ + .init(file: .e, rank: .five): .init(color: .white, figure: .king), + .init(file: .e, rank: .seven): .init(color: .white, figure: .pawn), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let ke6 = Move.translation(.init(figure: .king, targetSquare: .init(file: .e, rank: .six))) + try game.play(ke6) + + #expect(game.status == .draw(.byStalemate)) + } + + // MARK: - Promotion + + @Test + func promotionOnCapture() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .g, rank: .seven): .init(color: .white, figure: .pawn), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king), + .init(file: .h, rank: .eight): .init(color: .black, figure: .rook) + ]) + + let gxh8N = Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: .h, rank: .eight), + isCapture: true, + promotion: .knight + )) + try game.play(gxh8N) + + #expect(game.board[.init(file: .h, rank: .eight)] == .init(color: .white, figure: .knight)) + #expect(game.board[.init(file: .g, rank: .seven)] == nil) + } + + @Test + func promotionWithoutCapture() throws { + let game = Game(board: [ + .init(file: .e, rank: .one): .init(color: .white, figure: .king), + .init(file: .g, rank: .seven): .init(color: .white, figure: .pawn), + .init(file: .e, rank: .eight): .init(color: .black, figure: .king) + ]) + + let g8R = Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: .g, rank: .eight), + promotion: .rook + )) + try game.play(g8R) + + #expect(game.board[.init(file: .g, rank: .eight)] == .init(color: .white, figure: .rook)) + #expect(game.board[.init(file: .g, rank: .seven)] == nil) + } +} diff --git a/Tests/ChessCoreTests/MoveTests.swift b/Tests/ChessCoreTests/MoveTests.swift new file mode 100644 index 0000000..a072fbc --- /dev/null +++ b/Tests/ChessCoreTests/MoveTests.swift @@ -0,0 +1,28 @@ +import Testing +@testable import ChessCore + +struct MoveTests { + // MARK: - Is Capture + + @Test + func castlingMoveIsCapture() { + #expect(!Move.castling(.kingside).isCapture) + #expect(!Move.castling(.queenside).isCapture) + } + + @Test + func translationMoveIsCapture() { + let xe5 = Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: .e, rank: .five), + isCapture: true + )) + #expect(xe5.isCapture) + + let e5 = Move.translation(.init( + figure: .pawn, + targetSquare: .init(file: .e, rank: .five) + )) + #expect(!e5.isCapture) + } +} diff --git a/Tests/ChessCoreTests/PieceTests.swift b/Tests/ChessCoreTests/PieceTests.swift new file mode 100644 index 0000000..a37bfe9 --- /dev/null +++ b/Tests/ChessCoreTests/PieceTests.swift @@ -0,0 +1,45 @@ +import Testing +@testable import ChessCore + +struct PieceTests { + // MARK: - Piece.Color + + @Test + func pieceColorBackRank() { + #expect(Piece.Color.white.backRank == .one) + #expect(Piece.Color.black.backRank == .eight) + } + + @Test + func pieceColorForwardUnitVector() { + #expect(Piece.Color.white.forwardUnitVector.files == 0) + #expect(Piece.Color.white.forwardUnitVector.ranks == 1) + + #expect(Piece.Color.black.forwardUnitVector.files == 0) + #expect(Piece.Color.black.forwardUnitVector.ranks == -1) + } + + @Test + func pieceColorOpposite() { + #expect(Piece.Color.white.opposite == .black) + #expect(Piece.Color.black.opposite == .white) + } + + @Test + func pieceColorPawnSinglePushTargetRank() { + #expect(Piece.Color.white.pawnSinglePushTargetRank == .three) + #expect(Piece.Color.black.pawnSinglePushTargetRank == .six) + } + + @Test + func pieceColorPawnDoublePushTargetRank() { + #expect(Piece.Color.white.pawnDoublePushTargetRank == .four) + #expect(Piece.Color.black.pawnDoublePushTargetRank == .five) + } + + @Test + func pieceColorPawnRank() { + #expect(Piece.Color.white.pawnRank == .two) + #expect(Piece.Color.black.pawnRank == .seven) + } +} diff --git a/scripts/build-docs b/scripts/build-docs new file mode 100755 index 0000000..7ae21e2 --- /dev/null +++ b/scripts/build-docs @@ -0,0 +1,21 @@ +#!/bin/bash +set -ex + +# toolchain paths – adjust if Xcode is installed elsewhere +DOCC="/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/docc" + +# derive scheme name from the current directory (assumes project directory +# name matches the product/scheme name). +SCHEME="$(basename "$(pwd)")" + +# clean docs +rm -rf .build build docs + +# build docs +xcodebuild docbuild \ + -scheme "$SCHEME" \ + -destination 'platform=macOS,arch=arm64,name=My Mac' \ + -derivedDataPath build > /dev/null +$DOCC process-archive transform-for-static-hosting \ + --output-path ./docs \ + "./build/Build/Products/Debug/$SCHEME.doccarchive" > /dev/null