From c2558d57ea44de748afccc7f0f25c501c22b1f64 Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Mon, 24 Jun 2024 14:28:40 -0400 Subject: [PATCH 1/5] refactor board --- Sources/ChessCore/Game.swift | 725 -------------------- Sources/ChessCore/Models/Board.swift | 277 ++++++++ Sources/ChessCore/Models/Game.swift | 269 ++++++++ Sources/ChessCore/Models/Notation.swift | 142 ++++ Sources/ChessCore/{ => Models}/Piece.swift | 21 +- Sources/ChessCore/Models/Square.swift | 54 ++ Sources/ChessCore/{ => Models}/Vector.swift | 2 +- Sources/ChessCore/Square.swift | 18 - Sources/ChessCore/String.swift | 12 + Tests/ChessCoreTests/ChessTests.swift | 107 ++- 10 files changed, 880 insertions(+), 747 deletions(-) delete mode 100644 Sources/ChessCore/Game.swift create mode 100644 Sources/ChessCore/Models/Board.swift create mode 100644 Sources/ChessCore/Models/Game.swift create mode 100644 Sources/ChessCore/Models/Notation.swift rename Sources/ChessCore/{ => Models}/Piece.swift (55%) create mode 100644 Sources/ChessCore/Models/Square.swift rename Sources/ChessCore/{ => Models}/Vector.swift (79%) delete mode 100644 Sources/ChessCore/Square.swift create mode 100644 Sources/ChessCore/String.swift 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..6e6fdb3 --- /dev/null +++ b/Sources/ChessCore/Models/Board.swift @@ -0,0 +1,277 @@ + +// MARK: - +private extension Piece { + var forwardUnitVector: Vector { + Vector(ranks: color == .black ? -1 : 1) + } + + var startRank: Square.Rank { + guard case figure = .pawn else { + return color == .black ? .eight : .one + } + + return color == .black ? .seven : .two + } + + var startSquares: [Square] { + switch figure { + case .pawn: + return Square.File.allCases.map { file in + .init(file: file, rank: startRank) + } + + case .rook: + return [.init(file: .a, rank: startRank), .init(file: .h, rank: startRank)] + + case .knight: + return [.init(file: .b, rank: startRank), .init(file: .g, rank: startRank)] + + case .bishop: + return [.init(file: .c, rank: startRank), .init(file: .f, rank: startRank)] + + case .queen: + return [.init(file: .d, rank: startRank)] + + case .king: + return [.init(file: .e, rank: startRank)] + } + } + + 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)].compactMap { vector in + (originSquare + vector).map { targetSquare in + [targetSquare] + } + } + + case (.pawn, false): + guard let oneSquareForward = originSquare + forwardUnitVector else { + return [] + } + + guard let twoSquaresForward = oneSquareForward + forwardUnitVector, originSquare.rank == startRank else { + return [[oneSquareForward]] + } + + return [[oneSquareForward, twoSquaresForward]] + + case (.pawn, true): + return [Vector(files: -1, ranks: forwardUnitVector.ranks), Vector(files: 1, ranks: forwardUnitVector.ranks)].compactMap { vector in + (originSquare + vector).map { targetSquare in + [targetSquare] + } + } + + case (.queen, _): + return Vector.unitVectors.compactMap(originSquare.allSquaresInDirection) + + case (.rook, _): + return Vector.cardinalUnitVectors.compactMap(originSquare.allSquaresInDirection) + } + } +} + +// MARK: - +private extension Square { + static func - (lhs: Square, rhs: Vector) -> Square? { + lhs + Vector(files: -1 * rhs.files, ranks: -1 * rhs.ranks) + } + + func allSquaresInDirection(_ direction: Vector) -> [Self] { + guard let squareInDirection = self + direction else { + return [] + } + + return [squareInDirection] + squareInDirection.allSquaresInDirection(direction) + } +} + +// MARK: - +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 +} + +// 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) + + enum Move { + case move(originSquare: Square) + case capture(originSquare: Square) + + var isCapture: Bool { + guard case .capture = self else { + return false + } + + return true + } + + var originSquare: Square { + switch self { + case let .move(originSquare): + return originSquare + + case let .capture(originSquare): + return originSquare + } + } + } + + public static var board: Board { + Board(pieces: Piece.Color.allCases.flatMap { color in + Piece.Figure.allCases.map { figure in + .init(color: color, figure: figure) + } + }.reduce(into: .init()) { result, piece in + piece.startSquares.forEach { startSquare in + result[startSquare] = piece + } + }) + } + + enum Side { + case kingside + case queenside + } + +// private + let pieces: [Square: Piece] + + let squaresTouched: [Square] + + private let enPassant: Square? + + func isCheck(color: Piece.Color) -> Bool { + pieces.filter { _, piece in + piece.color == color.opposite + }.flatMap { originSquare, _ in + moves(.capture(originSquare: originSquare)) + }.compactMap { targetSquare in + pieces[targetSquare] + }.contains { piece in + piece.color == color && piece.figure == .king + } + } + + func isCheckmate(color: Piece.Color) -> Bool { + !pieces.filter { _, piece in + piece.color == color + }.contains { originSquare, _ in + let moves = moves(.move(originSquare: originSquare)) + moves(.capture(originSquare: originSquare)) + return moves.contains { targetSquare in + mutatedBoard(originSquare: originSquare, targetSquare: targetSquare) != nil + } + } + } + + func moves(_ move: Move) -> [Square] { + guard let piece = pieces[move.originSquare] else { + return [] + } + + return piece.moves(originSquare: move.originSquare, isCapture: move.isCapture).flatMap { path in + let collision: (offset: Int, piece: Piece)? = path.enumerated().first { _, targetSquare in + pieces[targetSquare] != nil + }.map { offset, targetSquare in + (offset, pieces[targetSquare]!) + } + + guard case .capture = move else { + return path.prefix(upTo: collision?.offset ?? path.endIndex) + } + + guard let collision, collision.piece.color == piece.color.opposite else { + guard let enPassant, piece.figure == .pawn, path.first == enPassant + piece.forwardUnitVector else { + return [] + } + return [path.first!] + } + + return path[collision.offset.. Self? { + mutations.reduce(self) { board, mutation in + board?.mutatedBoard(originSquare: mutation.originSquare, targetSquare: mutation.targetSquare) + } + } + + func mutatedBoard(originSquare origin: Square, targetSquare target: Square, promotion: Piece.Figure? = nil) -> Self? { + guard let piece = pieces[origin] else { + return self + } + + var pieces = self.pieces + if let enPassant = target - piece.forwardUnitVector, piece.figure == .pawn, origin.file != target.file, pieces[target] == nil { + pieces[enPassant] = nil + } + pieces[origin] = nil + + if let promotion { + // Prohibit invalid promoions. + guard piece.figure == .pawn, target.rank == Square.Rank.allCases.first || target.rank == Square.Rank.allCases.last, + promotion != .king, promotion != .pawn else { + return nil + } + pieces[target] = Piece(color: piece.color, figure: promotion) + } else { + pieces[target] = piece + } + + let enPassant = piece.figure == .pawn && abs(origin.rank.rawValue - target.rank.rawValue) == 2 ? target : nil + let squaresTouched = squaresTouched + (squaresTouched.contains(target) ? [] : [target]) + let board = Board(pieces: pieces, enPassant: enPassant, squaresTouched: squaresTouched) + + // Do not allow moving into check. + guard !board.isCheck(color: piece.color) else { + return nil + } + + return board + } + + init(pieces: [Square : Piece], enPassant: Square? = nil, squaresTouched: [Square] = []) { + self.pieces = pieces + self.enPassant = enPassant + self.squaresTouched = squaresTouched + } +} diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift new file mode 100644 index 0000000..3338d47 --- /dev/null +++ b/Sources/ChessCore/Models/Game.swift @@ -0,0 +1,269 @@ +import Foundation + +// MARK: - +extension Board: CustomStringConvertible { + public var description: String { + Square.Rank.allCases.reversed().map { rank in + " ".appending( + rank.description.appending(" ").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: " ") + ) + } +} + +// MARK: - +extension Notation: CustomStringConvertible { + var description: String { + switch self { + case let .end(victor): + guard let victor else { + return "1/2-1/2" + } + return victor == .black ? "0-1" : "1-0" + + case let .gameplay(gameplay): + return gameplay.description + } + } +} + +extension Notation.Gameplay: CustomStringConvertible { + var description: String { + "\(play)\(punctuation?.description ?? "")" + } +} + +extension Notation.Gameplay.Play: CustomStringConvertible { + var description: String { + switch self { + case let .castle(side): + switch side { + case .kingside: + return "O-O" + + case .queenside: + return "O-O-O" + } + + case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): + let disambiguation = (disambiguationFile?.description ?? "").appending(disambiguationRank?.description ?? "") + let promotion = promotion.map { promotion in + "=\(promotion)" + } ?? "" + return "\(figure.description)\(disambiguation)\(isCapture ? "x" : "")\(targetSquare)\(promotion)" + } + } +} + +extension Notation.Gameplay.Punctuation: CustomStringConvertible { + var description: String { + rawValue + } +} + +// MARK: - +extension Piece: CustomStringConvertible { + public var description: String { + let description = figure == .pawn ? "X" : figure.rawValue + return color == .white ? description : description.lowercased() + } +} + +extension Piece.Color: CustomStringConvertible { + var description: String { + rawValue + } +} + +extension Piece.Figure: CustomStringConvertible { + var description: String { + rawValue + } +} + +// MARK: - +extension Square: CustomStringConvertible { + public var description: String { + file.description.appending(rank.description) + } +} + +extension Square.Rank: CustomStringConvertible { + var description: String { + String(rawValue) + } +} + +extension Square.File: CustomStringConvertible { + var description: String { + String(Character(UnicodeScalar(rawValue + 96)!)) + } +} + +// MARK: - Game + +/// A model representing a chess game. +/// +/// Chess is a board game played between two players. +public struct Game { + private struct InvalidMove: Error { + let notation: String + } + + var isGameOver: Bool { + guard case .end = moves.last else { + return board.isCheckmate(color: moveColor) + } + return true + } + + private var board: Board + + private var moveColor: Piece.Color { + moves.count.isMultiple(of: 2) ? .white : .black + } + + private var moves = [Notation]() + + /// Move + /// - Parameter notation: Notation + public mutating func move(_ notationString: String) throws { + let error = InvalidMove(notation: notationString) + + guard let notation = Notation(string: notationString) else { + throw error + } + + if case let .gameplay(gameplay) = notation { + switch gameplay.play { + case let .castle(side): + guard let mutations = castle(color: moveColor, side: side), let mutatedBoard = board.mutatedBoard(mutations: mutations) else { + throw error + } + + board = mutatedBoard + + case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): + let eligibleSquares = board.pieces.filter { square, piece in + let move: Board.Move = isCapture ? .capture(originSquare: square) : .move(originSquare: square) + return piece.color == moveColor && piece.figure == figure && board.moves(move).contains(targetSquare) && + square.file == disambiguationFile ?? square.file && square.rank == disambiguationRank ?? square.rank + } + + guard let originSquare = eligibleSquares.first?.0, eligibleSquares.count == 1 else { + throw error + } + + guard let mutatedBoard = board.mutatedBoard(originSquare: originSquare, targetSquare: targetSquare, promotion: promotion) else { + throw error + } + + board = mutatedBoard + } + + // Compute game state. + let isCheck = board.isCheck(color: moveColor.opposite) + let isCheckmate = board.isCheckmate(color: moveColor.opposite) + + // Correct missing punctuation. + guard let punctuation = gameplay.punctuation else { + guard !isCheckmate else { + let gameplay = Notation.Gameplay(play: gameplay.play, punctuation: .checkmate) + let notation = Notation.gameplay(gameplay) + moves += [notation] + return + } + guard !isCheck else { + let gameplay = Notation.Gameplay(play: gameplay.play, punctuation: .check) + let notation = Notation.gameplay(gameplay) + moves += [notation] + return + } + moves += [notation] + return + } + + // Validate punctuation parsed from input notation. + switch punctuation { + case .check: + guard isCheck, !isCheckmate else { + throw error + } + case .checkmate: + guard isCheckmate else { + throw error + } + } + } + + moves += [notation] + } + + /// Designated initializer + public init(board: Board = .board) { + self.board = board + } + + private func castle(color: Piece.Color, side: Board.Side) -> [Board.Mutation]? { + let castlePath = castlePath(color: color, side: side) + let kingsSquare = kingsSquare(color: color) + + let openSquares: [Square] + if let openSquare = kingsSquare + Vector(files: -3), side == .queenside { + openSquares = castlePath + [openSquare] + } else { + openSquares = castlePath + } + + guard let rooksSquare = rooksSquare(color: color, side: side), !openSquares.contains(where: board.pieces.keys.contains), + !board.squaresTouched.contains(kingsSquare), !board.squaresTouched.contains(rooksSquare) else { + return nil + } + + return [ + (originSquare: kingsSquare, targetSquare: castlePath[0]), + (originSquare: castlePath[0], targetSquare: castlePath[1]), + (originSquare: rooksSquare, targetSquare: castlePath[0]), + ] + } + + private func kingsSquare(color: Piece.Color) -> Square { + board.pieces.filter { element in + element.value.color == color && element.value.figure == .king + }.keys.first! + } + + private func castlePath(color: Piece.Color, side: Board.Side) -> [Square] { + (side == .kingside ? [1, 2] : [-1, -2]).compactMap { files in + kingsSquare(color: color) + Vector(files: files) + } + } + + private func rooksSquare(color: Piece.Color, side: Board.Side) -> Square? { + let kingsSquare = kingsSquare(color: color) + return board.pieces.filter { element in + element.value.color == color && element.value.figure == .rook && + ((element.key.file.rawValue < kingsSquare.file.rawValue && side == .queenside) || + (element.key.file.rawValue > kingsSquare.file.rawValue && side == .kingside)) + }.first?.key + } +} + +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].description) + .appending(moves.count > i+1 ? " \(moves[i+1])" : "") + }.joined(separator: "\n") + .appending("\n\n")) + .appending(isGameOver ? "" : "\(moveColor.description.capitalized.appending(" to move."))\n\n") + .appending(board.description) + } +} diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift new file mode 100644 index 0000000..1af3632 --- /dev/null +++ b/Sources/ChessCore/Models/Notation.swift @@ -0,0 +1,142 @@ + +enum Notation { +// enum GameEnd { +// case draw +// case resignation(color: Piece.Color) +// +// fileprivate init?(_ string: String) { +// switch string { +// case "1-0": +// self = .resignation(color: .black) +// +// case "0-1": +// self = .resignation(color: .white) +// +// case "1/2-1/2": +// self = .draw +// +// default: +// return nil +// } +// } +// } + + struct Gameplay { + enum Play { + case castle(side: Board.Side) + 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 "O-O": + play = .castle(side: .kingside) + + case "O-O-O": + play = .castle(side: .queenside) + + default: + let figure = (string.first.map(Piece.Figure.init) ?? nil) ?? .pawn + if figure != .pawn { + string = String(string.dropFirst()) + } + + let isCapture = string.contains("x") + string = string.replacingOccurrences(of: "x", with: "") + + let promotion = string.last.map(Piece.Figure.init) ?? nil + if promotion != nil { + string = String(string.dropLast()) + guard string.last == "=" 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(notation: 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 "1-0": + self = .end(victor: .white) + + case "0-1": + self = .end(victor: .black) + + case "1/2-1/2": + self = .end(victor: nil) + + default: + guard let gameplay = Gameplay(string) else { + return nil + } + self = .gameplay(gameplay) + } + } +} diff --git a/Sources/ChessCore/Piece.swift b/Sources/ChessCore/Models/Piece.swift similarity index 55% rename from Sources/ChessCore/Piece.swift rename to Sources/ChessCore/Models/Piece.swift index 6beeab4..f8afc0b 100644 --- a/Sources/ChessCore/Piece.swift +++ b/Sources/ChessCore/Models/Piece.swift @@ -1,5 +1,6 @@ + /// A model representing a chess piece. -public struct Piece: Equatable { +public struct Piece { /// Color enum Color: String, CaseIterable { case white @@ -11,9 +12,13 @@ public struct Piece: Equatable { case bishop = "B" case king = "K" case knight = "N" - case pawn = "X" + case pawn = "" case queen = "Q" case rook = "R" + + init?(_ character: Character) { + self.init(rawValue: String(character)) + } } /// Color @@ -22,3 +27,15 @@ public struct Piece: Equatable { /// Figure let 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..fc15bfa --- /dev/null +++ b/Sources/ChessCore/Models/Square.swift @@ -0,0 +1,54 @@ + +/// 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 + 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: Square, rhs: Vector) -> Square? { + 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) + } +} 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..af8c16f 100644 --- a/Sources/ChessCore/Vector.swift +++ b/Sources/ChessCore/Models/Vector.swift @@ -1,4 +1,4 @@ -/// A model representing a translation on a chess board. + struct Vector { /// File translation let files: Int 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..8cb9c88 --- /dev/null +++ b/Sources/ChessCore/String.swift @@ -0,0 +1,12 @@ + +extension String { + static let blackResigns = "O-O" + static let check = "+" + static let checkmate = "=" + static let capture = "x" + static let draw = "O-O" + static let kingsideCastle = "O-O" + static let promotion = "=" + static let queensideCastle = "O-O" + static let whiteResign = "O-O" +} diff --git a/Tests/ChessCoreTests/ChessTests.swift b/Tests/ChessCoreTests/ChessTests.swift index 6a6429a..f397a3b 100644 --- a/Tests/ChessCoreTests/ChessTests.swift +++ b/Tests/ChessCoreTests/ChessTests.swift @@ -2,7 +2,19 @@ import XCTest @testable import ChessCore final class ChessTests: XCTestCase { - private var game = Game(board: [Square(file: .a, rank: .seven): Piece(color: .white, figure: .pawn)]) + private var game = Game(board: [.init(notation: "a7")!: .init(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"] { + game = Game() + try game.move(move) + } + + for move in ["Na3", "Nc3", "Nf3", "Nh3"] { + game = Game() + try game.move(move) + } + } func testPromotionToBishop() throws { try game.move("a8=B") @@ -22,5 +34,98 @@ final class ChessTests: XCTestCase { func testPromotionToQueen() throws { try game.move("a8=Q") + print(game) } + + func testKingsideCastle() throws { + var game = Game(board: [ + .init(notation: "e1")!: .init(color: .white, figure: .king), + .init(notation: "h1")!: .init(color: .white, figure: .rook), + ]) + print(game) + + try game.move("O-O") + print(game) + } + + func testQueensideCastle() throws { + var game = Game(board: [ + .init(notation: "e1")!: .init(color: .white, figure: .king), + .init(notation: "a1")!: .init(color: .white, figure: .rook), + ]) + print(game) + + try game.move("O-O-O") + print(game) + } + + func testMoveIntoCheck() throws { + var game = Game(board: [ + .init(notation: "d6")!: .init(color: .black, figure: .queen), + .init(notation: "f6")!: .init(color: .black, figure: .pawn), + .init(notation: "e8")!: .init(color: .white, figure: .king), + .init(notation: "a7")!: .init(color: .white, figure: .rook), + ]) + print(game) + + XCTAssertThrowsError(try game.move("Ke7")) + print(game) + } + +// func testCaptureOutOfCheck1() throws { +// var game = Game(board: [ +// .init(notation: "d6")!: .init(color: .white, figure: .queen), +// .init(notation: "f6")!: .init(color: .white, figure: .pawn), +// .init(notation: "e8")!: .init(color: .black, figure: .king), +//// .init(notation: "a7")!: .init(color: .black, figure: .rook), +// ]) +// print(game) +// +// try game.move("Qe7+") +// print(game) +// } +// +// func testCaptureOutOfCheck2() throws { +// var game = Game(board: [ +// "e5": .init(color: .white, figure: .queen), +// "f5": .init(color: .white, figure: .pawn), +// "a8": .init(color: .white, figure: .rook), +// "e7": .init(color: .black, figure: .king), +// "f7": .init(color: .black, figure: .pawn), +// ]) +// print(game) +// +// try game.move("Qe6") +// print(game) +// } +// +// func testDisambiguation() throws { +// var game = Game(board: [ +// "b3": .init(color: .white, figure: .knight), +// ]) +// print(game) +// +// try game.move("Nb3d4") +// print(game) +// } +// +// func testEnPassantCaptureOutOfCheck() throws { +// var game = Game(board: [ +// "a1": .init(color: .white, figure: .king), +// "f2": .init(color: .white, figure: .pawn), +// "e5": .init(color: .black, figure: .king), +// "g4": .init(color: .black, figure: .pawn), +// "a4": .init(color: .white, figure: .rook), +// "a6": .init(color: .white, figure: .rook), +// "d1": .init(color: .white, figure: .rook), +// "f8": .init(color: .white, figure: .rook), +// ]) +// print(game) +// +// try game.move("f4") +// print(game) +// +// try game.move("gxf3") +// print(game) +// } } From 29bb22892b04a11686fa64f3b42111ee96e6a97d Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Mon, 22 Jul 2024 12:35:44 -0400 Subject: [PATCH 2/5] refactor notation --- Sources/ChessCore/Models/Board.swift | 242 +----------- Sources/ChessCore/Models/Game.swift | 465 ++++++++++++++++++------ Sources/ChessCore/Models/Notation.swift | 39 +- Sources/ChessCore/Models/Piece.swift | 2 +- Sources/ChessCore/Models/Square.swift | 8 +- Sources/ChessCore/Models/Vector.swift | 2 + Sources/ChessCore/String.swift | 11 +- 7 files changed, 384 insertions(+), 385 deletions(-) diff --git a/Sources/ChessCore/Models/Board.swift b/Sources/ChessCore/Models/Board.swift index 6e6fdb3..bbe43ad 100644 --- a/Sources/ChessCore/Models/Board.swift +++ b/Sources/ChessCore/Models/Board.swift @@ -1,149 +1,16 @@ -// MARK: - -private extension Piece { - var forwardUnitVector: Vector { - Vector(ranks: color == .black ? -1 : 1) - } - - var startRank: Square.Rank { - guard case figure = .pawn else { - return color == .black ? .eight : .one - } - - return color == .black ? .seven : .two - } - - var startSquares: [Square] { - switch figure { - case .pawn: - return Square.File.allCases.map { file in - .init(file: file, rank: startRank) - } - - case .rook: - return [.init(file: .a, rank: startRank), .init(file: .h, rank: startRank)] - - case .knight: - return [.init(file: .b, rank: startRank), .init(file: .g, rank: startRank)] - - case .bishop: - return [.init(file: .c, rank: startRank), .init(file: .f, rank: startRank)] - - case .queen: - return [.init(file: .d, rank: startRank)] - - case .king: - return [.init(file: .e, rank: startRank)] - } - } - - 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)].compactMap { vector in - (originSquare + vector).map { targetSquare in - [targetSquare] - } - } - - case (.pawn, false): - guard let oneSquareForward = originSquare + forwardUnitVector else { - return [] - } - - guard let twoSquaresForward = oneSquareForward + forwardUnitVector, originSquare.rank == startRank else { - return [[oneSquareForward]] - } - - return [[oneSquareForward, twoSquaresForward]] - - case (.pawn, true): - return [Vector(files: -1, ranks: forwardUnitVector.ranks), Vector(files: 1, ranks: forwardUnitVector.ranks)].compactMap { vector in - (originSquare + vector).map { targetSquare in - [targetSquare] - } - } - - case (.queen, _): - return Vector.unitVectors.compactMap(originSquare.allSquaresInDirection) - - case (.rook, _): - return Vector.cardinalUnitVectors.compactMap(originSquare.allSquaresInDirection) - } - } -} - -// MARK: - -private extension Square { - static func - (lhs: Square, rhs: Vector) -> Square? { - lhs + Vector(files: -1 * rhs.files, ranks: -1 * rhs.ranks) - } - - func allSquaresInDirection(_ direction: Vector) -> [Self] { - guard let squareInDirection = self + direction else { - return [] - } - - return [squareInDirection] + squareInDirection.allSquaresInDirection(direction) - } -} - -// MARK: - -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 -} - // 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) + typealias Mutation = (originSquare: Square, targetSquare: Square, promotion: Piece.Figure?) enum Move { case move(originSquare: Square) case capture(originSquare: Square) - var isCapture: Bool { - guard case .capture = self else { - return false - } - - return true - } - var originSquare: Square { switch self { case let .move(originSquare): @@ -155,119 +22,16 @@ public struct Board { } } - public static var board: Board { - Board(pieces: Piece.Color.allCases.flatMap { color in - Piece.Figure.allCases.map { figure in - .init(color: color, figure: figure) - } - }.reduce(into: .init()) { result, piece in - piece.startSquares.forEach { startSquare in - result[startSquare] = piece - } - }) - } - enum Side { case kingside case queenside } - -// private + let pieces: [Square: Piece] let squaresTouched: [Square] - private let enPassant: Square? - - func isCheck(color: Piece.Color) -> Bool { - pieces.filter { _, piece in - piece.color == color.opposite - }.flatMap { originSquare, _ in - moves(.capture(originSquare: originSquare)) - }.compactMap { targetSquare in - pieces[targetSquare] - }.contains { piece in - piece.color == color && piece.figure == .king - } - } - - func isCheckmate(color: Piece.Color) -> Bool { - !pieces.filter { _, piece in - piece.color == color - }.contains { originSquare, _ in - let moves = moves(.move(originSquare: originSquare)) + moves(.capture(originSquare: originSquare)) - return moves.contains { targetSquare in - mutatedBoard(originSquare: originSquare, targetSquare: targetSquare) != nil - } - } - } - - func moves(_ move: Move) -> [Square] { - guard let piece = pieces[move.originSquare] else { - return [] - } - - return piece.moves(originSquare: move.originSquare, isCapture: move.isCapture).flatMap { path in - let collision: (offset: Int, piece: Piece)? = path.enumerated().first { _, targetSquare in - pieces[targetSquare] != nil - }.map { offset, targetSquare in - (offset, pieces[targetSquare]!) - } - - guard case .capture = move else { - return path.prefix(upTo: collision?.offset ?? path.endIndex) - } - - guard let collision, collision.piece.color == piece.color.opposite else { - guard let enPassant, piece.figure == .pawn, path.first == enPassant + piece.forwardUnitVector else { - return [] - } - return [path.first!] - } - - return path[collision.offset.. Self? { - mutations.reduce(self) { board, mutation in - board?.mutatedBoard(originSquare: mutation.originSquare, targetSquare: mutation.targetSquare) - } - } - - func mutatedBoard(originSquare origin: Square, targetSquare target: Square, promotion: Piece.Figure? = nil) -> Self? { - guard let piece = pieces[origin] else { - return self - } - - var pieces = self.pieces - if let enPassant = target - piece.forwardUnitVector, piece.figure == .pawn, origin.file != target.file, pieces[target] == nil { - pieces[enPassant] = nil - } - pieces[origin] = nil - - if let promotion { - // Prohibit invalid promoions. - guard piece.figure == .pawn, target.rank == Square.Rank.allCases.first || target.rank == Square.Rank.allCases.last, - promotion != .king, promotion != .pawn else { - return nil - } - pieces[target] = Piece(color: piece.color, figure: promotion) - } else { - pieces[target] = piece - } - - let enPassant = piece.figure == .pawn && abs(origin.rank.rawValue - target.rank.rawValue) == 2 ? target : nil - let squaresTouched = squaresTouched + (squaresTouched.contains(target) ? [] : [target]) - let board = Board(pieces: pieces, enPassant: enPassant, squaresTouched: squaresTouched) - - // Do not allow moving into check. - guard !board.isCheck(color: piece.color) else { - return nil - } - - return board - } + let enPassant: Square? init(pieces: [Square : Piece], enPassant: Square? = nil, squaresTouched: [Square] = []) { self.pieces = pieces diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift index 3338d47..c7b91d5 100644 --- a/Sources/ChessCore/Models/Game.swift +++ b/Sources/ChessCore/Models/Game.swift @@ -1,19 +1,237 @@ 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 .pawn: + files = Square.File.allCases + + case .rook: + files = [.a, .h] + + case .knight: + files = [.b, .g] + + case .bishop: + files = [.c, .f] + + case .queen: + files = [.d] + + case .king: + files = [.e] + } + + 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[Square(file: file, rank: rank)] = piece + } + }) + } +} + extension Board: CustomStringConvertible { public var description: String { - Square.Rank.allCases.reversed().map { rank in - " ".appending( - rank.description.appending(" ").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: " ") - ) + let allFiles = Square.File.allCases + return Square.Rank.allCases.reversed().map { rank in + " \(rank) ".appending(allFiles.map { file in + pieces[Square(file: file, rank: rank)]?.description ?? " " + }.joined(separator: " ")) + } + .joined(separator: "\n") + .appending("\n ") + .appending(allFiles.map(\.description).joined(separator: " ")) + } +} + +private extension Board { + func isCheck(color: Piece.Color) -> Bool { + pieces.filter { _, piece in + piece.color == color.opposite + }.flatMap { originSquare, _ in + moves(.capture(originSquare: originSquare)) + }.contains(kingsSquare(color: color)) + } + + func isCheckmate(color: Piece.Color) -> Bool { + !pieces.filter { _, piece in + piece.color == color + }.flatMap { originSquare, _ in + (moves(.move(originSquare: originSquare)) + moves(.capture(originSquare: originSquare))).map { targetSquare in + Mutation(originSquare: originSquare, targetSquare: targetSquare, promotion: nil) + } + }.contains { mutation in + mutatedBoard(mutations: [mutation]) != nil + } + } + + func mutated(play: Notation.Gameplay.Play, moveColor color: Piece.Color) -> Board? { + let mutations: [Mutation] + + switch play { + case let .castle(side): + let kingsSquare = kingsSquare(color: color) + + let castlePath = (side == .kingside ? [1, 2] : [-1, -2]).compactMap { files in + kingsSquare + Vector(files: files) + } + + let openSquares: [Square] + if let openSquare = kingsSquare - Vector(files: 3), side == .queenside { + openSquares = castlePath + [openSquare] + } else { + openSquares = castlePath + } + + let kingsFile = kingsSquare.file + let rooksSquare = pieces.filter { square, piece in + guard piece == .init(color: color, figure: .rook) else { + return false + } + + let file = square.file + return side == .kingside ? file > kingsFile : file < kingsFile + }.first?.key + + guard let rooksSquare, !openSquares.contains(where: pieces.keys.contains), !squaresTouched.contains(kingsSquare), + !squaresTouched.contains(rooksSquare), !isCheck(color: color) else { + return nil + } + + mutations = [ + (originSquare: kingsSquare, targetSquare: castlePath[0], promotion: nil), + (originSquare: castlePath[0], targetSquare: castlePath[1], promotion: nil), + (originSquare: rooksSquare, targetSquare: castlePath[0], promotion: nil), + ] + + case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): + let eligibleSquares = pieces.filter { square, piece in + if let disambiguationFile, square.file != disambiguationFile { + return false + } + + if let disambiguationRank, square.rank != disambiguationRank { + return false + } + + guard piece == .init(color: color, figure: figure) else { + return false + } + + return moves(isCapture ? .capture(originSquare: square) : .move(originSquare: square)).contains(targetSquare) + } + + guard let originSquare = eligibleSquares.first?.0, eligibleSquares.count == 1 else { + return nil + } + + let figure = pieces[originSquare]!.figure + let invalidPromotions = [Piece.Figure.king, Piece.Figure.pawn] + let promotionRanks = [Square.Rank.allCases.first!, Square.Rank.allCases.last!] + + // Pawns must be promoted when they reach the end of the board. + if figure == .pawn, promotionRanks.contains(targetSquare.rank), promotion == nil { + return nil + } + + // Only pawns can be promoted and only when they reach the end of the board. + if let promotion, figure != .pawn || !promotionRanks.contains(targetSquare.rank) || invalidPromotions.contains(promotion) { + return nil + } + + mutations = [(originSquare: originSquare, targetSquare: targetSquare, promotion: promotion)] + } + + guard let mutatedBoard = mutatedBoard(mutations: mutations) else { + return nil + } + + return mutatedBoard + } + + private func kingsSquare(color: Piece.Color) -> Square { + pieces.first { square, piece in + piece == .init(color: color, figure: .king) + }!.key + } + + private func moves(_ move: Move) -> [Square] { + let isCapture: Bool + if case .capture = move { + isCapture = true + } else { + isCapture = false + } + let isUnmoved = !squaresTouched.contains(move.originSquare) + let piece = pieces[move.originSquare]! + + return piece.moves(originSquare: move.originSquare, isCapture: isCapture, isUnmoved: isUnmoved).flatMap { path in + let collision: (offset: Int, piece: Piece)? = path.enumerated().first { _, targetSquare in + pieces[targetSquare] != nil + }.map { offset, targetSquare in + (offset, pieces[targetSquare]!) + } + + guard isCapture else { + return path.prefix(upTo: collision?.offset ?? path.endIndex) + } + + guard let collision, collision.piece.color == piece.color.opposite else { + guard let enPassant, piece.figure == .pawn, path.first == enPassant + piece.forwardUnitVector else { + return [] + } + return [path.first!] + } + + return path[collision.offset.. Self? { + mutations.reduce(self) { board, mutation in + guard let board else { + return nil + } + + let piece = board.pieces[mutation.originSquare]! + var pieces = board.pieces + pieces[mutation.originSquare] = nil + if let enPassant = mutation.targetSquare - piece.forwardUnitVector, piece.figure == .pawn, + mutation.originSquare.file != mutation.targetSquare.file, pieces[mutation.targetSquare] == nil { + pieces[enPassant] = nil + } + pieces[mutation.targetSquare] = Piece(color: piece.color, figure: mutation.promotion ?? piece.figure) + + let enPassant: Square? + if piece.figure == .pawn, abs(mutation.originSquare.rank.rawValue - mutation.targetSquare.rank.rawValue) == 2 { + enPassant = mutation.targetSquare + } else { + enPassant = nil + } + let squaresTouched = squaresTouched + (squaresTouched.contains(mutation.targetSquare) ? [] : [mutation.targetSquare]) + let mutatedBoard = Board(pieces: pieces, enPassant: enPassant, squaresTouched: squaresTouched) + + // Do not allow moving into/thru check. + guard !mutatedBoard.isCheck(color: piece.color) else { + return nil + } + + return mutatedBoard + } } } @@ -23,12 +241,12 @@ extension Notation: CustomStringConvertible { switch self { case let .end(victor): guard let victor else { - return "1/2-1/2" + return .drawGame } - return victor == .black ? "0-1" : "1-0" + return victor == .black ? .blackWins : .whiteWins case let .gameplay(gameplay): - return gameplay.description + return "\(gameplay)" } } } @@ -43,20 +261,12 @@ extension Notation.Gameplay.Play: CustomStringConvertible { var description: String { switch self { case let .castle(side): - switch side { - case .kingside: - return "O-O" - - case .queenside: - return "O-O-O" - } + return side == .kingside ? .kingsideCastle : .queensideCastle case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): let disambiguation = (disambiguationFile?.description ?? "").appending(disambiguationRank?.description ?? "") - let promotion = promotion.map { promotion in - "=\(promotion)" - } ?? "" - return "\(figure.description)\(disambiguation)\(isCapture ? "x" : "")\(targetSquare)\(promotion)" + let promotion = promotion.map(\.description).map(String.promotion.appending) ?? "" + return "\(figure)\(disambiguation)\(isCapture ? .capture : "")\(targetSquare)\(promotion)" } } } @@ -68,9 +278,68 @@ extension Notation.Gameplay.Punctuation: CustomStringConvertible { } // MARK: - +private extension Piece { + var forwardUnitVector: Vector { + Vector(ranks: color == .black ? -1 : 1) + } + + func moves(originSquare: Square, isCapture: Bool, isUnmoved: 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)].compactMap { vector in + (originSquare + vector).map { targetSquare in + [targetSquare] + } + } + + case (.pawn, false): + guard let oneSquareForward = originSquare + forwardUnitVector else { + return [] + } + + guard let twoSquaresForward = oneSquareForward + forwardUnitVector, isUnmoved else { + return [[oneSquareForward]] + } + + return [[oneSquareForward, twoSquaresForward]] + + case (.pawn, true): + let ranks = forwardUnitVector.ranks + return [Vector(files: -1, ranks: ranks), Vector(files: 1, ranks: ranks)].compactMap { vector in + (originSquare + vector).map { targetSquare in + [targetSquare] + } + } + + case (.queen, _): + return Vector.unitVectors.compactMap(originSquare.allSquaresInDirection) + + case (.rook, _): + return Vector.cardinalUnitVectors.compactMap(originSquare.allSquaresInDirection) + } + } +} + extension Piece: CustomStringConvertible { public var description: String { - let description = figure == .pawn ? "X" : figure.rawValue + let description = figure == .pawn ? "P" : figure.rawValue return color == .white ? description : description.lowercased() } } @@ -88,15 +357,29 @@ extension Piece.Figure: CustomStringConvertible { } // MARK: - +private extension Square { + func allSquaresInDirection(_ direction: Vector) -> [Self] { + guard let squareInDirection = self + direction else { + return [] + } + + return [squareInDirection] + squareInDirection.allSquaresInDirection(direction) + } +} + extension Square: CustomStringConvertible { public var description: String { file.description.appending(rank.description) } } -extension Square.Rank: CustomStringConvertible { - var description: String { - String(rawValue) +private extension Square.File { + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + static func > (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue > rhs.rawValue } } @@ -106,6 +389,31 @@ extension Square.File: CustomStringConvertible { } } +extension Square.Rank: CustomStringConvertible { + var description: String { + String(rawValue) + } +} + +// MARK: - +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 +} + // MARK: - Game /// A model representing a chess game. @@ -116,6 +424,14 @@ public struct Game { let notation: String } + private struct InvalidNotation: Error { + let notation: String + + init(_ notation: String) { + self.notation = notation + } + } + var isGameOver: Bool { guard case .end = moves.last else { return board.isCheckmate(color: moveColor) @@ -134,39 +450,17 @@ public struct Game { /// Move /// - Parameter notation: Notation public mutating func move(_ notationString: String) throws { - let error = InvalidMove(notation: notationString) - guard let notation = Notation(string: notationString) else { - throw error + throw InvalidNotation(notationString) } if case let .gameplay(gameplay) = notation { - switch gameplay.play { - case let .castle(side): - guard let mutations = castle(color: moveColor, side: side), let mutatedBoard = board.mutatedBoard(mutations: mutations) else { - throw error - } - - board = mutatedBoard - - case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): - let eligibleSquares = board.pieces.filter { square, piece in - let move: Board.Move = isCapture ? .capture(originSquare: square) : .move(originSquare: square) - return piece.color == moveColor && piece.figure == figure && board.moves(move).contains(targetSquare) && - square.file == disambiguationFile ?? square.file && square.rank == disambiguationRank ?? square.rank - } - - guard let originSquare = eligibleSquares.first?.0, eligibleSquares.count == 1 else { - throw error - } - - guard let mutatedBoard = board.mutatedBoard(originSquare: originSquare, targetSquare: targetSquare, promotion: promotion) else { - throw error - } - - board = mutatedBoard + guard let mutatedBoard = board.mutated(play: gameplay.play, moveColor: moveColor) else { + throw InvalidMove(notation: notationString) } + board = mutatedBoard + // Compute game state. let isCheck = board.isCheck(color: moveColor.opposite) let isCheckmate = board.isCheckmate(color: moveColor.opposite) @@ -179,26 +473,23 @@ public struct Game { moves += [notation] return } + guard !isCheck else { let gameplay = Notation.Gameplay(play: gameplay.play, punctuation: .check) let notation = Notation.gameplay(gameplay) moves += [notation] return } + moves += [notation] return } // Validate punctuation parsed from input notation. - switch punctuation { - case .check: - guard isCheck, !isCheckmate else { - throw error - } - case .checkmate: - guard isCheckmate else { - throw error - } + if case .check = punctuation, !isCheck || isCheckmate { + throw InvalidNotation(notationString) + } else if case .checkmate = punctuation, !isCheckmate { + throw InvalidNotation(notationString) } } @@ -209,50 +500,6 @@ public struct Game { public init(board: Board = .board) { self.board = board } - - private func castle(color: Piece.Color, side: Board.Side) -> [Board.Mutation]? { - let castlePath = castlePath(color: color, side: side) - let kingsSquare = kingsSquare(color: color) - - let openSquares: [Square] - if let openSquare = kingsSquare + Vector(files: -3), side == .queenside { - openSquares = castlePath + [openSquare] - } else { - openSquares = castlePath - } - - guard let rooksSquare = rooksSquare(color: color, side: side), !openSquares.contains(where: board.pieces.keys.contains), - !board.squaresTouched.contains(kingsSquare), !board.squaresTouched.contains(rooksSquare) else { - return nil - } - - return [ - (originSquare: kingsSquare, targetSquare: castlePath[0]), - (originSquare: castlePath[0], targetSquare: castlePath[1]), - (originSquare: rooksSquare, targetSquare: castlePath[0]), - ] - } - - private func kingsSquare(color: Piece.Color) -> Square { - board.pieces.filter { element in - element.value.color == color && element.value.figure == .king - }.keys.first! - } - - private func castlePath(color: Piece.Color, side: Board.Side) -> [Square] { - (side == .kingside ? [1, 2] : [-1, -2]).compactMap { files in - kingsSquare(color: color) + Vector(files: files) - } - } - - private func rooksSquare(color: Piece.Color, side: Board.Side) -> Square? { - let kingsSquare = kingsSquare(color: color) - return board.pieces.filter { element in - element.value.color == color && element.value.figure == .rook && - ((element.key.file.rawValue < kingsSquare.file.rawValue && side == .queenside) || - (element.key.file.rawValue > kingsSquare.file.rawValue && side == .kingside)) - }.first?.key - } } extension Game: CustomStringConvertible { diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift index 1af3632..57a4ce4 100644 --- a/Sources/ChessCore/Models/Notation.swift +++ b/Sources/ChessCore/Models/Notation.swift @@ -1,26 +1,7 @@ -enum Notation { -// enum GameEnd { -// case draw -// case resignation(color: Piece.Color) -// -// fileprivate init?(_ string: String) { -// switch string { -// case "1-0": -// self = .resignation(color: .black) -// -// case "0-1": -// self = .resignation(color: .white) -// -// case "1/2-1/2": -// self = .draw -// -// default: -// return nil -// } -// } -// } +// MARK: - Notation +enum Notation { struct Gameplay { enum Play { case castle(side: Board.Side) @@ -54,10 +35,10 @@ enum Notation { } switch string { - case "O-O": + case .kingsideCastle: play = .castle(side: .kingside) - case "O-O-O": + case .queensideCastle: play = .castle(side: .queenside) default: @@ -66,13 +47,13 @@ enum Notation { string = String(string.dropFirst()) } - let isCapture = string.contains("x") - string = string.replacingOccurrences(of: "x", with: "") + 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 == "=" else { + guard string.last == Character(String.promotion) else { return nil } string = String(string.dropLast()) @@ -123,13 +104,13 @@ enum Notation { init?(string: String) { switch string { - case "1-0": + case .whiteWins: self = .end(victor: .white) - case "0-1": + case .blackWins: self = .end(victor: .black) - case "1/2-1/2": + case .drawGame: self = .end(victor: nil) default: diff --git a/Sources/ChessCore/Models/Piece.swift b/Sources/ChessCore/Models/Piece.swift index f8afc0b..4c5f37c 100644 --- a/Sources/ChessCore/Models/Piece.swift +++ b/Sources/ChessCore/Models/Piece.swift @@ -1,6 +1,6 @@ /// A model representing a chess piece. -public struct Piece { +public struct Piece: Equatable { /// Color enum Color: String, CaseIterable { case white diff --git a/Sources/ChessCore/Models/Square.swift b/Sources/ChessCore/Models/Square.swift index fc15bfa..c6bdb98 100644 --- a/Sources/ChessCore/Models/Square.swift +++ b/Sources/ChessCore/Models/Square.swift @@ -1,4 +1,6 @@ +// MARK: - Square + /// A model representing a square on a chess board. public struct Square: Hashable { /// File @@ -45,10 +47,14 @@ public struct Square: Hashable { } extension Square { - static func + (lhs: Square, rhs: Vector) -> 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) } + + static func - (lhs: Self, rhs: Vector) -> Self? { + lhs + Vector(files: -1 * rhs.files, ranks: -1 * rhs.ranks) + } } diff --git a/Sources/ChessCore/Models/Vector.swift b/Sources/ChessCore/Models/Vector.swift index af8c16f..e6a9fa9 100644 --- a/Sources/ChessCore/Models/Vector.swift +++ b/Sources/ChessCore/Models/Vector.swift @@ -1,4 +1,6 @@ +// MARK: - Vector + struct Vector { /// File translation let files: Int diff --git a/Sources/ChessCore/String.swift b/Sources/ChessCore/String.swift index 8cb9c88..9c3bfdc 100644 --- a/Sources/ChessCore/String.swift +++ b/Sources/ChessCore/String.swift @@ -1,12 +1,11 @@ +// MARK: - extension String { - static let blackResigns = "O-O" - static let check = "+" - static let checkmate = "=" + static let blackWins = "0-1" static let capture = "x" - static let draw = "O-O" + static let drawGame = "1/2-1/2" static let kingsideCastle = "O-O" static let promotion = "=" - static let queensideCastle = "O-O" - static let whiteResign = "O-O" + static let queensideCastle = "O-O-O" + static let whiteWins = "1-0" } From e4e9f80295f57dc045b5049e5ea8b54d3e520797 Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Sun, 24 Nov 2024 20:54:29 -0500 Subject: [PATCH 3/5] rename castling sides --- Sources/ChessCore/Models/Board.swift | 5 -- Sources/ChessCore/Models/Game.swift | 98 +++++++++++++------------ Sources/ChessCore/Models/Notation.swift | 15 ++-- Sources/ChessCore/String.swift | 4 +- 4 files changed, 65 insertions(+), 57 deletions(-) diff --git a/Sources/ChessCore/Models/Board.swift b/Sources/ChessCore/Models/Board.swift index bbe43ad..a08ada5 100644 --- a/Sources/ChessCore/Models/Board.swift +++ b/Sources/ChessCore/Models/Board.swift @@ -21,11 +21,6 @@ public struct Board { } } } - - enum Side { - case kingside - case queenside - } let pieces: [Square: Piece] diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift index c7b91d5..e525639 100644 --- a/Sources/ChessCore/Models/Game.swift +++ b/Sources/ChessCore/Models/Game.swift @@ -62,9 +62,11 @@ private extension Board { func isCheck(color: Piece.Color) -> Bool { pieces.filter { _, piece in piece.color == color.opposite - }.flatMap { originSquare, _ in - moves(.capture(originSquare: originSquare)) - }.contains(kingsSquare(color: color)) + }.flatMap { square, _ in + moves(.capture(originSquare: square)) + }.contains { targetSquare in + pieces[targetSquare] == .init(color: color, figure: .king) + } } func isCheckmate(color: Piece.Color) -> Bool { @@ -83,39 +85,31 @@ private extension Board { let mutations: [Mutation] switch play { - case let .castle(side): - let kingsSquare = kingsSquare(color: color) - - let castlePath = (side == .kingside ? [1, 2] : [-1, -2]).compactMap { files in - kingsSquare + Vector(files: files) + case let .castle(castle): + guard !isCheck(color: color) else { + return nil } - let openSquares: [Square] - if let openSquare = kingsSquare - Vector(files: 3), side == .queenside { - openSquares = castlePath + [openSquare] - } else { - openSquares = castlePath + let rank: Square.Rank = color == .black ? .eight : .one + guard !(castle == .long ? [.b, .c, .d] : [.f, .g]).map({ file in + Square(file: file, rank: rank) + }).contains(where: pieces.keys.contains) else { + return nil } - let kingsFile = kingsSquare.file - let rooksSquare = pieces.filter { square, piece in - guard piece == .init(color: color, figure: .rook) else { - return false + 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 !squaresTouched.contains(square), pieces[square] == .init(color: color, figure: figure) else { + return nil } - - let file = square.file - return side == .kingside ? file > kingsFile : file < kingsFile - }.first?.key - - guard let rooksSquare, !openSquares.contains(where: pieces.keys.contains), !squaresTouched.contains(kingsSquare), - !squaresTouched.contains(rooksSquare), !isCheck(color: color) else { - return nil } + let rookTargetSquare = Square(file: castle == .long ? .d : .f, rank: rank) mutations = [ - (originSquare: kingsSquare, targetSquare: castlePath[0], promotion: nil), - (originSquare: castlePath[0], targetSquare: castlePath[1], promotion: nil), - (originSquare: rooksSquare, targetSquare: castlePath[0], promotion: nil), + (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): @@ -135,22 +129,37 @@ private extension Board { return moves(isCapture ? .capture(originSquare: square) : .move(originSquare: square)).contains(targetSquare) } - guard let originSquare = eligibleSquares.first?.0, eligibleSquares.count == 1 else { + guard eligibleSquares.count == 1 else { + return nil + } + + guard let originSquare = eligibleSquares.first?.0 else { return nil } let figure = pieces[originSquare]!.figure - let invalidPromotions = [Piece.Figure.king, Piece.Figure.pawn] - let promotionRanks = [Square.Rank.allCases.first!, Square.Rank.allCases.last!] + let promotionRank: Square.Rank = color == .black ? .one : .eight // Pawns must be promoted when they reach the end of the board. - if figure == .pawn, promotionRanks.contains(targetSquare.rank), promotion == nil { + if figure == .pawn, targetSquare.rank == promotionRank, promotion == nil { return nil } - // Only pawns can be promoted and only when they reach the end of the board. - if let promotion, figure != .pawn || !promotionRanks.contains(targetSquare.rank) || invalidPromotions.contains(promotion) { - return nil + if let promotion { + // Only pawns can be promoted . + if figure != .pawn { + return nil + } + + // only when they reach the end of the board + if targetSquare.rank != promotionRank { + return nil + } + + // and they must be promoted to knight, bishop, rook or queen. + if [Piece.Figure.king, Piece.Figure.pawn].contains(promotion) { + return nil + } } mutations = [(originSquare: originSquare, targetSquare: targetSquare, promotion: promotion)] @@ -163,12 +172,6 @@ private extension Board { return mutatedBoard } - private func kingsSquare(color: Piece.Color) -> Square { - pieces.first { square, piece in - piece == .init(color: color, figure: .king) - }!.key - } - private func moves(_ move: Move) -> [Square] { let isCapture: Bool if case .capture = move { @@ -178,7 +181,6 @@ private extension Board { } let isUnmoved = !squaresTouched.contains(move.originSquare) let piece = pieces[move.originSquare]! - return piece.moves(originSquare: move.originSquare, isCapture: isCapture, isUnmoved: isUnmoved).flatMap { path in let collision: (offset: Int, piece: Piece)? = path.enumerated().first { _, targetSquare in pieces[targetSquare] != nil @@ -260,8 +262,14 @@ extension Notation.Gameplay: CustomStringConvertible { extension Notation.Gameplay.Play: CustomStringConvertible { var description: String { switch self { - case let .castle(side): - return side == .kingside ? .kingsideCastle : .queensideCastle + 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 ?? "").appending(disambiguationRank?.description ?? "") @@ -340,7 +348,7 @@ private extension Piece { extension Piece: CustomStringConvertible { public var description: String { let description = figure == .pawn ? "P" : figure.rawValue - return color == .white ? description : description.lowercased() + return color == .black ? description.lowercased() : description } } diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift index 57a4ce4..8a8b8ec 100644 --- a/Sources/ChessCore/Models/Notation.swift +++ b/Sources/ChessCore/Models/Notation.swift @@ -4,7 +4,12 @@ enum Notation { struct Gameplay { enum Play { - case castle(side: Board.Side) + 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) } @@ -35,11 +40,11 @@ enum Notation { } switch string { - case .kingsideCastle: - play = .castle(side: .kingside) + case .castleLong: + play = .castle(castle: .long) - case .queensideCastle: - play = .castle(side: .queenside) + case .castleShort: + play = .castle(castle: .short) default: let figure = (string.first.map(Piece.Figure.init) ?? nil) ?? .pawn diff --git a/Sources/ChessCore/String.swift b/Sources/ChessCore/String.swift index 9c3bfdc..207463f 100644 --- a/Sources/ChessCore/String.swift +++ b/Sources/ChessCore/String.swift @@ -3,9 +3,9 @@ extension String { static let blackWins = "0-1" static let capture = "x" + static let castleLong = "O-O-O" + static let castleShort = "O-O" static let drawGame = "1/2-1/2" - static let kingsideCastle = "O-O" static let promotion = "=" - static let queensideCastle = "O-O-O" static let whiteWins = "1-0" } From 4ce2a967eb9684d7299519ed73f4b3bbdff1cfbe Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Wed, 27 Nov 2024 04:13:11 -0500 Subject: [PATCH 4/5] enum errors --- Sources/ChessCore/Models/Board.swift | 15 - .../ChessCore/Models/Errors/IllegalMove.swift | 16 + .../Models/Errors/InvalidNotation.swift | 14 + Sources/ChessCore/Models/Game.swift | 281 ++++++++---------- Sources/ChessCore/Models/Notation.swift | 2 +- Sources/ChessCore/Models/Piece.swift | 9 +- Sources/ChessCore/Models/Square.swift | 4 +- 7 files changed, 172 insertions(+), 169 deletions(-) create mode 100644 Sources/ChessCore/Models/Errors/IllegalMove.swift create mode 100644 Sources/ChessCore/Models/Errors/InvalidNotation.swift diff --git a/Sources/ChessCore/Models/Board.swift b/Sources/ChessCore/Models/Board.swift index a08ada5..3f3dd65 100644 --- a/Sources/ChessCore/Models/Board.swift +++ b/Sources/ChessCore/Models/Board.swift @@ -7,21 +7,6 @@ public struct Board { typealias Mutation = (originSquare: Square, targetSquare: Square, promotion: Piece.Figure?) - enum Move { - case move(originSquare: Square) - case capture(originSquare: Square) - - var originSquare: Square { - switch self { - case let .move(originSquare): - return originSquare - - case let .capture(originSquare): - return originSquare - } - } - } - let pieces: [Square: Piece] let squaresTouched: [Square] 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..feaee9c --- /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 badPunctuation(_: BadPunctuation) + case noPossiblePiece + case unparseable(notation: String) +} diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift index e525639..c9f77bd 100644 --- a/Sources/ChessCore/Models/Game.swift +++ b/Sources/ChessCore/Models/Game.swift @@ -12,23 +12,23 @@ public extension Board { return Board(pieces: allPieces.reduce(into: .init()) { pieces, piece in let files: [Square.File] switch piece.figure { - case .pawn: - files = Square.File.allCases + case .bishop: + files = [.c, .f] - case .rook: - files = [.a, .h] + case .king: + files = [.e] case .knight: files = [.b, .g] - case .bishop: - files = [.c, .f] + case .pawn: + files = Square.File.allCases case .queen: files = [.d] - case .king: - files = [.e] + case .rook: + files = [.a, .h] } pieces = files.reduce(into: pieces) { pieces, file in @@ -38,32 +38,18 @@ public extension Board { } else { rank = piece.color == .black ? .eight : .one } - pieces[Square(file: file, rank: rank)] = piece + pieces[.init(file: file, rank: rank)] = piece } }) } } -extension Board: CustomStringConvertible { - public var description: String { - let allFiles = Square.File.allCases - return Square.Rank.allCases.reversed().map { rank in - " \(rank) ".appending(allFiles.map { file in - pieces[Square(file: file, rank: rank)]?.description ?? " " - }.joined(separator: " ")) - } - .joined(separator: "\n") - .appending("\n ") - .appending(allFiles.map(\.description).joined(separator: " ")) - } -} - private extension Board { func isCheck(color: Piece.Color) -> Bool { pieces.filter { _, piece in - piece.color == color.opposite + piece.color != color }.flatMap { square, _ in - moves(.capture(originSquare: square)) + moves(from: square, isCapture: true) }.contains { targetSquare in pieces[targetSquare] == .init(color: color, figure: .king) } @@ -72,36 +58,40 @@ private extension Board { func isCheckmate(color: Piece.Color) -> Bool { !pieces.filter { _, piece in piece.color == color - }.flatMap { originSquare, _ in - (moves(.move(originSquare: originSquare)) + moves(.capture(originSquare: originSquare))).map { targetSquare in - Mutation(originSquare: originSquare, targetSquare: targetSquare, promotion: nil) + }.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 } } - func mutated(play: Notation.Gameplay.Play, moveColor color: Piece.Color) -> Board? { + func mutated(play: Notation.Gameplay.Play, moveColor color: Piece.Color) throws -> Board { let mutations: [Mutation] switch play { case let .castle(castle): guard !isCheck(color: color) else { - return nil + throw IllegalMove.cannotCastle(.inCheck) } let rank: Square.Rank = color == .black ? .eight : .one guard !(castle == .long ? [.b, .c, .d] : [.f, .g]).map({ file in Square(file: file, rank: rank) }).contains(where: pieces.keys.contains) else { - return nil + 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 !squaresTouched.contains(square), pieces[square] == .init(color: color, figure: figure) else { - return nil + guard pieces[square] == .init(color: color, figure: figure) else { + throw IllegalMove.cannotCastle(.pieceOutOfPosition(figure: figure)) + } + + guard !squaresTouched.contains(square) else { + throw IllegalMove.cannotCastle(.pieceMoved(figure: figure)) } } @@ -113,52 +103,50 @@ private extension Board { ] case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): - let eligibleSquares = pieces.filter { square, piece in - if let disambiguationFile, square.file != disambiguationFile { - return false + let eligibleSquares: [Square] = pieces.compactMap { square, piece in + guard piece == .init(color: color, figure: figure) else { + return nil } - if let disambiguationRank, square.rank != disambiguationRank { - return false + if let disambiguationFile, square.file != disambiguationFile { + return nil } - guard piece == .init(color: color, figure: figure) else { - return false + if let disambiguationRank, square.rank != disambiguationRank { + return nil } - return moves(isCapture ? .capture(originSquare: square) : .move(originSquare: square)).contains(targetSquare) + return moves(from: square, isCapture: isCapture).contains(targetSquare) ? square : nil } - guard eligibleSquares.count == 1 else { - return nil + guard let originSquare = eligibleSquares.first else { + throw InvalidNotation.noPossiblePiece } - guard let originSquare = eligibleSquares.first?.0 else { - return nil + guard eligibleSquares.count == 1 else { + throw InvalidNotation.ambiguous } - let figure = pieces[originSquare]!.figure - let promotionRank: Square.Rank = color == .black ? .one : .eight - // Pawns must be promoted when they reach the end of the board. - if figure == .pawn, targetSquare.rank == promotionRank, promotion == nil { - return nil + let promotionRank: Square.Rank = color == .black ? .one : .eight + guard promotion != nil || figure != .pawn || targetSquare.rank != promotionRank else { + throw IllegalMove.figureMustPromote } if let promotion { // Only pawns can be promoted . - if figure != .pawn { - return nil + guard figure == .pawn else { + throw IllegalMove.figureCannotPromote } // only when they reach the end of the board - if targetSquare.rank != promotionRank { - return nil + guard targetSquare.rank == promotionRank else { + throw IllegalMove.mustReachEndOfBoardToPromote } - // and they must be promoted to knight, bishop, rook or queen. - if [Piece.Figure.king, Piece.Figure.pawn].contains(promotion) { - return 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 } } @@ -166,40 +154,36 @@ private extension Board { } guard let mutatedBoard = mutatedBoard(mutations: mutations) else { - return nil + throw IllegalMove.cannotMoveIntoCheck } return mutatedBoard } - private func moves(_ move: Move) -> [Square] { - let isCapture: Bool - if case .capture = move { - isCapture = true - } else { - isCapture = false - } - let isUnmoved = !squaresTouched.contains(move.originSquare) - let piece = pieces[move.originSquare]! - return piece.moves(originSquare: move.originSquare, isCapture: isCapture, isUnmoved: isUnmoved).flatMap { path in - let collision: (offset: Int, piece: Piece)? = path.enumerated().first { _, targetSquare in - pieces[targetSquare] != nil - }.map { offset, targetSquare in - (offset, pieces[targetSquare]!) + private 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 piece's path. guard isCapture else { - return path.prefix(upTo: collision?.offset ?? path.endIndex) + return path.prefix(upTo: obstruction?.0 ?? path.endIndex) } - guard let collision, collision.piece.color == piece.color.opposite else { + 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!] } - return path[collision.offset.. [[Square]] { - switch (figure, isCapture) { - case (.bishop, _): - return Vector.diagonalUnitVectors.compactMap(originSquare.allSquaresInDirection) + 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, _): + case .king: return Vector.unitVectors.compactMap { direction in - (originSquare + direction).map { targetSquare in + (square + direction).map { targetSquare in [targetSquare] } } - case (.knight, _): + case .knight: return [Vector(files: -2, ranks: -1), Vector(files: -2, ranks: 1), Vector(files: -1, ranks: -2), @@ -312,35 +324,23 @@ private extension Piece { Vector(files: 1, ranks: 2), Vector(files: 2, ranks: -1), Vector(files: 2, ranks: 1)].compactMap { vector in - (originSquare + vector).map { targetSquare in + (square + vector).map { targetSquare in [targetSquare] } } - case (.pawn, false): - guard let oneSquareForward = originSquare + forwardUnitVector else { - return [] - } - - guard let twoSquaresForward = oneSquareForward + forwardUnitVector, isUnmoved else { - return [[oneSquareForward]] - } + case .pawn: + let oneSquareForward = (square + forwardUnitVector)! + let isOnStartRank = square.rank == .two || square.rank == .seven + return [[oneSquareForward, isOnStartRank ? oneSquareForward + forwardUnitVector : nil].compactMap { square in + square + }] - return [[oneSquareForward, twoSquaresForward]] + case .queen: + return Vector.unitVectors.compactMap(square.allSquaresInDirection) - case (.pawn, true): - let ranks = forwardUnitVector.ranks - return [Vector(files: -1, ranks: ranks), Vector(files: 1, ranks: ranks)].compactMap { vector in - (originSquare + vector).map { targetSquare in - [targetSquare] - } - } - - case (.queen, _): - return Vector.unitVectors.compactMap(originSquare.allSquaresInDirection) - - case (.rook, _): - return Vector.cardinalUnitVectors.compactMap(originSquare.allSquaresInDirection) + case .rook: + return Vector.cardinalUnitVectors.compactMap(square.allSquaresInDirection) } } } @@ -353,13 +353,13 @@ extension Piece: CustomStringConvertible { } extension Piece.Color: CustomStringConvertible { - var description: String { + public var description: String { rawValue } } extension Piece.Figure: CustomStringConvertible { - var description: String { + public var description: String { rawValue } } @@ -428,18 +428,6 @@ extension Vector { /// /// Chess is a board game played between two players. public struct Game { - private struct InvalidMove: Error { - let notation: String - } - - private struct InvalidNotation: Error { - let notation: String - - init(_ notation: String) { - self.notation = notation - } - } - var isGameOver: Bool { guard case .end = moves.last else { return board.isCheckmate(color: moveColor) @@ -447,7 +435,7 @@ public struct Game { return true } - private var board: Board + private(set) var board: Board private var moveColor: Piece.Color { moves.count.isMultiple(of: 2) ? .white : .black @@ -459,46 +447,41 @@ public struct Game { /// - Parameter notation: Notation public mutating func move(_ notationString: String) throws { guard let notation = Notation(string: notationString) else { - throw InvalidNotation(notationString) + throw InvalidNotation.unparseable(notation: notationString) } if case let .gameplay(gameplay) = notation { - guard let mutatedBoard = board.mutated(play: gameplay.play, moveColor: moveColor) else { - throw InvalidMove(notation: notationString) - } - - board = mutatedBoard + let board = try board.mutated(play: gameplay.play, moveColor: moveColor) // Compute game state. let isCheck = board.isCheck(color: moveColor.opposite) let isCheckmate = board.isCheckmate(color: moveColor.opposite) - // Correct missing punctuation. - guard let punctuation = gameplay.punctuation else { + // Validate punctuation parsed from input notation. + switch gameplay.punctuation { + case .check: + guard isCheck else { + throw InvalidNotation.badPunctuation(.isNotCheck) + } guard !isCheckmate else { - let gameplay = Notation.Gameplay(play: gameplay.play, punctuation: .checkmate) - let notation = Notation.gameplay(gameplay) - moves += [notation] - return + throw InvalidNotation.badPunctuation(.isCheckmate) } - guard !isCheck else { - let gameplay = Notation.Gameplay(play: gameplay.play, punctuation: .check) - let notation = Notation.gameplay(gameplay) - moves += [notation] - return + case .checkmate: + guard isCheckmate else { + throw InvalidNotation.badPunctuation(.isNotCheckmate) } - moves += [notation] - return + case .none: + guard !isCheckmate else { + throw InvalidNotation.badPunctuation(.isCheckmate) + } + guard !isCheck else { + throw InvalidNotation.badPunctuation(.isCheck) + } } - // Validate punctuation parsed from input notation. - if case .check = punctuation, !isCheck || isCheckmate { - throw InvalidNotation(notationString) - } else if case .checkmate = punctuation, !isCheckmate { - throw InvalidNotation(notationString) - } + self.board = board } moves += [notation] @@ -513,9 +496,9 @@ public struct Game { extension Game: CustomStringConvertible { public var description: String { (moves.isEmpty ? "" : stride(from: 0, to: moves.count, by: 2).map { i in - "\(i/2+1). " + "\(i / 2 + 1). " .appending(moves[i].description) - .appending(moves.count > i+1 ? " \(moves[i+1])" : "") + .appending(moves.count > i + 1 ? " \(moves[i + 1])" : "") }.joined(separator: "\n") .appending("\n\n")) .appending(isGameOver ? "" : "\(moveColor.description.capitalized.appending(" to move."))\n\n") diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift index 8a8b8ec..8395f84 100644 --- a/Sources/ChessCore/Models/Notation.swift +++ b/Sources/ChessCore/Models/Notation.swift @@ -94,7 +94,7 @@ enum Notation { disambiguationRank = nil } - guard let targetSquare = Square(notation: String(string)) else { + guard let targetSquare = Square(String(string)) else { return nil } diff --git a/Sources/ChessCore/Models/Piece.swift b/Sources/ChessCore/Models/Piece.swift index 4c5f37c..8750815 100644 --- a/Sources/ChessCore/Models/Piece.swift +++ b/Sources/ChessCore/Models/Piece.swift @@ -2,13 +2,13 @@ /// A model representing a chess piece. public struct Piece: Equatable { /// Color - enum Color: String, CaseIterable { + public enum Color: String, CaseIterable { case white case black } /// Figure - enum Figure: String, CaseIterable { + public enum Figure: String, CaseIterable { case bishop = "B" case king = "K" case knight = "N" @@ -26,6 +26,11 @@ public struct Piece: Equatable { /// Figure let figure: Figure + + public init(color: Color, figure: Figure) { + self.color = color + self.figure = figure + } } extension Piece.Color { diff --git a/Sources/ChessCore/Models/Square.swift b/Sources/ChessCore/Models/Square.swift index c6bdb98..3c76d1b 100644 --- a/Sources/ChessCore/Models/Square.swift +++ b/Sources/ChessCore/Models/Square.swift @@ -38,8 +38,8 @@ public struct Square: Hashable { } /// Convenience initializer - init?(notation: String) { - guard let file = notation.first.map(File.init) ?? nil, let rank = notation.last.map(Rank.init) ?? nil, notation.count == 2 else { + 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) From d139f53311e8ed14a702a075ae45614f4bb1a479 Mon Sep 17 00:00:00 2001 From: Ilias Karim Date: Sun, 1 Dec 2024 22:27:38 -0500 Subject: [PATCH 5/5] refactor game --- README.md | 54 +-- Sources/ChessCore/Models/Board.swift | 8 +- .../Models/Errors/InvalidNotation.swift | 2 +- Sources/ChessCore/Models/Game.swift | 365 +++++++++--------- Sources/ChessCore/Models/Notation.swift | 6 +- Sources/ChessCore/Models/Square.swift | 13 - Sources/ChessCore/String.swift | 11 +- Tests/ChessCoreTests/ChessTests.swift | 134 ++----- 8 files changed, 253 insertions(+), 340 deletions(-) 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/Models/Board.swift b/Sources/ChessCore/Models/Board.swift index 3f3dd65..6fac5f2 100644 --- a/Sources/ChessCore/Models/Board.swift +++ b/Sources/ChessCore/Models/Board.swift @@ -7,12 +7,14 @@ public struct Board { typealias Mutation = (originSquare: Square, targetSquare: Square, promotion: Piece.Figure?) - let pieces: [Square: Piece] + let enPassant: Square? - let squaresTouched: [Square] + var moves = [Notation]() - let enPassant: Square? + let pieces: [Square: Piece] + let squaresTouched: [Square] + init(pieces: [Square : Piece], enPassant: Square? = nil, squaresTouched: [Square] = []) { self.pieces = pieces self.enPassant = enPassant diff --git a/Sources/ChessCore/Models/Errors/InvalidNotation.swift b/Sources/ChessCore/Models/Errors/InvalidNotation.swift index feaee9c..c434c92 100644 --- a/Sources/ChessCore/Models/Errors/InvalidNotation.swift +++ b/Sources/ChessCore/Models/Errors/InvalidNotation.swift @@ -8,7 +8,7 @@ enum InvalidNotation: Error { } case ambiguous + case badMove case badPunctuation(_: BadPunctuation) - case noPossiblePiece case unparseable(notation: String) } diff --git a/Sources/ChessCore/Models/Game.swift b/Sources/ChessCore/Models/Game.swift index c9f77bd..739226b 100644 --- a/Sources/ChessCore/Models/Game.swift +++ b/Sources/ChessCore/Models/Game.swift @@ -45,19 +45,13 @@ public extension Board { } private extension Board { - func isCheck(color: Piece.Color) -> Bool { - pieces.filter { _, piece in - piece.color != color - }.flatMap { square, _ in - moves(from: square, isCapture: true) - }.contains { targetSquare in - pieces[targetSquare] == .init(color: color, figure: .king) - } + var isCheckmate: Bool { + isCheck(color: moveColor) && isNoMovePossible } - func isCheckmate(color: Piece.Color) -> Bool { + var isNoMovePossible: Bool { !pieces.filter { _, piece in - piece.color == color + 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) @@ -67,100 +61,19 @@ private extension Board { } } - func mutated(play: Notation.Gameplay.Play, moveColor color: Piece.Color) throws -> Board { - let mutations: [Mutation] - - switch play { - case let .castle(castle): - guard !isCheck(color: color) else { - throw IllegalMove.cannotCastle(.inCheck) - } - - let rank: Square.Rank = color == .black ? .eight : .one - guard !(castle == .long ? [.b, .c, .d] : [.f, .g]).map({ file in - Square(file: file, rank: rank) - }).contains(where: 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 pieces[square] == .init(color: color, figure: figure) else { - throw IllegalMove.cannotCastle(.pieceOutOfPosition(figure: figure)) - } - - guard !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] = pieces.compactMap { square, piece in - guard piece == .init(color: color, figure: figure) else { - return nil - } - - if let disambiguationFile, square.file != disambiguationFile { - return nil - } - - if let disambiguationRank, square.rank != disambiguationRank { - return nil - } - - return moves(from: square, isCapture: isCapture).contains(targetSquare) ? square : nil - } - - guard let originSquare = eligibleSquares.first else { - throw InvalidNotation.noPossiblePiece - } - - guard eligibleSquares.count == 1 else { - throw InvalidNotation.ambiguous - } - - // Pawns must be promoted when they reach the end of the board. - let promotionRank: Square.Rank = color == .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)] - } + var moveColor: Piece.Color { + moves.count.isMultiple(of: 2) ? .white : .black + } - guard let mutatedBoard = mutatedBoard(mutations: mutations) else { - throw IllegalMove.cannotMoveIntoCheck + 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) } - - return mutatedBoard } - private func moves(from originSquare: Square, isCapture: Bool) -> [Square] { + 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 @@ -168,7 +81,7 @@ private extension Board { pieces[square] != nil } - // Non-capture moves can move a piece up to the first obstruction in its path or the end of the piece's path. + // 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) } @@ -182,12 +95,12 @@ private extension Board { return [path.first!] } - // All other captures take the first obstruction in the moving piece's capture path, unless it is obstructed by a same color piece. + // All other captures take the first opposing piece in the path. return path[obstruction.0 ..< obstruction.0 + 1] } } - private func mutatedBoard(mutations: [Mutation]) -> Self? { + func mutatedBoard(mutations: [Mutation]) -> Self? { mutations.reduce(self) { board, mutation in guard let board else { return nil @@ -197,25 +110,25 @@ private extension Board { let piece = board.pieces[mutation.originSquare]! var pieces = board.pieces pieces[mutation.originSquare] = nil - if let enPassant = mutation.targetSquare - piece.forwardUnitVector, piece.figure == .pawn, - mutation.originSquare.file != mutation.targetSquare.file, pieces[mutation.targetSquare] == nil { - // Pawn moved diagonally to a blank square. Capture en passant. + 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) - // Calculate any en passant eligible pawn and touched squares. + // 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) - // No moving into check. - guard !mutatedBoard.isCheck(color: piece.color) else { + // Forbid moving into check. + guard !mutatedBoard.isCheck(color: moveColor) else { return nil } @@ -226,25 +139,43 @@ private extension Board { extension Board: CustomStringConvertible { public var description: String { - Square.Rank.allCases.reversed().map { rank in + 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") + }.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 .drawGame + return .draw } - return victor == .black ? .blackWins : .whiteWins + return victor == .black ? .blackVictory : .whiteVictory case let .gameplay(gameplay): return "\(gameplay)" @@ -271,7 +202,7 @@ extension Notation.Gameplay.Play: CustomStringConvertible { } case let .translation(disambiguationFile, disambiguationRank, figure, isCapture, promotion, targetSquare): - let disambiguation = (disambiguationFile?.description ?? "").appending(disambiguationRank?.description ?? "") + let disambiguation = "\(disambiguationFile?.description ?? "")\(disambiguationRank?.description ?? "")" let promotion = promotion.map(\.description).map(String.promotion.appending) ?? "" return "\(figure)\(disambiguation)\(isCapture ? .capture : "")\(targetSquare)\(promotion)" } @@ -309,8 +240,8 @@ private extension Piece { return Vector.diagonalUnitVectors.compactMap(square.allSquaresInDirection) case .king: - return Vector.unitVectors.compactMap { direction in - (square + direction).map { targetSquare in + return Vector.unitVectors.compactMap { vector in + (square + vector).map { targetSquare in [targetSquare] } } @@ -332,8 +263,8 @@ private extension Piece { case .pawn: let oneSquareForward = (square + forwardUnitVector)! let isOnStartRank = square.rank == .two || square.rank == .seven - return [[oneSquareForward, isOnStartRank ? oneSquareForward + forwardUnitVector : nil].compactMap { square in - square + return [[oneSquareForward, isOnStartRank ? oneSquareForward + forwardUnitVector : nil].compactMap { targetSquare in + targetSquare }] case .queen: @@ -366,28 +297,23 @@ extension Piece.Figure: CustomStringConvertible { // MARK: - private extension Square { - func allSquaresInDirection(_ direction: Vector) -> [Self] { - guard let squareInDirection = self + direction else { - return [] + 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) + } - return [squareInDirection] + squareInDirection.allSquaresInDirection(direction) + func allSquaresInDirection(_ direction: Vector) -> [Self] { + (self + direction).map { squareInDirection in + [squareInDirection] + squareInDirection.allSquaresInDirection(direction) + } ?? [] } } extension Square: CustomStringConvertible { public var description: String { - file.description.appending(rank.description) - } -} - -private extension Square.File { - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } - - static func > (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue > rhs.rawValue + "\(file)\(rank)" } } @@ -406,8 +332,8 @@ extension Square.Rank: CustomStringConvertible { // MARK: - extension Vector { static let cardinalUnitVectors: [Vector] = [ - .init(files: 0, ranks: -1), .init(files: -1, ranks: 0), + .init(files: 0, ranks: -1), .init(files: 0, ranks: 1), .init(files: 1, ranks: 0) ] @@ -428,21 +354,17 @@ extension Vector { /// /// Chess is a board game played between two players. public struct Game { - var isGameOver: Bool { - guard case .end = moves.last else { - return board.isCheckmate(color: moveColor) + /// 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 - private var moveColor: Piece.Color { - moves.count.isMultiple(of: 2) ? .white : .black - } - - private var moves = [Notation]() - /// Move /// - Parameter notation: Notation public mutating func move(_ notationString: String) throws { @@ -450,41 +372,131 @@ public struct Game { throw InvalidNotation.unparseable(notation: notationString) } - if case let .gameplay(gameplay) = notation { - let board = try board.mutated(play: gameplay.play, moveColor: moveColor) - - // Compute game state. - let isCheck = board.isCheck(color: moveColor.opposite) - let isCheckmate = board.isCheckmate(color: moveColor.opposite) + guard case let .gameplay(gameplay) = notation else { + board.moves += [notation] + return + } - // Validate punctuation parsed from input notation. - switch gameplay.punctuation { - case .check: - guard isCheck else { - throw InvalidNotation.badPunctuation(.isNotCheck) + 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 !isCheckmate else { - throw InvalidNotation.badPunctuation(.isCheckmate) + + guard !board.squaresTouched.contains(square) else { + throw IllegalMove.cannotCastle(.pieceMoved(figure: figure)) } - - case .checkmate: - guard isCheckmate else { - throw InvalidNotation.badPunctuation(.isNotCheckmate) + } + + 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 } - - case .none: - guard !isCheckmate else { - throw InvalidNotation.badPunctuation(.isCheckmate) + + if let disambiguationFile, square.file != disambiguationFile { + return nil } - guard !isCheck else { - throw InvalidNotation.badPunctuation(.isCheck) + + if let disambiguationRank, square.rank != disambiguationRank { + return nil } + + return board.moves(from: square, isCapture: isCapture).contains(targetSquare) ? square : nil } - - self.board = board + + 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)] } - - moves += [notation] + + 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 @@ -495,13 +507,12 @@ public struct Game { extension Game: CustomStringConvertible { public var description: String { - (moves.isEmpty ? "" : stride(from: 0, to: moves.count, by: 2).map { i in + let moves = board.moves.isEmpty ? "" : stride(from: 0, to: board.moves.count, by: 2).map { i in "\(i / 2 + 1). " - .appending(moves[i].description) - .appending(moves.count > i + 1 ? " \(moves[i + 1])" : "") + .appending(board.moves[i].description) + .appending(board.moves.count > i + 1 ? " \(board.moves[i + 1])" : "") }.joined(separator: "\n") - .appending("\n\n")) - .appending(isGameOver ? "" : "\(moveColor.description.capitalized.appending(" to move."))\n\n") - .appending(board.description) + + return "\(moves)\n\n\(board)" } } diff --git a/Sources/ChessCore/Models/Notation.swift b/Sources/ChessCore/Models/Notation.swift index 8395f84..1a01967 100644 --- a/Sources/ChessCore/Models/Notation.swift +++ b/Sources/ChessCore/Models/Notation.swift @@ -109,13 +109,13 @@ enum Notation { init?(string: String) { switch string { - case .whiteWins: + case .whiteVictory: self = .end(victor: .white) - case .blackWins: + case .blackVictory: self = .end(victor: .black) - case .drawGame: + case .draw: self = .end(victor: nil) default: diff --git a/Sources/ChessCore/Models/Square.swift b/Sources/ChessCore/Models/Square.swift index 3c76d1b..d04ecb4 100644 --- a/Sources/ChessCore/Models/Square.swift +++ b/Sources/ChessCore/Models/Square.swift @@ -45,16 +45,3 @@ public struct Square: Hashable { 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) - } - - static func - (lhs: Self, rhs: Vector) -> Self? { - lhs + Vector(files: -1 * rhs.files, ranks: -1 * rhs.ranks) - } -} diff --git a/Sources/ChessCore/String.swift b/Sources/ChessCore/String.swift index 207463f..291e38f 100644 --- a/Sources/ChessCore/String.swift +++ b/Sources/ChessCore/String.swift @@ -1,11 +1,14 @@ -// MARK: - extension String { - static let blackWins = "0-1" + static let blackVictory = "0-1" static let capture = "x" static let castleLong = "O-O-O" static let castleShort = "O-O" - static let drawGame = "1/2-1/2" + static let draw = "1/2-1/2" + static let drawGame = "Draw game" static let promotion = "=" - static let whiteWins = "1-0" + 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 f397a3b..c3a980e 100644 --- a/Tests/ChessCoreTests/ChessTests.swift +++ b/Tests/ChessCoreTests/ChessTests.swift @@ -2,130 +2,40 @@ import XCTest @testable import ChessCore final class ChessTests: XCTestCase { - private var game = Game(board: [.init(notation: "a7")!: .init(color: .white, figure: .pawn)]) - - func testFirstMoves() throws { + func testFirstMoves() throws { for move in ["a3", "a4", "b3", "b4", "c3", "c4", "d3", "d4", "e3", "e4", "f3", "f4", "g3", "g4", "h3", "h4"] { - game = Game() + var game = Game() try game.move(move) + print(game) } for move in ["Na3", "Nc3", "Nf3", "Nh3"] { - game = Game() + 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") - } - - func testPromotionToRook() throws { - try game.move("a8=R") - } - - func testPromotionToQueen() throws { - try game.move("a8=Q") - print(game) - } - - func testKingsideCastle() throws { - var game = Game(board: [ - .init(notation: "e1")!: .init(color: .white, figure: .king), - .init(notation: "h1")!: .init(color: .white, figure: .rook), - ]) - print(game) - - try game.move("O-O") - print(game) - } - - func testQueensideCastle() throws { - var game = Game(board: [ - .init(notation: "e1")!: .init(color: .white, figure: .king), - .init(notation: "a1")!: .init(color: .white, figure: .rook), - ]) - print(game) - - try game.move("O-O-O") + 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 testMoveIntoCheck() throws { - var game = Game(board: [ - .init(notation: "d6")!: .init(color: .black, figure: .queen), - .init(notation: "f6")!: .init(color: .black, figure: .pawn), - .init(notation: "e8")!: .init(color: .white, figure: .king), - .init(notation: "a7")!: .init(color: .white, figure: .rook), - ]) - print(game) - - XCTAssertThrowsError(try game.move("Ke7")) + 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) } - -// func testCaptureOutOfCheck1() throws { -// var game = Game(board: [ -// .init(notation: "d6")!: .init(color: .white, figure: .queen), -// .init(notation: "f6")!: .init(color: .white, figure: .pawn), -// .init(notation: "e8")!: .init(color: .black, figure: .king), -//// .init(notation: "a7")!: .init(color: .black, figure: .rook), -// ]) -// print(game) -// -// try game.move("Qe7+") -// print(game) -// } -// -// func testCaptureOutOfCheck2() throws { -// var game = Game(board: [ -// "e5": .init(color: .white, figure: .queen), -// "f5": .init(color: .white, figure: .pawn), -// "a8": .init(color: .white, figure: .rook), -// "e7": .init(color: .black, figure: .king), -// "f7": .init(color: .black, figure: .pawn), -// ]) -// print(game) -// -// try game.move("Qe6") -// print(game) -// } -// -// func testDisambiguation() throws { -// var game = Game(board: [ -// "b3": .init(color: .white, figure: .knight), -// ]) -// print(game) -// -// try game.move("Nb3d4") -// print(game) -// } -// -// func testEnPassantCaptureOutOfCheck() throws { -// var game = Game(board: [ -// "a1": .init(color: .white, figure: .king), -// "f2": .init(color: .white, figure: .pawn), -// "e5": .init(color: .black, figure: .king), -// "g4": .init(color: .black, figure: .pawn), -// "a4": .init(color: .white, figure: .rook), -// "a6": .init(color: .white, figure: .rook), -// "d1": .init(color: .white, figure: .rook), -// "f8": .init(color: .white, figure: .rook), -// ]) -// print(game) -// -// try game.move("f4") -// print(game) -// -// try game.move("gxf3") -// print(game) -// } }