refactor(rule): implement StandardRules with GameContext

This commit is contained in:
2026-04-03 13:11:20 +02:00
parent 60e43027fa
commit c59cc2ddcf
3 changed files with 216 additions and 155 deletions
@@ -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 =
@@ -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
@@ -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
// ── 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 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 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) =>
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)
// ── King ───────────────────────────────────────────────────────────
// ── Pawn ──────────────────────────────────────────────────────────────────
private def pawnCandidates(sit: Situation, from: Square, color: Color): List[Move] =
private def kingCandidates(
context: GameContext,
from: Square,
color: Color
): List[Move] =
val steps = QueenDirs.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to =>
context.board.pieceAt(to) match
case Some(p) if p.color == color => None
case _ => Some(Move(from, to))
}
}
steps ++ castlingCandidates(context, from, 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 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 =>
@@ -201,16 +251,16 @@ object StandardRules extends RuleSet:
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)
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) }
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
@@ -219,24 +269,16 @@ object StandardRules extends RuleSet:
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)