feat: NCS-25 Add linters to keep quality up (#27)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #27
Reviewed-by: Leon Hermann <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
This commit was merged in pull request #27.
This commit is contained in:
2026-04-12 20:58:39 +02:00
committed by Janis
parent 3cb3160731
commit fd4e67d4f7
79 changed files with 1671 additions and 1457 deletions
@@ -4,9 +4,9 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.board.Square
import de.nowchess.api.move.Move
/** Extension point for chess rule variants (standard, Chess960, etc.).
* All rule queries are stateless: given a GameContext, return the answer.
*/
/** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a
* GameContext, return the answer.
*/
trait RuleSet:
/** All pseudo-legal moves for the piece on `square` (ignores check). */
def candidateMoves(context: GameContext)(square: Square): List[Move]
@@ -32,8 +32,7 @@ trait RuleSet:
/** True if halfMoveClock >= 100 (50-move rule). */
def isFiftyMoveRule(context: GameContext): Boolean
/** Apply a legal move to produce the next game context.
* Handles all special move types: castling, en passant, promotion.
* Updates castling rights, en passant square, half-move clock, turn, and move history.
*/
/** Apply a legal move to produce the next game context. Handles all special move types: castling, en passant,
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
*/
def applyMove(context: GameContext)(move: Move): GameContext
@@ -7,20 +7,19 @@ import de.nowchess.rules.RuleSet
import scala.annotation.tailrec
/** Standard chess rules implementation.
* Handles move generation, validation, check/checkmate/stalemate detection.
*/
/** Standard chess rules implementation. Handles move generation, validation, check/checkmate/stalemate detection.
*/
object DefaultRules extends RuleSet:
// ── Direction vectors ──────────────────────────────────────────────
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
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 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 ─────────────────────────────────────
private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
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
@@ -29,13 +28,14 @@ object DefaultRules extends RuleSet:
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(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)
else
piece.pieceType match
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)
}
override def legalMoves(context: GameContext)(square: Square): List[Move] =
@@ -65,18 +65,18 @@ object DefaultRules extends RuleSet:
// ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
private def slidingMoves(
context: GameContext,
from: Square,
color: Color,
dirs: List[(Int, Int)]
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)
board: Board,
from: Square,
color: Color,
dir: (Int, Int),
): List[Move] =
@tailrec
def loop(sq: Square, acc: List[Move]): List[Move] =
@@ -84,40 +84,40 @@ object DefaultRules extends RuleSet:
case None => acc
case Some(next) =>
board.pieceAt(next) match
case None => loop(next, Move(from, next) :: acc)
case None => loop(next, Move(from, next) :: acc)
case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc
case Some(_) => acc
case Some(_) => acc
loop(from, Nil).reverse
// ── Knight ─────────────────────────────────────────────────────────
private def knightCandidates(
context: GameContext,
from: Square,
color: Color
context: GameContext,
from: Square,
color: Color,
): List[Move] =
KnightJumps.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to =>
context.board.pieceAt(to) match
case Some(p) if p.color == color => None
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to))
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to))
}
}
// ── King ───────────────────────────────────────────────────────────
private def kingCandidates(
context: GameContext,
from: Square,
color: Color
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(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to))
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to))
}
}
steps ++ castlingCandidates(context, from, color)
@@ -125,17 +125,17 @@ object DefaultRules extends RuleSet:
// ── Castling ───────────────────────────────────────────────────────
private case class CastlingMove(
kingFromAlg: String,
kingToAlg: String,
middleAlg: String,
rookFromAlg: String,
moveType: MoveType
kingFromAlg: String,
kingToAlg: String,
middleAlg: String,
rookFromAlg: String,
moveType: MoveType,
)
private def castlingCandidates(
context: GameContext,
from: Square,
color: Color
context: GameContext,
from: Square,
color: Color,
): List[Move] =
color match
case Color.White => whiteCastles(context, from)
@@ -146,10 +146,18 @@ object DefaultRules extends RuleSet:
if from != expected then List.empty
else
val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove(context, moves, context.castlingRights.whiteKingSide,
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside))
addCastleMove(context, moves, context.castlingRights.whiteQueenSide,
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside))
addCastleMove(
context,
moves,
context.castlingRights.whiteKingSide,
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside),
)
addCastleMove(
context,
moves,
context.castlingRights.whiteQueenSide,
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside),
)
moves.toList
private def blackCastles(context: GameContext, from: Square): List[Move] =
@@ -157,10 +165,18 @@ object DefaultRules extends RuleSet:
if from != expected then List.empty
else
val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove(context, moves, context.castlingRights.blackKingSide,
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside))
addCastleMove(context, moves, context.castlingRights.blackQueenSide,
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside))
addCastleMove(
context,
moves,
context.castlingRights.blackKingSide,
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside),
)
addCastleMove(
context,
moves,
context.castlingRights.blackQueenSide,
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside),
)
moves.toList
private def queensideBSquare(kingToAlg: String): List[String] =
@@ -170,10 +186,10 @@ object DefaultRules extends RuleSet:
case _ => List.empty
private def addCastleMove(
context: GameContext,
moves: scala.collection.mutable.ListBuffer[Move],
castlingRight: Boolean,
castlingMove: CastlingMove
context: GameContext,
moves: scala.collection.mutable.ListBuffer[Move],
castlingRight: Boolean,
castlingMove: CastlingMove,
): Unit =
if castlingRight then
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
@@ -185,16 +201,15 @@ object DefaultRules extends RuleSet:
kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
do
val color = context.turn
val color = context.turn
val kingPresent = context.board.pieceAt(kf).exists(p => p.color == color && p.pieceType == PieceType.King)
val rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook)
val squaresSafe =
!isAttackedBy(context.board, kf, color.opposite) &&
!isAttackedBy(context.board, km, color.opposite) &&
!isAttackedBy(context.board, kt, color.opposite)
!isAttackedBy(context.board, km, color.opposite) &&
!isAttackedBy(context.board, kt, color.opposite)
if kingPresent && rookPresent && squaresSafe then
moves += Move(kf, kt, castlingMove.moveType)
if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType)
private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
squares.forall(sq => board.pieceAt(sq).isEmpty)
@@ -202,22 +217,26 @@ object DefaultRules extends RuleSet:
// ── Pawn ───────────────────────────────────────────────────────────
private def pawnCandidates(
context: GameContext,
from: Square,
color: Color
context: GameContext,
from: Square,
color: Color,
): List[Move] =
val fwd = pawnForward(color)
val fwd = pawnForward(color)
val startRank = pawnStartRank(color)
val promoRank = pawnPromoRank(color)
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(context.board.pieceAt(mid).isEmpty) {
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
}.flatten
val double = Option
.when(from.rank.ordinal == startRank) {
from.offset(0, fwd).flatMap { mid =>
Option
.when(context.board.pieceAt(mid).isEmpty) {
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
}
.flatten
}
}
}.flatten
.flatten
val diagonalCaptures = List(-1, 1).flatMap { df =>
from.offset(df, fwd).flatMap { to =>
@@ -236,22 +255,22 @@ object DefaultRules extends RuleSet:
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
if dest.rank.ordinal == promoRank then
List(
PromotionPiece.Queen, PromotionPiece.Rook,
PromotionPiece.Bishop, PromotionPiece.Knight
PromotionPiece.Queen,
PromotionPiece.Rook,
PromotionPiece.Bishop,
PromotionPiece.Knight,
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
val stepSquares = single.toList ++ double.toList
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
val stepSquares = single.toList ++ double.toList
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
stepMoves ++ captureMoves ++ epCaptures
// ── Check detection ────────────────────────────────────────────────
private def kingSquare(board: Board, color: Color): Option[Square] =
Square.all.find(sq =>
board.pieceAt(sq).exists(p => p.color == color && p.pieceType == 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 =>
@@ -266,26 +285,26 @@ object DefaultRules 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) }
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.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 =>
@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(context: GameContext, move: Move): Boolean =
val nextBoard = context.board.applyMove(move)
val nextBoard = context.board.applyMove(move)
val nextContext = context.withBoard(nextBoard)
isCheck(nextContext)
@@ -293,7 +312,7 @@ object DefaultRules extends RuleSet:
override def applyMove(context: GameContext)(move: Move): GameContext =
val color = context.turn
val board = context.board
val board = context.board
val newBoard = move.moveType match
case MoveType.CastleKingside => applyCastle(board, color, kingside = true)
@@ -302,14 +321,14 @@ object DefaultRules extends RuleSet:
case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp)
case MoveType.Normal(_) => board.applyMove(move)
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
val newEnPassantSquare = computeEnPassantSquare(board, move)
val isCapture = move.moveType match
case MoveType.Normal(capture) => capture
case MoveType.EnPassant => true
case _ => board.pieceAt(move.to).isDefined
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn)
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
context
.withBoard(newBoard)
@@ -322,19 +341,18 @@ object DefaultRules extends RuleSet:
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
val rank = if color == Color.White then Rank.R1 else Rank.R8
val (kingFrom, kingTo, rookFrom, rookTo) =
if kingside then
(Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
else
(Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
else (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
board
.removed(kingFrom).removed(rookFrom)
.removed(kingFrom)
.removed(rookFrom)
.updated(kingTo, king)
.updated(rookTo, rook)
private def applyEnPassant(board: Board, move: Move): Board =
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
val capturedSquare = Square(move.to.file, capturedRank)
board.applyMove(move).removed(capturedSquare)
@@ -347,7 +365,7 @@ object DefaultRules extends RuleSet:
board.removed(move.from).updated(move.to, Piece(color, promotedType))
private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights =
val piece = board.pieceAt(move.from)
val piece = board.pieceAt(move.from)
val isKingMove = piece.exists(_.pieceType == PieceType.King)
val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
@@ -360,14 +378,14 @@ object DefaultRules extends RuleSet:
var r = rights
if isKingMove then r = r.revokeColor(color)
else if isRookMove then
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
// Also revoke if a rook is captured
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
r
@@ -386,9 +404,10 @@ object DefaultRules extends RuleSet:
private def insufficientMaterial(board: Board): Boolean =
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
pieces match
case Nil => true
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
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
&& p1.color != p2.color =>
true
case _ => false
@@ -65,7 +65,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove clears en passant square for non double pawn push"):
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1")
val move = Move(sq("e2"), sq("e3"))
val move = Move(sq("e2"), sq("e3"))
val next = DefaultRules.applyMove(context)(move)
@@ -73,7 +73,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove resets halfMoveClock on pawn move"):
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
val move = Move(sq("e2"), sq("e4"))
val move = Move(sq("e2"), sq("e4"))
val next = DefaultRules.applyMove(context)(move)
@@ -81,7 +81,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove increments halfMoveClock on quiet non pawn move"):
val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1")
val move = Move(sq("g1"), sq("f3"))
val move = Move(sq("g1"), sq("f3"))
val next = DefaultRules.applyMove(context)(move)
@@ -89,7 +89,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove resets halfMoveClock on capture"):
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1")
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context)(move)
@@ -98,7 +98,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove updates castling rights after king move"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
val move = Move(sq("e1"), sq("e2"))
val move = Move(sq("e1"), sq("e2"))
val next = DefaultRules.applyMove(context)(move)
@@ -109,7 +109,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove updates castling rights after rook move from h1"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1")
val move = Move(sq("h1"), sq("h2"))
val move = Move(sq("h1"), sq("h2"))
val next = DefaultRules.applyMove(context)(move)
@@ -118,7 +118,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove revokes opponent castling right when rook on starting square is captured"):
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1")
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context)(move)
@@ -126,7 +126,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes kingside castling and repositions king and rook"):
val context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1")
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context)(move)
@@ -137,7 +137,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes queenside castling and repositions king and rook"):
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1")
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
val next = DefaultRules.applyMove(context)(move)
@@ -148,7 +148,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes en passant and removes captured pawn"):
val context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
val next = DefaultRules.applyMove(context)(move)
@@ -158,7 +158,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes promotion with selected piece type"):
val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
val next = DefaultRules.applyMove(context)(move)
@@ -179,7 +179,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove preserves black castling rights after white kingside castling"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context)(move)
@@ -190,16 +190,18 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove can revoke both white castling rights when both rooks are captured"):
val context = GameContext(
board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
board =
contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
turn = Color.Black,
castlingRights = CastlingRights(true, true, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty
moves = List.empty,
)
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
val afterH1Capture = DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
val afterH1Capture =
DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
afterH1Capture.castlingRights.whiteKingSide shouldBe false
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
@@ -233,7 +235,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes black kingside castling and repositions pieces on rank 8"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context)(move)
@@ -244,7 +246,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove revokes black castling rights when black rook moves from h8"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("h8"), sq("h7"))
val move = Move(sq("h8"), sq("h7"))
val next = DefaultRules.applyMove(context)(move)
@@ -253,7 +255,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove revokes black queenside castling right when black rook moves from a8"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("a8"), sq("a7"))
val move = Move(sq("a8"), sq("a7"))
val next = DefaultRules.applyMove(context)(move)
@@ -262,7 +264,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove revokes black kingside castling right when rook on h8 is captured"):
val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1")
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context)(move)
@@ -270,31 +272,25 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("candidateMoves creates all promotion move variants for black pawn"):
val context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1")
val to = sq("a1")
val to = sq("a1")
val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2"))
val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2"))
val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece }
promotions.toSet shouldBe Set(
PromotionPiece.Queen,
PromotionPiece.Rook,
PromotionPiece.Bishop,
PromotionPiece.Knight
PromotionPiece.Knight,
)
test("applyMove promotion supports queen rook and bishop targets"):
val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
val bishop = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop)))
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
@@ -1,6 +1,6 @@
package de.nowchess.rule
import de.nowchess.api.board.{Board, Color, File, Rank, Square, Piece, PieceType, CastlingRights}
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.io.fen.FenParser
@@ -15,31 +15,31 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
// ── Pawn moves ──────────────────────────────────────────────────
test("pawn can move forward one square"):
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R2))
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe true
test("pawn can move forward two squares from starting position"):
val context = GameContext.initial
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
val e2Moves = moves.filter(m => m.from == Square(File.E, Rank.R2))
e2Moves.exists(m => m.to == Square(File.E, Rank.R4)) shouldBe true
test("pawn can capture diagonally"):
// FEN: white pawn e4, black pawn d5
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal])
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
test("pawn cannot move backward"):
// FEN: white pawn on e4
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R4))
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe false
@@ -47,18 +47,18 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("moving king out of check removes it from legal moves if king stays in check"):
// FEN: white king e1, black rook e8, white tries to move away
val fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1"
val fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
// King must move; e2 should be valid but d1 might be blocked by rook if still on same file
moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true
test("king cannot move to square attacked by opponent"):
// FEN: white king e1, black rook e2 defended by black king e3
val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1"
val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
// King cannot move to e2 (occupied and attacked)
val kingMovesToE2 = moves.filter(m => m.from == Square(File.E, Rank.R1) && m.to == Square(File.E, Rank.R2))
@@ -67,64 +67,67 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
// ── Castling legality ────────────────────────────────────────────
test("castling kingside is legal when king and rook unmoved and path clear"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1"
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
castles.nonEmpty shouldBe true
test("castling queenside is legal when king and rook unmoved and path clear"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1"
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
castles.nonEmpty shouldBe true
test("castling is illegal when castling rights are false"):
// FEN: king and rook in position, but castling rights disabled
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
castles.isEmpty shouldBe true
test("castling is illegal when king is in check"):
// FEN: white king e1 in check from black rook e8
val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"
val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside || m.moveType == MoveType.CastleQueenside)
castles.isEmpty shouldBe true
test("castling is illegal when path has piece in the way"):
// FEN: white king e1, white rook h1, white bishop f1 (blocks f-file)
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1"
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
castles.isEmpty shouldBe true
test("castling queenside is illegal when knight blocks on b8"):
// Black king e8, black rook a8, black knight b8 (blocks queenside path)
val board = Board(Map(
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King)
))
val board = Board(
Map(
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
),
)
val context = GameContext(
board = board,
turn = Color.Black,
castlingRights = CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
castlingRights =
CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty
moves = List.empty,
)
val moves = rules.allLegalMoves(context)
@@ -135,18 +138,18 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("en passant is legal when en passant square is set"):
// FEN: white pawn e5, black pawn d5 (just double-pushed), en passant square d6
val fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1"
val fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
epMoves.exists(m => m.to == Square(File.D, Rank.R6)) shouldBe true
test("en passant is illegal when en passant square is none"):
// FEN: white pawn e5, black pawn d5, but no en passant square
val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1"
val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
epMoves.isEmpty shouldBe true
@@ -155,9 +158,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("pinned piece cannot move and expose king to check"):
// FEN: white king e1, white bishop d2 (pinned), black rook a2
val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1"
val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
// Bishop on d2 is pinned by rook on a2; it cannot move
val bishopMoves = moves.filter(m => m.from == Square(File.C, Rank.R2))
@@ -166,9 +169,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("piece blocking a check is legal"):
// FEN: white king e1, white rook d1, black bishop a4 attacking e1 via d2
// Actually, this is complex. Let's use: white king e1, black rook e8, white pawn blocks on e2
val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1"
val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val moves = rules.allLegalMoves(context)
// White is in check; only moves that block or move the king are legal
moves.nonEmpty shouldBe true