refactor: NCS-8 removed Context and replaced it with History

This commit is contained in:
2026-03-28 16:08:35 +01:00
parent 4d800e88eb
commit 7bfd2468ce
15 changed files with 396 additions and 583 deletions
@@ -8,10 +8,12 @@ object Board:
extension (b: Board) extension (b: Board)
def pieceAt(sq: Square): Option[Piece] = b.get(sq) def pieceAt(sq: Square): Option[Piece] = b.get(sq)
def updated(sq: Square, piece: Piece): Board = b.updated(sq, piece)
def removed(sq: Square): Board = b.removed(sq)
def withMove(from: Square, to: Square): (Board, Option[Piece]) = def withMove(from: Square, to: Square): (Board, Option[Piece]) =
val captured = b.get(to) val captured = b.get(to)
val updated = b.removed(from).updated(to, b(from)) val updatedBoard = b.removed(from).updated(to, b(from))
(updated, captured) (updatedBoard, captured)
def pieces: Map[Square, Piece] = b def pieces: Map[Square, Piece] = b
val initial: Board = val initial: Board =
@@ -1,11 +1,12 @@
package de.nowchess.chess package de.nowchess.chess
import de.nowchess.api.board.Board
import de.nowchess.api.board.Color import de.nowchess.api.board.Color
import de.nowchess.chess.controller.GameController import de.nowchess.chess.controller.GameController
import de.nowchess.chess.logic.GameContext import de.nowchess.chess.logic.GameHistory
object Main { object Main {
def main(args: Array[String]): Unit = def main(args: Array[String]): Unit =
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.") println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
GameController.gameLoop(GameContext.initial, Color.White) GameController.gameLoop(Board.initial, GameHistory.empty, Color.White)
} }
@@ -2,8 +2,7 @@ package de.nowchess.chess.controller
import scala.io.StdIn import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.api.game.CastlingRights import de.nowchess.chess.logic.*
import de.nowchess.chess.logic.{GameContext, MoveValidator, GameRules, PositionStatus, CastleSide, withCastle}
import de.nowchess.chess.view.Renderer import de.nowchess.chess.view.Renderer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -17,8 +16,8 @@ object MoveResult:
case object NoPiece extends MoveResult case object NoPiece extends MoveResult
case object WrongColor extends MoveResult case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult case object IllegalMove extends MoveResult
case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult case object Stalemate extends MoveResult
@@ -31,7 +30,7 @@ object GameController:
/** Pure function: interprets one raw input line against the current game context. /** 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. * Has no I/O side effects all output must be handled by the caller.
*/ */
def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult = def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
raw.trim match raw.trim match
case "quit" | "q" => case "quit" | "q" =>
MoveResult.Quit MoveResult.Quit
@@ -40,97 +39,67 @@ object GameController:
case None => case None =>
MoveResult.InvalidFormat(trimmed) MoveResult.InvalidFormat(trimmed)
case Some((from, to)) => case Some((from, to)) =>
ctx.board.pieceAt(from) match board.pieceAt(from) match
case None => case None =>
MoveResult.NoPiece MoveResult.NoPiece
case Some(piece) if piece.color != turn => case Some(piece) if piece.color != turn =>
MoveResult.WrongColor MoveResult.WrongColor
case Some(_) => case Some(_) =>
if !MoveValidator.isLegal(ctx, from, to) then if !MoveValidator.isLegal(board, history, from, to) then
MoveResult.IllegalMove MoveResult.IllegalMove
else else
val castleOpt = if MoveValidator.isCastle(ctx.board, from, to) val castleOpt = if MoveValidator.isCastle(board, from, to)
then Some(MoveValidator.castleSide(from, to)) then Some(MoveValidator.castleSide(from, to))
else None else None
val (newBoard, captured) = castleOpt match val (newBoard, captured) = castleOpt match
case Some(side) => (ctx.board.withCastle(turn, side), None) case Some(side) => (board.withCastle(turn, side), None)
case None => ctx.board.withMove(from, to) case None => board.withMove(from, to)
val newCtx = applyRightsRevocation( val newHistory = history.addMove(from, to, castleOpt)
ctx.copy(board = newBoard), turn, from, to, castleOpt GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
) case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
GameRules.gameStatus(newCtx, turn.opposite) match case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
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.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate 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, /** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends. * prints the outcome, and recurses until the game ends.
*/ */
def gameLoop(ctx: GameContext, turn: Color): Unit = def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
println() println()
print(Renderer.render(ctx.board)) print(Renderer.render(board))
println(s"${turn.label}'s turn. Enter move: ") println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(ctx, turn, input) match processMove(board, history, turn, input) match
case MoveResult.Quit => case MoveResult.Quit =>
println("Game over. Goodbye!") println("Game over. Goodbye!")
case MoveResult.InvalidFormat(raw) => case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.") println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(ctx, turn) gameLoop(board, history, turn)
case MoveResult.NoPiece => case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.") println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(ctx, turn) gameLoop(board, history, turn)
case MoveResult.WrongColor => case MoveResult.WrongColor =>
println(s"That is not your piece.") println(s"That is not your piece.")
gameLoop(ctx, turn) gameLoop(board, history, turn)
case MoveResult.IllegalMove => case MoveResult.IllegalMove =>
println(s"Illegal move.") println(s"Illegal move.")
gameLoop(ctx, turn) gameLoop(board, history, turn)
case MoveResult.Moved(newCtx, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
val prevTurn = newTurn.opposite val prevTurn = newTurn.opposite
captured.foreach: cap => captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newCtx, newTurn) gameLoop(newBoard, newHistory, newTurn)
case MoveResult.MovedInCheck(newCtx, captured, newTurn) => case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
val prevTurn = newTurn.opposite val prevTurn = newTurn.opposite
captured.foreach: cap => captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!") println(s"${newTurn.label} is in check!")
gameLoop(newCtx, newTurn) gameLoop(newBoard, newHistory, newTurn)
case MoveResult.Checkmate(winner) => case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.") println(s"Checkmate! ${winner.label} wins.")
gameLoop(GameContext.initial, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White)
case MoveResult.Stalemate => case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.") println("Stalemate! The game is a draw.")
gameLoop(GameContext.initial, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White)
@@ -0,0 +1,23 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
enum CastleSide:
case Kingside, Queenside
extension (b: Board)
def withCastle(color: Color, side: CastleSide): Board =
val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingFrom = Square(File.E, rank)
val (kingTo, rookFrom, rookTo) = side match
case CastleSide.Kingside =>
(Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
case CastleSide.Queenside =>
(Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
val king = b.pieceAt(kingFrom).get
val rook = b.pieceAt(rookFrom).get
b.removed(kingFrom).removed(rookFrom)
.updated(kingTo, king)
.updated(rookTo, rook)
@@ -0,0 +1,31 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.{Color, File, Rank, Square}
import de.nowchess.api.game.CastlingRights
/** Derives castling rights from move history. */
object CastlingRightsCalculator:
def deriveCastlingRights(history: GameHistory, color: Color): CastlingRights =
val (kingRow, kingsideRookFile, queensideRookFile) = color match
case Color.White => (Rank.R1, File.H, File.A)
case Color.Black => (Rank.R8, File.H, File.A)
// Check if king has moved
val kingHasMoved = history.moves.exists: move =>
move.from == Square(File.E, kingRow) || move.castleSide.isDefined
if kingHasMoved then
CastlingRights.None
else
// Check if kingside rook has moved or was captured
val kingsideLost = history.moves.exists: move =>
move.from == Square(kingsideRookFile, kingRow) ||
move.to == Square(kingsideRookFile, kingRow)
// Check if queenside rook has moved or was captured
val queensideLost = history.moves.exists: move =>
move.from == Square(queensideRookFile, kingRow) ||
move.to == Square(queensideRookFile, kingRow)
CastlingRights(kingSide = !kingsideLost, queenSide = !queensideLost)
@@ -1,47 +0,0 @@
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))
@@ -0,0 +1,21 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.Square
/** A single move in the game history. */
case class Move(
from: Square,
to: Square,
castleSide: Option[CastleSide] = None
)
/** Complete game history: ordered list of moves. */
case class GameHistory(moves: List[Move] = List.empty):
def addMove(move: Move): GameHistory =
GameHistory(moves :+ move)
def addMove(from: Square, to: Square, castleSide: Option[CastleSide] = None): GameHistory =
addMove(Move(from, to, castleSide))
object GameHistory:
val empty: GameHistory = GameHistory()
@@ -1,7 +1,7 @@
package de.nowchess.chess.logic package de.nowchess.chess.logic
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.chess.logic.GameContext import de.nowchess.chess.logic.GameHistory
enum PositionStatus: enum PositionStatus:
case Normal, InCheck, Mated, Drawn case Normal, InCheck, Mated, Drawn
@@ -20,17 +20,17 @@ object GameRules:
} }
/** All (from, to) moves for `color` that do not leave their own king in check. */ /** All (from, to) moves for `color` that do not leave their own king in check. */
def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] = def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] =
ctx.board.pieces board.pieces
.collect { case (from, piece) if piece.color == color => from } .collect { case (from, piece) if piece.color == color => from }
.flatMap { from => .flatMap { from =>
MoveValidator.legalTargets(ctx, from) // context-aware: includes castling MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
.filter { to => .filter { to =>
val newBoard = val newBoard =
if MoveValidator.isCastle(ctx.board, from, to) then if MoveValidator.isCastle(board, from, to) then
ctx.board.withCastle(color, MoveValidator.castleSide(from, to)) board.withCastle(color, MoveValidator.castleSide(from, to))
else else
ctx.board.withMove(from, to)._1 board.withMove(from, to)._1
!isInCheck(newBoard, color) !isInCheck(newBoard, color)
} }
.map(to => from -> to) .map(to => from -> to)
@@ -38,9 +38,9 @@ object GameRules:
.toSet .toSet
/** Position status for the side whose turn it is (`color`). */ /** Position status for the side whose turn it is (`color`). */
def gameStatus(ctx: GameContext, color: Color): PositionStatus = def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
val moves = legalMoves(ctx, color) val moves = legalMoves(board, history, color)
val inCheck = isInCheck(ctx.board, color) val inCheck = isInCheck(board, color)
if moves.isEmpty && inCheck then PositionStatus.Mated if moves.isEmpty && inCheck then PositionStatus.Mated
else if moves.isEmpty then PositionStatus.Drawn else if moves.isEmpty then PositionStatus.Drawn
else if inCheck then PositionStatus.InCheck else if inCheck then PositionStatus.InCheck
@@ -1,7 +1,7 @@
package de.nowchess.chess.logic package de.nowchess.chess.logic
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.chess.logic.{GameContext, CastleSide} import de.nowchess.chess.logic.{CastleSide, GameHistory}
object MoveValidator: object MoveValidator:
@@ -126,37 +126,37 @@ object MoveValidator:
def castleSide(from: Square, to: Square): CastleSide = def castleSide(from: Square, to: Square): CastleSide =
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
def castlingTargets(ctx: GameContext, color: Color): Set[Square] = def castlingTargets(board: Board, history: GameHistory, color: Color): Set[Square] =
val rights = ctx.castlingFor(color) val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
val rank = if color == Color.White then Rank.R1 else Rank.R8 val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingSq = Square(File.E, rank) val kingSq = Square(File.E, rank)
val enemy = color.opposite val enemy = color.opposite
if !ctx.board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) || if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
GameRules.isInCheck(ctx.board, color) then Set.empty GameRules.isInCheck(board, color) then Set.empty
else else
val kingsideSq = Option.when( val kingsideSq = Option.when(
rights.kingSide && rights.kingSide &&
ctx.board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) && board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) &&
List(Square(File.F, rank), Square(File.G, rank)).forall(s => ctx.board.pieceAt(s).isEmpty) && List(Square(File.F, rank), Square(File.G, rank)).forall(s => board.pieceAt(s).isEmpty) &&
!List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(ctx.board, s, enemy)) !List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(board, s, enemy))
)(Square(File.G, rank)) )(Square(File.G, rank))
val queensideSq = Option.when( val queensideSq = Option.when(
rights.queenSide && rights.queenSide &&
ctx.board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) && board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) &&
List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => ctx.board.pieceAt(s).isEmpty) && List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => board.pieceAt(s).isEmpty) &&
!List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(ctx.board, s, enemy)) !List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(board, s, enemy))
)(Square(File.C, rank)) )(Square(File.C, rank))
kingsideSq.toSet ++ queensideSq.toSet kingsideSq.toSet ++ queensideSq.toSet
def legalTargets(ctx: GameContext, from: Square): Set[Square] = def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
ctx.board.pieceAt(from) match board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King => case Some(piece) if piece.pieceType == PieceType.King =>
legalTargets(ctx.board, from) ++ castlingTargets(ctx, piece.color) legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
case _ => case _ =>
legalTargets(ctx.board, from) legalTargets(board, from)
def isLegal(ctx: GameContext, from: Square, to: Square): Boolean = def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
legalTargets(ctx, from).contains(to) legalTargets(board, history, from).contains(to)
@@ -2,7 +2,7 @@ package de.nowchess.chess.controller
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.game.CastlingRights import de.nowchess.api.game.CastlingRights
import de.nowchess.chess.logic.{GameContext, CastleSide} import de.nowchess.chess.logic.{CastleSide, GameHistory, Move}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -11,54 +11,61 @@ import java.io.ByteArrayInputStream
class GameControllerTest extends AnyFunSuite with Matchers: class GameControllerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r) private def sq(f: File, r: Rank): Square = Square(f, r)
private val initial = GameContext.initial private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
GameController.processMove(board, history, turn, raw)
private def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
GameController.gameLoop(board, history, turn)
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
// ──── processMove ──────────────────────────────────────────────────── // ──── processMove ────────────────────────────────────────────────────
test("processMove: 'quit' input returns Quit"): test("processMove: 'quit' input returns Quit"):
GameController.processMove(initial, Color.White, "quit") shouldBe MoveResult.Quit processMove(Board.initial, GameHistory.empty, Color.White, "quit") shouldBe MoveResult.Quit
test("processMove: 'q' input returns Quit"): test("processMove: 'q' input returns Quit"):
GameController.processMove(initial, Color.White, "q") shouldBe MoveResult.Quit processMove(Board.initial, GameHistory.empty, Color.White, "q") shouldBe MoveResult.Quit
test("processMove: quit with surrounding whitespace returns Quit"): test("processMove: quit with surrounding whitespace returns Quit"):
GameController.processMove(initial, Color.White, " quit ") shouldBe MoveResult.Quit processMove(Board.initial, GameHistory.empty, Color.White, " quit ") shouldBe MoveResult.Quit
test("processMove: unparseable input returns InvalidFormat"): test("processMove: unparseable input returns InvalidFormat"):
GameController.processMove(initial, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz") processMove(Board.initial, GameHistory.empty, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz")
test("processMove: valid format but empty square returns NoPiece"): test("processMove: valid format but empty square returns NoPiece"):
// E3 is empty in the initial position // E3 is empty in the initial position
GameController.processMove(initial, Color.White, "e3e4") shouldBe MoveResult.NoPiece processMove(Board.initial, GameHistory.empty, Color.White, "e3e4") shouldBe MoveResult.NoPiece
test("processMove: piece of wrong color returns WrongColor"): test("processMove: piece of wrong color returns WrongColor"):
// E7 has a Black pawn; it is White's turn // E7 has a Black pawn; it is White's turn
GameController.processMove(initial, Color.White, "e7e6") shouldBe MoveResult.WrongColor processMove(Board.initial, GameHistory.empty, Color.White, "e7e6") shouldBe MoveResult.WrongColor
test("processMove: geometrically illegal move returns IllegalMove"): test("processMove: geometrically illegal move returns IllegalMove"):
// White pawn at E2 cannot jump three squares to E5 // White pawn at E2 cannot jump three squares to E5
GameController.processMove(initial, Color.White, "e2e5") shouldBe MoveResult.IllegalMove processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
test("processMove: legal pawn move returns Moved with updated board and flipped turn"): test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
GameController.processMove(initial, Color.White, "e2e4") match processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(newCtx, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
newCtx.board.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn) newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
newCtx.board.pieceAt(sq(File.E, Rank.R2)) shouldBe None newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
captured shouldBe None captured shouldBe None
newTurn shouldBe Color.Black newTurn shouldBe Color.Black
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
test("processMove: legal capture returns Moved with the captured piece"): test("processMove: legal capture returns Moved with the captured piece"):
val captureCtx = GameContext(Board(Map( val board = Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn, sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R6) -> Piece.BlackPawn, sq(File.D, Rank.R6) -> Piece.BlackPawn,
sq(File.H, Rank.R1) -> Piece.BlackKing, sq(File.H, Rank.R1) -> Piece.BlackKing,
sq(File.H, Rank.R8) -> Piece.WhiteKing sq(File.H, Rank.R8) -> Piece.WhiteKing
))) ))
GameController.processMove(captureCtx, Color.White, "e5d6") match processMove(board, GameHistory.empty, Color.White, "e5d6") match
case MoveResult.Moved(newCtx, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
captured shouldBe Some(Piece.BlackPawn) captured shouldBe Some(Piece.BlackPawn)
newCtx.board.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
newTurn shouldBe Color.Black newTurn shouldBe Color.Black
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
@@ -70,33 +77,33 @@ class GameControllerTest extends AnyFunSuite with Matchers:
test("gameLoop: 'quit' exits cleanly without exception"): test("gameLoop: 'quit' exits cleanly without exception"):
withInput("quit\n"): withInput("quit\n"):
GameController.gameLoop(GameContext.initial, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: EOF (null readLine) exits via quit fallback"): test("gameLoop: EOF (null readLine) exits via quit fallback"):
withInput(""): withInput(""):
GameController.gameLoop(GameContext.initial, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: invalid format prints message and recurses until quit"): test("gameLoop: invalid format prints message and recurses until quit"):
withInput("badmove\nquit\n"): withInput("badmove\nquit\n"):
GameController.gameLoop(GameContext.initial, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: NoPiece prints message and recurses until quit"): test("gameLoop: NoPiece prints message and recurses until quit"):
// E3 is empty in the initial position // E3 is empty in the initial position
withInput("e3e4\nquit\n"): withInput("e3e4\nquit\n"):
GameController.gameLoop(GameContext.initial, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: WrongColor prints message and recurses until quit"): test("gameLoop: WrongColor prints message and recurses until quit"):
// E7 has a Black pawn; it is White's turn // E7 has a Black pawn; it is White's turn
withInput("e7e6\nquit\n"): withInput("e7e6\nquit\n"):
GameController.gameLoop(GameContext.initial, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: IllegalMove prints message and recurses until quit"): test("gameLoop: IllegalMove prints message and recurses until quit"):
withInput("e2e5\nquit\n"): withInput("e2e5\nquit\n"):
GameController.gameLoop(GameContext.initial, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: legal non-capture move recurses with new board then quits"): test("gameLoop: legal non-capture move recurses with new board then quits"):
withInput("e2e4\nquit\n"): withInput("e2e4\nquit\n"):
GameController.gameLoop(GameContext.initial, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: capture move prints capture message then recurses and quits"): test("gameLoop: capture move prints capture message then recurses and quits"):
val captureBoard = Board(Map( val captureBoard = Board(Map(
@@ -106,7 +113,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.WhiteKing sq(File.H, Rank.R8) -> Piece.WhiteKing
)) ))
withInput("e5d6\nquit\n"): withInput("e5d6\nquit\n"):
GameController.gameLoop(GameContext(captureBoard), Color.White) gameLoop(captureBoard, GameHistory.empty, Color.White)
// ──── helpers ──────────────────────────────────────────────────────── // ──── helpers ────────────────────────────────────────────────────────
@@ -120,37 +127,37 @@ class GameControllerTest extends AnyFunSuite with Matchers:
test("processMove: legal move that delivers check returns MovedInCheck"): test("processMove: legal move that delivers check returns MovedInCheck"):
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check // White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check
// Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated // Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated
val ctx = GameContext(Board(Map( val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook, sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing, sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
))) ))
GameController.processMove(ctx, Color.White, "a1a8") match processMove(b, GameHistory.empty, Color.White, "a1a8") match
case MoveResult.MovedInCheck(_, _, newTurn) => newTurn shouldBe Color.Black case MoveResult.MovedInCheck(_, _, _, newTurn) => newTurn shouldBe Color.Black
case other => fail(s"Expected MovedInCheck, got $other") case other => fail(s"Expected MovedInCheck, got $other")
test("processMove: legal move that results in checkmate returns Checkmate"): test("processMove: legal move that results in checkmate returns Checkmate"):
// White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8) // White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8)
// After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position) // After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position)
// Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6 // Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6
val ctx = GameContext(Board(Map( val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteQueen, sq(File.A, Rank.R1) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing, sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
))) ))
GameController.processMove(ctx, Color.White, "a1h8") match processMove(b, GameHistory.empty, Color.White, "a1h8") match
case MoveResult.Checkmate(winner) => winner shouldBe Color.White case MoveResult.Checkmate(winner) => winner shouldBe Color.White
case other => fail(s"Expected Checkmate(White), got $other") case other => fail(s"Expected Checkmate(White), got $other")
test("processMove: legal move that results in stalemate returns Stalemate"): test("processMove: legal move that results in stalemate returns Stalemate"):
// White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6 // White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6
// After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position) // After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position)
val ctx = GameContext(Board(Map( val b = Board(Map(
sq(File.B, Rank.R1) -> Piece.WhiteQueen, sq(File.B, Rank.R1) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing, sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
))) ))
GameController.processMove(ctx, Color.White, "b1b6") match processMove(b, GameHistory.empty, Color.White, "b1b6") match
case MoveResult.Stalemate => succeed case MoveResult.Stalemate => succeed
case other => fail(s"Expected Stalemate, got $other") case other => fail(s"Expected Stalemate, got $other")
@@ -165,7 +172,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
)) ))
val output = captureOutput: val output = captureOutput:
withInput("a1h8\nquit\n"): withInput("a1h8\nquit\n"):
GameController.gameLoop(GameContext(b), Color.White) gameLoop(b, GameHistory.empty, Color.White)
output should include("Checkmate! White wins.") output should include("Checkmate! White wins.")
test("gameLoop: stalemate prints draw message and resets to new game"): test("gameLoop: stalemate prints draw message and resets to new game"):
@@ -176,7 +183,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
)) ))
val output = captureOutput: val output = captureOutput:
withInput("b1b6\nquit\n"): withInput("b1b6\nquit\n"):
GameController.gameLoop(GameContext(b), Color.White) gameLoop(b, GameHistory.empty, Color.White)
output should include("Stalemate! The game is a draw.") output should include("Stalemate! The game is a draw.")
test("gameLoop: MovedInCheck without capture prints check message"): test("gameLoop: MovedInCheck without capture prints check message"):
@@ -187,7 +194,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
)) ))
val output = captureOutput: val output = captureOutput:
withInput("a1a8\nquit\n"): withInput("a1a8\nquit\n"):
GameController.gameLoop(GameContext(b), Color.White) gameLoop(b, GameHistory.empty, Color.White)
output should include("Black is in check!") output should include("Black is in check!")
test("gameLoop: MovedInCheck with capture prints both capture and check message"): test("gameLoop: MovedInCheck with capture prints both capture and check message"):
@@ -200,208 +207,161 @@ class GameControllerTest extends AnyFunSuite with Matchers:
)) ))
val output = captureOutput: val output = captureOutput:
withInput("a1a8\nquit\n"): withInput("a1a8\nquit\n"):
GameController.gameLoop(GameContext(b), Color.White) gameLoop(b, GameHistory.empty, Color.White)
output should include("captures") output should include("captures")
output should include("Black is in check!") output should include("Black is in check!")
// ──── castling execution ───────────────────────────────────────────── // ──── castling execution ─────────────────────────────────────────────
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"): test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)), ))
whiteCastling = CastlingRights.Both, processMove(b, GameHistory.empty, Color.White, "e1g1") match
blackCastling = CastlingRights.None case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
) newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
GameController.processMove(ctx, Color.White, "e1g1") match newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
case MoveResult.Moved(newCtx, captured, newTurn) => newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
newCtx.board.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing) newBoard.pieceAt(sq(File.H, Rank.R1)) shouldBe None
newCtx.board.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
newCtx.board.pieceAt(sq(File.E, Rank.R1)) shouldBe None
newCtx.board.pieceAt(sq(File.H, Rank.R1)) shouldBe None
captured shouldBe None captured shouldBe None
newTurn shouldBe Color.Black newTurn shouldBe Color.Black
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
test("processMove: e1c1 returns Moved with king on c1 and rook on d1"): test("processMove: e1c1 returns Moved with king on c1 and rook on d1"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook, sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)), ))
whiteCastling = CastlingRights.Both, processMove(b, GameHistory.empty, Color.White, "e1c1") match
blackCastling = CastlingRights.None case MoveResult.Moved(newBoard, _, _, _) =>
) newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
GameController.processMove(ctx, Color.White, "e1c1") match newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
case MoveResult.Moved(newCtx, _, _) =>
newCtx.board.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
newCtx.board.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
// ──── rights revocation ────────────────────────────────────────────── // ──── rights revocation ──────────────────────────────────────────────
test("processMove: e1g1 revokes both white castling rights"): test("processMove: e1g1 revokes both white castling rights"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)), ))
whiteCastling = CastlingRights.Both, processMove(b, GameHistory.empty, Color.White, "e1g1") match
blackCastling = CastlingRights.None case MoveResult.Moved(_, newHistory, _, _) =>
) castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
GameController.processMove(ctx, Color.White, "e1g1") match
case MoveResult.Moved(newCtx, _, _) =>
newCtx.whiteCastling shouldBe CastlingRights.None
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
test("processMove: moving rook from h1 revokes white kingside right"): test("processMove: moving rook from h1 revokes white kingside right"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)), ))
whiteCastling = CastlingRights.Both, processMove(b, GameHistory.empty, Color.White, "h1h4") match
blackCastling = CastlingRights.None case MoveResult.Moved(_, newHistory, _, _) =>
) castlingRights(newHistory, Color.White).kingSide shouldBe false
GameController.processMove(ctx, Color.White, "h1h4") match castlingRights(newHistory, Color.White).queenSide shouldBe true
case MoveResult.Moved(newCtx, _, _) => case MoveResult.MovedInCheck(_, newHistory, _, _) =>
newCtx.whiteCastling.kingSide shouldBe false castlingRights(newHistory, Color.White).kingSide shouldBe false
newCtx.whiteCastling.queenSide shouldBe true castlingRights(newHistory, Color.White).queenSide shouldBe true
case MoveResult.MovedInCheck(newCtx, _, _) =>
newCtx.whiteCastling.kingSide shouldBe false
newCtx.whiteCastling.queenSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: moving king from e1 revokes both white rights"): test("processMove: moving king from e1 revokes both white rights"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)), ))
whiteCastling = CastlingRights.Both, processMove(b, GameHistory.empty, Color.White, "e1e2") match
blackCastling = CastlingRights.None case MoveResult.Moved(_, newHistory, _, _) =>
) castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
GameController.processMove(ctx, Color.White, "e1e2") match
case MoveResult.Moved(newCtx, _, _) =>
newCtx.whiteCastling shouldBe CastlingRights.None
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
test("processMove: enemy capture on h1 revokes white kingside right"): test("processMove: enemy capture on h1 revokes white kingside right"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R2) -> Piece.BlackRook, sq(File.H, Rank.R2) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
)), ))
whiteCastling = CastlingRights.Both, processMove(b, GameHistory.empty, Color.Black, "h2h1") match
blackCastling = CastlingRights.None case MoveResult.Moved(_, newHistory, _, _) =>
) castlingRights(newHistory, Color.White).kingSide shouldBe false
GameController.processMove(ctx, Color.Black, "h2h1") match case MoveResult.MovedInCheck(_, newHistory, _, _) =>
case MoveResult.Moved(newCtx, _, _) => castlingRights(newHistory, Color.White).kingSide shouldBe false
newCtx.whiteCastling.kingSide shouldBe false
case MoveResult.MovedInCheck(newCtx, _, _) =>
newCtx.whiteCastling.kingSide shouldBe false
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: castle attempt when rights revoked returns IllegalMove"): test("processMove: castle attempt when rights revoked returns IllegalMove"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)), ))
whiteCastling = CastlingRights.None, val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2)).addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R1))
blackCastling = CastlingRights.None processMove(b, history, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
)
GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
test("processMove: castle attempt when rook not on home square returns IllegalMove"): test("processMove: castle attempt when rook not on home square returns IllegalMove"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.G, Rank.R1) -> Piece.WhiteRook, sq(File.G, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)), ))
whiteCastling = CastlingRights.Both, processMove(b, GameHistory.empty, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
blackCastling = CastlingRights.None
)
GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
test("processMove: moving king from e8 revokes both black rights"): test("processMove: moving king from e8 revokes both black rights"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R8) -> Piece.BlackKing, sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.H, Rank.R1) -> Piece.WhiteKing sq(File.H, Rank.R1) -> Piece.WhiteKing
)), ))
whiteCastling = CastlingRights.None, processMove(b, GameHistory.empty, Color.Black, "e8e7") match
blackCastling = CastlingRights.Both case MoveResult.Moved(_, newHistory, _, _) =>
) castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
GameController.processMove(ctx, Color.Black, "e8e7") match case MoveResult.MovedInCheck(_, newHistory, _, _) =>
case MoveResult.Moved(newCtx, _, _) => castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
newCtx.blackCastling shouldBe CastlingRights.None
case MoveResult.MovedInCheck(newCtx, _, _) =>
newCtx.blackCastling shouldBe CastlingRights.None
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: moving rook from a8 revokes black queenside right"): test("processMove: moving rook from a8 revokes black queenside right"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R8) -> Piece.BlackKing, sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.A, Rank.R8) -> Piece.BlackRook, sq(File.A, Rank.R8) -> Piece.BlackRook,
sq(File.H, Rank.R1) -> Piece.WhiteKing sq(File.H, Rank.R1) -> Piece.WhiteKing
)), ))
whiteCastling = CastlingRights.None, processMove(b, GameHistory.empty, Color.Black, "a8a1") match
blackCastling = CastlingRights.Both case MoveResult.Moved(_, newHistory, _, _) =>
) castlingRights(newHistory, Color.Black).queenSide shouldBe false
GameController.processMove(ctx, Color.Black, "a8a7") match castlingRights(newHistory, Color.Black).kingSide shouldBe true
case MoveResult.Moved(newCtx, _, _) => case MoveResult.MovedInCheck(_, newHistory, _, _) =>
newCtx.blackCastling.queenSide shouldBe false castlingRights(newHistory, Color.Black).queenSide shouldBe false
newCtx.blackCastling.kingSide shouldBe true castlingRights(newHistory, Color.Black).kingSide shouldBe true
case MoveResult.MovedInCheck(newCtx, _, _) =>
newCtx.blackCastling.queenSide shouldBe false
newCtx.blackCastling.kingSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: moving rook from h8 revokes black kingside right"): test("processMove: moving rook from h8 revokes black kingside right"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R8) -> Piece.BlackKing, sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.H, Rank.R8) -> Piece.BlackRook, sq(File.H, Rank.R8) -> Piece.BlackRook,
sq(File.H, Rank.R1) -> Piece.WhiteKing sq(File.A, Rank.R1) -> Piece.WhiteKing
)), ))
whiteCastling = CastlingRights.None, processMove(b, GameHistory.empty, Color.Black, "h8h4") match
blackCastling = CastlingRights.Both case MoveResult.Moved(_, newHistory, _, _) =>
) castlingRights(newHistory, Color.Black).kingSide shouldBe false
GameController.processMove(ctx, Color.Black, "h8h7") match castlingRights(newHistory, Color.Black).queenSide shouldBe true
case MoveResult.Moved(newCtx, _, _) => case MoveResult.MovedInCheck(_, newHistory, _, _) =>
newCtx.blackCastling.kingSide shouldBe false castlingRights(newHistory, Color.Black).kingSide shouldBe false
newCtx.blackCastling.queenSide shouldBe true castlingRights(newHistory, Color.Black).queenSide shouldBe true
case MoveResult.MovedInCheck(newCtx, _, _) =>
newCtx.blackCastling.kingSide shouldBe false
newCtx.blackCastling.queenSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: enemy capture on a1 revokes white queenside right"): test("processMove: enemy capture on a1 revokes white queenside right"):
val ctx = GameContext( val b = Board(Map(
board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook, sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.A, Rank.R2) -> Piece.BlackRook, sq(File.A, Rank.R2) -> Piece.BlackRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)), ))
whiteCastling = CastlingRights.Both, processMove(b, GameHistory.empty, Color.Black, "a2a1") match
blackCastling = CastlingRights.None case MoveResult.Moved(_, newHistory, _, _) =>
) castlingRights(newHistory, Color.White).queenSide shouldBe false
GameController.processMove(ctx, Color.Black, "a2a1") match case MoveResult.MovedInCheck(_, newHistory, _, _) =>
case MoveResult.Moved(newCtx, _, _) => castlingRights(newHistory, Color.White).queenSide shouldBe false
newCtx.whiteCastling.queenSide shouldBe false
case MoveResult.MovedInCheck(newCtx, _, _) =>
newCtx.whiteCastling.queenSide shouldBe false
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -0,0 +1,70 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.api.game.CastlingRights
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CastlingRightsCalculatorTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
test("Empty history gives full castling rights"):
val rights = CastlingRightsCalculator.deriveCastlingRights(GameHistory.empty, Color.White)
rights shouldBe CastlingRights.Both
test("White loses kingside rights after h1 rook moves"):
val history = GameHistory.empty.addMove(sq(File.H, Rank.R1), sq(File.H, Rank.R2))
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
rights.kingSide shouldBe false
rights.queenSide shouldBe true
test("White loses queenside rights after a1 rook moves"):
val history = GameHistory.empty.addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2))
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
rights.queenSide shouldBe false
rights.kingSide shouldBe true
test("White loses all rights after king moves"):
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2))
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
rights shouldBe CastlingRights.None
test("Black loses kingside rights after h8 rook moves"):
val history = GameHistory.empty.addMove(sq(File.H, Rank.R8), sq(File.H, Rank.R7))
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
rights.kingSide shouldBe false
rights.queenSide shouldBe true
test("Black loses queenside rights after a8 rook moves"):
val history = GameHistory.empty.addMove(sq(File.A, Rank.R8), sq(File.A, Rank.R7))
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
rights.queenSide shouldBe false
rights.kingSide shouldBe true
test("Black loses all rights after king moves"):
val history = GameHistory.empty.addMove(sq(File.E, Rank.R8), sq(File.E, Rank.R7))
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
rights shouldBe CastlingRights.None
test("Castle move revokes all castling rights"):
val history = GameHistory.empty.addMove(
sq(File.E, Rank.R1),
sq(File.G, Rank.R1),
Some(CastleSide.Kingside)
)
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
rights shouldBe CastlingRights.None
test("Other pieces moving does not revoke castling rights"):
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
rights shouldBe CastlingRights.Both
test("Multiple moves preserve white kingside but lose queenside"):
val history = GameHistory.empty
.addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2)) // White queenside rook moves
.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) // Black pawn moves
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
rights.kingSide shouldBe true
rights.queenSide shouldBe false
@@ -1,81 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.api.game.CastlingRights
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameContextTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
test("GameContext.initial has Board.initial and CastlingRights.Both for both sides"):
GameContext.initial.board shouldBe Board.initial
GameContext.initial.whiteCastling shouldBe CastlingRights.Both
GameContext.initial.blackCastling shouldBe CastlingRights.Both
test("castlingFor returns white rights for Color.White"):
GameContext.initial.castlingFor(Color.White) shouldBe CastlingRights.Both
test("castlingFor returns black rights for Color.Black"):
GameContext.initial.castlingFor(Color.Black) shouldBe CastlingRights.Both
test("withUpdatedRights updates white castling without touching black"):
val ctx = GameContext.initial.withUpdatedRights(Color.White, CastlingRights.None)
ctx.whiteCastling shouldBe CastlingRights.None
ctx.blackCastling shouldBe CastlingRights.Both
test("withUpdatedRights updates black castling without touching white"):
val ctx = GameContext.initial.withUpdatedRights(Color.Black, CastlingRights.None)
ctx.blackCastling shouldBe CastlingRights.None
ctx.whiteCastling shouldBe CastlingRights.Both
test("withCastle: white kingside — king e1→g1, rook h1→f1"):
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook
)
val after = b.withCastle(Color.White, CastleSide.Kingside)
after.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
after.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
after.pieceAt(sq(File.E, Rank.R1)) shouldBe None
after.pieceAt(sq(File.H, Rank.R1)) shouldBe None
test("withCastle: white queenside — king e1→c1, rook a1→d1"):
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook
)
val after = b.withCastle(Color.White, CastleSide.Queenside)
after.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
after.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
after.pieceAt(sq(File.E, Rank.R1)) shouldBe None
after.pieceAt(sq(File.A, Rank.R1)) shouldBe None
test("withCastle: black kingside — king e8→g8, rook h8→f8"):
val b = board(
sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.H, Rank.R8) -> Piece.BlackRook
)
val after = b.withCastle(Color.Black, CastleSide.Kingside)
after.pieceAt(sq(File.G, Rank.R8)) shouldBe Some(Piece.BlackKing)
after.pieceAt(sq(File.F, Rank.R8)) shouldBe Some(Piece.BlackRook)
after.pieceAt(sq(File.E, Rank.R8)) shouldBe None
after.pieceAt(sq(File.H, Rank.R8)) shouldBe None
test("withCastle: black queenside — king e8→c8, rook a8→d8"):
val b = board(
sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.A, Rank.R8) -> Piece.BlackRook
)
val after = b.withCastle(Color.Black, CastleSide.Queenside)
after.pieceAt(sq(File.C, Rank.R8)) shouldBe Some(Piece.BlackKing)
after.pieceAt(sq(File.D, Rank.R8)) shouldBe Some(Piece.BlackRook)
after.pieceAt(sq(File.E, Rank.R8)) shouldBe None
after.pieceAt(sq(File.A, Rank.R8)) shouldBe None
test("GameContext single-arg apply defaults to CastlingRights.None for both sides"):
val ctx = GameContext(Board.initial)
ctx.whiteCastling shouldBe CastlingRights.None
ctx.blackCastling shouldBe CastlingRights.None
@@ -0,0 +1,36 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameHistoryTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
test("GameHistory starts empty"):
val history = GameHistory.empty
history.moves shouldBe empty
test("GameHistory can add a move"):
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
history.moves should have length 1
history.moves.head.from shouldBe sq(File.E, Rank.R2)
history.moves.head.to shouldBe sq(File.E, Rank.R4)
history.moves.head.castleSide shouldBe None
test("GameHistory can add multiple moves in order"):
val h1 = GameHistory.empty
val h2 = h1.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val h3 = h2.addMove(sq(File.C, Rank.R7), sq(File.C, Rank.R5))
h3.moves should have length 2
h3.moves(0).from shouldBe sq(File.E, Rank.R2)
h3.moves(1).from shouldBe sq(File.C, Rank.R7)
test("GameHistory can add a castle move"):
val history = GameHistory.empty.addMove(
sq(File.E, Rank.R1),
sq(File.G, Rank.R1),
Some(CastleSide.Kingside)
)
history.moves.head.castleSide shouldBe Some(CastleSide.Kingside)
@@ -2,7 +2,7 @@ package de.nowchess.chess.logic
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.game.CastlingRights import de.nowchess.api.game.CastlingRights
import de.nowchess.chess.logic.GameContext import de.nowchess.chess.logic.{GameHistory, Move}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -12,7 +12,11 @@ class GameRulesTest extends AnyFunSuite with Matchers:
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap) private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
/** Wrap a board in a GameContext with no castling rights — for non-castling tests. */ /** Wrap a board in a GameContext with no castling rights — for non-castling tests. */
private def ctx(entries: (Square, Piece)*): GameContext = GameContext(Board(entries.toMap)) private def testLegalMoves(entries: (Square, Piece)*)(color: Color): Set[(Square, Square)] =
GameRules.legalMoves(Board(entries.toMap), GameHistory.empty, color)
private def testGameStatus(entries: (Square, Piece)*)(color: Color): PositionStatus =
GameRules.gameStatus(Board(entries.toMap), GameHistory.empty, color)
// ──── isInCheck ────────────────────────────────────────────────────── // ──── isInCheck ──────────────────────────────────────────────────────
@@ -41,20 +45,20 @@ class GameRulesTest extends AnyFunSuite with Matchers:
test("legalMoves: move that exposes own king to rook is excluded"): test("legalMoves: move that exposes own king to rook is excluded"):
// White King E1, White Rook E4 (pinned on E-file), Black Rook E8 // White King E1, White Rook E4 (pinned on E-file), Black Rook E8
// Moving the White Rook off the E-file would expose the king // Moving the White Rook off the E-file would expose the king
val moves = GameRules.legalMoves(ctx( val moves = testLegalMoves(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R4) -> Piece.WhiteRook, sq(File.E, Rank.R4) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook sq(File.E, Rank.R8) -> Piece.BlackRook
), Color.White) )(Color.White)
moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4)) moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4))
test("legalMoves: move that blocks check is included"): test("legalMoves: move that blocks check is included"):
// White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5 // White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5
val moves = GameRules.legalMoves(ctx( val moves = testLegalMoves(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook, sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook sq(File.E, Rank.R8) -> Piece.BlackRook
), Color.White) )(Color.White)
moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5)) moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5))
// ──── gameStatus ────────────────────────────────────────────────────── // ──── gameStatus ──────────────────────────────────────────────────────
@@ -62,70 +66,59 @@ class GameRulesTest extends AnyFunSuite with Matchers:
test("gameStatus: checkmate returns Mated"): test("gameStatus: checkmate returns Mated"):
// White Qh8, Ka6; Black Ka8 // White Qh8, Ka6; Black Ka8
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position) // Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
GameRules.gameStatus(ctx( testGameStatus(
sq(File.H, Rank.R8) -> Piece.WhiteQueen, sq(File.H, Rank.R8) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing, sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
), Color.Black) shouldBe PositionStatus.Mated )(Color.Black) shouldBe PositionStatus.Mated
test("gameStatus: stalemate returns Drawn"): test("gameStatus: stalemate returns Drawn"):
// White Qb6, Kc6; Black Ka8 // White Qb6, Kc6; Black Ka8
// Black king has no legal moves and is not in check (spec-verified position) // Black king has no legal moves and is not in check (spec-verified position)
GameRules.gameStatus(ctx( testGameStatus(
sq(File.B, Rank.R6) -> Piece.WhiteQueen, sq(File.B, Rank.R6) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing, sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
), Color.Black) shouldBe PositionStatus.Drawn )(Color.Black) shouldBe PositionStatus.Drawn
test("gameStatus: king in check with legal escape returns InCheck"): test("gameStatus: king in check with legal escape returns InCheck"):
// White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7 // White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7
GameRules.gameStatus(ctx( testGameStatus(
sq(File.A, Rank.R8) -> Piece.WhiteRook, sq(File.A, Rank.R8) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackKing sq(File.E, Rank.R8) -> Piece.BlackKing
), Color.Black) shouldBe PositionStatus.InCheck )(Color.Black) shouldBe PositionStatus.InCheck
test("gameStatus: normal starting position returns Normal"): test("gameStatus: normal starting position returns Normal"):
GameRules.gameStatus(GameContext(Board.initial), Color.White) shouldBe PositionStatus.Normal GameRules.gameStatus(Board.initial, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
test("legalMoves: includes castling destination when available"): test("legalMoves: includes castling destination when available"):
val c = GameContext( val b = board(
board = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
), )
whiteCastling = CastlingRights.Both, GameRules.legalMoves(b, GameHistory.empty, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
blackCastling = CastlingRights.None
)
GameRules.legalMoves(c, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
test("legalMoves: excludes castling when king is in check"): test("legalMoves: excludes castling when king is in check"):
val c = GameContext( val b = board(
board = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook, sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
), )
whiteCastling = CastlingRights.Both, GameRules.legalMoves(b, GameHistory.empty, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
blackCastling = CastlingRights.None
)
GameRules.legalMoves(c, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
test("gameStatus: returns Normal (not Drawn) when castling is the only legal move"): test("gameStatus: returns Normal (not Drawn) when castling is the only legal move"):
// White King e1, Rook h1 (kingside castling available). // White King e1, Rook h1 (kingside castling available).
// Black Rooks d2 and f2 box the king: d1 attacked by d2, e2 attacked by both, // Black Rooks d2 and f2 box the king: d1 attacked by d2, e2 attacked by both,
// f1 attacked by f2. King cannot move to any adjacent square without entering // f1 attacked by f2. King cannot move to any adjacent square without entering
// an attacked square or an enemy piece. Only legal move: castle to g1. // an attacked square or an enemy piece. Only legal move: castle to g1.
val c = GameContext( val b = board(
board = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.D, Rank.R2) -> Piece.BlackRook, sq(File.D, Rank.R2) -> Piece.BlackRook,
sq(File.F, Rank.R2) -> Piece.BlackRook, sq(File.F, Rank.R2) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
), )
whiteCastling = CastlingRights(kingSide = true, queenSide = false), // No history means castling rights are intact
blackCastling = CastlingRights.None GameRules.gameStatus(b, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
)
GameRules.gameStatus(c, Color.White) shouldBe PositionStatus.Normal
@@ -2,7 +2,7 @@ package de.nowchess.chess.logic
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.api.game.CastlingRights import de.nowchess.api.game.CastlingRights
import de.nowchess.chess.logic.{GameContext, CastleSide} import de.nowchess.chess.logic.{CastleSide, GameHistory}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -211,168 +211,3 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R4) -> Piece.BlackRook sq(File.E, Rank.R4) -> Piece.BlackRook
) )
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4)) MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4))
// ──── castlingTargets ────────────────────────────────────────────────
private def ctxWithRights(
entries: (Square, Piece)*
)(white: CastlingRights = CastlingRights.Both,
black: CastlingRights = CastlingRights.Both
): GameContext =
GameContext(Board(entries.toMap), white, black)
test("castlingTargets: white kingside available when all conditions met"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.castlingTargets(ctx, Color.White) should contain(sq(File.G, Rank.R1))
test("castlingTargets: white queenside available when all conditions met"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.castlingTargets(ctx, Color.White) should contain(sq(File.C, Rank.R1))
test("castlingTargets: black kingside available when all conditions met"):
val ctx = ctxWithRights(
sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.H, Rank.R8) -> Piece.BlackRook,
sq(File.H, Rank.R1) -> Piece.WhiteKing
)()
MoveValidator.castlingTargets(ctx, Color.Black) should contain(sq(File.G, Rank.R8))
test("castlingTargets: black queenside available when all conditions met"):
val ctx = ctxWithRights(
sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.A, Rank.R8) -> Piece.BlackRook,
sq(File.H, Rank.R1) -> Piece.WhiteKing
)()
MoveValidator.castlingTargets(ctx, Color.Black) should contain(sq(File.C, Rank.R8))
test("castlingTargets: blocked when transit square is occupied"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.F, Rank.R1) -> Piece.WhiteBishop,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
test("castlingTargets: blocked when king is in check"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.castlingTargets(ctx, Color.White) shouldBe empty
test("castlingTargets: blocked when transit square f1 is attacked"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.F, Rank.R8) -> Piece.BlackRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
test("castlingTargets: blocked when landing square g1 is attacked"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.G, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
test("castlingTargets: blocked when kingSide right is false"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)(white = CastlingRights(kingSide = false, queenSide = true))
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
test("castlingTargets: blocked when queenSide right is false"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)(white = CastlingRights(kingSide = true, queenSide = false))
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.C, Rank.R1)
test("castlingTargets: blocked when relevant rook is not on home square"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.G, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
// ──── context-aware legalTargets includes castling ────────────────────
test("legalTargets(ctx, from): king on e1 includes g1 when castling available"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.legalTargets(ctx, sq(File.E, Rank.R1)) should contain(sq(File.G, Rank.R1))
test("legalTargets(ctx, from): non-king pieces unchanged by context"):
val ctx = ctxWithRights(
sq(File.D, Rank.R4) -> Piece.WhiteBishop,
sq(File.H, Rank.R8) -> Piece.BlackKing,
sq(File.H, Rank.R1) -> Piece.WhiteKing
)()
MoveValidator.legalTargets(ctx, sq(File.D, Rank.R4)) shouldBe
MoveValidator.legalTargets(ctx.board, sq(File.D, Rank.R4))
// ──── isCastle / castleSide / isLegal(ctx) ───────────────────────────
test("isCastle: returns true when king moves two files"):
val board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook
))
MoveValidator.isCastle(board, sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe true
test("isCastle: returns false when king moves one file"):
val board = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing
))
MoveValidator.isCastle(board, sq(File.E, Rank.R1), sq(File.F, Rank.R1)) shouldBe false
test("castleSide: returns Kingside when moving to higher file"):
MoveValidator.castleSide(sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe CastleSide.Kingside
test("castleSide: returns Queenside when moving to lower file"):
MoveValidator.castleSide(sq(File.E, Rank.R1), sq(File.C, Rank.R1)) shouldBe CastleSide.Queenside
test("isLegal(ctx): returns true for legal castling move"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.isLegal(ctx, sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe true
test("isLegal(ctx): returns false for illegal castling move when rights revoked"):
val ctx = ctxWithRights(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)(white = CastlingRights.None)
MoveValidator.isLegal(ctx, sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe false
test("castlingTargets: returns empty when king not on home square"):
val ctx = ctxWithRights(
sq(File.D, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
)()
MoveValidator.castlingTargets(ctx, Color.White) shouldBe empty