From e1c45156797801ace4613e01671bc1a0997b791e Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Mon, 8 Sep 2025 08:52:46 +0000 Subject: [PATCH 01/10] update dotfiles --- .swiftformat | 9 +++++++++ .swiftlint.yml | 26 +++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 .swiftformat 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 From 6a3efeb73abb50c96846f7f307e26a7d1b1491dd Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Mon, 8 Sep 2025 09:13:59 +0000 Subject: [PATCH 02/10] refactor game --- Sources/ChessCore/Models/Board.swift | 7 +- .../ChessCore/Models/Errors/IllegalMove.swift | 16 - .../Models/Errors/InvalidNotation.swift | 4 +- Sources/ChessCore/Models/Game.swift | 572 +++++++++++------- Sources/ChessCore/Models/Notation.swift | 157 ++--- Sources/ChessCore/Models/Piece.swift | 12 - Sources/ChessCore/Models/Square.swift | 19 +- Tests/ChessCoreTests/ChessTests.swift | 42 +- 8 files changed, 459 insertions(+), 370 deletions(-) delete mode 100644 Sources/ChessCore/Models/Errors/IllegalMove.swift diff --git a/Sources/ChessCore/Models/Board.swift b/Sources/ChessCore/Models/Board.swift index 6fac5f2..52dc4e1 100644 --- a/Sources/ChessCore/Models/Board.swift +++ b/Sources/ChessCore/Models/Board.swift @@ -5,7 +5,7 @@ /// /// 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?) + typealias Mutation = (Board) -> (Board) let enPassant: Square? @@ -13,11 +13,8 @@ public struct Board { let pieces: [Square: Piece] - let squaresTouched: [Square] - - init(pieces: [Square : Piece], enPassant: Square? = nil, squaresTouched: [Square] = []) { + init(pieces: [Square : Piece], enPassant: Square? = nil) { 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 index c434c92..14ff7e9 100644 --- a/Sources/ChessCore/Models/Errors/InvalidNotation.swift +++ b/Sources/ChessCore/Models/Errors/InvalidNotation.swift @@ -7,8 +7,8 @@ enum InvalidNotation: Error { case isNotCheckmate } - case ambiguous - case badMove + case ambiguous([String]) case badPunctuation(_: BadPunctuation) + case illegalMove case unparseable(notation: String) } diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift index 739226b..3323398 100644 --- a/Sources/ChessCore/Models/Game.swift +++ b/Sources/ChessCore/Models/Game.swift @@ -1,47 +1,49 @@ import Foundation // MARK: - + public extension Board { static var board: Board { - let allPieces = Piece.Color.allCases.flatMap { color in + Board(pieces: 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 { + }.reduce(into: .init()) { pieces, piece in + let files: [Square.File] = switch piece.figure { case .bishop: - files = [.c, .f] + [.c, .f] case .king: - files = [.e] + [.e] case .knight: - files = [.b, .g] + [.b, .g] case .pawn: - files = Square.File.allCases + Square.File.allCases case .queen: - files = [.d] + [.d] case .rook: - files = [.a, .h] + [.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 + pieces[.init(file: file, rank: piece.startRank)] = piece } }) } + + func moves(from square: Square) -> [Square] { + guard let piece = pieces[square], piece.color == moveColor else { + return [] + } + + let captures = moves(for: piece, from: square, isCapture: true) + let nonCaptures = moves(for: piece, from: square, isCapture: false) + return captures + nonCaptures + } } private extension Board { @@ -52,9 +54,9 @@ private extension Board { 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) + }.flatMap { originSquare, piece in + moves(from: originSquare).map { targetSquare in + mutation(for: piece, from: originSquare, to: targetSquare, promoteTo: nil) } }.contains { mutation in mutatedBoard(mutations: [mutation]) != nil @@ -66,68 +68,158 @@ private extension Board { } func isCheck(color: Piece.Color) -> Bool { - pieces.flatMap { square, _ in - moves(from: square, isCapture: true) + pieces.filter { _, piece in + piece.color == color.opposite + }.flatMap { square, piece in + moves(for: piece, from: square, isCapture: true) }.contains { targetSquare in - pieces[targetSquare] == .init(color: color, figure: .king) + pieces[targetSquare]?.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 + func moves(for piece: Piece, from square: Square, isCapture: Bool) -> [Square] { + let castleMoves: [Square] + + let backRank = moveColor.backRank + let kingSquare = Square(file: .e, rank: backRank) + + if piece.figure == .king, + pieces[kingSquare] == .init(color: moveColor, figure: .king), + !moves.contains(where: { move in + move.description.contains(kingSquare.description) + }), + !isCapture, !isCheck(color: moveColor) { + let longRookSquare = Square(file: .a, rank: backRank) + let castleLong: [Square] = if ![Square(file: .b, rank: backRank), + Square(file: .c, rank: backRank), + Square(file: .d, rank: backRank)].contains(where: pieces.keys.contains), + pieces[longRookSquare] == Piece(color: moveColor, figure: .rook), + !moves.contains(where: { move in + move.description.contains(longRookSquare.description) + }) { + [Square(file: .c, rank: .one)] + } else { + [] + } + + let shortRookSquare = Square(file: .h, rank: backRank) + let castleShort: [Square] = if ![Square(file: .f, rank: backRank), + Square(file: .g, rank: backRank)].contains(where: pieces.keys.contains), + pieces[shortRookSquare] == Piece(color: moveColor, figure: .rook), + !moves.contains(where: { move in + move.description.contains(shortRookSquare.description) + }) { + [Square(file: .g, rank: .one)] + } else { + [] + } + + castleMoves = castleLong + castleShort + } else { + castleMoves = [] + } + + let traditionalMoves = (isCapture ? piece.capturePaths(from: square) : piece.movePaths(from: square)).flatMap { path in let obstruction = path.enumerated().first { _, square in - pieces[square] != nil + pieces.keys.contains(square) } - // Non-capture moves can move a piece up to the first obstruction in its path or the end of the path if its unobstructed. + // Non-capture moves can move a piece up to the first obstruction in its path or the end of the path if it's unobstructed. guard isCapture else { return path.prefix(upTo: obstruction?.0 ?? path.endIndex) } + // En passant captures are the only captures where the captured piece is not in the capture path. 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] } + + return castleMoves + traditionalMoves } - func mutatedBoard(mutations: [Mutation]) -> Self? { - mutations.reduce(self) { board, mutation in - guard let board else { - return nil + func mutations(for piece: Piece, from originSquare: Square, isCapture: Bool) -> [Notation.Play: [Mutation]] { + moves(for: piece, from: originSquare, isCapture: isCapture).reduce(into: [:]) { mutations, targetSquare in + let backRank = piece.color.backRank + if piece.figure == .king, originSquare.file == .e, targetSquare.file == .c || targetSquare.file == .g { + let isCastleLong = targetSquare.file == .c + + let kingOriginSquare = Square(file: .e, rank: backRank) + let kingTargetSquare = Square(file: isCastleLong ? .c : .g, rank: backRank) + let rookOriginSquare = Square(file: isCastleLong ? .a : .h, rank: backRank) + let rookTargetSquare = Square(file: isCastleLong ? .d : .f, rank: backRank) + + // King must move over the rook's target square without moving into check. + mutations[.castle(castle: isCastleLong ? .long : .short)] = [{ board in + var pieces = board.pieces + pieces[kingOriginSquare] = nil + pieces[rookTargetSquare] = .init(color: board.moveColor, figure: .king) + return Board(pieces: pieces) + }, { board in + var pieces = board.pieces + pieces[rookTargetSquare] = nil + pieces[kingTargetSquare] = .init(color: board.moveColor, figure: .king) + return Board(pieces: pieces) + }, { board in + var pieces = board.pieces + pieces[rookOriginSquare] = nil + pieces[rookTargetSquare] = .init(color: board.moveColor, figure: .rook) + return Board(pieces: pieces) + }] + return } - // Replace pieces. - let piece = board.pieces[mutation.originSquare]! + let promotions: [Piece.Figure?] = if piece.figure == .pawn, targetSquare.rank == moveColor.opposite.backRank { + [.bishop, .knight, .queen, .rook] + } else { + [nil] + } + + mutations = promotions.reduce(into: mutations) { mutations, promotion in + mutations[.translation(originFile: originSquare.file, + originRank: originSquare.rank, + figure: piece.figure, + isCapture: isCapture, + promotion: promotion, + targetSquare: targetSquare)] = [mutation(for: piece, from: originSquare, to: targetSquare, promoteTo: promotion)] + } + } + } + + func mutation(for piece: Piece, from originSquare: Square, to targetSquare: Square, promoteTo promotion: Piece.Figure?) -> Mutation { + { board in + // Move piece. var pieces = board.pieces - pieces[mutation.originSquare] = nil - if let enPassant = board.enPassant, piece.figure == .pawn, mutation.targetSquare == enPassant + piece.forwardUnitVector { + pieces[originSquare] = nil + pieces[targetSquare] = .init(color: piece.color, figure: promotion ?? piece.figure) + + // Capture en passant. + if let enPassant = board.enPassant, piece.figure == .pawn, 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 - } + let enPassant = (piece.figure == .pawn && abs(originSquare.rank.rawValue - targetSquare.rank.rawValue) == 2) ? targetSquare : nil + + return Board(pieces: pieces, enPassant: enPassant) + } + } - // Update touched squares. - let squaresTouched = squaresTouched + (squaresTouched.contains(mutation.targetSquare) ? [] : [mutation.targetSquare]) - let mutatedBoard = Board(pieces: pieces, enPassant: enPassant, squaresTouched: squaresTouched) + func mutatedBoard(mutations: [Mutation]) -> Self? { + mutations.reduce(self) { board, mutation in + guard let board else { + return nil + } - // Forbid moving into check. + // To move into check is forbidden. + let mutatedBoard = mutation(board) guard !mutatedBoard.isCheck(color: moveColor) else { return nil } @@ -137,59 +229,81 @@ private extension Board { } } -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)" +extension Board: CustomDebugStringConvertible { + public var debugDescription: String { + let state = switch (moves.last, isCheckmate, isNoMovePossible) { + case (.some(.end(.some)), _, _), (_, true, _): + (moves.last.flatMap { move in + if case let .end(.some(color)) = move { + color + } else { + nil + } + } ?? moveColor.opposite).description.capitalized.appending(" \(String.wins)") + + case (.some(.end(nil)), _, _), (_, _, true): + String.drawGame + + default: + "\(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 ?? " " + pieces[Square(file: file, rank: rank)]?.debugDescription ?? " " }.joined(separator: " ")) - }.joined(separator: "\n") - .appending("\n ") - .appending(Square.File.allCases.map(\.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 + switch victor { + case .black: + .blackVictory + + case .white: + .whiteVictory + + case .none: + .draw } - return victor == .black ? .blackVictory : .whiteVictory - case let .gameplay(gameplay): - return "\(gameplay)" + case let .play(play, punctuation): + "\(play)\(punctuation?.description ?? "")" } } } -extension Notation.Gameplay: CustomStringConvertible { - var description: String { - "\(play)\(punctuation?.description ?? "")" +extension Notation.Play { + static func ~= (lhs: Self, rhs: Self) -> Bool { + guard case let .translation(lhsOriginFile, lhsOriginRank, lhsFigure, lhsIsCapture, lhsPromotion, lhsTargetSquare) = lhs, + case let .translation(rhsOriginFile, rhsOriginRank, rhsFigure, rhsIsCapture, rhsPromotion, rhsTargetSquare) = rhs else { + return lhs == rhs + } + + if let lhsOriginFile, let rhsOriginFile, lhsOriginFile != rhsOriginFile { + return false + } + + if let lhsOriginRank, let rhsOriginRank, lhsOriginRank != rhsOriginRank { + return false + } + + return lhsFigure == rhsFigure + && lhsIsCapture == rhsIsCapture + && lhsTargetSquare == rhsTargetSquare + && lhsPromotion == rhsPromotion } } -extension Notation.Gameplay.Play: CustomStringConvertible { +extension Notation.Play: CustomStringConvertible { var description: String { switch self { case let .castle(castle): @@ -201,28 +315,45 @@ extension Notation.Gameplay.Play: CustomStringConvertible { 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)" + case let .translation(originFile, originRank, figure, isCapture, promotion, targetSquare): + let disambiguation = "\(originFile?.description ?? "")\(originRank?.description ?? "")" + let promotion = promotion.map(\.rawValue).map(String.promotion.appending) ?? "" + return "\(figure.rawValue)\(disambiguation)\(isCapture ? .capture : "")\(targetSquare)\(promotion)" } } } -extension Notation.Gameplay.Punctuation: CustomStringConvertible { +extension Notation.Punctuation: CustomStringConvertible { var description: String { rawValue } } // MARK: - + private extension Piece { var forwardUnitVector: Vector { - Vector(ranks: color == .black ? -1 : 1) + switch color { + case .black: + .init(ranks: -1) + + case .white: + .init(ranks: 1) + } + } + + var startRank: Square.Rank { + switch figure { + case .pawn: + .init(rawValue: color.backRank.rawValue + forwardUnitVector.ranks)! + + default: + .init(rawValue: color.backRank.rawValue)! + } } func capturePaths(from square: Square) -> [[Square]] { - guard .pawn == figure else { + guard figure == .pawn else { return movePaths(from: square) } @@ -237,49 +368,76 @@ private extension Piece { func movePaths(from square: Square) -> [[Square]] { switch figure { case .bishop: - return Vector.diagonalUnitVectors.compactMap(square.allSquaresInDirection) + Vector.diagonalUnitVectors.compactMap(square.allSquaresInDirection) case .king: - return Vector.unitVectors.compactMap { vector in + 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 + [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 - }] + if square.rank == startRank { + [[(square + forwardUnitVector)!], [((square + forwardUnitVector)! + forwardUnitVector)!]] + } else { + [[(square + forwardUnitVector)!]] + } case .queen: - return Vector.unitVectors.compactMap(square.allSquaresInDirection) + Vector.unitVectors.compactMap(square.allSquaresInDirection) case .rook: - return Vector.cardinalUnitVectors.compactMap(square.allSquaresInDirection) + 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: CustomDebugStringConvertible { + public var debugDescription: String { + switch color { + case .black: + figure.debugDescription.lowercased() + + case .white: + figure.debugDescription + } + } +} + +extension Piece.Color { + var backRank: Square.Rank { + switch self { + case .black: + .eight + + case .white: + .one + } + } + + var opposite: Self { + switch self { + case .black: + .white + + case .white: + .black + } } } @@ -289,13 +447,20 @@ extension Piece.Color: CustomStringConvertible { } } -extension Piece.Figure: CustomStringConvertible { - public var description: String { - rawValue +extension Piece.Figure: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .pawn: + "P" + + default: + 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 { @@ -330,15 +495,16 @@ extension Square.Rank: CustomStringConvertible { } // MARK: - + extension Vector { - static let cardinalUnitVectors: [Vector] = [ + static let cardinalUnitVectors: [Self] = [ .init(files: -1, ranks: 0), .init(files: 0, ranks: -1), .init(files: 0, ranks: 1), .init(files: 1, ranks: 0) ] - static let diagonalUnitVectors: [Vector] = [ + static let diagonalUnitVectors: [Self] = [ .init(files: -1, ranks: -1), .init(files: -1, ranks: 1), .init(files: 1, ranks: -1), @@ -366,114 +532,96 @@ public struct Game { 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) + /// - Parameter notation: Move notation + public mutating func move(notation: String) throws { + guard let notation = Notation(notation: notation) else { + throw InvalidNotation.unparseable(notation: notation) } - guard case let .gameplay(gameplay) = notation else { + guard case let .play(play, punctuation) = 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 + let plays = board.pieces.filter { _, piece in + piece.color == board.moveColor + }.enumerated().flatMap { _, element in + let (square, piece) = element + let isCapture = if case let .translation(_, _, _, isCapture, _, _) = play { + isCapture + } else { + false } - - if let promotion { - // Only pawns can be promoted - guard figure == .pawn else { - throw IllegalMove.figureCannotPromote + + let moves = board.moves(for: piece, from: square, isCapture: isCapture) + return moves.reduce(into: [Notation.Play: [Board.Mutation]]()) { mutations, targetSquare in + let backRank = piece.color.backRank + if piece.figure == .king, square.file == .e, targetSquare.file == .c || targetSquare.file == .g { + let isCastleLong = targetSquare.file == .c + + let kingOriginSquare = Square(file: .e, rank: backRank) + let kingTargetSquare = Square(file: isCastleLong ? .c : .g, rank: backRank) + let rookOriginSquare = Square(file: isCastleLong ? .a : .h, rank: backRank) + let rookTargetSquare = Square(file: isCastleLong ? .d : .f, rank: backRank) + + // King must move over the rook's target square without moving into check. + mutations[.castle(castle: isCastleLong ? .long : .short)] = [{ board in + var pieces = board.pieces + pieces[kingOriginSquare] = nil + pieces[rookTargetSquare] = .init(color: board.moveColor, figure: .king) + return Board(pieces: pieces) + }, { board in + var pieces = board.pieces + pieces[rookTargetSquare] = nil + pieces[kingTargetSquare] = .init(color: board.moveColor, figure: .king) + return Board(pieces: pieces) + }, { board in + var pieces = board.pieces + pieces[rookOriginSquare] = nil + pieces[rookTargetSquare] = .init(color: board.moveColor, figure: .rook) + return Board(pieces: pieces) + }] + return } - - // only when they reach the end of the board - guard targetSquare.rank == promotionRank else { - throw IllegalMove.mustReachEndOfBoardToPromote + + let promotions: [Piece.Figure?] = if piece.figure == .pawn, targetSquare.rank == board.moveColor.opposite.backRank { + [.bishop, .knight, .queen, .rook] + } else { + [nil] } - - // 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 = promotions.reduce(into: mutations) { mutations, promotion in + mutations[.translation(originFile: square.file, + originRank: square.rank, + figure: piece.figure, + isCapture: isCapture, + promotion: promotion, + targetSquare: targetSquare)] = [ + board.mutation(for: piece, from: square, to: targetSquare, promoteTo: promotion) + ] } } - - mutations = [(originSquare: originSquare, targetSquare: targetSquare, promotion: promotion)] + + }.filter { key, _ in + key ~= play + } + + guard plays.count < 2 else { + throw InvalidNotation.ambiguous(plays.map(\.key).map(\.description)) } - - guard var mutatedBoard = board.mutatedBoard(mutations: mutations) else { - throw IllegalMove.cannotMoveIntoCheck + + guard let play = plays.first, var mutatedBoard = board.mutatedBoard(mutations: play.value) else { + throw InvalidNotation.illegalMove } - - mutatedBoard.moves = self.board.moves + [notation] - + + mutatedBoard.moves = 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 { + switch punctuation { case .check: guard isCheck else { throw InvalidNotation.badPunctuation(.isNotCheck) @@ -481,22 +629,22 @@ public struct Game { guard !isCheckmate else { throw InvalidNotation.badPunctuation(.isCheckmate) } - + case .checkmate: guard isCheckmate else { throw InvalidNotation.badPunctuation(.isNotCheckmate) } - + case .none: - guard !isCheckmate else { + guard !isCheckmate else { throw InvalidNotation.badPunctuation(.isCheckmate) } guard !isCheck else { throw InvalidNotation.badPunctuation(.isCheck) } } - - self.board = mutatedBoard + + board = mutatedBoard } /// Designated initializer @@ -505,13 +653,13 @@ public struct Game { } } -extension Game: CustomStringConvertible { - public var description: String { +extension Game: CustomDebugStringConvertible { + public var debugDescription: 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") + }.joined(separator: "\n") return "\(moves)\n\n\(board)" } diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift index 1a01967..7553e5d 100644 --- a/Sources/ChessCore/Models/Notation.swift +++ b/Sources/ChessCore/Models/Notation.swift @@ -1,128 +1,99 @@ +import Foundation // 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 Play: Hashable { + enum Castle { + case long + case short } - enum Punctuation: String { - case check = "+" - case checkmate = "#" + case castle(castle: Castle) + case translation(originFile: Square.File?, + originRank: Square.Rank?, + figure: Piece.Figure, + isCapture: Bool, + promotion: Piece.Figure?, + targetSquare: Square) + } - fileprivate init?(_ character: Character) { - self.init(rawValue: String(character)) - } + enum Punctuation: String { + case check = "+" + case checkmate = "#" + + fileprivate init?(_ character: Character) { + self.init(rawValue: String(character)) } + } - let play: Play - let punctuation: Punctuation? + case end(victor: Piece.Color?) + case play(_ play: Play, punctuation: Punctuation?) - init(play: Play, punctuation: Punctuation?) { - self.play = play - self.punctuation = punctuation - } + init?(notation: String) { + switch notation { + case .whiteVictory: + self = .end(victor: .white) - fileprivate init?(_ string: String) { - var string = string + case .blackVictory: + self = .end(victor: .black) - punctuation = string.last.map(Punctuation.init) ?? nil + case .draw: + self = .end(victor: nil) + + default: + var notation = notation + + let punctuation = notation.last.map(Punctuation.init) ?? nil if punctuation != nil { - string = String(string.dropLast()) + notation = String(notation.dropLast()) } - switch string { + switch notation { case .castleLong: - play = .castle(castle: .long) + self = .play(.castle(castle: .long), punctuation: punctuation) case .castleShort: - play = .castle(castle: .short) + self = .play(.castle(castle: .short), punctuation: punctuation) default: - let figure = (string.first.map(Piece.Figure.init) ?? nil) ?? .pawn - if figure != .pawn { - string = String(string.dropFirst()) + let regex = try! NSRegularExpression(pattern: "([KQRBN])?([a-h])?([1-8])?([x])?([a-h][1-8])(?:=([QRBN]))?$") + guard let match = regex.firstMatch(in: notation, range: NSRange(notation.startIndex..., in: notation)) else { + return nil } - let isCapture = string.contains(String.capture) - string = string.replacingOccurrences(of: String.capture, with: "") + let figure = (Range(match.range(at: 1), in: notation).flatMap { range in + Piece.Figure(rawValue: String(notation[range])) + }) ?? .pawn - 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 originFile = Range(match.range(at: 2), in: notation).flatMap { range in + Square.File(String(notation[range]).first!) } - 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 + let originRank = Range(match.range(at: 3), in: notation).flatMap { range in + Square.Rank(rawValue: Int(notation[range])!) } - guard let targetSquare = Square(String(string)) else { + let isCapture = Range(match.range(at: 4), in: notation) != nil + + guard let targetSquare = Range(match.range(at: 5), in: notation).flatMap({ range in + Square(notation: String(notation[range])) + }) 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) + let promotion = Range(match.range(at: 6), in: notation).flatMap { range in + Piece.Figure(rawValue: String(notation[range])) + } - default: - guard let gameplay = Gameplay(string) else { - return nil + self = .play(.translation(originFile: originFile, + originRank: originRank, + figure: figure, + isCapture: isCapture, + promotion: promotion, + targetSquare: targetSquare), + punctuation: punctuation) } - self = .gameplay(gameplay) } } } diff --git a/Sources/ChessCore/Models/Piece.swift b/Sources/ChessCore/Models/Piece.swift index 8750815..40942cb 100644 --- a/Sources/ChessCore/Models/Piece.swift +++ b/Sources/ChessCore/Models/Piece.swift @@ -32,15 +32,3 @@ public struct Piece: Equatable { 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 index d04ecb4..a3e4f53 100644 --- a/Sources/ChessCore/Models/Square.swift +++ b/Sources/ChessCore/Models/Square.swift @@ -7,7 +7,7 @@ public struct Square: Hashable { enum File: Int, CaseIterable { case a = 1, b, c, d, e, f, g, h - internal init?(_ character: Character) { + init?(_ character: Character) { guard let ascii = character.asciiValue else { return nil } @@ -19,7 +19,7 @@ public struct Square: Hashable { enum Rank: Int, CaseIterable { case one = 1, two, three, four, five, six, seven, eight - internal init?(_ character: Character) { + init?(_ character: Character) { guard let int = Int(String(character)) else { return nil } @@ -27,19 +27,20 @@ public struct Square: Hashable { } } - internal let file: File + let file: File + + let rank: Rank - 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 { + public init?(notation: String) { + guard let file = notation.first.map(File.init) ?? nil, let rank = notation.last.map(Rank.init) ?? nil, + notation.count == 2 else { return nil } self.init(file: file, rank: rank) diff --git a/Tests/ChessCoreTests/ChessTests.swift b/Tests/ChessCoreTests/ChessTests.swift index c3a980e..7a581eb 100644 --- a/Tests/ChessCoreTests/ChessTests.swift +++ b/Tests/ChessCoreTests/ChessTests.swift @@ -3,38 +3,38 @@ import XCTest 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) + let game = Game() + #expect([.init(notation: "a3")!, .init(notation: "c3")!].allSatisfy(game.board.moves(from: .init(notation: "b1")!).contains)) + #expect([.init(notation: "f3")!, .init(notation: "h3")!].allSatisfy(game.board.moves(from: .init(notation: "g1")!).contains)) + + for file in Square.File.allCases { + #expect([ + .init(file: file, rank: .three), + .init(file: file, rank: .four) + ].allSatisfy(game.board.moves(from: .init(file: file, rank: .two)).contains)) } } 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#") + try game.move(notation: "e4") + try game.move(notation: "e5") + try game.move(notation: "Qh5") + try game.move(notation: "Nc6") + try game.move(notation: "Bc4") + try game.move(notation: "Nf6") + try game.move(notation: "Qxf7#") print(game) + #expect(game.isGameOver) } 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) + .init(notation: "e5")!: .init(color: .white, figure: .king), + .init(notation: "e8")!: .init(color: .black, figure: .king), + .init(notation: "e7")!: .init(color: .white, figure: .pawn) ])) - try game.move("Ke6") + try game.move(notation: "Ke6") print(game) XCTAssertTrue(game.isGameOver) } From ea6ebb727d65c6e795719304b10f2710db93ac43 Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Mon, 8 Sep 2025 09:52:53 +0000 Subject: [PATCH 03/10] refactor board --- Sources/ChessCore/Models/Board.swift | 298 +++++++- .../Models/Errors/InvalidNotation.swift | 16 +- Sources/ChessCore/Models/Game.swift | 647 +++--------------- Sources/ChessCore/Models/Notation.swift | 47 ++ Sources/ChessCore/Models/Piece.swift | 90 ++- Sources/ChessCore/Models/Square.swift | 47 +- Sources/ChessCore/Models/Vector.swift | 3 - .../ChessCore/Protocols/BoardDataSource.swift | 8 + Sources/ChessCore/String.swift | 1 - Tests/ChessCoreTests/ChessTests.swift | 42 +- 10 files changed, 601 insertions(+), 598 deletions(-) create mode 100644 Sources/ChessCore/Protocols/BoardDataSource.swift diff --git a/Sources/ChessCore/Models/Board.swift b/Sources/ChessCore/Models/Board.swift index 52dc4e1..70e9221 100644 --- a/Sources/ChessCore/Models/Board.swift +++ b/Sources/ChessCore/Models/Board.swift @@ -1,4 +1,95 @@ +// MARK: - +private extension Square { + func allSquaresInDirection(_ direction: Vector) -> [Self] { + switch self + direction { + case let .some(square): + [square] + square.allSquaresInDirection(direction) + + default: + [] + } + } +} + +// MARK: - +private extension Piece { + func capturePaths(from square: Square) -> [[Square]] { + switch figure { + case .pawn: + [Vector(files: -1, ranks: forwardUnitVector.ranks), + Vector(files: 1, ranks: forwardUnitVector.ranks)].compactMap { vector in + (square + vector).map { targetSquare in + [targetSquare] + } + } + + default: + movePaths(from: square) + } + } + + func movePaths(from square: Square) -> [[Square]] { + switch figure { + case .bishop: + Vector.diagonalUnitVectors.compactMap(square.allSquaresInDirection) + + case .king: + Vector.unitVectors.compactMap { vector in + (square + vector).map { targetSquare in + [targetSquare] + } + } + + case .knight: + [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: + if square.rank == startRank { + [[(square + forwardUnitVector)!, ((square + forwardUnitVector)! + forwardUnitVector)!]] + } else { + [[(square + forwardUnitVector)!]] + } + + case .queen: + Vector.unitVectors.compactMap(square.allSquaresInDirection) + + case .rook: + Vector.cardinalUnitVectors.compactMap(square.allSquaresInDirection) + } + } +} + +// MARK: - +private extension Vector { + static let cardinalUnitVectors: [Self] = [ + .init(files: -1, ranks: 0), + .init(files: 0, ranks: -1), + .init(files: 0, ranks: 1), + .init(files: 1, ranks: 0) + ] + + static let diagonalUnitVectors: [Self] = [ + .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: - Board /// A model representing a chess board. @@ -7,14 +98,209 @@ public struct Board { typealias Mutation = (Board) -> (Board) - let enPassant: Square? + public let pieces: [Square: Piece] + + weak var dataSource: BoardDataSource? +} + +public extension Board { + /// Conventional board setup + static var board: Board { + Board(pieces: Piece.Color.allCases.flatMap { color in + Piece.Figure.allCases.map { figure in + Piece(color: color, figure: figure) + } + }.reduce(into: .init()) { pieces, piece in + let files: [Square.File] = switch piece.figure { + case .bishop: + [.c, .f] + + case .king: + [.e] + + case .knight: + [.b, .g] + + case .pawn: + Square.File.allCases + + case .queen: + [.d] + + case .rook: + [.a, .h] + } - var moves = [Notation]() + pieces = files.reduce(into: pieces) { pieces, file in + pieces[.init(file: file, rank: piece.startRank)] = piece + } + }) + } + + /// Possible moves from a square + /// - Parameter square: Source square + /// - Returns: Destination squares + func moves(from square: Square) -> [Square] { + guard let piece = pieces[square], piece.color == moveColor else { + return [] + } + + let captures = moves(for: piece, from: square, isCapture: true) + let nonCaptures = moves(for: piece, from: square, isCapture: false) + return captures + nonCaptures + } +} + +extension Board: CustomDebugStringConvertible { + public var debugDescription: String { + Square.Rank.allCases.reversed().map { rank in + " \(rank) ".appending(Square.File.allCases.map { file in + pieces[Square(file: file, rank: rank)]?.debugDescription ?? " " + }.joined(separator: " ")) + }.joined(separator: "\n").appending("\n ").appending(Square.File.allCases.map(\.description).joined(separator: " ")) + } +} - let pieces: [Square: Piece] +extension Board: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (Square, Piece)...) { + self.init(pieces: .init(uniqueKeysWithValues: elements)) + } +} + +extension Board { + var isCheck: Bool { + pieces.filter { _, piece in + piece.color == moveColor.opposite + }.flatMap { square, piece in + moves(for: piece, from: square, isCapture: true) + }.contains { targetSquare in + pieces[targetSquare]?.figure == .king + } + } + + var isCheckmate: Bool { + isCheck && isNoMovePossible + } + + var isNoMovePossible: Bool { + !pieces.contains { square, piece in + moves(from: square).contains { targetSquare in + mutatedBoard(mutations: [mutation(for: piece, from: square, to: targetSquare, promoteTo: nil)]) != nil + } + } + } + + func moves(for piece: Piece, from square: Square, isCapture: Bool) -> [Square] { + let castleMoves: [Square] + + let backRank = moveColor.backRank + let kingSquare = Square(file: .e, rank: backRank) + + if piece.figure == .king, + hasPieceNotMoved(piece: .init(color: moveColor, figure: .king), square: kingSquare), + !isCapture, + !isCheck { + let longRookSquare = Square(file: .a, rank: backRank) + let castleLong: [Square] = if ![Square(file: .b, rank: backRank), + Square(file: .c, rank: backRank), + Square(file: .d, rank: backRank)].contains(where: pieces.keys.contains), + hasPieceNotMoved(piece: .init(color: moveColor, figure: .rook), square: longRookSquare) { + [Square(file: .c, rank: .one)] + } else { + [] + } + + let shortRookSquare = Square(file: .h, rank: backRank) + let castleShort: [Square] = if ![Square(file: .f, rank: backRank), + Square(file: .g, rank: backRank)].contains(where: pieces.keys.contains), + hasPieceNotMoved(piece: .init(color: moveColor, figure: .rook), square: shortRookSquare) { + [Square(file: .g, rank: .one)] + } else { + [] + } + + castleMoves = castleLong + castleShort + } else { + castleMoves = [] + } + + let traditionalMoves = (isCapture ? piece.capturePaths(from: square) : piece.movePaths(from: square)).flatMap { path in + let obstruction = path.enumerated().first { _, square in + pieces.keys.contains(square) + } + + // Non-capture moves can move a piece up to the first obstruction in its path or the end of the path if it's unobstructed. + guard isCapture else { + return path.prefix(upTo: obstruction?.0 ?? path.endIndex) + } + + // En passant captures are the only captures where the captured piece is not in the capture path. + guard let obstruction, pieces[obstruction.1]!.color != piece.color else { + guard let enPassant, piece.figure == .pawn, path.first == enPassant + piece.forwardUnitVector else { + return [] + } + + return [path.first!] + } + + // All other captures take the first opposing piece in the path. + return path[obstruction.0 ..< obstruction.0 + 1] + } + + return castleMoves + traditionalMoves + } + + func mutation(for piece: Piece, + from originSquare: Square, + to targetSquare: Square, + promoteTo promotion: Piece.Figure?) -> Mutation { + { board in + // Move piece. + var pieces = board.pieces + pieces[originSquare] = nil + pieces[targetSquare] = .init(color: piece.color, figure: promotion ?? piece.figure) + + // Capture en passant. + if let enPassant = board.enPassant, piece.figure == .pawn, targetSquare == enPassant + piece.forwardUnitVector { + pieces[enPassant] = nil + } + + // Check for pawns that can be captured en passant. + let enPassant = (piece.figure == .pawn && abs(originSquare.rank.rawValue - targetSquare.rank.rawValue) == 2) ? targetSquare : nil + dataSource?.enPassant = enPassant + + return Board(pieces: pieces) + } + } + + func mutatedBoard(mutations: [Mutation]) -> Self? { + mutations.reduce(self) { board, mutation in + guard let board else { + return nil + } + + // To move into check is forbidden. + var mutatedBoard = mutation(board) + mutatedBoard.dataSource = dataSource + guard !mutatedBoard.isCheck else { + return nil + } + + return mutatedBoard + } + } +} + +private extension Board { + var enPassant: Square? { + dataSource?.enPassant + } + + var moveColor: Piece.Color { + dataSource?.moveColor ?? .white + } - init(pieces: [Square : Piece], enPassant: Square? = nil) { - self.pieces = pieces - self.enPassant = enPassant + func hasPieceNotMoved(piece: Piece, square: Square) -> Bool { + dataSource?.hasPieceNotMoved(piece: piece, square: square) ?? true } } diff --git a/Sources/ChessCore/Models/Errors/InvalidNotation.swift b/Sources/ChessCore/Models/Errors/InvalidNotation.swift index 14ff7e9..377cf88 100644 --- a/Sources/ChessCore/Models/Errors/InvalidNotation.swift +++ b/Sources/ChessCore/Models/Errors/InvalidNotation.swift @@ -1,14 +1,8 @@ -enum InvalidNotation: Error { - enum BadPunctuation { - case isCheck - case isCheckmate - case isNotCheck - case isNotCheckmate - } - - case ambiguous([String]) - case badPunctuation(_: BadPunctuation) +/// A model representing an invalid notation error. +public enum InvalidNotation: Error { + case ambiguous(candidates: [String]) + case badPunctuation(correctPunctuation: String) case illegalMove - case unparseable(notation: String) + case unparseable } diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift index 3323398..c04dfb1 100644 --- a/Sources/ChessCore/Models/Game.swift +++ b/Sources/ChessCore/Models/Game.swift @@ -1,287 +1,7 @@ import Foundation // MARK: - - -public extension Board { - static var board: Board { - Board(pieces: Piece.Color.allCases.flatMap { color in - Piece.Figure.allCases.map { figure in - Piece(color: color, figure: figure) - } - }.reduce(into: .init()) { pieces, piece in - let files: [Square.File] = switch piece.figure { - case .bishop: - [.c, .f] - - case .king: - [.e] - - case .knight: - [.b, .g] - - case .pawn: - Square.File.allCases - - case .queen: - [.d] - - case .rook: - [.a, .h] - } - - pieces = files.reduce(into: pieces) { pieces, file in - pieces[.init(file: file, rank: piece.startRank)] = piece - } - }) - } - - func moves(from square: Square) -> [Square] { - guard let piece = pieces[square], piece.color == moveColor else { - return [] - } - - let captures = moves(for: piece, from: square, isCapture: true) - let nonCaptures = moves(for: piece, from: square, isCapture: false) - return captures + nonCaptures - } -} - -private extension Board { - var isCheckmate: Bool { - isCheck(color: moveColor) && isNoMovePossible - } - - var isNoMovePossible: Bool { - !pieces.filter { _, piece in - piece.color == moveColor - }.flatMap { originSquare, piece in - moves(from: originSquare).map { targetSquare in - mutation(for: piece, from: originSquare, to: targetSquare, promoteTo: 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.filter { _, piece in - piece.color == color.opposite - }.flatMap { square, piece in - moves(for: piece, from: square, isCapture: true) - }.contains { targetSquare in - pieces[targetSquare]?.figure == .king - } - } - - func moves(for piece: Piece, from square: Square, isCapture: Bool) -> [Square] { - let castleMoves: [Square] - - let backRank = moveColor.backRank - let kingSquare = Square(file: .e, rank: backRank) - - if piece.figure == .king, - pieces[kingSquare] == .init(color: moveColor, figure: .king), - !moves.contains(where: { move in - move.description.contains(kingSquare.description) - }), - !isCapture, !isCheck(color: moveColor) { - let longRookSquare = Square(file: .a, rank: backRank) - let castleLong: [Square] = if ![Square(file: .b, rank: backRank), - Square(file: .c, rank: backRank), - Square(file: .d, rank: backRank)].contains(where: pieces.keys.contains), - pieces[longRookSquare] == Piece(color: moveColor, figure: .rook), - !moves.contains(where: { move in - move.description.contains(longRookSquare.description) - }) { - [Square(file: .c, rank: .one)] - } else { - [] - } - - let shortRookSquare = Square(file: .h, rank: backRank) - let castleShort: [Square] = if ![Square(file: .f, rank: backRank), - Square(file: .g, rank: backRank)].contains(where: pieces.keys.contains), - pieces[shortRookSquare] == Piece(color: moveColor, figure: .rook), - !moves.contains(where: { move in - move.description.contains(shortRookSquare.description) - }) { - [Square(file: .g, rank: .one)] - } else { - [] - } - - castleMoves = castleLong + castleShort - } else { - castleMoves = [] - } - - let traditionalMoves = (isCapture ? piece.capturePaths(from: square) : piece.movePaths(from: square)).flatMap { path in - let obstruction = path.enumerated().first { _, square in - pieces.keys.contains(square) - } - - // Non-capture moves can move a piece up to the first obstruction in its path or the end of the path if it's unobstructed. - guard isCapture else { - return path.prefix(upTo: obstruction?.0 ?? path.endIndex) - } - - // En passant captures are the only captures where the captured piece is not in the capture path. - guard let obstruction, pieces[obstruction.1]!.color != piece.color else { - guard let enPassant, piece.figure == .pawn, path.first == enPassant + piece.forwardUnitVector else { - return [] - } - - return [path.first!] - } - - // All other captures take the first opposing piece in the path. - return path[obstruction.0 ..< obstruction.0 + 1] - } - - return castleMoves + traditionalMoves - } - - func mutations(for piece: Piece, from originSquare: Square, isCapture: Bool) -> [Notation.Play: [Mutation]] { - moves(for: piece, from: originSquare, isCapture: isCapture).reduce(into: [:]) { mutations, targetSquare in - let backRank = piece.color.backRank - if piece.figure == .king, originSquare.file == .e, targetSquare.file == .c || targetSquare.file == .g { - let isCastleLong = targetSquare.file == .c - - let kingOriginSquare = Square(file: .e, rank: backRank) - let kingTargetSquare = Square(file: isCastleLong ? .c : .g, rank: backRank) - let rookOriginSquare = Square(file: isCastleLong ? .a : .h, rank: backRank) - let rookTargetSquare = Square(file: isCastleLong ? .d : .f, rank: backRank) - - // King must move over the rook's target square without moving into check. - mutations[.castle(castle: isCastleLong ? .long : .short)] = [{ board in - var pieces = board.pieces - pieces[kingOriginSquare] = nil - pieces[rookTargetSquare] = .init(color: board.moveColor, figure: .king) - return Board(pieces: pieces) - }, { board in - var pieces = board.pieces - pieces[rookTargetSquare] = nil - pieces[kingTargetSquare] = .init(color: board.moveColor, figure: .king) - return Board(pieces: pieces) - }, { board in - var pieces = board.pieces - pieces[rookOriginSquare] = nil - pieces[rookTargetSquare] = .init(color: board.moveColor, figure: .rook) - return Board(pieces: pieces) - }] - return - } - - let promotions: [Piece.Figure?] = if piece.figure == .pawn, targetSquare.rank == moveColor.opposite.backRank { - [.bishop, .knight, .queen, .rook] - } else { - [nil] - } - - mutations = promotions.reduce(into: mutations) { mutations, promotion in - mutations[.translation(originFile: originSquare.file, - originRank: originSquare.rank, - figure: piece.figure, - isCapture: isCapture, - promotion: promotion, - targetSquare: targetSquare)] = [mutation(for: piece, from: originSquare, to: targetSquare, promoteTo: promotion)] - } - } - } - - func mutation(for piece: Piece, from originSquare: Square, to targetSquare: Square, promoteTo promotion: Piece.Figure?) -> Mutation { - { board in - // Move piece. - var pieces = board.pieces - pieces[originSquare] = nil - pieces[targetSquare] = .init(color: piece.color, figure: promotion ?? piece.figure) - - // Capture en passant. - if let enPassant = board.enPassant, piece.figure == .pawn, targetSquare == enPassant + piece.forwardUnitVector { - pieces[enPassant] = nil - } - - // Check for pawns that can be captured en passant. - let enPassant = (piece.figure == .pawn && abs(originSquare.rank.rawValue - targetSquare.rank.rawValue) == 2) ? targetSquare : nil - - return Board(pieces: pieces, enPassant: enPassant) - } - } - - func mutatedBoard(mutations: [Mutation]) -> Self? { - mutations.reduce(self) { board, mutation in - guard let board else { - return nil - } - - // To move into check is forbidden. - let mutatedBoard = mutation(board) - guard !mutatedBoard.isCheck(color: moveColor) else { - return nil - } - - return mutatedBoard - } - } -} - -extension Board: CustomDebugStringConvertible { - public var debugDescription: String { - let state = switch (moves.last, isCheckmate, isNoMovePossible) { - case (.some(.end(.some)), _, _), (_, true, _): - (moves.last.flatMap { move in - if case let .end(.some(color)) = move { - color - } else { - nil - } - } ?? moveColor.opposite).description.capitalized.appending(" \(String.wins)") - - case (.some(.end(nil)), _, _), (_, _, true): - String.drawGame - - default: - "\(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)]?.debugDescription ?? " " - }.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): - switch victor { - case .black: - .blackVictory - - case .white: - .whiteVictory - - case .none: - .draw - } - - case let .play(play, punctuation): - "\(play)\(punctuation?.description ?? "")" - } - } -} - -extension Notation.Play { +private extension Notation.Play { static func ~= (lhs: Self, rhs: Self) -> Bool { guard case let .translation(lhsOriginFile, lhsOriginRank, lhsFigure, lhsIsCapture, lhsPromotion, lhsTargetSquare) = lhs, case let .translation(rhsOriginFile, rhsOriginRank, rhsFigure, rhsIsCapture, rhsPromotion, rhsTargetSquare) = rhs else { @@ -303,250 +23,69 @@ extension Notation.Play { } } -extension Notation.Play: CustomStringConvertible { - var description: String { - switch self { - case let .castle(castle): - switch castle { - case .long: - return .castleLong - - case .short: - return .castleShort - } - - case let .translation(originFile, originRank, figure, isCapture, promotion, targetSquare): - let disambiguation = "\(originFile?.description ?? "")\(originRank?.description ?? "")" - let promotion = promotion.map(\.rawValue).map(String.promotion.appending) ?? "" - return "\(figure.rawValue)\(disambiguation)\(isCapture ? .capture : "")\(targetSquare)\(promotion)" - } - } -} - -extension Notation.Punctuation: CustomStringConvertible { - var description: String { - rawValue - } -} - -// MARK: - - -private extension Piece { - var forwardUnitVector: Vector { - switch color { - case .black: - .init(ranks: -1) - - case .white: - .init(ranks: 1) - } - } - - var startRank: Square.Rank { - switch figure { - case .pawn: - .init(rawValue: color.backRank.rawValue + forwardUnitVector.ranks)! - - default: - .init(rawValue: color.backRank.rawValue)! - } - } - - func capturePaths(from square: Square) -> [[Square]] { - guard figure == .pawn else { - return movePaths(from: square) - } +// MARK: - Game - return [Vector(files: -1, ranks: forwardUnitVector.ranks), - Vector(files: 1, ranks: forwardUnitVector.ranks)].compactMap { vector in - (square + vector).map { targetSquare in - [targetSquare] - } - } +/// A model representing a chess game. +/// +/// Chess is a board game played between two players. +public class Game { + /// A model representing a game state. + public enum Status { + case winner(color: Piece.Color, isByResignation: Bool) + case draw(isStalemate: Bool) + case toMove(Piece.Color) } - func movePaths(from square: Square) -> [[Square]] { - switch figure { - case .bishop: - Vector.diagonalUnitVectors.compactMap(square.allSquaresInDirection) - - case .king: - Vector.unitVectors.compactMap { vector in - (square + vector).map { targetSquare in - [targetSquare] - } - } - - case .knight: - [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] + /// Game state + public var status: Status { + switch (moves.last, board.isCheckmate, board.isNoMovePossible) { + case (.some(.end(.some)), _, _), (_, true, _): + .winner(color: moves.last.flatMap { move in + if case let .end(.some(color)) = move { + color + } else { + nil } - } - - case .pawn: - if square.rank == startRank { - [[(square + forwardUnitVector)!], [((square + forwardUnitVector)! + forwardUnitVector)!]] - } else { - [[(square + forwardUnitVector)!]] - } + } ?? moveColor.opposite, isByResignation: !board.isCheckmate) - case .queen: - Vector.unitVectors.compactMap(square.allSquaresInDirection) - - case .rook: - Vector.cardinalUnitVectors.compactMap(square.allSquaresInDirection) - } - } -} - -extension Piece: CustomDebugStringConvertible { - public var debugDescription: String { - switch color { - case .black: - figure.debugDescription.lowercased() - - case .white: - figure.debugDescription - } - } -} - -extension Piece.Color { - var backRank: Square.Rank { - switch self { - case .black: - .eight - - case .white: - .one - } - } - - var opposite: Self { - switch self { - case .black: - .white - - case .white: - .black - } - } -} - -extension Piece.Color: CustomStringConvertible { - public var description: String { - rawValue - } -} - -extension Piece.Figure: CustomDebugStringConvertible { - public var debugDescription: String { - switch self { - case .pawn: - "P" + case (.some(.end(nil)), _, _), (_, _, true): + .draw(isStalemate: board.isNoMovePossible) default: - rawValue + .toMove(moveColor) } } -} - -// 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: [Self] = [ - .init(files: -1, ranks: 0), - .init(files: 0, ranks: -1), - .init(files: 0, ranks: 1), - .init(files: 1, ranks: 0) - ] - - static let diagonalUnitVectors: [Self] = [ - .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 + /// Game board + public private(set) var board: Board -/// A model representing a chess game. -/// -/// Chess is a board game played between two players. -public struct Game { - /// Game state + /// Game over public var isGameOver: Bool { - guard case .end = board.moves.last else { - return board.isNoMovePossible + if case .toMove = status { + return false } return true } - /// Game board - private(set) var board: Board + var enPassant: Square? + + private var moves = [Notation]() /// Move - /// - Parameter notation: Move notation - public mutating func move(notation: String) throws { + /// - Parameter notation: move notation + public func move(notation: String) throws { guard let notation = Notation(notation: notation) else { - throw InvalidNotation.unparseable(notation: notation) + throw InvalidNotation.unparseable } guard case let .play(play, punctuation) = notation else { - board.moves += [notation] + moves += [notation] return } let plays = board.pieces.filter { _, piece in - piece.color == board.moveColor - }.enumerated().flatMap { _, element in - let (square, piece) = element + piece.color == moveColor + }.flatMap { square, piece in let isCapture = if case let .translation(_, _, _, isCapture, _, _) = play { isCapture } else { @@ -555,10 +94,9 @@ public struct Game { let moves = board.moves(for: piece, from: square, isCapture: isCapture) return moves.reduce(into: [Notation.Play: [Board.Mutation]]()) { mutations, targetSquare in - let backRank = piece.color.backRank if piece.figure == .king, square.file == .e, targetSquare.file == .c || targetSquare.file == .g { + let backRank = piece.color.backRank let isCastleLong = targetSquare.file == .c - let kingOriginSquare = Square(file: .e, rank: backRank) let kingTargetSquare = Square(file: isCastleLong ? .c : .g, rank: backRank) let rookOriginSquare = Square(file: isCastleLong ? .a : .h, rank: backRank) @@ -566,25 +104,28 @@ public struct Game { // King must move over the rook's target square without moving into check. mutations[.castle(castle: isCastleLong ? .long : .short)] = [{ board in - var pieces = board.pieces - pieces[kingOriginSquare] = nil - pieces[rookTargetSquare] = .init(color: board.moveColor, figure: .king) - return Board(pieces: pieces) + .init(pieces: board.pieces.filter { square, _ in + square != kingOriginSquare + }.merging([rookTargetSquare: .init(color: self.moveColor, figure: .king)]) { _, new in + new + }) }, { board in - var pieces = board.pieces - pieces[rookTargetSquare] = nil - pieces[kingTargetSquare] = .init(color: board.moveColor, figure: .king) - return Board(pieces: pieces) + .init(pieces: board.pieces.filter { square, _ in + square != rookTargetSquare + }.merging([kingTargetSquare: .init(color: self.moveColor, figure: .king)]) { _, new in + new + }) }, { board in - var pieces = board.pieces - pieces[rookOriginSquare] = nil - pieces[rookTargetSquare] = .init(color: board.moveColor, figure: .rook) - return Board(pieces: pieces) + .init(pieces: board.pieces.filter { square, _ in + square != rookOriginSquare + }.merging([rookTargetSquare: .init(color: self.moveColor, figure: .rook)]) { _, new in + new + }) }] return } - let promotions: [Piece.Figure?] = if piece.figure == .pawn, targetSquare.rank == board.moveColor.opposite.backRank { + let promotions: [Piece.Figure?] = if piece.figure == .pawn, targetSquare.rank == moveColor.opposite.backRank { [.bishop, .knight, .queen, .rook] } else { [nil] @@ -601,46 +142,34 @@ public struct Game { ] } } - }.filter { key, _ in key ~= play } guard plays.count < 2 else { - throw InvalidNotation.ambiguous(plays.map(\.key).map(\.description)) + throw InvalidNotation.ambiguous(candidates: plays.map(\.key).map(\.description)) } - guard let play = plays.first, var mutatedBoard = board.mutatedBoard(mutations: play.value) else { + guard let play = plays.first, let mutatedBoard = board.mutatedBoard(mutations: play.value) else { throw InvalidNotation.illegalMove } - mutatedBoard.moves = board.moves + [notation] - - // Compute game state. - let isCheck = mutatedBoard.isCheck(color: mutatedBoard.moveColor) - let isCheckmate = mutatedBoard.isCheckmate + moves += [notation] - // Validate punctuation parsed from input notation. - switch punctuation { - case .check: - guard isCheck else { - throw InvalidNotation.badPunctuation(.isNotCheck) + if mutatedBoard.isCheckmate { + guard punctuation == .checkmate else { + moves = moves.dropLast() + throw InvalidNotation.badPunctuation(correctPunctuation: Notation.Punctuation.checkmate.description) } - guard !isCheckmate else { - throw InvalidNotation.badPunctuation(.isCheckmate) + } else if mutatedBoard.isCheck { + guard punctuation == .check else { + moves = moves.dropLast() + throw InvalidNotation.badPunctuation(correctPunctuation: Notation.Punctuation.check.description) } - - 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) + } else { + guard punctuation == nil else { + moves = moves.dropLast() + throw InvalidNotation.badPunctuation(correctPunctuation: "") } } @@ -650,17 +179,45 @@ public struct Game { /// Designated initializer public init(board: Board = .board) { self.board = board + self.board.dataSource = self + } +} + +extension Game: BoardDataSource { + var moveColor: Piece.Color { + moves.count.isMultiple(of: 2) ? .white : .black + } + + func hasPieceNotMoved(piece: Piece, square: Square) -> Bool { + board.pieces[square] == piece && !moves.contains { notation in + notation.description.contains(square.description) + } } } extension Game: CustomDebugStringConvertible { public var debugDescription: String { - let moves = board.moves.isEmpty ? "" : stride(from: 0, to: board.moves.count, by: 2).map { i in + let transcript = moves.isEmpty ? "" : stride(from: 0, to: 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])" : "") + .appending(moves[i].description) + .appending(moves.count > i + 1 ? " \(moves[i + 1])" : "") }.joined(separator: "\n") - return "\(moves)\n\n\(board)" + return "\(transcript)\(moves.isEmpty ? "" : "\n\n")\(status).\n\n\(board)" + } +} + +extension Game.Status: CustomStringConvertible { + public var description: String { + switch self { + case let .winner(color, _): + "\(color.description.capitalized) \(String.wins)" + + case .draw: + .drawGame + + case let .toMove(color): + "\(color.description.capitalized) \(String.toMove)" + } } } diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift index 7553e5d..61f960d 100644 --- a/Sources/ChessCore/Models/Notation.swift +++ b/Sources/ChessCore/Models/Notation.swift @@ -97,3 +97,50 @@ enum Notation { } } } + +extension Notation: CustomStringConvertible { + var description: String { + switch self { + case let .end(victor): + switch victor { + case .black: + .blackVictory + + case .white: + .whiteVictory + + case .none: + .draw + } + + case let .play(play, punctuation): + "\(play)\(punctuation?.description ?? "")" + } + } +} + +extension Notation.Play: CustomStringConvertible { + var description: String { + switch self { + case let .castle(castle): + switch castle { + case .long: + return .castleLong + + case .short: + return .castleShort + } + + case let .translation(originFile, originRank, figure, isCapture, promotion, targetSquare): + let disambiguation = "\(originFile?.description ?? "")\(originRank?.description ?? "")" + let promotion = promotion.map(\.rawValue).map(String.promotion.appending) ?? "" + return "\(figure.rawValue)\(disambiguation)\(isCapture ? .capture : "")\(targetSquare)\(promotion)" + } + } +} + +extension Notation.Punctuation: CustomStringConvertible { + var description: String { + rawValue + } +} diff --git a/Sources/ChessCore/Models/Piece.swift b/Sources/ChessCore/Models/Piece.swift index 40942cb..c997ba1 100644 --- a/Sources/ChessCore/Models/Piece.swift +++ b/Sources/ChessCore/Models/Piece.swift @@ -1,4 +1,6 @@ +// MARK: - Piece + /// A model representing a chess piece. public struct Piece: Equatable { /// Color @@ -9,12 +11,12 @@ public struct Piece: Equatable { /// Figure public enum Figure: String, CaseIterable { + case rook = "R" + case knight = "N" case bishop = "B" + case queen = "Q" case king = "K" - case knight = "N" case pawn = "" - case queen = "Q" - case rook = "R" init?(_ character: Character) { self.init(rawValue: String(character)) @@ -22,13 +24,91 @@ public struct Piece: Equatable { } /// Color - let color: Color + public let color: Color /// Figure - let figure: Figure + public let figure: Figure + /// Designated initializer + /// - Parameters: + /// - color: Color + /// - figure: Figure public init(color: Color, figure: Figure) { self.color = color self.figure = figure } } + +extension Piece: CustomDebugStringConvertible { + public var debugDescription: String { + switch color { + case .black: + figure.debugDescription.lowercased() + + case .white: + figure.debugDescription + } + } +} + +extension Piece { + var forwardUnitVector: Vector { + switch color { + case .black: + .init(ranks: -1) + + case .white: + .init(ranks: 1) + } + } + + var startRank: Square.Rank { + switch figure { + case .pawn: + .init(rawValue: color.backRank.rawValue + forwardUnitVector.ranks)! + + default: + .init(rawValue: color.backRank.rawValue)! + } + } +} + +extension Piece.Color { + var backRank: Square.Rank { + switch self { + case .black: + .eight + + case .white: + .one + } + } + + var opposite: Self { + switch self { + case .black: + .white + + case .white: + .black + } + } +} + +extension Piece.Color: CustomStringConvertible { + public var description: String { + rawValue + } +} + +extension Piece.Figure: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .pawn: + "P" + + default: + rawValue + } + } +} diff --git a/Sources/ChessCore/Models/Square.swift b/Sources/ChessCore/Models/Square.swift index a3e4f53..505b2e2 100644 --- a/Sources/ChessCore/Models/Square.swift +++ b/Sources/ChessCore/Models/Square.swift @@ -4,7 +4,7 @@ /// A model representing a square on a chess board. public struct Square: Hashable { /// File - enum File: Int, CaseIterable { + public enum File: Int, CaseIterable { case a = 1, b, c, d, e, f, g, h init?(_ character: Character) { @@ -16,7 +16,7 @@ public struct Square: Hashable { } /// Rank - enum Rank: Int, CaseIterable { + public enum Rank: Int, CaseIterable { case one = 1, two, three, four, five, six, seven, eight init?(_ character: Character) { @@ -27,22 +27,57 @@ public struct Square: Hashable { } } - let file: File + /// File + public let file: File - let rank: Rank + /// Rank + public let rank: Rank /// Designated initializer - init(file: File, rank: Rank) { + /// - Parameters: + /// - file: file + /// - rank: rank + public init(file: File, rank: Rank) { self.file = file self.rank = rank } /// Convenience initializer + /// - Parameter string: notation public init?(notation: String) { - guard let file = notation.first.map(File.init) ?? nil, let rank = notation.last.map(Rank.init) ?? nil, + guard let file = notation.first.map(File.init) ?? nil, + let rank = notation.last.map(Rank.init) ?? nil, notation.count == 2 else { return nil } self.init(file: file, rank: rank) } } + +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) + } +} + +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/Models/Vector.swift b/Sources/ChessCore/Models/Vector.swift index e6a9fa9..9c88a87 100644 --- a/Sources/ChessCore/Models/Vector.swift +++ b/Sources/ChessCore/Models/Vector.swift @@ -2,13 +2,10 @@ // 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/Protocols/BoardDataSource.swift b/Sources/ChessCore/Protocols/BoardDataSource.swift new file mode 100644 index 0000000..17cb97b --- /dev/null +++ b/Sources/ChessCore/Protocols/BoardDataSource.swift @@ -0,0 +1,8 @@ + +protocol BoardDataSource: AnyObject { + var enPassant: Square? { get set } + + var moveColor: Piece.Color { get } + + func hasPieceNotMoved(piece: Piece, square: Square) -> Bool +} diff --git a/Sources/ChessCore/String.swift b/Sources/ChessCore/String.swift index 291e38f..5c5302a 100644 --- a/Sources/ChessCore/String.swift +++ b/Sources/ChessCore/String.swift @@ -7,7 +7,6 @@ extension String { 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/Tests/ChessCoreTests/ChessTests.swift b/Tests/ChessCoreTests/ChessTests.swift index 7a581eb..c3a980e 100644 --- a/Tests/ChessCoreTests/ChessTests.swift +++ b/Tests/ChessCoreTests/ChessTests.swift @@ -3,38 +3,38 @@ import XCTest final class ChessTests: XCTestCase { func testFirstMoves() throws { - let game = Game() - #expect([.init(notation: "a3")!, .init(notation: "c3")!].allSatisfy(game.board.moves(from: .init(notation: "b1")!).contains)) - #expect([.init(notation: "f3")!, .init(notation: "h3")!].allSatisfy(game.board.moves(from: .init(notation: "g1")!).contains)) - - for file in Square.File.allCases { - #expect([ - .init(file: file, rank: .three), - .init(file: file, rank: .four) - ].allSatisfy(game.board.moves(from: .init(file: file, rank: .two)).contains)) + 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(notation: "e4") - try game.move(notation: "e5") - try game.move(notation: "Qh5") - try game.move(notation: "Nc6") - try game.move(notation: "Bc4") - try game.move(notation: "Nf6") - try game.move(notation: "Qxf7#") + 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) - #expect(game.isGameOver) } func testStalemate() throws { var game = Game(board: .init(pieces: [ - .init(notation: "e5")!: .init(color: .white, figure: .king), - .init(notation: "e8")!: .init(color: .black, figure: .king), - .init(notation: "e7")!: .init(color: .white, figure: .pawn) + .init("e5")!: .init(color: .white, figure: .king), + .init("e8")!: .init(color: .black, figure: .king), + .init("e7")!: .init(color: .white, figure: .pawn) ])) - try game.move(notation: "Ke6") + try game.move("Ke6") print(game) XCTAssertTrue(game.isGameOver) } From ab34326f572a65438541dc0cea81d3e08715ba9b Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Mon, 8 Sep 2025 09:54:36 +0000 Subject: [PATCH 04/10] Refactor chess model and game rules Reorganize the core board, move, piece, square, and turn types into focused source files with dedicated game and move errors. Add board move generation and application coverage for castling, en passant, promotions, checks, checkmate, and stalemate. Track game turns and statuses, including wins, draw by agreement, stalemate, and draw by the fifty-move rule. Rename move terminology to kingside/queenside castling and clearer pawn start-target rank helpers, with updated tests and notation strings. Co-authored-by: Cursor --- .vscode/tasks.json | 98 ++++ Package.swift | 13 +- README.md | 101 +--- Sources/ChessCore/Board.swift | 523 +++++++++++++++++ Sources/ChessCore/Errors/GameStateError.swift | 5 + Sources/ChessCore/Errors/MoveError.swift | 8 + .../ChessCore/Extensions/String+Display.swift | 17 + .../Extensions/String+Notation.swift | 22 + Sources/ChessCore/Game.swift | 201 +++++++ Sources/ChessCore/Models/Board.swift | 306 ---------- .../Models/Errors/InvalidNotation.swift | 8 - Sources/ChessCore/Models/Game.swift | 223 -------- Sources/ChessCore/Models/Notation.swift | 146 ----- Sources/ChessCore/Models/Piece.swift | 114 ---- Sources/ChessCore/Models/Square.swift | 83 --- Sources/ChessCore/Models/Vector.swift | 13 - Sources/ChessCore/Move.swift | 119 ++++ Sources/ChessCore/Piece.swift | 87 +++ .../ChessCore/Protocols/BoardDataSource.swift | 7 +- Sources/ChessCore/Square.swift | 52 ++ Sources/ChessCore/String.swift | 13 - Sources/ChessCore/Turn.swift | 38 ++ Tests/ChessCoreTests/BoardTests.swift | 331 +++++++++++ Tests/ChessCoreTests/ChessTests.swift | 41 -- Tests/ChessCoreTests/GameTests.swift | 538 ++++++++++++++++++ Tests/ChessCoreTests/MoveTests.swift | 28 + Tests/ChessCoreTests/PieceTests.swift | 45 ++ 27 files changed, 2135 insertions(+), 1045 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 Sources/ChessCore/Board.swift create mode 100644 Sources/ChessCore/Errors/GameStateError.swift create mode 100644 Sources/ChessCore/Errors/MoveError.swift create mode 100644 Sources/ChessCore/Extensions/String+Display.swift create mode 100644 Sources/ChessCore/Extensions/String+Notation.swift create mode 100644 Sources/ChessCore/Game.swift delete mode 100644 Sources/ChessCore/Models/Board.swift delete mode 100644 Sources/ChessCore/Models/Errors/InvalidNotation.swift delete mode 100644 Sources/ChessCore/Models/Game.swift delete mode 100644 Sources/ChessCore/Models/Notation.swift delete mode 100644 Sources/ChessCore/Models/Piece.swift delete mode 100644 Sources/ChessCore/Models/Square.swift delete mode 100644 Sources/ChessCore/Models/Vector.swift create mode 100644 Sources/ChessCore/Move.swift create mode 100644 Sources/ChessCore/Piece.swift create mode 100644 Sources/ChessCore/Square.swift delete mode 100644 Sources/ChessCore/String.swift create mode 100644 Sources/ChessCore/Turn.swift create mode 100644 Tests/ChessCoreTests/BoardTests.swift delete mode 100644 Tests/ChessCoreTests/ChessTests.swift create mode 100644 Tests/ChessCoreTests/GameTests.swift create mode 100644 Tests/ChessCoreTests/MoveTests.swift create mode 100644 Tests/ChessCoreTests/PieceTests.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..6f60dc6 100644 --- a/Package.swift +++ b/Package.swift @@ -3,17 +3,24 @@ 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..f23df76 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,20 @@ -# Chess +# ChessCore -Chess Swift package +A lightweight Swift chess core library. -## 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) - } -} -``` +## Build -#### Output +```bash +swift build ``` -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. +## Test - 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 +```bash +swift test ``` diff --git a/Sources/ChessCore/Board.swift b/Sources/ChessCore/Board.swift new file mode 100644 index 0000000..821c54e --- /dev/null +++ b/Sources/ChessCore/Board.swift @@ -0,0 +1,523 @@ +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): + return [{ board in + let targetSquare = translation.targetSquare + + var pieces = board.pieces + pieces[square] = nil + pieces[targetSquare] = .init( + color: piece.color, + figure: translation.promotion ?? piece.figure + ) + + if let enPassantSquare = board.enPassantSquare, + piece.figure == .pawn, + targetSquare == enPassantSquare + piece.color.forwardUnitVector { + pieces[enPassantSquare] = nil + } + + let enPassantSquare: Square? = if piece.figure == .pawn, + square.rank == piece.color.pawnStartRank, + targetSquare.rank == piece.color.pawnDoublePushTargetRankFromStart { + targetSquare + } else { + nil + } + + return .init(pieces: pieces, enPassantSquare: enPassantSquare) + }] + + case let .castling(castling): + let kingTargetSquare = castling.kingTargetSquare(for: piece.color) + let rook = Piece(color: piece.color, figure: .rook) + let rookSquare = castling.rookSquare(for: piece.color) + let rookTargetSquare = castling.rookTargetSquare(for: piece.color) + + // First transform: place the king on `rookTargetSquare` (the square it + // crosses between start and end). Legality is checked after each step so + // the king cannot castle through attack. + return [{ board in + var pieces = board.pieces + pieces[square] = nil + pieces[rookTargetSquare] = piece + return .init(pieces: pieces) + }, { board in + var pieces = board.pieces + pieces[kingTargetSquare] = piece + pieces[rookSquare] = nil + pieces[rookTargetSquare] = rook + return .init(pieces: pieces) + }] + } + } +} + +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 startFiles: [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 startRank: Square.Rank { + switch figure { + case .pawn: + color.pawnStartRank + + 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.pawnStartRank): + [[color.pawnSinglePushTargetRankFromStart, color.pawnDoublePushTargetRankFromStart].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?] { + if figure == .pawn, targetSquare.rank == color.opposite.backRank { + [.queen, .rook, .bishop, .knight] + } else { + [nil] + } + } +} + +private extension Square { + static func + (lhs: Self, rhs: Board.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 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] { + if let square = self + vector { + [square] + square.linearPath(for: vector) + } else { + [] + } + } +} + +/// 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.startFiles.map { file in + (.init(file: file, rank: piece.startRank), piece) + } + })) + } + + /// 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 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] + } + + /// 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]] { + guard let piece = self[square] else { + return [:] + } + + return .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 else { + throw MoveError.illegalMove + } + + guard 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 + if let transformedBoard, !transformedBoard.isInCheck { + return transformedBoard + } + return nil + } + } + + 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] { + [false, true].flatMap { isCapture in + moves(for: piece, from: square, isCapture: isCapture) + } + .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 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) + } + .reduce(into: moves) { moves, castling in + moves.append(.castling(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 var endIndex: Index { + pieces.endIndex + } + + public var startIndex: Index { + pieces.startIndex + } + + public subscript(position: Index) -> (key: Square, value: Piece) { + pieces[position] + } + + 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..8608084 --- /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 queenside castling. + static let castlingLongNotation = "O-O-O" + + /// SAN notation for kingside castling. + static let castlingShortNotation = "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..19b2624 --- /dev/null +++ b/Sources/ChessCore/Game.swift @@ -0,0 +1,201 @@ +import Combine +import Foundation + +private extension Board { + var punctuation: Turn.Punctuation? { + switch status { + 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. + if let punctuation = board.punctuation, let lastIndex = turns.indices.last { + turns[lastIndex] = .move(move, punctuation: 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 70e9221..0000000 --- a/Sources/ChessCore/Models/Board.swift +++ /dev/null @@ -1,306 +0,0 @@ - -// MARK: - -private extension Square { - func allSquaresInDirection(_ direction: Vector) -> [Self] { - switch self + direction { - case let .some(square): - [square] + square.allSquaresInDirection(direction) - - default: - [] - } - } -} - -// MARK: - -private extension Piece { - func capturePaths(from square: Square) -> [[Square]] { - switch figure { - case .pawn: - [Vector(files: -1, ranks: forwardUnitVector.ranks), - Vector(files: 1, ranks: forwardUnitVector.ranks)].compactMap { vector in - (square + vector).map { targetSquare in - [targetSquare] - } - } - - default: - movePaths(from: square) - } - } - - func movePaths(from square: Square) -> [[Square]] { - switch figure { - case .bishop: - Vector.diagonalUnitVectors.compactMap(square.allSquaresInDirection) - - case .king: - Vector.unitVectors.compactMap { vector in - (square + vector).map { targetSquare in - [targetSquare] - } - } - - case .knight: - [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: - if square.rank == startRank { - [[(square + forwardUnitVector)!, ((square + forwardUnitVector)! + forwardUnitVector)!]] - } else { - [[(square + forwardUnitVector)!]] - } - - case .queen: - Vector.unitVectors.compactMap(square.allSquaresInDirection) - - case .rook: - Vector.cardinalUnitVectors.compactMap(square.allSquaresInDirection) - } - } -} - -// MARK: - -private extension Vector { - static let cardinalUnitVectors: [Self] = [ - .init(files: -1, ranks: 0), - .init(files: 0, ranks: -1), - .init(files: 0, ranks: 1), - .init(files: 1, ranks: 0) - ] - - static let diagonalUnitVectors: [Self] = [ - .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: - 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 = (Board) -> (Board) - - public let pieces: [Square: Piece] - - weak var dataSource: BoardDataSource? -} - -public extension Board { - /// Conventional board setup - static var board: Board { - Board(pieces: Piece.Color.allCases.flatMap { color in - Piece.Figure.allCases.map { figure in - Piece(color: color, figure: figure) - } - }.reduce(into: .init()) { pieces, piece in - let files: [Square.File] = switch piece.figure { - case .bishop: - [.c, .f] - - case .king: - [.e] - - case .knight: - [.b, .g] - - case .pawn: - Square.File.allCases - - case .queen: - [.d] - - case .rook: - [.a, .h] - } - - pieces = files.reduce(into: pieces) { pieces, file in - pieces[.init(file: file, rank: piece.startRank)] = piece - } - }) - } - - /// Possible moves from a square - /// - Parameter square: Source square - /// - Returns: Destination squares - func moves(from square: Square) -> [Square] { - guard let piece = pieces[square], piece.color == moveColor else { - return [] - } - - let captures = moves(for: piece, from: square, isCapture: true) - let nonCaptures = moves(for: piece, from: square, isCapture: false) - return captures + nonCaptures - } -} - -extension Board: CustomDebugStringConvertible { - public var debugDescription: String { - Square.Rank.allCases.reversed().map { rank in - " \(rank) ".appending(Square.File.allCases.map { file in - pieces[Square(file: file, rank: rank)]?.debugDescription ?? " " - }.joined(separator: " ")) - }.joined(separator: "\n").appending("\n ").appending(Square.File.allCases.map(\.description).joined(separator: " ")) - } -} - -extension Board: ExpressibleByDictionaryLiteral { - public init(dictionaryLiteral elements: (Square, Piece)...) { - self.init(pieces: .init(uniqueKeysWithValues: elements)) - } -} - -extension Board { - var isCheck: Bool { - pieces.filter { _, piece in - piece.color == moveColor.opposite - }.flatMap { square, piece in - moves(for: piece, from: square, isCapture: true) - }.contains { targetSquare in - pieces[targetSquare]?.figure == .king - } - } - - var isCheckmate: Bool { - isCheck && isNoMovePossible - } - - var isNoMovePossible: Bool { - !pieces.contains { square, piece in - moves(from: square).contains { targetSquare in - mutatedBoard(mutations: [mutation(for: piece, from: square, to: targetSquare, promoteTo: nil)]) != nil - } - } - } - - func moves(for piece: Piece, from square: Square, isCapture: Bool) -> [Square] { - let castleMoves: [Square] - - let backRank = moveColor.backRank - let kingSquare = Square(file: .e, rank: backRank) - - if piece.figure == .king, - hasPieceNotMoved(piece: .init(color: moveColor, figure: .king), square: kingSquare), - !isCapture, - !isCheck { - let longRookSquare = Square(file: .a, rank: backRank) - let castleLong: [Square] = if ![Square(file: .b, rank: backRank), - Square(file: .c, rank: backRank), - Square(file: .d, rank: backRank)].contains(where: pieces.keys.contains), - hasPieceNotMoved(piece: .init(color: moveColor, figure: .rook), square: longRookSquare) { - [Square(file: .c, rank: .one)] - } else { - [] - } - - let shortRookSquare = Square(file: .h, rank: backRank) - let castleShort: [Square] = if ![Square(file: .f, rank: backRank), - Square(file: .g, rank: backRank)].contains(where: pieces.keys.contains), - hasPieceNotMoved(piece: .init(color: moveColor, figure: .rook), square: shortRookSquare) { - [Square(file: .g, rank: .one)] - } else { - [] - } - - castleMoves = castleLong + castleShort - } else { - castleMoves = [] - } - - let traditionalMoves = (isCapture ? piece.capturePaths(from: square) : piece.movePaths(from: square)).flatMap { path in - let obstruction = path.enumerated().first { _, square in - pieces.keys.contains(square) - } - - // Non-capture moves can move a piece up to the first obstruction in its path or the end of the path if it's unobstructed. - guard isCapture else { - return path.prefix(upTo: obstruction?.0 ?? path.endIndex) - } - - // En passant captures are the only captures where the captured piece is not in the capture path. - guard let obstruction, pieces[obstruction.1]!.color != piece.color else { - guard let enPassant, piece.figure == .pawn, path.first == enPassant + piece.forwardUnitVector else { - return [] - } - - return [path.first!] - } - - // All other captures take the first opposing piece in the path. - return path[obstruction.0 ..< obstruction.0 + 1] - } - - return castleMoves + traditionalMoves - } - - func mutation(for piece: Piece, - from originSquare: Square, - to targetSquare: Square, - promoteTo promotion: Piece.Figure?) -> Mutation { - { board in - // Move piece. - var pieces = board.pieces - pieces[originSquare] = nil - pieces[targetSquare] = .init(color: piece.color, figure: promotion ?? piece.figure) - - // Capture en passant. - if let enPassant = board.enPassant, piece.figure == .pawn, targetSquare == enPassant + piece.forwardUnitVector { - pieces[enPassant] = nil - } - - // Check for pawns that can be captured en passant. - let enPassant = (piece.figure == .pawn && abs(originSquare.rank.rawValue - targetSquare.rank.rawValue) == 2) ? targetSquare : nil - dataSource?.enPassant = enPassant - - return Board(pieces: pieces) - } - } - - func mutatedBoard(mutations: [Mutation]) -> Self? { - mutations.reduce(self) { board, mutation in - guard let board else { - return nil - } - - // To move into check is forbidden. - var mutatedBoard = mutation(board) - mutatedBoard.dataSource = dataSource - guard !mutatedBoard.isCheck else { - return nil - } - - return mutatedBoard - } - } -} - -private extension Board { - var enPassant: Square? { - dataSource?.enPassant - } - - var moveColor: Piece.Color { - dataSource?.moveColor ?? .white - } - - func hasPieceNotMoved(piece: Piece, square: Square) -> Bool { - dataSource?.hasPieceNotMoved(piece: piece, square: square) ?? true - } -} diff --git a/Sources/ChessCore/Models/Errors/InvalidNotation.swift b/Sources/ChessCore/Models/Errors/InvalidNotation.swift deleted file mode 100644 index 377cf88..0000000 --- a/Sources/ChessCore/Models/Errors/InvalidNotation.swift +++ /dev/null @@ -1,8 +0,0 @@ - -/// A model representing an invalid notation error. -public enum InvalidNotation: Error { - case ambiguous(candidates: [String]) - case badPunctuation(correctPunctuation: String) - case illegalMove - case unparseable -} diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift deleted file mode 100644 index c04dfb1..0000000 --- a/Sources/ChessCore/Models/Game.swift +++ /dev/null @@ -1,223 +0,0 @@ -import Foundation - -// MARK: - -private extension Notation.Play { - static func ~= (lhs: Self, rhs: Self) -> Bool { - guard case let .translation(lhsOriginFile, lhsOriginRank, lhsFigure, lhsIsCapture, lhsPromotion, lhsTargetSquare) = lhs, - case let .translation(rhsOriginFile, rhsOriginRank, rhsFigure, rhsIsCapture, rhsPromotion, rhsTargetSquare) = rhs else { - return lhs == rhs - } - - if let lhsOriginFile, let rhsOriginFile, lhsOriginFile != rhsOriginFile { - return false - } - - if let lhsOriginRank, let rhsOriginRank, lhsOriginRank != rhsOriginRank { - return false - } - - return lhsFigure == rhsFigure - && lhsIsCapture == rhsIsCapture - && lhsTargetSquare == rhsTargetSquare - && lhsPromotion == rhsPromotion - } -} - -// MARK: - Game - -/// A model representing a chess game. -/// -/// Chess is a board game played between two players. -public class Game { - /// A model representing a game state. - public enum Status { - case winner(color: Piece.Color, isByResignation: Bool) - case draw(isStalemate: Bool) - case toMove(Piece.Color) - } - - /// Game state - public var status: Status { - switch (moves.last, board.isCheckmate, board.isNoMovePossible) { - case (.some(.end(.some)), _, _), (_, true, _): - .winner(color: moves.last.flatMap { move in - if case let .end(.some(color)) = move { - color - } else { - nil - } - } ?? moveColor.opposite, isByResignation: !board.isCheckmate) - - case (.some(.end(nil)), _, _), (_, _, true): - .draw(isStalemate: board.isNoMovePossible) - - default: - .toMove(moveColor) - } - } - - /// Game board - public private(set) var board: Board - - /// Game over - public var isGameOver: Bool { - if case .toMove = status { - return false - } - return true - } - - var enPassant: Square? - - private var moves = [Notation]() - - /// Move - /// - Parameter notation: move notation - public func move(notation: String) throws { - guard let notation = Notation(notation: notation) else { - throw InvalidNotation.unparseable - } - - guard case let .play(play, punctuation) = notation else { - moves += [notation] - return - } - - let plays = board.pieces.filter { _, piece in - piece.color == moveColor - }.flatMap { square, piece in - let isCapture = if case let .translation(_, _, _, isCapture, _, _) = play { - isCapture - } else { - false - } - - let moves = board.moves(for: piece, from: square, isCapture: isCapture) - return moves.reduce(into: [Notation.Play: [Board.Mutation]]()) { mutations, targetSquare in - if piece.figure == .king, square.file == .e, targetSquare.file == .c || targetSquare.file == .g { - let backRank = piece.color.backRank - let isCastleLong = targetSquare.file == .c - let kingOriginSquare = Square(file: .e, rank: backRank) - let kingTargetSquare = Square(file: isCastleLong ? .c : .g, rank: backRank) - let rookOriginSquare = Square(file: isCastleLong ? .a : .h, rank: backRank) - let rookTargetSquare = Square(file: isCastleLong ? .d : .f, rank: backRank) - - // King must move over the rook's target square without moving into check. - mutations[.castle(castle: isCastleLong ? .long : .short)] = [{ board in - .init(pieces: board.pieces.filter { square, _ in - square != kingOriginSquare - }.merging([rookTargetSquare: .init(color: self.moveColor, figure: .king)]) { _, new in - new - }) - }, { board in - .init(pieces: board.pieces.filter { square, _ in - square != rookTargetSquare - }.merging([kingTargetSquare: .init(color: self.moveColor, figure: .king)]) { _, new in - new - }) - }, { board in - .init(pieces: board.pieces.filter { square, _ in - square != rookOriginSquare - }.merging([rookTargetSquare: .init(color: self.moveColor, figure: .rook)]) { _, new in - new - }) - }] - return - } - - let promotions: [Piece.Figure?] = if piece.figure == .pawn, targetSquare.rank == moveColor.opposite.backRank { - [.bishop, .knight, .queen, .rook] - } else { - [nil] - } - - mutations = promotions.reduce(into: mutations) { mutations, promotion in - mutations[.translation(originFile: square.file, - originRank: square.rank, - figure: piece.figure, - isCapture: isCapture, - promotion: promotion, - targetSquare: targetSquare)] = [ - board.mutation(for: piece, from: square, to: targetSquare, promoteTo: promotion) - ] - } - } - }.filter { key, _ in - key ~= play - } - - guard plays.count < 2 else { - throw InvalidNotation.ambiguous(candidates: plays.map(\.key).map(\.description)) - } - - guard let play = plays.first, let mutatedBoard = board.mutatedBoard(mutations: play.value) else { - throw InvalidNotation.illegalMove - } - - moves += [notation] - - if mutatedBoard.isCheckmate { - guard punctuation == .checkmate else { - moves = moves.dropLast() - throw InvalidNotation.badPunctuation(correctPunctuation: Notation.Punctuation.checkmate.description) - } - } else if mutatedBoard.isCheck { - guard punctuation == .check else { - moves = moves.dropLast() - throw InvalidNotation.badPunctuation(correctPunctuation: Notation.Punctuation.check.description) - } - } else { - guard punctuation == nil else { - moves = moves.dropLast() - throw InvalidNotation.badPunctuation(correctPunctuation: "") - } - } - - board = mutatedBoard - } - - /// Designated initializer - public init(board: Board = .board) { - self.board = board - self.board.dataSource = self - } -} - -extension Game: BoardDataSource { - var moveColor: Piece.Color { - moves.count.isMultiple(of: 2) ? .white : .black - } - - func hasPieceNotMoved(piece: Piece, square: Square) -> Bool { - board.pieces[square] == piece && !moves.contains { notation in - notation.description.contains(square.description) - } - } -} - -extension Game: CustomDebugStringConvertible { - public var debugDescription: String { - let transcript = moves.isEmpty ? "" : stride(from: 0, to: moves.count, by: 2).map { i in - "\(i / 2 + 1). " - .appending(moves[i].description) - .appending(moves.count > i + 1 ? " \(moves[i + 1])" : "") - }.joined(separator: "\n") - - return "\(transcript)\(moves.isEmpty ? "" : "\n\n")\(status).\n\n\(board)" - } -} - -extension Game.Status: CustomStringConvertible { - public var description: String { - switch self { - case let .winner(color, _): - "\(color.description.capitalized) \(String.wins)" - - case .draw: - .drawGame - - case let .toMove(color): - "\(color.description.capitalized) \(String.toMove)" - } - } -} diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift deleted file mode 100644 index 61f960d..0000000 --- a/Sources/ChessCore/Models/Notation.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation - -// MARK: - Notation - -enum Notation { - enum Play: Hashable { - enum Castle { - case long - case short - } - - case castle(castle: Castle) - case translation(originFile: Square.File?, - originRank: 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)) - } - } - - case end(victor: Piece.Color?) - case play(_ play: Play, punctuation: Punctuation?) - - init?(notation: String) { - switch notation { - case .whiteVictory: - self = .end(victor: .white) - - case .blackVictory: - self = .end(victor: .black) - - case .draw: - self = .end(victor: nil) - - default: - var notation = notation - - let punctuation = notation.last.map(Punctuation.init) ?? nil - if punctuation != nil { - notation = String(notation.dropLast()) - } - - switch notation { - case .castleLong: - self = .play(.castle(castle: .long), punctuation: punctuation) - - case .castleShort: - self = .play(.castle(castle: .short), punctuation: punctuation) - - default: - let regex = try! NSRegularExpression(pattern: "([KQRBN])?([a-h])?([1-8])?([x])?([a-h][1-8])(?:=([QRBN]))?$") - guard let match = regex.firstMatch(in: notation, range: NSRange(notation.startIndex..., in: notation)) else { - return nil - } - - let figure = (Range(match.range(at: 1), in: notation).flatMap { range in - Piece.Figure(rawValue: String(notation[range])) - }) ?? .pawn - - let originFile = Range(match.range(at: 2), in: notation).flatMap { range in - Square.File(String(notation[range]).first!) - } - - let originRank = Range(match.range(at: 3), in: notation).flatMap { range in - Square.Rank(rawValue: Int(notation[range])!) - } - - let isCapture = Range(match.range(at: 4), in: notation) != nil - - guard let targetSquare = Range(match.range(at: 5), in: notation).flatMap({ range in - Square(notation: String(notation[range])) - }) else { - return nil - } - - let promotion = Range(match.range(at: 6), in: notation).flatMap { range in - Piece.Figure(rawValue: String(notation[range])) - } - - self = .play(.translation(originFile: originFile, - originRank: originRank, - figure: figure, - isCapture: isCapture, - promotion: promotion, - targetSquare: targetSquare), - punctuation: punctuation) - } - } - } -} - -extension Notation: CustomStringConvertible { - var description: String { - switch self { - case let .end(victor): - switch victor { - case .black: - .blackVictory - - case .white: - .whiteVictory - - case .none: - .draw - } - - case let .play(play, punctuation): - "\(play)\(punctuation?.description ?? "")" - } - } -} - -extension Notation.Play: CustomStringConvertible { - var description: String { - switch self { - case let .castle(castle): - switch castle { - case .long: - return .castleLong - - case .short: - return .castleShort - } - - case let .translation(originFile, originRank, figure, isCapture, promotion, targetSquare): - let disambiguation = "\(originFile?.description ?? "")\(originRank?.description ?? "")" - let promotion = promotion.map(\.rawValue).map(String.promotion.appending) ?? "" - return "\(figure.rawValue)\(disambiguation)\(isCapture ? .capture : "")\(targetSquare)\(promotion)" - } - } -} - -extension Notation.Punctuation: CustomStringConvertible { - var description: String { - rawValue - } -} diff --git a/Sources/ChessCore/Models/Piece.swift b/Sources/ChessCore/Models/Piece.swift deleted file mode 100644 index c997ba1..0000000 --- a/Sources/ChessCore/Models/Piece.swift +++ /dev/null @@ -1,114 +0,0 @@ - -// MARK: - Piece - -/// 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 rook = "R" - case knight = "N" - case bishop = "B" - case queen = "Q" - case king = "K" - case pawn = "" - - init?(_ character: Character) { - self.init(rawValue: String(character)) - } - } - - /// Color - public let color: Color - - /// Figure - public let figure: Figure - - /// Designated initializer - /// - Parameters: - /// - color: Color - /// - figure: Figure - public init(color: Color, figure: Figure) { - self.color = color - self.figure = figure - } -} - -extension Piece: CustomDebugStringConvertible { - public var debugDescription: String { - switch color { - case .black: - figure.debugDescription.lowercased() - - case .white: - figure.debugDescription - } - } -} - -extension Piece { - var forwardUnitVector: Vector { - switch color { - case .black: - .init(ranks: -1) - - case .white: - .init(ranks: 1) - } - } - - var startRank: Square.Rank { - switch figure { - case .pawn: - .init(rawValue: color.backRank.rawValue + forwardUnitVector.ranks)! - - default: - .init(rawValue: color.backRank.rawValue)! - } - } -} - -extension Piece.Color { - var backRank: Square.Rank { - switch self { - case .black: - .eight - - case .white: - .one - } - } - - var opposite: Self { - switch self { - case .black: - .white - - case .white: - .black - } - } -} - -extension Piece.Color: CustomStringConvertible { - public var description: String { - rawValue - } -} - -extension Piece.Figure: CustomDebugStringConvertible { - public var debugDescription: String { - switch self { - case .pawn: - "P" - - default: - rawValue - } - } -} diff --git a/Sources/ChessCore/Models/Square.swift b/Sources/ChessCore/Models/Square.swift deleted file mode 100644 index 505b2e2..0000000 --- a/Sources/ChessCore/Models/Square.swift +++ /dev/null @@ -1,83 +0,0 @@ - -// MARK: - Square - -/// A model representing a square on a chess board. -public struct Square: Hashable { - /// File - public enum File: Int, CaseIterable { - case a = 1, b, c, d, e, f, g, h - - init?(_ character: Character) { - guard let ascii = character.asciiValue else { - return nil - } - self.init(rawValue: Int(ascii) - 96) - } - } - - /// Rank - public enum Rank: Int, CaseIterable { - case one = 1, two, three, four, five, six, seven, eight - - init?(_ character: Character) { - guard let int = Int(String(character)) else { - return nil - } - self.init(rawValue: int) - } - } - - /// File - public let file: File - - /// Rank - public let rank: Rank - - /// Designated initializer - /// - Parameters: - /// - file: file - /// - rank: rank - public init(file: File, rank: Rank) { - self.file = file - self.rank = rank - } - - /// Convenience initializer - /// - Parameter string: notation - public init?(notation: String) { - guard let file = notation.first.map(File.init) ?? nil, - let rank = notation.last.map(Rank.init) ?? nil, - notation.count == 2 else { - return nil - } - self.init(file: file, rank: rank) - } -} - -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) - } -} - -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/Models/Vector.swift b/Sources/ChessCore/Models/Vector.swift deleted file mode 100644 index 9c88a87..0000000 --- a/Sources/ChessCore/Models/Vector.swift +++ /dev/null @@ -1,13 +0,0 @@ - -// MARK: - Vector - -struct Vector { - let files: Int - - let ranks: Int - - 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..eced8a7 --- /dev/null +++ b/Sources/ChessCore/Move.swift @@ -0,0 +1,119 @@ +/// 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 { + /// A castling move. + /// + /// Castling is either kingside or queenside. + public enum Castling: CaseIterable { + /// 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 { + /// 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: + .castlingShortNotation + + case .queenside: + .castlingLongNotation + } + } +} + +extension Move.Translation: CustomStringConvertible { + public var description: String { + let disambiguation = "\(disambiguationFile?.description ?? "")\(disambiguationRank?.description ?? "")" + let promotion = promotion.map(\.rawValue).map(String.promotionNotation.appending) ?? "" + return """ + \(figure.rawValue)\(disambiguation)\(isCapture ? .captureNotation : "")\(targetSquare)\(promotion) + """ + } +} diff --git a/Sources/ChessCore/Piece.swift b/Sources/ChessCore/Piece.swift new file mode 100644 index 0000000..0c1cd09 --- /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 { + /// A piece's color. + /// + /// A color identifies which side a piece belongs to. + public enum Color: String, CaseIterable { + 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 pawnDoublePushTargetRankFromStart: Square.Rank { + .init(rawValue: pawnSinglePushTargetRankFromStart.rawValue + forwardUnitVector.ranks)! + } + + var pawnSinglePushTargetRankFromStart: Square.Rank { + .init(rawValue: pawnStartRank.rawValue + forwardUnitVector.ranks)! + } + + var pawnStartRank: 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 { + 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 index 17cb97b..e026f40 100644 --- a/Sources/ChessCore/Protocols/BoardDataSource.swift +++ b/Sources/ChessCore/Protocols/BoardDataSource.swift @@ -1,8 +1,5 @@ - protocol BoardDataSource: AnyObject { - var enPassant: Square? { get set } - - var moveColor: Piece.Color { get } + var toMove: Piece.Color { get } - func hasPieceNotMoved(piece: Piece, square: Square) -> Bool + 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..b6f1e4a --- /dev/null +++ b/Sources/ChessCore/Square.swift @@ -0,0 +1,52 @@ +/// A square on a chessboard. +/// +/// Each square is identified by its ``File`` (column) and ``Rank`` (row). +public struct Square: Hashable { + /// A square's file. + /// + /// A file identifies a square's column on the board, labeled from `a` to `h`. + public enum File: Int, CaseIterable { + case a = 1, 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 { + case one = 1, 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 5c5302a..0000000 --- a/Sources/ChessCore/String.swift +++ /dev/null @@ -1,13 +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 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..ad97afe --- /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 testDataSourceIsWeaklyHeld() { + 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 testBlackCastlingMoves() { + 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 testWhiteCastlingMoves() { + 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 testInitialBoardSetup() { + 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(board[.init(file: file, rank: .one)] == .init( + color: .white, + figure: 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(board[.init(file: file, rank: .eight)] == .init( + color: .black, + figure: backRank[file]! + )) + } + } + + @Test + func testInitialKnightMovesFromB1() { + 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 testInitialKnightMovesFromG1() { + 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 testInitialPawnMoves() { + 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 testBlackPromotionMoves() { + 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 testWhitePromotionMoves() { + 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..693b324 --- /dev/null +++ b/Tests/ChessCoreTests/GameTests.swift @@ -0,0 +1,538 @@ +import Testing +@testable import ChessCore + +struct GameTests { + // MARK: - Castling + + @Test + func testCastlingIsIllegalAfterKingIsMoved() 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 testCastlingIsIllegalWhenKingIsInCheck() 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 testCastlingLegalityIsSideSpecificWhenBFileIsBlocked() 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 testCastlingLegalityIsSideSpecificWhenCFileIsAttacked() 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 testCastlingLegalityIsSideSpecificWhenCFileIsBlocked() 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 testCastlingLegalityIsSideSpecificWhenDFileIsAttacked() 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 testCastlingLegalityIsSideSpecificWhenDFileIsBlocked() 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 testCastlingLegalityIsSideSpecificWhenFFileIsAttacked() 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 testCastlingLegalityIsSideSpecificWhenFFileIsBlocked() 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 testCastlingLegalityIsSideSpecificWhenGFileIsAttacked() 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 testCastlingLegalityIsSideSpecificWhenGFileIsBlocked() 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 testCastlingRightsAreRookSpecificAfterKingsideRookIsMoved() 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 testCastlingRightsAreRookSpecificAfterQueensideRookIsMoved() 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 testQueensideCastlingIsLegalWhenBFileIsAttacked() 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 testCanCaptureEnPassantAfterDoubleStepPawnMove() 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 testCannotCaptureEnPassantAfterAnotherMove() 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 testCannotCaptureEnPassantAfterSingleStepPawnMove() 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 testFiftyMoveRule() 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 testScholarsMate() 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 testStalemate() 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 testPromotionOnCapture() 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 testPromotionWithoutCapture() 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..d9f0800 --- /dev/null +++ b/Tests/ChessCoreTests/MoveTests.swift @@ -0,0 +1,28 @@ +import Testing +@testable import ChessCore + +struct MoveTests { + // MARK: - Is Capture + + @Test + func testCastlingMoveIsCapture() { + #expect(!Move.castling(.kingside).isCapture) + #expect(!Move.castling(.queenside).isCapture) + } + + @Test + func testTranslationMoveIsCapture() { + 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..4771e68 --- /dev/null +++ b/Tests/ChessCoreTests/PieceTests.swift @@ -0,0 +1,45 @@ +import Testing +@testable import ChessCore + +struct PieceTests { + // MARK: - Piece.Color + + @Test + func testPieceColorBackRank() { + #expect(Piece.Color.white.backRank == .one) + #expect(Piece.Color.black.backRank == .eight) + } + + @Test + func testPieceColorForwardUnitVector() { + #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 testPieceColorOpposite() { + #expect(Piece.Color.white.opposite == .black) + #expect(Piece.Color.black.opposite == .white) + } + + @Test + func testPieceColorPawnSinglePushRankFromStartRank() { + #expect(Piece.Color.white.pawnSinglePushTargetRankFromStart == .three) + #expect(Piece.Color.black.pawnSinglePushTargetRankFromStart == .six) + } + + @Test + func testPieceColorPawnDoublePushRankFromStartRank() { + #expect(Piece.Color.white.pawnDoublePushTargetRankFromStart == .four) + #expect(Piece.Color.black.pawnDoublePushTargetRankFromStart == .five) + } + + @Test + func testPieceColorPawnStartRank() { + #expect(Piece.Color.white.pawnStartRank == .two) + #expect(Piece.Color.black.pawnStartRank == .seven) + } +} From 9e392f8f3d2a755b16400cc2794757c6a2db7cfd Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Sat, 3 Oct 2026 19:30:47 +0000 Subject: [PATCH 05/10] add build-docs script --- .gitignore | 2 ++ scripts/build-docs | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100755 scripts/build-docs 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/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 From 3ff80a04a0ee7ff94962abd68297012d56e89f3c Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Sat, 3 Oct 2026 19:37:09 +0000 Subject: [PATCH 06/10] Update Swift 6 toolchain and CI Co-authored-by: Cursor --- .github/workflows/codecov.yml | 12 +++++++----- Package.swift | 2 +- Sources/ChessCore/Move.swift | 6 +++--- Sources/ChessCore/Piece.swift | 6 +++--- Sources/ChessCore/Square.swift | 6 +++--- 5 files changed, 17 insertions(+), 15 deletions(-) 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/Package.swift b/Package.swift index 6f60dc6..4509d38 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.0 import PackageDescription let package = Package( diff --git a/Sources/ChessCore/Move.swift b/Sources/ChessCore/Move.swift index eced8a7..7b8d505 100644 --- a/Sources/ChessCore/Move.swift +++ b/Sources/ChessCore/Move.swift @@ -5,11 +5,11 @@ /// 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 { +public enum Move: Hashable, Sendable { /// A castling move. /// /// Castling is either kingside or queenside. - public enum Castling: CaseIterable { + public enum Castling: CaseIterable, Sendable { /// Kingside (O-O) castling. case kingside @@ -22,7 +22,7 @@ public enum Move: Hashable { /// 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 { + public struct Translation: Hashable, Sendable { /// Moving figure. public let figure: Piece.Figure diff --git a/Sources/ChessCore/Piece.swift b/Sources/ChessCore/Piece.swift index 0c1cd09..0b8b8c6 100644 --- a/Sources/ChessCore/Piece.swift +++ b/Sources/ChessCore/Piece.swift @@ -1,11 +1,11 @@ /// A chess piece. /// /// Each piece is identified by its ``Color`` (side) and ``Figure`` (kind). -public struct Piece: Equatable { +public struct Piece: Equatable, Sendable { /// A piece's color. /// /// A color identifies which side a piece belongs to. - public enum Color: String, CaseIterable { + public enum Color: String, CaseIterable, Sendable { case white, black /// Opposing piece color. @@ -37,7 +37,7 @@ public struct Piece: Equatable { /// A piece's figure. /// /// A figure identifies the kind of chess piece. - public enum Figure: String, CaseIterable { + public enum Figure: String, CaseIterable, Sendable { case king = "K" case queen = "Q" diff --git a/Sources/ChessCore/Square.swift b/Sources/ChessCore/Square.swift index b6f1e4a..feecb2e 100644 --- a/Sources/ChessCore/Square.swift +++ b/Sources/ChessCore/Square.swift @@ -1,18 +1,18 @@ /// A square on a chessboard. /// /// Each square is identified by its ``File`` (column) and ``Rank`` (row). -public struct Square: Hashable { +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 { + public enum File: Int, CaseIterable, Sendable { case a = 1, 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 { + public enum Rank: Int, CaseIterable, Sendable { case one = 1, two, three, four, five, six, seven, eight } From 3812037b5f4508c4ccf65018553c8afc0c421256 Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Fri, 9 Oct 2026 02:55:34 +0000 Subject: [PATCH 07/10] re-edit --- Sources/ChessCore/Board.swift | 165 ++++++++---------- .../Extensions/String+Notation.swift | 8 +- Sources/ChessCore/Game.swift | 8 +- Sources/ChessCore/Move.swift | 21 ++- Sources/ChessCore/Piece.swift | 10 +- Tests/ChessCoreTests/BoardTests.swift | 26 +-- Tests/ChessCoreTests/GameTests.swift | 44 ++--- Tests/ChessCoreTests/MoveTests.swift | 4 +- Tests/ChessCoreTests/PieceTests.swift | 24 +-- 9 files changed, 150 insertions(+), 160 deletions(-) diff --git a/Sources/ChessCore/Board.swift b/Sources/ChessCore/Board.swift index 821c54e..0661147 100644 --- a/Sources/ChessCore/Board.swift +++ b/Sources/ChessCore/Board.swift @@ -15,53 +15,44 @@ private extension Move { func transforms(for piece: Piece, from square: Square) -> [Board.Transform] { switch self { case let .translation(translation): - return [{ board in + [{ board in let targetSquare = translation.targetSquare - var pieces = board.pieces - pieces[square] = nil - pieces[targetSquare] = .init( - color: piece.color, - figure: translation.promotion ?? piece.figure - ) - - if let enPassantSquare = board.enPassantSquare, - piece.figure == .pawn, - targetSquare == enPassantSquare + piece.color.forwardUnitVector { - pieces[enPassantSquare] = nil - } - - let enPassantSquare: Square? = if piece.figure == .pawn, - square.rank == piece.color.pawnStartRank, - targetSquare.rank == piece.color.pawnDoublePushTargetRankFromStart { - targetSquare - } else { - nil + 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: enPassantSquare) + return .init( + pieces: pieces, + enPassantSquare: piece.figure == .pawn && abs(targetSquare.rank.rawValue - square.rank.rawValue) == 2 ? + targetSquare + : nil + ) }] case let .castling(castling): - let kingTargetSquare = castling.kingTargetSquare(for: piece.color) - let rook = Piece(color: piece.color, figure: .rook) - let rookSquare = castling.rookSquare(for: piece.color) - let rookTargetSquare = castling.rookTargetSquare(for: piece.color) - - // First transform: place the king on `rookTargetSquare` (the square it - // crosses between start and end). Legality is checked after each step so - // the king cannot castle through attack. - return [{ board in - var pieces = board.pieces - pieces[square] = nil - pieces[rookTargetSquare] = piece - return .init(pieces: pieces) + // 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 - var pieces = board.pieces - pieces[kingTargetSquare] = piece - pieces[rookSquare] = nil - pieces[rookTargetSquare] = rook - return .init(pieces: pieces) + .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 }) }] } } @@ -104,8 +95,20 @@ private extension Move.Translation { } } +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) + } +} + private extension Piece { - var startFiles: [Square.File] { + var files: [Square.File] { switch figure { case .king: [.e] @@ -127,10 +130,10 @@ private extension Piece { } } - var startRank: Square.Rank { + var rank: Square.Rank { switch figure { case .pawn: - color.pawnStartRank + color.pawnRank default: color.backRank @@ -161,8 +164,8 @@ private extension Piece { } }) - case (.pawn, false, color.pawnStartRank): - [[color.pawnSinglePushTargetRankFromStart, color.pawnDoublePushTargetRankFromStart].map { rank in + case (.pawn, false, color.pawnRank): + [[color.pawnSinglePushTargetRank, color.pawnDoublePushTargetRank].map { rank in .init(file: square.file, rank: rank) }] @@ -177,24 +180,13 @@ private extension Piece { } func promotions(for targetSquare: Square) -> [Piece.Figure?] { - if figure == .pawn, targetSquare.rank == color.opposite.backRank { + (figure == .pawn && targetSquare.rank == color.opposite.backRank) ? [.queen, .rook, .bishop, .knight] - } else { - [nil] - } + : [nil] } } private extension Square { - static func + (lhs: Self, rhs: Board.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 linearPaths(for vectors: [Board.Vector]) -> [[Self]] { vectors.compactMap(linearPath) } @@ -208,11 +200,9 @@ private extension Square { } private func linearPath(for vector: Board.Vector) -> [Self] { - if let square = self + vector { + (self + vector).map { square in [square] + square.linearPath(for: vector) - } else { - [] - } + } ?? [] } } @@ -253,8 +243,8 @@ public struct Board { } } .flatMap { piece in - piece.startFiles.map { file in - (.init(file: file, rank: piece.startRank), piece) + piece.files.map { file in + (.init(file: file, rank: piece.rank), piece) } })) } @@ -316,19 +306,17 @@ public struct Board { /// - 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]] { - guard let piece = self[square] else { - return [:] - } - - return .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) + 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 { @@ -346,11 +334,8 @@ public struct Board { throw MoveError.ambiguousMove(candidates: matchingMoves.map(\.0)) } - guard let (_, piece, square) = matchingMoves.first else { - throw MoveError.illegalMove - } - - guard let transformedBoard = applying(move: move, for: piece, from: square) else { + guard let (_, piece, square) = matchingMoves.first, + let transformedBoard = applying(move: move, for: piece, from: square) else { throw MoveError.illegalMove } @@ -361,10 +346,11 @@ public struct Board { move.transforms(for: piece, from: square).reduce(self) { board, transform in var transformedBoard = board.map(transform) transformedBoard?.dataSource = dataSource - if let transformedBoard, !transformedBoard.isInCheck { - return transformedBoard + guard let transformedBoard, !transformedBoard.isInCheck else { + return nil } - return nil + + return transformedBoard } } @@ -378,9 +364,10 @@ public struct Board { } private func legalMoves(for piece: Piece, from square: Square) -> [Move] { - [false, true].flatMap { isCapture in - moves(for: piece, from: square, isCapture: isCapture) - } + ( + 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 } @@ -401,16 +388,14 @@ public struct Board { return moves } - return Move.Castling.allCases.filter { castling in + 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) } - .reduce(into: moves) { moves, castling in - moves.append(.castling(castling)) - } + .map(Move.castling) } private func translations(for piece: Piece, from square: Square, isCapture: Bool) -> [Move.Translation] { diff --git a/Sources/ChessCore/Extensions/String+Notation.swift b/Sources/ChessCore/Extensions/String+Notation.swift index 8608084..1efad35 100644 --- a/Sources/ChessCore/Extensions/String+Notation.swift +++ b/Sources/ChessCore/Extensions/String+Notation.swift @@ -5,11 +5,11 @@ public extension String { /// SAN marker used in move notation for captures. static let captureNotation = "x" - /// SAN notation for queenside castling. - static let castlingLongNotation = "O-O-O" - /// SAN notation for kingside castling. - static let castlingShortNotation = "O-O" + 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" diff --git a/Sources/ChessCore/Game.swift b/Sources/ChessCore/Game.swift index 19b2624..1552b8d 100644 --- a/Sources/ChessCore/Game.swift +++ b/Sources/ChessCore/Game.swift @@ -1,9 +1,9 @@ import Combine import Foundation -private extension Board { +private extension Board.Status { var punctuation: Turn.Punctuation? { - switch status { + switch self { case .check: .check @@ -132,9 +132,7 @@ public class Game: BoardDataSource, ObservableObject { turns += [.move(move, punctuation: nil)] // Calculate the punctuation after adding the move to turns played. - if let punctuation = board.punctuation, let lastIndex = turns.indices.last { - turns[lastIndex] = .move(move, punctuation: punctuation) - } + turns[turns.indices.last!] = .move(move, punctuation: board.status?.punctuation) } func hasPieceMoved(_ piece: Piece, from square: Square) -> Bool { diff --git a/Sources/ChessCore/Move.swift b/Sources/ChessCore/Move.swift index 7b8d505..a615d19 100644 --- a/Sources/ChessCore/Move.swift +++ b/Sources/ChessCore/Move.swift @@ -100,20 +100,27 @@ extension Move.Castling: CustomStringConvertible { public var description: String { switch self { case .kingside: - .castlingShortNotation + .castlingKingsideNotation case .queenside: - .castlingLongNotation + .castlingQueensideNotation } } } extension Move.Translation: CustomStringConvertible { public var description: String { - let disambiguation = "\(disambiguationFile?.description ?? "")\(disambiguationRank?.description ?? "")" - let promotion = promotion.map(\.rawValue).map(String.promotionNotation.appending) ?? "" - return """ - \(figure.rawValue)\(disambiguation)\(isCapture ? .captureNotation : "")\(targetSquare)\(promotion) - """ + 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 index 0b8b8c6..b4e379f 100644 --- a/Sources/ChessCore/Piece.swift +++ b/Sources/ChessCore/Piece.swift @@ -21,15 +21,15 @@ public struct Piece: Equatable, Sendable { .init(files: 0, ranks: self == .white ? 1 : -1) } - var pawnDoublePushTargetRankFromStart: Square.Rank { - .init(rawValue: pawnSinglePushTargetRankFromStart.rawValue + forwardUnitVector.ranks)! + var pawnDoublePushTargetRank: Square.Rank { + .init(rawValue: pawnSinglePushTargetRank.rawValue + forwardUnitVector.ranks)! } - var pawnSinglePushTargetRankFromStart: Square.Rank { - .init(rawValue: pawnStartRank.rawValue + forwardUnitVector.ranks)! + var pawnSinglePushTargetRank: Square.Rank { + .init(rawValue: pawnRank.rawValue + forwardUnitVector.ranks)! } - var pawnStartRank: Square.Rank { + var pawnRank: Square.Rank { .init(rawValue: backRank.rawValue + forwardUnitVector.ranks)! } } diff --git a/Tests/ChessCoreTests/BoardTests.swift b/Tests/ChessCoreTests/BoardTests.swift index ad97afe..bbf65ec 100644 --- a/Tests/ChessCoreTests/BoardTests.swift +++ b/Tests/ChessCoreTests/BoardTests.swift @@ -15,7 +15,7 @@ struct BoardTests { } @Test - func testDataSourceIsWeaklyHeld() { + func dataSourceIsWeaklyHeld() { var board = Board.board var dataSource: BoardDataSource? = MockBoardDataSource() @@ -29,7 +29,7 @@ struct BoardTests { // MARK: - Castling @Test - func testBlackCastlingMoves() { + 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), @@ -97,7 +97,7 @@ struct BoardTests { } @Test - func testWhiteCastlingMoves() { + 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), @@ -165,7 +165,7 @@ struct BoardTests { // MARK: - Initial Position @Test - func testInitialBoardSetup() { + func initialBoardSetup() throws { let board = Board.board #expect(board.count == 32) @@ -175,9 +175,9 @@ struct BoardTests { ] for file in Square.File.allCases { - #expect(board[.init(file: file, rank: .one)] == .init( + #expect(try board[.init(file: file, rank: .one)] == .init( color: .white, - figure: backRank[file]! + figure: #require(backRank[file]) )) #expect(board[.init(file: file, rank: .two)] == .init( @@ -194,15 +194,15 @@ struct BoardTests { figure: .pawn )) - #expect(board[.init(file: file, rank: .eight)] == .init( + #expect(try board[.init(file: file, rank: .eight)] == .init( color: .black, - figure: backRank[file]! + figure: #require(backRank[file]) )) } } @Test - func testInitialKnightMovesFromB1() { + func initialKnightMovesFromB1() { let na3 = Move.translation(.init( figure: .knight, targetSquare: .init(file: .a, rank: .three), @@ -228,7 +228,7 @@ struct BoardTests { } @Test - func testInitialKnightMovesFromG1() { + func initialKnightMovesFromG1() { let nf3 = Move.translation(.init( figure: .knight, targetSquare: .init(file: .f, rank: .three), @@ -254,7 +254,7 @@ struct BoardTests { } @Test - func testInitialPawnMoves() { + func initialPawnMoves() { for file in Square.File.allCases { let expectedMoveToRankThree = Move.translation(.init( figure: .pawn, @@ -284,7 +284,7 @@ struct BoardTests { // MARK: - Promotion @Test - func testBlackPromotionMoves() { + func blackPromotionMoves() { var board: Board = [ .init(file: .e, rank: .two): .init(color: .black, figure: .pawn) ] @@ -308,7 +308,7 @@ struct BoardTests { } @Test - func testWhitePromotionMoves() { + func whitePromotionMoves() { let board: Board = [ .init(file: .e, rank: .seven): .init(color: .white, figure: .pawn) ] diff --git a/Tests/ChessCoreTests/GameTests.swift b/Tests/ChessCoreTests/GameTests.swift index 693b324..ea23690 100644 --- a/Tests/ChessCoreTests/GameTests.swift +++ b/Tests/ChessCoreTests/GameTests.swift @@ -5,7 +5,7 @@ struct GameTests { // MARK: - Castling @Test - func testCastlingIsIllegalAfterKingIsMoved() throws { + 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), @@ -31,7 +31,7 @@ struct GameTests { } @Test - func testCastlingIsIllegalWhenKingIsInCheck() throws { + 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), @@ -52,7 +52,7 @@ struct GameTests { } @Test - func testCastlingLegalityIsSideSpecificWhenBFileIsBlocked() throws { + 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), @@ -76,7 +76,7 @@ struct GameTests { } @Test - func testCastlingLegalityIsSideSpecificWhenCFileIsAttacked() throws { + 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), @@ -100,7 +100,7 @@ struct GameTests { } @Test - func testCastlingLegalityIsSideSpecificWhenCFileIsBlocked() throws { + 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), @@ -124,7 +124,7 @@ struct GameTests { } @Test - func testCastlingLegalityIsSideSpecificWhenDFileIsAttacked() throws { + 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), @@ -148,7 +148,7 @@ struct GameTests { } @Test - func testCastlingLegalityIsSideSpecificWhenDFileIsBlocked() throws { + 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), @@ -172,7 +172,7 @@ struct GameTests { } @Test - func testCastlingLegalityIsSideSpecificWhenFFileIsAttacked() throws { + 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), @@ -196,7 +196,7 @@ struct GameTests { } @Test - func testCastlingLegalityIsSideSpecificWhenFFileIsBlocked() throws { + 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), @@ -220,7 +220,7 @@ struct GameTests { } @Test - func testCastlingLegalityIsSideSpecificWhenGFileIsAttacked() throws { + 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), @@ -244,7 +244,7 @@ struct GameTests { } @Test - func testCastlingLegalityIsSideSpecificWhenGFileIsBlocked() throws { + 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), @@ -268,7 +268,7 @@ struct GameTests { } @Test - func testCastlingRightsAreRookSpecificAfterKingsideRookIsMoved() throws { + 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), @@ -297,7 +297,7 @@ struct GameTests { } @Test - func testCastlingRightsAreRookSpecificAfterQueensideRookIsMoved() throws { + 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), @@ -326,7 +326,7 @@ struct GameTests { } @Test - func testQueensideCastlingIsLegalWhenBFileIsAttacked() throws { + 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), @@ -346,7 +346,7 @@ struct GameTests { // MARK: - En Passant @Test - func testCanCaptureEnPassantAfterDoubleStepPawnMove() throws { + 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), @@ -369,7 +369,7 @@ struct GameTests { } @Test - func testCannotCaptureEnPassantAfterAnotherMove() throws { + 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), @@ -398,7 +398,7 @@ struct GameTests { } @Test - func testCannotCaptureEnPassantAfterSingleStepPawnMove() throws { + 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), @@ -423,7 +423,7 @@ struct GameTests { // MARK: - Game End @Test - func testFiftyMoveRule() throws { + 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), @@ -445,7 +445,7 @@ struct GameTests { } @Test - func testScholarsMate() throws { + func scholarsMate() throws { let game = Game() // 1. @@ -481,7 +481,7 @@ struct GameTests { } @Test - func testStalemate() throws { + 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), @@ -497,7 +497,7 @@ struct GameTests { // MARK: - Promotion @Test - func testPromotionOnCapture() throws { + 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), @@ -518,7 +518,7 @@ struct GameTests { } @Test - func testPromotionWithoutCapture() throws { + 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), diff --git a/Tests/ChessCoreTests/MoveTests.swift b/Tests/ChessCoreTests/MoveTests.swift index d9f0800..a072fbc 100644 --- a/Tests/ChessCoreTests/MoveTests.swift +++ b/Tests/ChessCoreTests/MoveTests.swift @@ -5,13 +5,13 @@ struct MoveTests { // MARK: - Is Capture @Test - func testCastlingMoveIsCapture() { + func castlingMoveIsCapture() { #expect(!Move.castling(.kingside).isCapture) #expect(!Move.castling(.queenside).isCapture) } @Test - func testTranslationMoveIsCapture() { + func translationMoveIsCapture() { let xe5 = Move.translation(.init( figure: .pawn, targetSquare: .init(file: .e, rank: .five), diff --git a/Tests/ChessCoreTests/PieceTests.swift b/Tests/ChessCoreTests/PieceTests.swift index 4771e68..a37bfe9 100644 --- a/Tests/ChessCoreTests/PieceTests.swift +++ b/Tests/ChessCoreTests/PieceTests.swift @@ -5,13 +5,13 @@ struct PieceTests { // MARK: - Piece.Color @Test - func testPieceColorBackRank() { + func pieceColorBackRank() { #expect(Piece.Color.white.backRank == .one) #expect(Piece.Color.black.backRank == .eight) } @Test - func testPieceColorForwardUnitVector() { + func pieceColorForwardUnitVector() { #expect(Piece.Color.white.forwardUnitVector.files == 0) #expect(Piece.Color.white.forwardUnitVector.ranks == 1) @@ -20,26 +20,26 @@ struct PieceTests { } @Test - func testPieceColorOpposite() { + func pieceColorOpposite() { #expect(Piece.Color.white.opposite == .black) #expect(Piece.Color.black.opposite == .white) } @Test - func testPieceColorPawnSinglePushRankFromStartRank() { - #expect(Piece.Color.white.pawnSinglePushTargetRankFromStart == .three) - #expect(Piece.Color.black.pawnSinglePushTargetRankFromStart == .six) + func pieceColorPawnSinglePushTargetRank() { + #expect(Piece.Color.white.pawnSinglePushTargetRank == .three) + #expect(Piece.Color.black.pawnSinglePushTargetRank == .six) } @Test - func testPieceColorPawnDoublePushRankFromStartRank() { - #expect(Piece.Color.white.pawnDoublePushTargetRankFromStart == .four) - #expect(Piece.Color.black.pawnDoublePushTargetRankFromStart == .five) + func pieceColorPawnDoublePushTargetRank() { + #expect(Piece.Color.white.pawnDoublePushTargetRank == .four) + #expect(Piece.Color.black.pawnDoublePushTargetRank == .five) } @Test - func testPieceColorPawnStartRank() { - #expect(Piece.Color.white.pawnStartRank == .two) - #expect(Piece.Color.black.pawnStartRank == .seven) + func pieceColorPawnRank() { + #expect(Piece.Color.white.pawnRank == .two) + #expect(Piece.Color.black.pawnRank == .seven) } } From 7e690e736a96647337072a48da510adc4fd8b3ed Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Fri, 9 Oct 2026 02:56:55 +0000 Subject: [PATCH 08/10] rewrap --- Sources/ChessCore/Game.swift | 4 ++-- Sources/ChessCore/Move.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/ChessCore/Game.swift b/Sources/ChessCore/Game.swift index 1552b8d..14b697f 100644 --- a/Sources/ChessCore/Game.swift +++ b/Sources/ChessCore/Game.swift @@ -30,8 +30,8 @@ private extension Move { /// 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. +/// 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. /// diff --git a/Sources/ChessCore/Move.swift b/Sources/ChessCore/Move.swift index a615d19..5078936 100644 --- a/Sources/ChessCore/Move.swift +++ b/Sources/ChessCore/Move.swift @@ -19,9 +19,9 @@ public enum Move: Hashable, Sendable { /// 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. + /// 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 From 788a8eadd875d4dcd3361da3c26f19f7938091c4 Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Fri, 9 Oct 2026 03:42:09 +0000 Subject: [PATCH 09/10] reorder --- Sources/ChessCore/Board.swift | 50 +++++++++++++++++----------------- Sources/ChessCore/Square.swift | 8 ++++-- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/Sources/ChessCore/Board.swift b/Sources/ChessCore/Board.swift index 0661147..725d60a 100644 --- a/Sources/ChessCore/Board.swift +++ b/Sources/ChessCore/Board.swift @@ -95,18 +95,6 @@ private extension Move.Translation { } } -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) - } -} - private extension Piece { var files: [Square.File] { switch figure { @@ -206,6 +194,18 @@ private extension Square { } } +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 @@ -249,6 +249,15 @@ public struct Board { })) } + /// 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) { @@ -290,15 +299,6 @@ public struct Board { dataSource?.toMove ?? .white } - /// 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] - } - /// Retrieves the moves that can be made from a specific square on the board. /// /// - Parameters: @@ -439,6 +439,10 @@ public struct Board { 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 } @@ -447,10 +451,6 @@ extension Board: Collection { pieces.startIndex } - public subscript(position: Index) -> (key: Square, value: Piece) { - pieces[position] - } - public func index(after i: Index) -> Index { pieces.index(after: i) } diff --git a/Sources/ChessCore/Square.swift b/Sources/ChessCore/Square.swift index feecb2e..38c445e 100644 --- a/Sources/ChessCore/Square.swift +++ b/Sources/ChessCore/Square.swift @@ -6,14 +6,18 @@ public struct Square: Hashable, Sendable { /// /// A file identifies a square's column on the board, labeled from `a` to `h`. public enum File: Int, CaseIterable, Sendable { - case a = 1, b, c, d, e, f, g, h + 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, two, three, four, five, six, seven, eight + case one = 1 + + case two, three, four, five, six, seven, eight } /// Square file. From d5d80da8ecd3729d95464cb71fc5331781e58b27 Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Fri, 9 Oct 2026 18:58:06 +0000 Subject: [PATCH 10/10] update README.md --- README.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f23df76..5b7054d 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,13 @@ # ChessCore -A lightweight Swift chess core library. +A Swift chess engine. ## Requirements - Swift 6+ - iOS 13+ / macOS 10.15+ -## Build +## Examples -```bash -swift build -``` - -## Test - -```bash -swift test -``` +- [Chess](https://github.com/iliaskarim/Chess) — command-line demo +- [ChessUI](https://github.com/iliaskarim/chessui) — SwiftUI interface