diff --git a/README.md b/README.md index 9e8ff62..39befd4 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ Chess Swift package ### Interactive command line program ```swift +var game = Game() print(game) -while game.victor == nil { +while !game.isGameOver { print("\n?", terminator: " ") guard let line = readLine() else { continue } do { @@ -22,45 +23,45 @@ while game.victor == nil { #### Output ``` - White to move +White to move. 8 r n b q k b n r - 7 x x x x x x x x + 7 p p p p p p p p 6 5 4 3 - 2 X X X X X X X X + 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 +Black to move. 8 r n b q k b n r - 7 x x x x x x x x + 7 p p p p p p p p 6 5 4 - 3 X - 2 X X X X X X X + 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 +White to move. 8 r n b q k b n r - 7 x x x x x x x - 6 x + 7 p p p p p p p + 6 p 5 4 - 3 X - 2 X X X X X X X + 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 @@ -68,32 +69,31 @@ while game.victor == nil { 1. f3 e6 2. g4 - Black to move +Black to move. 8 r n b q k b n r - 7 x x x x x x x - 6 x + 7 p p p p p p p + 6 p 5 - 4 X - 3 X - 2 X X X X X X + 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 +? Qh4# 1. f3 e6 2. g4 Qh4# - Game over - Black wins. +Black wins. 8 r n b k b n r - 7 x x x x x x x - 6 x + 7 p p p p p p p + 6 p 5 - 4 X q - 3 X - 2 X X X X X X + 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 ``` diff --git a/Sources/ChessCore/Game.swift b/Sources/ChessCore/Game.swift deleted file mode 100644 index 275a5e8..0000000 --- a/Sources/ChessCore/Game.swift +++ /dev/null @@ -1,725 +0,0 @@ -import Foundation - -// MARK: Piece -private extension Piece { - var forwardUnitVector: Vector { - .forwardUnitVector(color) - } - - var startSquares: [Square] { - figure.startFiles.map { file in - .init(file: file, rank: figure.startRank(color: color)) - } - } - - func moves(originSquare: Square, isCapture: Bool) -> [[Square]] { - switch (figure, isCapture) { - case (.bishop, _): - return Vector.diagonalUnitVectors.compactMap(originSquare.allSquaresInDirection) - - case (.king, _): - return Vector.unitVectors.compactMap { direction in - (originSquare + direction).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)].reduce(into: .init()) { targetSquares, vector in - targetSquares += (originSquare + vector).map { targetSquare in - [[targetSquare]] - } ?? [] - } - - case (.pawn, false): - return (originSquare + forwardUnitVector).map { oneSquareForward in - guard let twoSquaresForward = oneSquareForward + forwardUnitVector, - originSquare.rank == figure.startRank(color: color) else { - return [[oneSquareForward]] - } - return [[oneSquareForward, twoSquaresForward]] - } ?? [] - - case (.pawn, true): - return [Vector(files: -1), Vector(files: 1)].compactMap { direction in - (originSquare + direction + forwardUnitVector).map { targetSquare in - [targetSquare] - } - } - - case (.queen, _): - return Vector.unitVectors.compactMap(originSquare.allSquaresInDirection) - - case (.rook, _): - return Vector.cardinalUnitVectors.compactMap(originSquare.allSquaresInDirection) - } - } -} - -private extension Piece.Color { - var opposite: Self { - switch self { - case .white: - return .black - - case .black: - return .white - } - } -} - -private extension Piece.Figure { - var startFiles: [Square.File] { - switch self { - case .pawn: - return Square.File.allCases - - case .rook: - return [.a, .h] - - case .knight: - return [.b, .g] - - case .bishop: - return [.c, .f] - - case .queen: - return [.d] - - case .king: - return [.e] - } - } - - func startRank(color: Piece.Color) -> Square.Rank { - switch (self, color) { - case (.pawn, .black): - return .seven - - case (.pawn, .white): - return .two - - case (_, .black): - return .eight - - case (_, .white): - return .one - } - } - - init?(_ character: Character) { - self.init(rawValue: String(character)) - } -} - -// MARK: Square -private extension Square { - static let a1: Self = .init(file: .a, rank: .one) - - static let b1: Self = .init(file: .b, rank: .one) - - static let c1: Self = .init(file: .c, rank: .one) - - static let d1: Self = .init(file: .d, rank: .one) - - static let e1: Self = .init(file: .e, rank: .one) - - static let f1: Self = .init(file: .f, rank: .one) - - static let g1: Self = .init(file: .g, rank: .one) - - static let h1: Self = .init(file: .h, rank: .one) - - static let a8: Self = .init(file: .a, rank: .eight) - - static let b8: Self = .init(file: .b, rank: .eight) - - static let c8: Self = .init(file: .c, rank: .eight) - - static let d8: Self = .init(file: .d, rank: .eight) - - static let e8: Self = .init(file: .e, rank: .eight) - - static let f8: Self = .init(file: .f, rank: .eight) - - static let g8: Self = .init(file: .g, rank: .eight) - - static let h8: Self = .init(file: .h, rank: .eight) - - func allSquaresInDirection(_ direction: Vector) -> [Self] { - (self + direction).map { squareInDirection -> [Square] in - [squareInDirection] + squareInDirection.allSquaresInDirection(direction) - } ?? [] - } - - init?(notation: String) { - guard let file = notation.first.map({ fileNotation in - File(fileNotation) - }), let rank = notation.last.map({ rankNotation in - Rank(rankNotation) - }), notation.count == 2 else { - return nil - } - self.init(file: file, rank: rank) - } - - init?(file: File?, rank: Rank?) { - guard let file, let rank else { - return nil - } - self = .init(file: file, rank: rank) - } -} - -private extension Optional where Wrapped == Square { - static func + (lhs: Square?, rhs: Vector) -> Square? { - lhs.map { lhs in - .init(file: lhs.file + rhs.files, rank: lhs.rank + rhs.ranks) - } ?? nil - } - - static func - (lhs: Square?, rhs: Vector) -> Square? { - lhs.map { lhs in - .init(file: lhs.file - rhs.files, rank: lhs.rank - rhs.ranks) - } ?? nil - } -} - -private extension Square.File { - static func + (lhs: Square.File, rhs: Int) -> Square.File? { - .init(integerValue: lhs.integerValue + rhs) - } - - static func - (lhs: Square.File, rhs: Int) -> Square.File? { - Self.init(integerValue: lhs.integerValue - rhs) - } - - var integerValue: Int { - Self.allCases.firstIndex(of: self)! + 1 - } - - init?(integerValue: Int) { - guard Self.allCases.indices.contains(integerValue - 1) else { - return nil - } - self = Self.allCases[integerValue - 1] - } - - init?(_ character: Character) { - self.init(rawValue: String(character)) - } -} - -private extension Square.Rank { - static func + (lhs: Square.Rank, rhs: Int) -> Square.Rank? { - .init(rawValue: lhs.rawValue + rhs) - } - - static func - (lhs: Square.Rank, rhs: Int) -> Square.Rank? { - .init(rawValue: lhs.rawValue - rhs) - } - - static func - (lhs: Square.Rank, rhs: Square.Rank) -> Int { - lhs.rawValue - rhs.rawValue - } - - init?(_ character: Character) { - guard let int = Int(String(character)) else { - return nil - } - self.init(rawValue: int) - } -} - -extension Square: CustomStringConvertible { - public var description: String { - file.rawValue.appending(String(rank.rawValue)) - } -} - -// MARK: Vector -extension Vector { - static let cardinalUnitVectors: [Vector] = [ - .init(files: 0, ranks: -1), - .init(files: -1, ranks: 0), - .init(files: 0, ranks: 1), - .init(files: 1, ranks: 0) - ] - - static let diagonalUnitVectors: [Vector] = [ - .init(files: -1, ranks: -1), - .init(files: -1, ranks: 1), - .init(files: 1, ranks: -1), - .init(files: 1, ranks: 1) - ] - - static let unitVectors = cardinalUnitVectors + diagonalUnitVectors - - static func forwardUnitVector(_ color: Piece.Color) -> Vector { - Vector(ranks: color == .black ? -1 : 1) - } -} - - -// MARK: - Game - -/// A model representing a chess game. -/// -/// Chess is a board game played between two players. -public struct Game { - /// Board - public typealias Board = [Square: Piece] - - fileprivate enum Outcome { - case checkmate(victor: Piece.Color) - case drawnGame(isStalemate: Bool) - case resignedGame(victor: Piece.Color) - } - - private struct InvalidMove: Error { - let notation: String - } - - private var board: Board - - private var enPassant: Square? - - private var kingsMoved = (black: false, white: false) - - private var rooksMoved = (black: (kingside: false, queenside: false), white: (kingside: false, queenside: false)) - - private var moveColor: Piece.Color { - moves.count.isMultiple(of: 2) ? .white : .black - } - - private var moves = [String]() - - private var outcome: Outcome? - - private var victor: Piece.Color? { - switch outcome { - case let .checkmate(victor): - return victor - - case let .resignedGame(victor): - return victor - - default: - return nil - } - } - - /// Move - /// - Parameter notation: Notation - public mutating func move(_ notation: String) throws { - switch (notation, moveColor) { - case ("1-0", _): - if moveColor == .black { - moves += [notation] - } - outcome = .resignedGame(victor: .white) - return - - case ("0-1", _): - if moveColor == .white { - moves += [notation] - } - outcome = .resignedGame(victor: .black) - return - - case ("1/2-1/2", _): - moves += [notation] - outcome = .drawnGame(isStalemate: false) - return - - case ("O-O", .black): - guard !board.isCheck(color: .black, enPassant: enPassant), !kingsMoved.black, - !rooksMoved.black.kingside, board[.f8] == nil, board[.g8] == nil, - board[.h8] == Piece(color: .black, figure: .rook) else { - throw InvalidMove(notation: notation) - } - - var mutableBoard = board - mutableBoard[.f8] = mutableBoard.removeValue(forKey: .e8) - guard !mutableBoard.isCheck(color: .black, enPassant: enPassant) else { - throw InvalidMove(notation: notation) - } - - mutableBoard[.g8] = mutableBoard.removeValue(forKey: .f8) - guard !mutableBoard.isCheck(color: .black, enPassant: enPassant) else { - throw InvalidMove(notation: notation) - } - - board = mutableBoard - board[.f8] = board.removeValue(forKey: .h8) - - case ("O-O", .white): - guard !board.isCheck(color: .white, enPassant: enPassant), !kingsMoved.white, - !rooksMoved.white.kingside, board[.f1] == nil, board[.g1] == nil, - board[.h1] == Piece(color: .white, figure: .rook) else { - throw InvalidMove(notation: notation) - } - - var mutableBoard = board - mutableBoard[.f1] = mutableBoard.removeValue(forKey: .e1) - guard !mutableBoard.isCheck(color: .white, enPassant: enPassant) else { - throw InvalidMove(notation: notation) - } - - mutableBoard[.g1] = mutableBoard.removeValue(forKey: .f1) - guard !mutableBoard.isCheck(color: .white, enPassant: enPassant) else { - throw InvalidMove(notation: notation) - } - - board = mutableBoard - board[.f1] = board.removeValue(forKey: .h1) - - case ("O-O-O", .black): - guard !board.isCheck(color: .black, enPassant: enPassant), !kingsMoved.black, - !rooksMoved.black.queenside, board[.d8] == nil, board[.c8] == nil, board[.b8] == nil, - board[.a8] == Piece(color: .black, figure: .rook) else { - throw InvalidMove(notation: notation) - } - - var mutableBoard = board - mutableBoard[.d8] = mutableBoard.removeValue(forKey: .e8) - guard !mutableBoard.isCheck(color: .black, enPassant: enPassant) else { - throw InvalidMove(notation: notation) - } - - mutableBoard[.c8] = mutableBoard.removeValue(forKey: .d8) - guard !mutableBoard.isCheck(color: .black, enPassant: enPassant) else { - throw InvalidMove(notation: notation) - } - - board = mutableBoard - board[.d8] = board.removeValue(forKey: .a8) - - case ("O-O-O", .white): - guard !board.isCheck(color: .white, enPassant: enPassant), !kingsMoved.white, !rooksMoved.white.queenside, - board[.d1] == nil, board[.c1] == nil, board[.b1] == nil, - board[.a1] == Piece(color: .white, figure: .rook) else { - throw InvalidMove(notation: notation) - } - - var mutableBoard = board - mutableBoard[.d1] = mutableBoard.removeValue(forKey: .e1) - guard !mutableBoard.isCheck(color: .white, enPassant: enPassant) else { - throw InvalidMove(notation: notation) - } - - mutableBoard[.c1] = mutableBoard.removeValue(forKey: .d1) - guard !mutableBoard.isCheck(color: .white, enPassant: enPassant) else { - throw InvalidMove(notation: notation) - } - - board = mutableBoard - board[.d1] = board.removeValue(forKey: .a1) - - default: - let isCapture = notation.contains("x") - let filteredNotation = notation.filter { character in - !["x", "+", "#"].contains(character) - } - let nextIndex = filteredNotation.index(after: filteredNotation.startIndex) - let piece: Piece - - if let figureNotation = notation.first, let figure = Piece.Figure(figureNotation) { - piece = Piece(color: moveColor, figure: figure) - } else { - piece = Piece(color: moveColor, figure: .pawn) - } - - var targetSquareNotation: Substring - - if piece.figure == .pawn, !isCapture { - targetSquareNotation = filteredNotation[filteredNotation.startIndex.. Bool { - contains { square, piece in - piece.color == color.opposite && - moves(.capture(originSquare: square, enPassant: enPassant)).compactMap { targetSquare in - self[targetSquare] - }.contains(Piece(color: color, figure: .king)) - } - } - - func isCheckmate(color: Piece.Color, enPassant: Square?) -> Bool { - !contains { square, piece in - piece.color == color && - (moves(.move(originSquare: square)) + moves(.capture(originSquare: square, enPassant: enPassant))).contains { targetSquare in - !mutatedBoard(originSquare: square, targetSquare: targetSquare).isCheck(color: color, enPassant: enPassant) - } - } - } - - func moves(_ move: Move) -> [Square] { - self[move.originSquare].map { piece in - piece.moves(originSquare: move.originSquare, isCapture: move.isCapture).flatMap { path in - let offset = path.enumerated().first { _, targetSquare in - self[targetSquare] != nil - }?.offset - - guard case let .capture(_, enPassant) = move else { - return path.prefix(upTo: offset ?? path.endIndex) - } - - guard let offset, self[path[offset]]?.color == piece.color.opposite else { - guard piece.figure == .pawn, let targetSquare = path.first, - targetSquare == enPassant + piece.forwardUnitVector else { - return [] - } - return [targetSquare] - } - - return path[offset.. Self { - var board = self - if let piece = board[origin], let enPassant = target - piece.forwardUnitVector, piece.figure == .pawn, - origin.file != target.file, board[target] == nil { - board[enPassant] = nil - } - board[target] = board.removeValue(forKey: origin) - return board - } -} - -extension Game: CustomStringConvertible { - public var description: String { - (!moves.isEmpty ? stride(from: 0, to: moves.count, by: 2).map { i in - "\(i/2+1). " - .appending(moves[i]) - .appending(moves.count > i+1 ? " \(moves[i+1])" : "") - }.joined(separator: "\n") - .appending("\n\n") : "") - .appending(" \(outcome?.description ?? moveColor.rawValue.capitalized.appending(" to move."))\n\n") - .appending( - Square.Rank.allCases.reversed().map { rank in - " ".appending( - String(rank.rawValue).appending(" ").appending( - Square.File.allCases.map { file in - if let piece = board[Square(file: file, rank: rank)] { - return piece.color == .white ? piece.figure.rawValue : piece.figure.rawValue.lowercased() - } else { - return " " - } - }.joined(separator: " ") - ) - ) - }.joined(separator: "\n").appending("\n ").appending( - Square.File.allCases.map { file in - file.rawValue - }.joined(separator: " ") - ) - ) - } -} - -extension Game.Outcome: CustomStringConvertible { - var description: String { - switch self { - case let .checkmate(victor): - return "\(victor.rawValue.capitalized) wins." - - case .drawnGame: - return "Game drawn." - - case let .resignedGame(victor): - return "\(victor.rawValue.capitalized) wins." - } - } -} diff --git a/Sources/ChessCore/Models/Board.swift b/Sources/ChessCore/Models/Board.swift new file mode 100644 index 0000000..6fac5f2 --- /dev/null +++ b/Sources/ChessCore/Models/Board.swift @@ -0,0 +1,23 @@ + +// MARK: - Board + +/// A model representing a chess board. +/// +/// Chess boards consist of black and white figures arranged on an eight-by-eight grid. +public struct Board { + typealias Mutation = (originSquare: Square, targetSquare: Square, promotion: Piece.Figure?) + + let enPassant: Square? + + var moves = [Notation]() + + let pieces: [Square: Piece] + + let squaresTouched: [Square] + + init(pieces: [Square : Piece], enPassant: Square? = nil, squaresTouched: [Square] = []) { + self.pieces = pieces + self.enPassant = enPassant + self.squaresTouched = squaresTouched + } +} diff --git a/Sources/ChessCore/Models/Errors/IllegalMove.swift b/Sources/ChessCore/Models/Errors/IllegalMove.swift new file mode 100644 index 0000000..3f47a59 --- /dev/null +++ b/Sources/ChessCore/Models/Errors/IllegalMove.swift @@ -0,0 +1,16 @@ + +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 new file mode 100644 index 0000000..c434c92 --- /dev/null +++ b/Sources/ChessCore/Models/Errors/InvalidNotation.swift @@ -0,0 +1,14 @@ + +enum InvalidNotation: Error { + enum BadPunctuation { + case isCheck + case isCheckmate + case isNotCheck + case isNotCheckmate + } + + case ambiguous + case badMove + case badPunctuation(_: BadPunctuation) + case unparseable(notation: String) +} diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift new file mode 100644 index 0000000..739226b --- /dev/null +++ b/Sources/ChessCore/Models/Game.swift @@ -0,0 +1,518 @@ +import Foundation + +// MARK: - +public extension Board { + static var board: Board { + let allPieces = Piece.Color.allCases.flatMap { color in + Piece.Figure.allCases.map { figure in + Piece(color: color, figure: figure) + } + } + + return Board(pieces: allPieces.reduce(into: .init()) { pieces, piece in + let files: [Square.File] + switch piece.figure { + case .bishop: + files = [.c, .f] + + case .king: + files = [.e] + + case .knight: + files = [.b, .g] + + case .pawn: + files = Square.File.allCases + + case .queen: + files = [.d] + + case .rook: + files = [.a, .h] + } + + pieces = files.reduce(into: pieces) { pieces, file in + let rank: Square.Rank + if case piece.figure = .pawn { + rank = piece.color == .black ? .seven : .two + } else { + rank = piece.color == .black ? .eight : .one + } + pieces[.init(file: file, rank: rank)] = piece + } + }) + } +} + +private extension Board { + var isCheckmate: Bool { + isCheck(color: moveColor) && isNoMovePossible + } + + var isNoMovePossible: Bool { + !pieces.filter { _, piece in + piece.color == moveColor + }.flatMap { square, _ in + (moves(from: square, isCapture: false) + moves(from: square, isCapture: true)).map { targetSquare in + Mutation(originSquare: square, targetSquare: targetSquare, promotion: nil) + } + }.contains { mutation in + mutatedBoard(mutations: [mutation]) != nil + } + } + + var moveColor: Piece.Color { + moves.count.isMultiple(of: 2) ? .white : .black + } + + func isCheck(color: Piece.Color) -> Bool { + pieces.flatMap { square, _ in + moves(from: square, isCapture: true) + }.contains { targetSquare in + pieces[targetSquare] == .init(color: color, figure: .king) + } + } + + func moves(from originSquare: Square, isCapture: Bool) -> [Square] { + let piece = pieces[originSquare]! + let paths = isCapture ? piece.capturePaths(from: originSquare) : piece.movePaths(from: originSquare) + return paths.flatMap { path in + let obstruction = path.enumerated().first { _, square in + pieces[square] != nil + } + + // Non-capture moves can move a piece up to the first obstruction in its path or the end of the path if its unobstructed. + guard isCapture else { + return path.prefix(upTo: obstruction?.0 ?? path.endIndex) + } + + guard let obstruction, pieces[obstruction.1]!.color != piece.color else { + guard let enPassant, piece.figure == .pawn, path.first == enPassant + piece.forwardUnitVector else { + return [] + } + + // En passant captures are the only captures where the captured piece is not in the capture path. + return [path.first!] + } + + // All other captures take the first opposing piece in the path. + return path[obstruction.0 ..< obstruction.0 + 1] + } + } + + func mutatedBoard(mutations: [Mutation]) -> Self? { + mutations.reduce(self) { board, mutation in + guard let board else { + return nil + } + + // Replace pieces. + let piece = board.pieces[mutation.originSquare]! + var pieces = board.pieces + pieces[mutation.originSquare] = nil + if let enPassant = board.enPassant, piece.figure == .pawn, mutation.targetSquare == enPassant + piece.forwardUnitVector { + pieces[enPassant] = nil + } + pieces[mutation.targetSquare] = Piece(color: piece.color, figure: mutation.promotion ?? piece.figure) + + // Check for pawns that can be captured en passant. + let enPassant: Square? + if piece.figure == .pawn, abs(mutation.originSquare.rank.rawValue - mutation.targetSquare.rank.rawValue) == 2 { + enPassant = mutation.targetSquare + } else { + enPassant = nil + } + + // Update touched squares. + let squaresTouched = squaresTouched + (squaresTouched.contains(mutation.targetSquare) ? [] : [mutation.targetSquare]) + let mutatedBoard = Board(pieces: pieces, enPassant: enPassant, squaresTouched: squaresTouched) + + // Forbid moving into check. + guard !mutatedBoard.isCheck(color: moveColor) else { + return nil + } + + return mutatedBoard + } + } +} + +extension Board: CustomStringConvertible { + public var description: String { + let state: String + if case let .end(victor) = moves.last { + switch victor { + case let .some(color): + state = "\(color.description.capitalized) \(String.wins)" + case nil: + state = .drawGame + } + } else if isCheckmate { + state = "\((moveColor == .black ? Piece.Color.white : Piece.Color.black).description.capitalized) \(String.wins)" + } else if isNoMovePossible { + state = .drawGame + } else { + state = "\(moveColor.description.capitalized) \(String.toMove)" + } + + let grid = Square.Rank.allCases.reversed().map { rank in + " \(rank) ".appending(Square.File.allCases.map { file in + pieces[Square(file: file, rank: rank)]?.description ?? " " + }.joined(separator: " ")) + }.joined(separator: "\n") + .appending("\n ") + .appending(Square.File.allCases.map(\.description).joined(separator: " ")) + + return "\(state).\n\n\(grid)" + } +} + +// MARK: - +extension Notation: CustomStringConvertible { + var description: String { + switch self { + case let .end(victor): + guard let victor else { + return .draw + } + return victor == .black ? .blackVictory : .whiteVictory + + case let .gameplay(gameplay): + return "\(gameplay)" + } + } +} + +extension Notation.Gameplay: CustomStringConvertible { + var description: String { + "\(play)\(punctuation?.description ?? "")" + } +} + +extension Notation.Gameplay.Play: CustomStringConvertible { + var description: String { + switch self { + case let .castle(castle): + switch castle { + case .long: + return .castleLong + + case .short: + return .castleShort + } + + case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): + let disambiguation = "\(disambiguationFile?.description ?? "")\(disambiguationRank?.description ?? "")" + let promotion = promotion.map(\.description).map(String.promotion.appending) ?? "" + return "\(figure)\(disambiguation)\(isCapture ? .capture : "")\(targetSquare)\(promotion)" + } + } +} + +extension Notation.Gameplay.Punctuation: CustomStringConvertible { + var description: String { + rawValue + } +} + +// MARK: - +private extension Piece { + var forwardUnitVector: Vector { + Vector(ranks: color == .black ? -1 : 1) + } + + func capturePaths(from square: Square) -> [[Square]] { + guard .pawn == figure else { + return movePaths(from: square) + } + + return [Vector(files: -1, ranks: forwardUnitVector.ranks), + Vector(files: 1, ranks: forwardUnitVector.ranks)].compactMap { vector in + (square + vector).map { targetSquare in + [targetSquare] + } + } + } + + func movePaths(from square: Square) -> [[Square]] { + switch figure { + case .bishop: + return Vector.diagonalUnitVectors.compactMap(square.allSquaresInDirection) + + case .king: + return Vector.unitVectors.compactMap { vector in + (square + vector).map { targetSquare in + [targetSquare] + } + } + + case .knight: + return [Vector(files: -2, ranks: -1), + Vector(files: -2, ranks: 1), + Vector(files: -1, ranks: -2), + Vector(files: -1, ranks: 2), + Vector(files: 1, ranks: -2), + Vector(files: 1, ranks: 2), + Vector(files: 2, ranks: -1), + Vector(files: 2, ranks: 1)].compactMap { vector in + (square + vector).map { targetSquare in + [targetSquare] + } + } + + case .pawn: + let oneSquareForward = (square + forwardUnitVector)! + let isOnStartRank = square.rank == .two || square.rank == .seven + return [[oneSquareForward, isOnStartRank ? oneSquareForward + forwardUnitVector : nil].compactMap { targetSquare in + targetSquare + }] + + case .queen: + return Vector.unitVectors.compactMap(square.allSquaresInDirection) + + case .rook: + return Vector.cardinalUnitVectors.compactMap(square.allSquaresInDirection) + } + } +} + +extension Piece: CustomStringConvertible { + public var description: String { + let description = figure == .pawn ? "P" : figure.rawValue + return color == .black ? description.lowercased() : description + } +} + +extension Piece.Color: CustomStringConvertible { + public var description: String { + rawValue + } +} + +extension Piece.Figure: CustomStringConvertible { + public var description: String { + rawValue + } +} + +// MARK: - +private extension Square { + static func + (lhs: Self, rhs: Vector) -> Self? { + guard let file = File(rawValue: lhs.file.rawValue + rhs.files), let rank = Rank(rawValue: lhs.rank.rawValue + rhs.ranks) else { + return nil + } + return .init(file: file, rank: rank) + } + + func allSquaresInDirection(_ direction: Vector) -> [Self] { + (self + direction).map { squareInDirection in + [squareInDirection] + squareInDirection.allSquaresInDirection(direction) + } ?? [] + } +} + +extension Square: CustomStringConvertible { + public var description: String { + "\(file)\(rank)" + } +} + +extension Square.File: CustomStringConvertible { + var description: String { + String(Character(UnicodeScalar(rawValue + 96)!)) + } +} + +extension Square.Rank: CustomStringConvertible { + var description: String { + String(rawValue) + } +} + +// MARK: - +extension Vector { + static let cardinalUnitVectors: [Vector] = [ + .init(files: -1, ranks: 0), + .init(files: 0, ranks: -1), + .init(files: 0, ranks: 1), + .init(files: 1, ranks: 0) + ] + + static let diagonalUnitVectors: [Vector] = [ + .init(files: -1, ranks: -1), + .init(files: -1, ranks: 1), + .init(files: 1, ranks: -1), + .init(files: 1, ranks: 1) + ] + + static let unitVectors = cardinalUnitVectors + diagonalUnitVectors +} + +// MARK: - Game + +/// A model representing a chess game. +/// +/// Chess is a board game played between two players. +public struct Game { + /// Game state + public var isGameOver: Bool { + guard case .end = board.moves.last else { + return board.isNoMovePossible + } + return true + } + + /// Game board + private(set) var board: Board + + /// Move + /// - Parameter notation: Notation + public mutating func move(_ notationString: String) throws { + guard let notation = Notation(string: notationString) else { + throw InvalidNotation.unparseable(notation: notationString) + } + + guard case let .gameplay(gameplay) = notation else { + board.moves += [notation] + return + } + + let mutations: [Board.Mutation] + + switch gameplay.play { + case let .castle(castle): + guard !board.isCheck(color: board.moveColor) else { + throw IllegalMove.cannotCastle(.inCheck) + } + + let rank: Square.Rank = board.moveColor == .black ? .eight : .one + guard !(castle == .long ? [.b, .c, .d] : [.f, .g]).map({ file in + Square(file: file, rank: rank) + }).contains(where: board.pieces.keys.contains) else { + throw IllegalMove.cannotCastle(.obstructed) + } + + let kingOriginSquare = Square(file: .e, rank: rank) + let rookOriginSquare = Square(file: castle == .long ? .a : .h, rank: rank) + for (figure, square) in [Piece.Figure.king: kingOriginSquare, Piece.Figure.rook: rookOriginSquare] { + guard board.pieces[square] == .init(color: board.moveColor, figure: figure) else { + throw IllegalMove.cannotCastle(.pieceOutOfPosition(figure: figure)) + } + + guard !board.squaresTouched.contains(square) else { + throw IllegalMove.cannotCastle(.pieceMoved(figure: figure)) + } + } + + let rookTargetSquare = Square(file: castle == .long ? .d : .f, rank: rank) + mutations = [ + (originSquare: kingOriginSquare, targetSquare: rookTargetSquare, promotion: nil), + (originSquare: rookTargetSquare, targetSquare: .init(file: castle == .long ?.c : .g, rank: rank), promotion: nil), + (originSquare: rookOriginSquare, targetSquare: rookTargetSquare, promotion: nil) + ] + + case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): + let eligibleSquares: [Square] = board.pieces.compactMap { square, piece in + guard piece == .init(color: board.moveColor, figure: figure) else { + return nil + } + + if let disambiguationFile, square.file != disambiguationFile { + return nil + } + + if let disambiguationRank, square.rank != disambiguationRank { + return nil + } + + return board.moves(from: square, isCapture: isCapture).contains(targetSquare) ? square : nil + } + + guard let originSquare = eligibleSquares.first else { + throw InvalidNotation.badMove + } + + guard eligibleSquares.count == 1 else { + throw InvalidNotation.ambiguous + } + + // Pawns must be promoted when they reach the end of the board. + let promotionRank: Square.Rank = board.moveColor == .black ? .one : .eight + guard promotion != nil || figure != .pawn || targetSquare.rank != promotionRank else { + throw IllegalMove.figureMustPromote + } + + if let promotion { + // Only pawns can be promoted + guard figure == .pawn else { + throw IllegalMove.figureCannotPromote + } + + // only when they reach the end of the board + guard targetSquare.rank == promotionRank else { + throw IllegalMove.mustReachEndOfBoardToPromote + } + + // and they must be promoted to bishop, knight, rook or queen. + guard ![Piece.Figure.king, Piece.Figure.pawn].contains(promotion) else { + throw IllegalMove.cannotPromoteToFigure + } + } + + mutations = [(originSquare: originSquare, targetSquare: targetSquare, promotion: promotion)] + } + + guard var mutatedBoard = board.mutatedBoard(mutations: mutations) else { + throw IllegalMove.cannotMoveIntoCheck + } + + mutatedBoard.moves = self.board.moves + [notation] + + // Compute game state. + let isCheck = mutatedBoard.isCheck(color: mutatedBoard.moveColor) + let isCheckmate = mutatedBoard.isCheckmate + + // Validate punctuation parsed from input notation. + switch gameplay.punctuation { + case .check: + guard isCheck else { + throw InvalidNotation.badPunctuation(.isNotCheck) + } + guard !isCheckmate else { + throw InvalidNotation.badPunctuation(.isCheckmate) + } + + case .checkmate: + guard isCheckmate else { + throw InvalidNotation.badPunctuation(.isNotCheckmate) + } + + case .none: + guard !isCheckmate else { + throw InvalidNotation.badPunctuation(.isCheckmate) + } + guard !isCheck else { + throw InvalidNotation.badPunctuation(.isCheck) + } + } + + self.board = mutatedBoard + } + + /// Designated initializer + public init(board: Board = .board) { + self.board = board + } +} + +extension Game: CustomStringConvertible { + public var description: String { + let moves = board.moves.isEmpty ? "" : stride(from: 0, to: board.moves.count, by: 2).map { i in + "\(i / 2 + 1). " + .appending(board.moves[i].description) + .appending(board.moves.count > i + 1 ? " \(board.moves[i + 1])" : "") + }.joined(separator: "\n") + + return "\(moves)\n\n\(board)" + } +} diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift new file mode 100644 index 0000000..1a01967 --- /dev/null +++ b/Sources/ChessCore/Models/Notation.swift @@ -0,0 +1,128 @@ + +// MARK: - Notation + +enum Notation { + struct Gameplay { + enum Play { + enum Castle { + case long + case short + } + + case castle(castle: Castle) + case translation(disambiguationFile: Square.File?, disambiguationRank: Square.Rank?, figure: Piece.Figure, + isCapture: Bool, promotion: Piece.Figure?, targetSquare: Square) + } + + enum Punctuation: String { + case check = "+" + case checkmate = "#" + + fileprivate init?(_ character: Character) { + self.init(rawValue: String(character)) + } + } + + let play: Play + let punctuation: Punctuation? + + init(play: Play, punctuation: Punctuation?) { + self.play = play + self.punctuation = punctuation + } + + fileprivate init?(_ string: String) { + var string = string + + punctuation = string.last.map(Punctuation.init) ?? nil + if punctuation != nil { + string = String(string.dropLast()) + } + + switch string { + case .castleLong: + play = .castle(castle: .long) + + case .castleShort: + play = .castle(castle: .short) + + default: + let figure = (string.first.map(Piece.Figure.init) ?? nil) ?? .pawn + if figure != .pawn { + string = String(string.dropFirst()) + } + + let isCapture = string.contains(String.capture) + string = string.replacingOccurrences(of: String.capture, with: "") + + let promotion = string.last.map(Piece.Figure.init) ?? nil + if promotion != nil { + string = String(string.dropLast()) + guard string.last == Character(String.promotion) else { + return nil + } + string = String(string.dropLast()) + } + + let disambiguationFile: Square.File? + let disambiguationRank: Square.Rank? + if string.count == 4 { + guard let file = string.first.map(Square.File.init) ?? nil else { + return nil + } + disambiguationFile = file + string = String(string.dropFirst()) + + guard let rank = string.first.map(Square.Rank.init) ?? nil else { + return nil + } + disambiguationRank = rank + string = String(string.dropFirst()) + } else if string.count == 3 { + if let file = string.first.map(Square.File.init) ?? nil { + disambiguationFile = file + disambiguationRank = nil + } else if let rank = string.first.map(Square.Rank.init) ?? nil { + disambiguationFile = nil + disambiguationRank = rank + } else { + return nil + } + string = String(string.dropFirst()) + } else { + disambiguationFile = nil + disambiguationRank = nil + } + + guard let targetSquare = Square(String(string)) else { + return nil + } + + play = .translation(disambiguationFile: disambiguationFile, disambiguationRank: disambiguationRank, figure: figure, + isCapture: isCapture, promotion: promotion, targetSquare: targetSquare) + } + } + } + + case end(victor: Piece.Color?) + case gameplay(_ gameplay: Gameplay) + + init?(string: String) { + switch string { + case .whiteVictory: + self = .end(victor: .white) + + case .blackVictory: + self = .end(victor: .black) + + case .draw: + self = .end(victor: nil) + + default: + guard let gameplay = Gameplay(string) else { + return nil + } + self = .gameplay(gameplay) + } + } +} diff --git a/Sources/ChessCore/Models/Piece.swift b/Sources/ChessCore/Models/Piece.swift new file mode 100644 index 0000000..8750815 --- /dev/null +++ b/Sources/ChessCore/Models/Piece.swift @@ -0,0 +1,46 @@ + +/// A model representing a chess piece. +public struct Piece: Equatable { + /// Color + public enum Color: String, CaseIterable { + case white + case black + } + + /// Figure + public enum Figure: String, CaseIterable { + case bishop = "B" + case king = "K" + case knight = "N" + case pawn = "" + case queen = "Q" + case rook = "R" + + init?(_ character: Character) { + self.init(rawValue: String(character)) + } + } + + /// Color + let color: Color + + /// Figure + let figure: Figure + + public init(color: Color, figure: Figure) { + self.color = color + self.figure = figure + } +} + +extension Piece.Color { + var opposite: Self { + switch self { + case .white: + return .black + + case .black: + return .white + } + } +} diff --git a/Sources/ChessCore/Models/Square.swift b/Sources/ChessCore/Models/Square.swift new file mode 100644 index 0000000..d04ecb4 --- /dev/null +++ b/Sources/ChessCore/Models/Square.swift @@ -0,0 +1,47 @@ + +// MARK: - Square + +/// A model representing a square on a chess board. +public struct Square: Hashable { + /// File + enum File: Int, CaseIterable { + case a = 1, b, c, d, e, f, g, h + + internal init?(_ character: Character) { + guard let ascii = character.asciiValue else { + return nil + } + self.init(rawValue: Int(ascii) - 96) + } + } + + /// Rank + enum Rank: Int, CaseIterable { + case one = 1, two, three, four, five, six, seven, eight + + internal init?(_ character: Character) { + guard let int = Int(String(character)) else { + return nil + } + self.init(rawValue: int) + } + } + + internal let file: File + + internal let rank: Rank + + /// Designated initializer + init(file: File, rank: Rank) { + self.file = file + self.rank = rank + } + + /// Convenience initializer + public init?(_ string: String) { + guard let file = string.first.map(File.init) ?? nil, let rank = string.last.map(Rank.init) ?? nil, string.count == 2 else { + return nil + } + self.init(file: file, rank: rank) + } +} diff --git a/Sources/ChessCore/Vector.swift b/Sources/ChessCore/Models/Vector.swift similarity index 79% rename from Sources/ChessCore/Vector.swift rename to Sources/ChessCore/Models/Vector.swift index dfc633a..e6a9fa9 100644 --- a/Sources/ChessCore/Vector.swift +++ b/Sources/ChessCore/Models/Vector.swift @@ -1,4 +1,6 @@ -/// A model representing a translation on a chess board. + +// MARK: - Vector + struct Vector { /// File translation let files: Int diff --git a/Sources/ChessCore/Piece.swift b/Sources/ChessCore/Piece.swift deleted file mode 100644 index 6beeab4..0000000 --- a/Sources/ChessCore/Piece.swift +++ /dev/null @@ -1,24 +0,0 @@ -/// A model representing a chess piece. -public struct Piece: Equatable { - /// Color - enum Color: String, CaseIterable { - case white - case black - } - - /// Figure - enum Figure: String, CaseIterable { - case bishop = "B" - case king = "K" - case knight = "N" - case pawn = "X" - case queen = "Q" - case rook = "R" - } - - /// Color - let color: Color - - /// Figure - let figure: Figure -} diff --git a/Sources/ChessCore/Square.swift b/Sources/ChessCore/Square.swift deleted file mode 100644 index e377103..0000000 --- a/Sources/ChessCore/Square.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// A model representing a square on a chess board. -public struct Square: Hashable { - /// File - enum File: String, CaseIterable { - case a, b, c, d, e, f, g, h - } - - /// Rank - enum Rank: Int, CaseIterable { - case one = 1, two, three, four, five, six, seven, eight - } - - /// File - let file: File - - /// Rank - let rank: Rank -} diff --git a/Sources/ChessCore/String.swift b/Sources/ChessCore/String.swift new file mode 100644 index 0000000..291e38f --- /dev/null +++ b/Sources/ChessCore/String.swift @@ -0,0 +1,14 @@ + +extension String { + static let blackVictory = "0-1" + static let capture = "x" + static let castleLong = "O-O-O" + static let castleShort = "O-O" + static let draw = "1/2-1/2" + static let drawGame = "Draw game" + static let promotion = "=" + static let stalemate = "Stalemate" + static let toMove = "to move" + static let whiteVictory = "1-0" + static let wins = "wins" +} diff --git a/Tests/ChessCoreTests/ChessTests.swift b/Tests/ChessCoreTests/ChessTests.swift index 6a6429a..c3a980e 100644 --- a/Tests/ChessCoreTests/ChessTests.swift +++ b/Tests/ChessCoreTests/ChessTests.swift @@ -2,25 +2,40 @@ import XCTest @testable import ChessCore final class ChessTests: XCTestCase { - private var game = Game(board: [Square(file: .a, rank: .seven): Piece(color: .white, figure: .pawn)]) + 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) + } - func testPromotionToBishop() throws { - try game.move("a8=B") - } - - func testPromotionToKing() { - XCTAssertThrowsError(try game.move("a8=K")) - } - - func testPromotionToKnight() throws { - try game.move("a8=N") + for move in ["Na3", "Nc3", "Nf3", "Nh3"] { + var game = Game() + try game.move(move) + print(game) + } } - func testPromotionToRook() throws { - try game.move("a8=R") + 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 testPromotionToQueen() throws { - try game.move("a8=Q") + 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) } }