From c59cc2ddcf43e9b4eca35bed4306ec541c8c1708 Mon Sep 17 00:00:00 2001 From: Janis Date: Fri, 3 Apr 2026 13:11:20 +0200 Subject: [PATCH] refactor(rule): implement StandardRules with GameContext --- .../scala/de/nowchess/api/board/Board.scala | 3 + .../scala/de/nowchess/api/board/Square.scala | 16 + .../de/nowchess/rules/StandardRules.scala | 352 ++++++++++-------- 3 files changed, 216 insertions(+), 155 deletions(-) diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Board.scala b/modules/api/src/main/scala/de/nowchess/api/board/Board.scala index f71ac33..ec1f27e 100644 --- a/modules/api/src/main/scala/de/nowchess/api/board/Board.scala +++ b/modules/api/src/main/scala/de/nowchess/api/board/Board.scala @@ -14,6 +14,9 @@ object Board: val captured = b.get(to) val updatedBoard = b.removed(from).updated(to, b(from)) (updatedBoard, captured) + def applyMove(move: de.nowchess.api.move.Move): Board = + val (updatedBoard, _) = b.withMove(move.from, move.to) + updatedBoard def pieces: Map[Square, Piece] = b val initial: Board = diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Square.scala b/modules/api/src/main/scala/de/nowchess/api/board/Square.scala index 284b67b..44c3263 100644 --- a/modules/api/src/main/scala/de/nowchess/api/board/Square.scala +++ b/modules/api/src/main/scala/de/nowchess/api/board/Square.scala @@ -39,3 +39,19 @@ object Square: if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None ) for f <- fileOpt; r <- rankOpt yield Square(f, r) + + val all: IndexedSeq[Square] = + for + r <- Rank.values.toIndexedSeq + f <- File.values.toIndexedSeq + yield Square(f, r) + + /** Compute a target square by offsetting file and rank. + * Returns None if the resulting square is outside the board (0-7 range). */ + extension (sq: Square) + def offset(fileDelta: Int, rankDelta: Int): Option[Square] = + val newFileOrd = sq.file.ordinal + fileDelta + val newRankOrd = sq.rank.ordinal + rankDelta + if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then + Some(Square(File.values(newFileOrd), Rank.values(newRankOrd))) + else None \ No newline at end of file diff --git a/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala index b282db2..0d3a7d0 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala @@ -1,192 +1,242 @@ package de.nowchess.rules -import org.maichess.mono.model.* +import de.nowchess.api.game.GameContext +import de.nowchess.api.board.{Board, Color, Square, PieceType, Piece} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import scala.annotation.tailrec +/** Standard chess rules implementation. + * Handles move generation, validation, check/checkmate/stalemate detection. + */ 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)) + // ── Direction vectors ────────────────────────────────────────────── + 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 + // ── Pawn configuration helpers ───────────────────────────────────── + 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] + // ── Public API ───────────────────────────────────────────────────── + + override def candidateMoves(context: GameContext, square: Square): List[Move] = + context.board.pieceAt(square).fold(List.empty[Move]) { piece => + if piece.color != context.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) + case PieceType.Pawn => pawnCandidates(context, square, piece.color) + case PieceType.Knight => knightCandidates(context, square, piece.color) + case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs) + case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs) + case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs) + case PieceType.King => kingCandidates(context, square, piece.color) } - def legalMoves(sit: Situation, sq: Square): List[Move] = - candidateMoves(sit, sq).filter { move => - !leavesKingInCheck(sit, move) && !castlesThroughCheck(sit, move) + override def legalMoves(context: GameContext, square: Square): List[Move] = + candidateMoves(context, square).filter { move => + !leavesKingInCheck(context, move) } - def allLegalMoves(sit: Situation): List[Move] = - Square.all.toList.flatMap(sq => legalMoves(sit, sq)) + override def allLegalMoves(context: GameContext): List[Move] = + Square.all.flatMap(sq => legalMoves(context, sq)).toList - def isCheck(sit: Situation): Boolean = - kingSquare(sit.board, sit.turn) - .fold(false)(sq => isAttackedBy(sit.board, sq, sit.turn.opposite)) + override def isCheck(context: GameContext): Boolean = + kingSquare(context.board, context.turn) + .fold(false)(sq => isAttackedBy(context.board, sq, context.turn.opposite)) - def isCheckmate(sit: Situation): Boolean = - isCheck(sit) && allLegalMoves(sit).isEmpty + override def isCheckmate(context: GameContext): Boolean = + isCheck(context) && allLegalMoves(context).isEmpty - def isStalemate(sit: Situation): Boolean = - !isCheck(sit) && allLegalMoves(sit).isEmpty + override def isStalemate(context: GameContext): Boolean = + !isCheck(context) && allLegalMoves(context).isEmpty - def isInsufficientMaterial(sit: Situation): Boolean = - insufficientMaterial(sit.board) + override def isInsufficientMaterial(context: GameContext): Boolean = + insufficientMaterial(context.board) - def isFiftyMoveRule(sit: Situation): Boolean = - sit.halfMoveClock >= 100 + override def isFiftyMoveRule(context: GameContext): Boolean = + context.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)) + // ── Sliding pieces (Bishop, Rook, Queen) ─────────────────────────── - private def castRay(board: Board, from: Square, color: Color, dir: (Int, Int)): List[Move] = - @annotation.tailrec + private def slidingMoves( + context: GameContext, + from: Square, + color: Color, + dirs: List[(Int, Int)] + ): List[Move] = + dirs.flatMap(dir => castRay(context.board, from, color, dir)) + + private def castRay( + board: Board, + from: Square, + color: Color, + dir: (Int, Int) + ): List[Move] = + @tailrec def loop(sq: Square, acc: List[Move]): List[Move] = sq.offset(dir._1, dir._2) match - case None => acc + 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 + case None => loop(next, Move(from, next) :: acc) + case Some(p) if p.color != color => Move(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) => + // ── Knight ───────────────────────────────────────────────────────── + + private def knightCandidates( + context: GameContext, + from: Square, + color: Color + ): List[Move] = + KnightJumps.flatMap { (df, dr) => from.offset(df, dr).flatMap { to => - sit.board.pieceAt(to) match + context.board.pieceAt(to) match case Some(p) if p.color == color => None - case _ => Some(NormalMove(from, to)) + case _ => Some(Move(from, to)) } } - // ── King ────────────────────────────────────────────────────────────────── - private def kingCandidates(sit: Situation, from: Square, color: Color): List[Move] = - val steps = queenDirs.flatMap { (df, dr) => + // ── King ─────────────────────────────────────────────────────────── + + private def kingCandidates( + context: GameContext, + from: Square, + color: Color + ): List[Move] = + val steps = QueenDirs.flatMap { (df, dr) => from.offset(df, dr).flatMap { to => - sit.board.pieceAt(to) match + context.board.pieceAt(to) match case Some(p) if p.color == color => None - case _ => Some(NormalMove(from, to)) + case _ => Some(Move(from, to)) } } - steps ++ castlingCandidates(sit, from, color) + steps ++ castlingCandidates(context, from, color) - // ── Pawn ────────────────────────────────────────────────────────────────── - private def pawnCandidates(sit: Situation, from: Square, color: Color): List[Move] = - val fwd = pawnForward(color) + // ── Castling ─────────────────────────────────────────────────────── + + private def castlingCandidates( + context: GameContext, + from: Square, + color: Color + ): List[Move] = + color match + case Color.White => whiteCastles(context, from) + case Color.Black => blackCastles(context, from) + + private def whiteCastles(context: GameContext, from: Square): List[Move] = + val expected = Square.fromAlgebraic("e1").getOrElse(from) + if from != expected then List.empty + else + val moves = scala.collection.mutable.ListBuffer[Move]() + + // King-side castling + if context.castlingRights.whiteKingSide then + val clearSqs = List("f1", "g1").flatMap(Square.fromAlgebraic) + if squaresEmpty(context.board, clearSqs) then + for + kf <- Square.fromAlgebraic("e1") + kt <- Square.fromAlgebraic("g1") + do moves += Move(kf, kt, MoveType.CastleKingside) + + // Queen-side castling + if context.castlingRights.whiteQueenSide then + val clearSqs = List("b1", "c1", "d1").flatMap(Square.fromAlgebraic) + if squaresEmpty(context.board, clearSqs) then + for + kf <- Square.fromAlgebraic("e1") + kt <- Square.fromAlgebraic("c1") + do moves += Move(kf, kt, MoveType.CastleQueenside) + + moves.toList + + private def blackCastles(context: GameContext, from: Square): List[Move] = + val expected = Square.fromAlgebraic("e8").getOrElse(from) + if from != expected then List.empty + else + val moves = scala.collection.mutable.ListBuffer[Move]() + + // King-side castling + if context.castlingRights.blackKingSide then + val clearSqs = List("f8", "g8").flatMap(Square.fromAlgebraic) + if squaresEmpty(context.board, clearSqs) then + for + kf <- Square.fromAlgebraic("e8") + kt <- Square.fromAlgebraic("g8") + do moves += Move(kf, kt, MoveType.CastleKingside) + + // Queen-side castling + if context.castlingRights.blackQueenSide then + val clearSqs = List("b8", "c8", "d8").flatMap(Square.fromAlgebraic) + if squaresEmpty(context.board, clearSqs) then + for + kf <- Square.fromAlgebraic("e8") + kt <- Square.fromAlgebraic("c8") + do moves += Move(kf, kt, MoveType.CastleQueenside) + + moves.toList + + private def squaresEmpty(board: Board, squares: List[Square]): Boolean = + squares.forall(sq => board.pieceAt(sq).isEmpty) + + // ── Pawn ─────────────────────────────────────────────────────────── + + private def pawnCandidates( + context: GameContext, + 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) { + val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty) + val double = Option.when(from.rank.ordinal == 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) + Option.when(context.board.pieceAt(mid).isEmpty) { + from.offset(0, fwd * 2).filter(to => context.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) + context.board.pieceAt(to).filter(_.color != color).map(_ => to) } } - val epCaptures: List[EnPassantMove] = sit.enPassantSquare.toList.flatMap { epSq => + val epCaptures: List[Move] = context.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)) + Move(from, epSq, MoveType.EnPassant) } } } - 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)) + def toMoves(dest: Square): List[Move] = + if dest.rank.ordinal == promoRank then + List( + PromotionPiece.Queen, PromotionPiece.Rook, + PromotionPiece.Bishop, PromotionPiece.Knight + ).map(pt => Move(from, dest, MoveType.Promotion(pt))) + else List(Move(from, dest)) - val stepSquares = single.toList ++ double.toList - val stepMoves = stepSquares.flatMap(toMoves) + 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) + // ── Check detection ──────────────────────────────────────────────── - 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))) + Square.all.find(sq => + board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King) + ) private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean = Square.all.exists { sq => @@ -198,45 +248,37 @@ object StandardRules extends RuleSet: private def squareAttacks(board: Board, from: Square, piece: Piece, target: Square): Boolean = val fwd = pawnForward(piece.color) piece.pieceType match - case PieceType.Pawn => + 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) } + 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 + @tailrec def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match - case None => false - case Some(next) if next == target => true + case None => false + case Some(next) if next == target => true case Some(next) if board.pieceAt(next).isEmpty => loop(next) - case Some(_) => false + 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 leavesKingInCheck(context: GameContext, move: Move): Boolean = + val nextBoard = context.board.applyMove(move) + val nextContext = context.withBoard(nextBoard) + isCheck(nextContext) - 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 ────────────────────────────────────────── - // ── Insufficient material ───────────────────────────────────────────────── private def insufficientMaterial(board: Board): Boolean = - val nonKings = board.pieces.values.iterator.toList.filter(_.pieceType != PieceType.King) - nonKings match + val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King) + pieces match case Nil => true case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true case List(p1, p2)