diff --git a/modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala b/modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala new file mode 100644 index 0000000..ec3baec --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala @@ -0,0 +1,70 @@ +package de.nowchess.api.board + +/** + * Unified castling rights tracker for all four sides. + * Tracks whether castling is still available for each side and direction. + * + * @param whiteKingSide White's king-side castling (0-0) still legally available + * @param whiteQueenSide White's queen-side castling (0-0-0) still legally available + * @param blackKingSide Black's king-side castling (0-0) still legally available + * @param blackQueenSide Black's queen-side castling (0-0-0) still legally available + */ +final case class CastlingRights( + whiteKingSide: Boolean, + whiteQueenSide: Boolean, + blackKingSide: Boolean, + blackQueenSide: Boolean +): + /** + * Check if either side has any castling rights remaining. + */ + def hasAnyRights: Boolean = + whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide + + /** + * Check if a specific color has any castling rights remaining. + */ + def hasRights(color: Color): Boolean = color match + case Color.White => whiteKingSide || whiteQueenSide + case Color.Black => blackKingSide || blackQueenSide + + /** + * Revoke all castling rights for a specific color. + */ + def revokeColor(color: Color): CastlingRights = color match + case Color.White => copy(whiteKingSide = false, whiteQueenSide = false) + case Color.Black => copy(blackKingSide = false, blackQueenSide = false) + + /** + * Revoke a specific castling right. + */ + def revokeKingSide(color: Color): CastlingRights = color match + case Color.White => copy(whiteKingSide = false) + case Color.Black => copy(blackKingSide = false) + + /** + * Revoke a specific castling right. + */ + def revokeQueenSide(color: Color): CastlingRights = color match + case Color.White => copy(whiteQueenSide = false) + case Color.Black => copy(blackQueenSide = false) + +object CastlingRights: + /** No castling rights for any side. */ + val None: CastlingRights = CastlingRights( + whiteKingSide = false, + whiteQueenSide = false, + blackKingSide = false, + blackQueenSide = false + ) + + /** All castling rights available. */ + val All: CastlingRights = CastlingRights( + whiteKingSide = true, + whiteQueenSide = true, + blackKingSide = true, + blackQueenSide = true + ) + + /** Standard starting position castling rights (both sides can castle both ways). */ + val Initial: CastlingRights = All diff --git a/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala b/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala new file mode 100644 index 0000000..3312548 --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala @@ -0,0 +1,44 @@ +package de.nowchess.api.game + +import de.nowchess.api.board.{Board, Color, Square, CastlingRights} +import de.nowchess.api.move.Move + +/** Immutable bundle of complete game state. + * All state changes produce new GameContext instances. + */ +case class GameContext( + board: Board, + turn: Color, + castlingRights: CastlingRights, + enPassantSquare: Option[Square], + halfMoveClock: Int, + moves: List[Move] +): + /** Create new context with updated board. */ + def withBoard(newBoard: Board): GameContext = copy(board = newBoard) + + /** Create new context with updated turn. */ + def withTurn(newTurn: Color): GameContext = copy(turn = newTurn) + + /** Create new context with updated castling rights. */ + def withCastlingRights(newRights: CastlingRights): GameContext = copy(castlingRights = newRights) + + /** Create new context with updated en passant square. */ + def withEnPassantSquare(newSq: Option[Square]): GameContext = copy(enPassantSquare = newSq) + + /** Create new context with updated half-move clock. */ + def withHalfMoveClock(newClock: Int): GameContext = copy(halfMoveClock = newClock) + + /** Create new context with move appended to history. */ + def withMove(move: Move): GameContext = copy(moves = moves :+ move) + +object GameContext: + /** Initial position: white to move, all castling rights, no en passant. */ + def initial: GameContext = GameContext( + board = Board.initial, + turn = Color.White, + castlingRights = CastlingRights.Initial, + enPassantSquare = None, + halfMoveClock = 0, + moves = List.empty + ) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala b/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala new file mode 100644 index 0000000..906987e --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala @@ -0,0 +1,29 @@ +package de.nowchess.rules + +import de.nowchess.chess.{Move, Situation, Square} + +/** Extension point for chess rule variants. Implement to support Chess960, etc. */ +trait RuleSet: + /** All pseudo-legal moves for the piece on `square` (ignores check). */ + def candidateMoves(situation: Situation, square: Square): List[Move] + + /** Legal moves for `square`: candidates that don't leave own king in check. */ + def legalMoves(situation: Situation, square: Square): List[Move] + + /** All legal moves for the side to move. */ + def allLegalMoves(situation: Situation): List[Move] + + /** True if the side to move's king is in check. */ + def isCheck(situation: Situation): Boolean + + /** True if the side to move is in check and has no legal moves. */ + def isCheckmate(situation: Situation): Boolean + + /** True if the side to move is not in check and has no legal moves. */ + def isStalemate(situation: Situation): Boolean + + /** True if neither side has enough material to checkmate. */ + def isInsufficientMaterial(situation: Situation): Boolean + + /** True if halfMoveClock >= 100 (50-move rule). */ + def isFiftyMoveRule(situation: Situation): Boolean diff --git a/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala new file mode 100644 index 0000000..b282db2 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala @@ -0,0 +1,245 @@ +package de.nowchess.rules + +import org.maichess.mono.model.* + +object StandardRules extends RuleSet: + + // ── Directions ──────────────────────────────────────────────────────────── + private val rookDirs: List[(Int, Int)] = List((1,0),(-1,0),(0,1),(0,-1)) + private val bishopDirs: List[(Int, Int)] = List((1,1),(1,-1),(-1,1),(-1,-1)) + private val queenDirs: List[(Int, Int)] = rookDirs ++ bishopDirs + private val knightJumps: List[(Int, Int)] = + List((2,1),(2,-1),(-2,1),(-2,-1),(1,2),(1,-2),(-1,2),(-1,-2)) + + // ── Pawn configuration helpers ──────────────────────────────────────────── + // curried: fix color, get the forward direction for that color's pawns + private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1 + private def pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6 + private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0 + + // ── Public API ──────────────────────────────────────────────────────────── + def candidateMoves(sit: Situation, sq: Square): List[Move] = + sit.board.pieceAt(sq).fold(List.empty[Move]) { piece => + if piece.color != sit.turn then List.empty[Move] + else piece.pieceType match + case PieceType.Pawn => pawnCandidates(sit, sq, piece.color) + case PieceType.Knight => knightCandidates(sit, sq, piece.color) + case PieceType.Bishop => slidingMoves(sit, sq, piece.color, bishopDirs) + case PieceType.Rook => slidingMoves(sit, sq, piece.color, rookDirs) + case PieceType.Queen => slidingMoves(sit, sq, piece.color, queenDirs) + case PieceType.King => kingCandidates(sit, sq, piece.color) + } + + def legalMoves(sit: Situation, sq: Square): List[Move] = + candidateMoves(sit, sq).filter { move => + !leavesKingInCheck(sit, move) && !castlesThroughCheck(sit, move) + } + + def allLegalMoves(sit: Situation): List[Move] = + Square.all.toList.flatMap(sq => legalMoves(sit, sq)) + + def isCheck(sit: Situation): Boolean = + kingSquare(sit.board, sit.turn) + .fold(false)(sq => isAttackedBy(sit.board, sq, sit.turn.opposite)) + + def isCheckmate(sit: Situation): Boolean = + isCheck(sit) && allLegalMoves(sit).isEmpty + + def isStalemate(sit: Situation): Boolean = + !isCheck(sit) && allLegalMoves(sit).isEmpty + + def isInsufficientMaterial(sit: Situation): Boolean = + insufficientMaterial(sit.board) + + def isFiftyMoveRule(sit: Situation): Boolean = + sit.halfMoveClock >= 100 + + // ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────────── + private def slidingMoves(sit: Situation, from: Square, color: Color, dirs: List[(Int, Int)]): List[Move] = + dirs.flatMap(dir => castRay(sit.board, from, color, dir)) + + private def castRay(board: Board, from: Square, color: Color, dir: (Int, Int)): List[Move] = + @annotation.tailrec + def loop(sq: Square, acc: List[Move]): List[Move] = + sq.offset(dir._1, dir._2) match + case None => acc + case Some(next) => + board.pieceAt(next) match + case None => loop(next, NormalMove(from, next) :: acc) + case Some(p) if p.color != color => NormalMove(from, next) :: acc + case Some(_) => acc + loop(from, Nil).reverse + + // ── Knight ──────────────────────────────────────────────────────────────── + private def knightCandidates(sit: Situation, from: Square, color: Color): List[Move] = + knightJumps.flatMap { (df, dr) => + from.offset(df, dr).flatMap { to => + sit.board.pieceAt(to) match + case Some(p) if p.color == color => None + case _ => Some(NormalMove(from, to)) + } + } + + // ── King ────────────────────────────────────────────────────────────────── + private def kingCandidates(sit: Situation, from: Square, color: Color): List[Move] = + val steps = queenDirs.flatMap { (df, dr) => + from.offset(df, dr).flatMap { to => + sit.board.pieceAt(to) match + case Some(p) if p.color == color => None + case _ => Some(NormalMove(from, to)) + } + } + steps ++ castlingCandidates(sit, from, color) + + // ── Pawn ────────────────────────────────────────────────────────────────── + private def pawnCandidates(sit: Situation, from: Square, color: Color): List[Move] = + val fwd = pawnForward(color) + val startRank = pawnStartRank(color) + val promoRank = pawnPromoRank(color) + + val single = from.offset(0, fwd).filter(to => sit.board.pieceAt(to).isEmpty) + + val double = Option.when(from.rank.toInt == startRank) { + from.offset(0, fwd).flatMap { mid => + Option.when(sit.board.pieceAt(mid).isEmpty) { + from.offset(0, fwd * 2).filter(to => sit.board.pieceAt(to).isEmpty) + }.flatten + } + }.flatten + + val diagonalCaptures = List(-1, 1).flatMap { df => + from.offset(df, fwd).flatMap { to => + sit.board.pieceAt(to).filter(_.color != color).map(_ => to) + } + } + + val epCaptures: List[EnPassantMove] = sit.enPassantSquare.toList.flatMap { epSq => + List(-1, 1).flatMap { df => + from.offset(df, fwd).filter(_ == epSq).map { to => + EnPassantMove(from, to, Square(to.file, from.rank)) + } + } + } + + def toMoves(dest: Square): List[NormalMove] = + if dest.rank.toInt == promoRank then + List(PieceType.Queen, PieceType.Rook, PieceType.Bishop, PieceType.Knight) + .map(pt => NormalMove(from, dest, Some(pt))) + else List(NormalMove(from, dest)) + + val stepSquares = single.toList ++ double.toList + val stepMoves = stepSquares.flatMap(toMoves) + val captureMoves = diagonalCaptures.flatMap(toMoves) + stepMoves ++ captureMoves ++ epCaptures + + // ── Castling ────────────────────────────────────────────────────────────── + private def castlingCandidates(sit: Situation, from: Square, color: Color): List[CastlingMove] = + color match + case Color.White => whiteCastles(sit, from) + case Color.Black => blackCastles(sit, from) + + private def squaresEmpty(board: Board, squares: List[String]): Boolean = + squares.forall(alg => Square.fromAlgebraic(alg).fold(false)(sq => board.pieceAt(sq).isEmpty)) + + private def castleMoves( + sit: Situation, + from: Square, + kingSideRight: Boolean, + queenSideRight: Boolean, + kingSquareAlg: String, + kingSideSquares: List[String], + queenSideSquares: List[String], + kingSideCoords: (String, String, String, String), + queenSideCoords: (String, String, String, String) + ): List[CastlingMove] = + val expected = Square.fromAlgebraic(kingSquareAlg).getOrElse(from) + + def makeCastle(rights: Boolean, clearSquares: List[String], coords: (String, String, String, String)): Option[CastlingMove] = + for + _ <- Option.when(rights && from == expected && squaresEmpty(sit.board, clearSquares))(()) + kf <- Square.fromAlgebraic(coords._1) + kt <- Square.fromAlgebraic(coords._2) + rf <- Square.fromAlgebraic(coords._3) + rt <- Square.fromAlgebraic(coords._4) + yield CastlingMove(kf, kt, rf, rt) + + List( + makeCastle(kingSideRight, kingSideSquares, kingSideCoords), + makeCastle(queenSideRight, queenSideSquares, queenSideCoords) + ).flatten + + private def whiteCastles(sit: Situation, from: Square): List[CastlingMove] = + castleMoves(sit, from, + sit.castlingRights.whiteKingSide, sit.castlingRights.whiteQueenSide, + "e1", + List("f1", "g1"), List("b1", "c1", "d1"), + ("e1", "g1", "h1", "f1"), ("e1", "c1", "a1", "d1") + ) + + private def blackCastles(sit: Situation, from: Square): List[CastlingMove] = + castleMoves(sit, from, + sit.castlingRights.blackKingSide, sit.castlingRights.blackQueenSide, + "e8", + List("f8", "g8"), List("b8", "c8", "d8"), + ("e8", "g8", "h8", "f8"), ("e8", "c8", "a8", "d8") + ) + + // ── Check detection ─────────────────────────────────────────────────────── + private def kingSquare(board: Board, color: Color): Option[Square] = + Square.all.find(sq => board.pieceAt(sq).contains(Piece(color, PieceType.King))) + + private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean = + Square.all.exists { sq => + board.pieceAt(sq).fold(false) { p => + p.color == attacker && squareAttacks(board, sq, p, target) + } + } + + private def squareAttacks(board: Board, from: Square, piece: Piece, target: Square): Boolean = + val fwd = pawnForward(piece.color) + piece.pieceType match + case PieceType.Pawn => + from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target) + case PieceType.Knight => + knightJumps.exists { (df, dr) => from.offset(df, dr).contains(target) } + case PieceType.Bishop => rayReaches(board, from, bishopDirs, target) + case PieceType.Rook => rayReaches(board, from, rookDirs, target) + case PieceType.Queen => rayReaches(board, from, queenDirs, target) + case PieceType.King => + queenDirs.exists { (df, dr) => from.offset(df, dr).contains(target) } + + private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean = + dirs.exists { dir => + @annotation.tailrec + def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match + case None => false + case Some(next) if next == target => true + case Some(next) if board.pieceAt(next).isEmpty => loop(next) + case Some(_) => false + loop(from) + } + + private def leavesKingInCheck(sit: Situation, move: Move): Boolean = + val nextBoard = sit.board.applyMove(move) + kingSquare(nextBoard, sit.turn).fold(false) { sq => + isAttackedBy(nextBoard, sq, sit.turn.opposite) + } + + private def castlesThroughCheck(sit: Situation, move: Move): Boolean = move match + case CastlingMove(from, to, _, _) => + val passSq = if to.file.toInt > from.file.toInt + then from.offset(1, 0) + else from.offset(-1, 0) + isCheck(sit) || passSq.fold(false)(sq => isAttackedBy(sit.board, sq, sit.turn.opposite)) + case _ => false + + // ── Insufficient material ───────────────────────────────────────────────── + private def insufficientMaterial(board: Board): Boolean = + val nonKings = board.pieces.values.iterator.toList.filter(_.pieceType != PieceType.King) + nonKings match + case Nil => true + case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true + case List(p1, p2) + if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop + && p1.color != p2.color => true + case _ => false