feat: NCS-11 implement 50-move rule with player claim via TUI menu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LQ63
2026-03-30 12:51:43 +02:00
parent fc42ccfeee
commit 0eaeb06e2b
3 changed files with 172 additions and 56 deletions
@@ -8,5 +8,5 @@ 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(Board.initial, GameHistory.empty, Color.White) GameController.gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
} }
@@ -1,7 +1,7 @@
package de.nowchess.chess.controller 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, PieceType, Rank, Square}
import de.nowchess.chess.logic.* import de.nowchess.chess.logic.*
import de.nowchess.chess.view.Renderer import de.nowchess.chess.view.Renderer
@@ -16,10 +16,11 @@ 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(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newHalfMoveClock: Int, newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newHalfMoveClock: Int, 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
case object DrawClaimed extends MoveResult
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Controller // Controller
@@ -30,10 +31,13 @@ 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(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = def processMove(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int, raw: String): MoveResult =
raw.trim match raw.trim match
case "quit" | "q" => case "quit" | "q" =>
MoveResult.Quit MoveResult.Quit
case "draw" =>
if halfMoveClock >= 50 then MoveResult.DrawClaimed
else MoveResult.InvalidFormat("draw")
case trimmed => case trimmed =>
Parser.parseMove(trimmed) match Parser.parseMove(trimmed) match
case None => case None =>
@@ -44,7 +48,7 @@ object GameController:
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(piece) =>
if !MoveValidator.isLegal(board, history, from, to) then if !MoveValidator.isLegal(board, history, from, to) then
MoveResult.IllegalMove MoveResult.IllegalMove
else else
@@ -60,52 +64,68 @@ object GameController:
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn) val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq)) (b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap) else (b, cap)
val isReset = piece.pieceType == PieceType.Pawn || captured.isDefined || isEP
val newClock = if isReset then 0 else halfMoveClock + 1
val newHistory = history.addMove(from, to, castleOpt) val newHistory = history.addMove(from, to, castleOpt)
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite) case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, newClock, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, newClock, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn) case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate case PositionStatus.Drawn => MoveResult.Stalemate
/** 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(board: Board, history: GameHistory, turn: Color): Unit = def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int): Unit =
println() println()
print(Renderer.render(board)) print(Renderer.render(board))
val input =
if halfMoveClock >= 50 then
println(s"[50-move rule] ${turn.label} may claim a draw, or continue playing.")
println(" 1. Claim draw")
println(" 2. Continue")
Option(StdIn.readLine()).getOrElse("2").trim match
case "1" => "draw"
case _ =>
println(s"${turn.label}'s turn. Enter move: ") println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, history, turn, input) match else
println(s"${turn.label}'s turn. Enter move: ")
Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, history, turn, halfMoveClock, input) match
case MoveResult.Quit => case MoveResult.Quit =>
println("Game over. Goodbye!") println("Game over. Goodbye!")
case MoveResult.DrawClaimed =>
println("Draw claimed by 50-move rule.")
gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
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(board, history, turn) gameLoop(board, history, turn, halfMoveClock)
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(board, history, turn) gameLoop(board, history, turn, halfMoveClock)
case MoveResult.WrongColor => case MoveResult.WrongColor =>
println(s"That is not your piece.") println(s"That is not your piece.")
gameLoop(board, history, turn) gameLoop(board, history, turn, halfMoveClock)
case MoveResult.IllegalMove => case MoveResult.IllegalMove =>
println(s"Illegal move.") println(s"Illegal move.")
gameLoop(board, history, turn) gameLoop(board, history, turn, halfMoveClock)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, newClock, 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(newBoard, newHistory, newTurn) gameLoop(newBoard, newHistory, newTurn, newClock)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => case MoveResult.MovedInCheck(newBoard, newHistory, captured, newClock, 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(newBoard, newHistory, newTurn) gameLoop(newBoard, newHistory, newTurn, newClock)
case MoveResult.Checkmate(winner) => case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.") println(s"Checkmate! ${winner.label} wins.")
gameLoop(Board.initial, GameHistory.empty, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
case MoveResult.Stalemate => case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.") println("Stalemate! The game is a draw.")
gameLoop(Board.initial, GameHistory.empty, Color.White) gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
@@ -11,11 +11,11 @@ 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 def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = private def processMove(board: Board, history: GameHistory, turn: Color, raw: String, halfMoveClock: Int = 0): MoveResult =
GameController.processMove(board, history, turn, raw) GameController.processMove(board, history, turn, halfMoveClock, raw)
private def gameLoop(board: Board, history: GameHistory, turn: Color): Unit = private def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int = 0): Unit =
GameController.gameLoop(board, history, turn) GameController.gameLoop(board, history, turn, halfMoveClock)
private def castlingRights(history: GameHistory, color: Color): CastlingRights = private def castlingRights(history: GameHistory, color: Color): CastlingRights =
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color) de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
@@ -48,7 +48,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
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"):
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, _, newTurn) =>
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn) newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
captured shouldBe None captured shouldBe None
@@ -63,7 +63,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.WhiteKing sq(File.H, Rank.R8) -> Piece.WhiteKing
)) ))
processMove(board, GameHistory.empty, Color.White, "e5d6") match processMove(board, GameHistory.empty, Color.White, "e5d6") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, _, newTurn) =>
captured shouldBe Some(Piece.BlackPawn) captured shouldBe Some(Piece.BlackPawn)
newBoard.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
@@ -133,7 +133,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)) ))
processMove(b, GameHistory.empty, 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"):
@@ -220,7 +220,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)) ))
processMove(b, GameHistory.empty, Color.White, "e1g1") match processMove(b, GameHistory.empty, Color.White, "e1g1") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, _, newTurn) =>
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing) newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook) newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
@@ -236,7 +236,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)) ))
processMove(b, GameHistory.empty, Color.White, "e1c1") match processMove(b, GameHistory.empty, Color.White, "e1c1") match
case MoveResult.Moved(newBoard, _, _, _) => case MoveResult.Moved(newBoard, _, _, _, _) =>
newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing) newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook) newBoard.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")
@@ -250,7 +250,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)) ))
processMove(b, GameHistory.empty, Color.White, "e1g1") match processMove(b, GameHistory.empty, Color.White, "e1g1") match
case MoveResult.Moved(_, newHistory, _, _) => case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
@@ -261,10 +261,10 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)) ))
processMove(b, GameHistory.empty, Color.White, "h1h4") match processMove(b, GameHistory.empty, Color.White, "h1h4") match
case MoveResult.Moved(_, newHistory, _, _) => case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true castlingRights(newHistory, Color.White).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) => case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true castlingRights(newHistory, Color.White).queenSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -275,7 +275,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)) ))
processMove(b, GameHistory.empty, Color.White, "e1e2") match processMove(b, GameHistory.empty, Color.White, "e1e2") match
case MoveResult.Moved(_, newHistory, _, _) => case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
@@ -287,9 +287,9 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
)) ))
processMove(b, GameHistory.empty, Color.Black, "h2h1") match processMove(b, GameHistory.empty, Color.Black, "h2h1") match
case MoveResult.Moved(_, newHistory, _, _) => case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false castlingRights(newHistory, Color.White).kingSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _) => case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false castlingRights(newHistory, Color.White).kingSide shouldBe false
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -316,9 +316,9 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R1) -> Piece.WhiteKing sq(File.H, Rank.R1) -> Piece.WhiteKing
)) ))
processMove(b, GameHistory.empty, Color.Black, "e8e7") match processMove(b, GameHistory.empty, Color.Black, "e8e7") match
case MoveResult.Moved(_, newHistory, _, _) => case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
case MoveResult.MovedInCheck(_, newHistory, _, _) => case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -329,10 +329,10 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R1) -> Piece.WhiteKing sq(File.H, Rank.R1) -> Piece.WhiteKing
)) ))
processMove(b, GameHistory.empty, Color.Black, "a8a1") match processMove(b, GameHistory.empty, Color.Black, "a8a1") match
case MoveResult.Moved(_, newHistory, _, _) => case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true castlingRights(newHistory, Color.Black).kingSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) => case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true castlingRights(newHistory, Color.Black).kingSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -344,10 +344,10 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.A, Rank.R1) -> Piece.WhiteKing sq(File.A, Rank.R1) -> Piece.WhiteKing
)) ))
processMove(b, GameHistory.empty, Color.Black, "h8h4") match processMove(b, GameHistory.empty, Color.Black, "h8h4") match
case MoveResult.Moved(_, newHistory, _, _) => case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true castlingRights(newHistory, Color.Black).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) => case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true castlingRights(newHistory, Color.Black).queenSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -360,9 +360,9 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)) ))
processMove(b, GameHistory.empty, Color.Black, "a2a1") match processMove(b, GameHistory.empty, Color.Black, "a2a1") match
case MoveResult.Moved(_, newHistory, _, _) => case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false castlingRights(newHistory, Color.White).queenSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _) => case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false castlingRights(newHistory, Color.White).queenSide shouldBe false
case other => fail(s"Expected Moved or MovedInCheck, got $other") case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -377,9 +377,9 @@ class GameControllerTest extends AnyFunSuite with Matchers:
Square(File.E, Rank.R8) -> Piece.BlackKing Square(File.E, Rank.R8) -> Piece.BlackKing
)) ))
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5)) val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
val result = GameController.processMove(b, h, Color.White, "e5d6") val result = GameController.processMove(b, h, Color.White, 0, "e5d6")
result match result match
case MoveResult.Moved(newBoard, _, captured, _) => case MoveResult.Moved(newBoard, _, captured, _, _) =>
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
captured shouldBe Some(Piece.BlackPawn) captured shouldBe Some(Piece.BlackPawn)
@@ -394,10 +394,106 @@ class GameControllerTest extends AnyFunSuite with Matchers:
Square(File.E, Rank.R1) -> Piece.WhiteKing Square(File.E, Rank.R1) -> Piece.WhiteKing
)) ))
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val result = GameController.processMove(b, h, Color.Black, "d4e3") val result = GameController.processMove(b, h, Color.Black, 0, "d4e3")
result match result match
case MoveResult.Moved(newBoard, _, captured, _) => case MoveResult.Moved(newBoard, _, captured, _, _) =>
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
captured shouldBe Some(Piece.WhitePawn) captured shouldBe Some(Piece.WhitePawn)
case other => fail(s"Expected Moved but got $other") case other => fail(s"Expected Moved but got $other")
// ──── processMove: 50-move rule draw claim ───────────────────────────────
test("processMove: 'draw' with halfMoveClock = 50 returns DrawClaimed"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 50, "draw") shouldBe MoveResult.DrawClaimed
test("processMove: 'draw' with halfMoveClock = 49 returns InvalidFormat"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 49, "draw") shouldBe MoveResult.InvalidFormat("draw")
// ──── processMove: halfMoveClock update ──────────────────────────────────
test("processMove: pawn move resets halfMoveClock to 0"):
GameController.processMove(Board.initial, GameHistory.empty, Color.White, 10, "e2e4") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: capture resets halfMoveClock to 0"):
val b = Board(Map(
sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.D, Rank.R5) -> Piece.BlackPawn,
sq(File.H, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 15, "a5d5") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: en passant capture resets halfMoveClock to 0"):
val b = Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn,
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
GameController.processMove(b, h, Color.White, 20, "e5d6") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: quiet piece move increments halfMoveClock"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 10, "a1a5") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 11
case other => fail(s"Expected Moved, got $other")
test("processMove: MovedInCheck carries updated halfMoveClock"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 7, "a1a8") match
case MoveResult.MovedInCheck(_, _, _, newClock, _) => newClock shouldBe 8
case other => fail(s"Expected MovedInCheck, got $other")
// ──── gameLoop: 50-move rule menu ────────────────────────────────────────
test("gameLoop: shows 50-move rule menu when halfMoveClock >= 50 and draw claimed"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("1\nquit\n"):
GameController.gameLoop(b, GameHistory.empty, Color.White, 50)
output should include("50-move rule")
output should include("Draw claimed by 50-move rule.")
test("gameLoop: shows 50-move rule menu when halfMoveClock >= 50 and player continues"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("2\nquit\n"):
GameController.gameLoop(b, GameHistory.empty, Color.White, 50)
output should include("50-move rule")
output should include("White's turn")
test("gameLoop: no 50-move rule menu when halfMoveClock < 50"):
val output = captureOutput:
withInput("quit\n"):
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White, 49)
output should not include "50-move rule"