feat: implement legal castling (#1)
Build & Test (NowChessSystems) TeamCity build finished

## Summary

  - Introduces `GameContext` wrapper (board + castling rights) threading through the entire engine pipeline
  - Extends `MoveValidator` with `castlingTargets`, context-aware `legalTargets`/`isLegal` overloads, and helpers (`isCastle`, `castleSide`)
  - Updates `GameRules.legalMoves` and `gameStatus` to use `GameContext`, preventing false stalemate when castling is the only legal move
  - Adds castle detection and atomic execution (`withCastle`) to `GameController.processMove`, plus full rights revocation via source- and
  destination-square tables

  ## Test Plan

  - [ ] 142 tests passing, 100% statement and branch coverage on `modules/core`
  - [ ] White/Black kingside (e1g1/e8g8) and queenside (e1c1/e8c8) castling moves execute correctly
  - [ ] All six legality conditions enforced (rights flags, home squares, empty transit, king not in check, transit/landing squares not attacked)
  - [ ] Rights revoked on king moves, own rook moves, castle moves, and enemy rook captures
  - [ ] False stalemate correctly prevented when castling is the only escape

Co-authored-by: LQ63 <lkhermann@web.de>
Co-authored-by: Janis <janis.e.20@gmx.de>
Reviewed-on: #1
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
This commit was merged in pull request #1.
This commit is contained in:
2026-03-24 17:55:00 +01:00
committed by Janis
parent d0289e16f4
commit 00d326c1ba
12 changed files with 2054 additions and 87 deletions
@@ -1,10 +1,11 @@
package de.nowchess.chess
import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.board.Color
import de.nowchess.chess.controller.GameController
import de.nowchess.chess.logic.GameContext
object Main {
def main(args: Array[String]): Unit =
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
GameController.gameLoop(Board.initial, Color.White)
GameController.gameLoop(GameContext.initial, Color.White)
}
@@ -1,8 +1,9 @@
package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, Piece}
import de.nowchess.chess.logic.{MoveValidator, GameRules, PositionStatus}
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.api.game.CastlingRights
import de.nowchess.chess.logic.{GameContext, MoveValidator, GameRules, PositionStatus, CastleSide, withCastle}
import de.nowchess.chess.view.Renderer
// ---------------------------------------------------------------------------
@@ -11,15 +12,15 @@ import de.nowchess.chess.view.Renderer
sealed trait MoveResult
object MoveResult:
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult
// ---------------------------------------------------------------------------
// Controller
@@ -27,10 +28,10 @@ object MoveResult:
object GameController:
/** Pure function: interprets one raw input line against the current board state.
/** Pure function: interprets one raw input line against the current game context.
* Has no I/O side effects all output must be handled by the caller.
*/
def processMove(board: Board, turn: Color, raw: String): MoveResult =
def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
@@ -39,61 +40,97 @@ object GameController:
case None =>
MoveResult.InvalidFormat(trimmed)
case Some((from, to)) =>
board.pieceAt(from) match
ctx.board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, from, to) then
if !MoveValidator.isLegal(ctx, from, to) then
MoveResult.IllegalMove
else
val (newBoard, captured) = board.withMove(from, to)
GameRules.gameStatus(newBoard, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, captured, turn.opposite)
val castleOpt = if MoveValidator.isCastle(ctx.board, from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val (newBoard, captured) = castleOpt match
case Some(side) => (ctx.board.withCastle(turn, side), None)
case None => ctx.board.withMove(from, to)
val newCtx = applyRightsRevocation(
ctx.copy(board = newBoard), turn, from, to, castleOpt
)
GameRules.gameStatus(newCtx, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
private def applyRightsRevocation(
ctx: GameContext,
turn: Color,
from: Square,
to: Square,
castle: Option[CastleSide]
): GameContext =
// Step 1: Revoke all rights for a castling move (idempotent with step 2)
val ctx0 = castle.fold(ctx)(_ => ctx.withUpdatedRights(turn, CastlingRights.None))
// Step 2: Source-square revocation
val ctx1 = from match
case Square(File.E, Rank.R1) => ctx0.withUpdatedRights(Color.White, CastlingRights.None)
case Square(File.E, Rank.R8) => ctx0.withUpdatedRights(Color.Black, CastlingRights.None)
case Square(File.A, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(queenSide = false))
case Square(File.H, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(kingSide = false))
case Square(File.A, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(queenSide = false))
case Square(File.H, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(kingSide = false))
case _ => ctx0
// Step 3: Destination-square revocation (enemy captures a rook on its home square)
to match
case Square(File.A, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(queenSide = false))
case Square(File.H, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(kingSide = false))
case Square(File.A, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(queenSide = false))
case Square(File.H, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(kingSide = false))
case _ => ctx1
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends.
*/
def gameLoop(board: Board, turn: Color): Unit =
def gameLoop(ctx: GameContext, turn: Color): Unit =
println()
print(Renderer.render(board))
print(Renderer.render(ctx.board))
println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, turn, input) match
processMove(ctx, turn, input) match
case MoveResult.Quit =>
println("Game over. Goodbye!")
case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(board, turn)
gameLoop(ctx, turn)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(board, turn)
gameLoop(ctx, turn)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(board, turn)
gameLoop(ctx, turn)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(board, turn)
case MoveResult.Moved(newBoard, captured, newTurn) =>
gameLoop(ctx, turn)
case MoveResult.Moved(newCtx, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newBoard, newTurn)
case MoveResult.MovedInCheck(newBoard, captured, newTurn) =>
gameLoop(newCtx, newTurn)
case MoveResult.MovedInCheck(newCtx, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!")
gameLoop(newBoard, newTurn)
gameLoop(newCtx, newTurn)
case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.")
gameLoop(Board.initial, Color.White)
gameLoop(GameContext.initial, Color.White)
case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.")
gameLoop(Board.initial, Color.White)
gameLoop(GameContext.initial, Color.White)
@@ -0,0 +1,47 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.CastlingRights
enum CastleSide:
case Kingside, Queenside
case class GameContext(
board: Board,
whiteCastling: CastlingRights,
blackCastling: CastlingRights
):
def castlingFor(color: Color): CastlingRights =
if color == Color.White then whiteCastling else blackCastling
def withUpdatedRights(color: Color, rights: CastlingRights): GameContext =
if color == Color.White then copy(whiteCastling = rights)
else copy(blackCastling = rights)
object GameContext:
/** Convenience constructor for test boards: no castling rights on either side. */
def apply(board: Board): GameContext =
GameContext(board, CastlingRights.None, CastlingRights.None)
val initial: GameContext =
GameContext(Board.initial, CastlingRights.Both, CastlingRights.Both)
extension (b: Board)
def withCastle(color: Color, side: CastleSide): Board =
val (kingFrom, kingTo, rookFrom, rookTo) = (color, side) match
case (Color.White, CastleSide.Kingside) =>
(Square(File.E, Rank.R1), Square(File.G, Rank.R1),
Square(File.H, Rank.R1), Square(File.F, Rank.R1))
case (Color.White, CastleSide.Queenside) =>
(Square(File.E, Rank.R1), Square(File.C, Rank.R1),
Square(File.A, Rank.R1), Square(File.D, Rank.R1))
case (Color.Black, CastleSide.Kingside) =>
(Square(File.E, Rank.R8), Square(File.G, Rank.R8),
Square(File.H, Rank.R8), Square(File.F, Rank.R8))
case (Color.Black, CastleSide.Queenside) =>
(Square(File.E, Rank.R8), Square(File.C, Rank.R8),
Square(File.A, Rank.R8), Square(File.D, Rank.R8))
val king = Piece(color, PieceType.King)
val rook = Piece(color, PieceType.Rook)
Board(b.pieces.removed(kingFrom).removed(rookFrom)
.updated(kingTo, king).updated(rookTo, rook))
@@ -1,6 +1,7 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.chess.logic.GameContext
enum PositionStatus:
case Normal, InCheck, Mated, Drawn
@@ -19,13 +20,17 @@ object GameRules:
}
/** All (from, to) moves for `color` that do not leave their own king in check. */
def legalMoves(board: Board, color: Color): Set[(Square, Square)] =
board.pieces
def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] =
ctx.board.pieces
.collect { case (from, piece) if piece.color == color => from }
.flatMap { from =>
MoveValidator.legalTargets(board, from)
MoveValidator.legalTargets(ctx, from) // context-aware: includes castling
.filter { to =>
val (newBoard, _) = board.withMove(from, to)
val newBoard =
if MoveValidator.isCastle(ctx.board, from, to) then
ctx.board.withCastle(color, MoveValidator.castleSide(from, to))
else
ctx.board.withMove(from, to)._1
!isInCheck(newBoard, color)
}
.map(to => from -> to)
@@ -33,9 +38,9 @@ object GameRules:
.toSet
/** Position status for the side whose turn it is (`color`). */
def gameStatus(board: Board, color: Color): PositionStatus =
val moves = legalMoves(board, color)
val inCheck = isInCheck(board, color)
def gameStatus(ctx: GameContext, color: Color): PositionStatus =
val moves = legalMoves(ctx, color)
val inCheck = isInCheck(ctx.board, color)
if moves.isEmpty && inCheck then PositionStatus.Mated
else if moves.isEmpty then PositionStatus.Drawn
else if inCheck then PositionStatus.InCheck
@@ -1,6 +1,7 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{GameContext, CastleSide}
object MoveValidator:
@@ -110,3 +111,57 @@ object MoveValidator:
(diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) =>
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
.toSet
// ── Castling helpers ────────────────────────────────────────────────────────
private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean =
board.pieces.exists { case (from, piece) =>
piece.color == attackerColor && legalTargets(board, from).contains(sq)
}
def isCastle(board: Board, from: Square, to: Square): Boolean =
board.pieceAt(from).exists(_.pieceType == PieceType.King) &&
math.abs(to.file.ordinal - from.file.ordinal) == 2
def castleSide(from: Square, to: Square): CastleSide =
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
def castlingTargets(ctx: GameContext, color: Color): Set[Square] =
val rights = ctx.castlingFor(color)
val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingSq = Square(File.E, rank)
val enemy = color.opposite
if ctx.board.pieceAt(kingSq) != Some(Piece(color, PieceType.King)) then return Set.empty
if GameRules.isInCheck(ctx.board, color) then return Set.empty
var result = Set.empty[Square]
if rights.kingSide then
val rookSq = Square(File.H, rank)
val transit = List(Square(File.F, rank), Square(File.G, rank))
if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) &&
transit.forall(s => ctx.board.pieceAt(s).isEmpty) &&
!transit.exists(s => isAttackedBy(ctx.board, s, enemy)) then
result += Square(File.G, rank)
if rights.queenSide then
val rookSq = Square(File.A, rank)
val emptySquares = List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank))
val transitSqs = List(Square(File.D, rank), Square(File.C, rank))
if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) &&
emptySquares.forall(s => ctx.board.pieceAt(s).isEmpty) &&
!transitSqs.exists(s => isAttackedBy(ctx.board, s, enemy)) then
result += Square(File.C, rank)
result
def legalTargets(ctx: GameContext, from: Square): Set[Square] =
ctx.board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King =>
legalTargets(ctx.board, from) ++ castlingTargets(ctx, piece.color)
case _ =>
legalTargets(ctx.board, from)
def isLegal(ctx: GameContext, from: Square, to: Square): Boolean =
legalTargets(ctx, from).contains(to)