From 0eaeb06e2bf460ce525cd5cd97b9a6c5c403aec7 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 30 Mar 2026 12:51:43 +0200 Subject: [PATCH] feat: NCS-11 implement 50-move rule with player claim via TUI menu Co-Authored-By: Claude Sonnet 4.6 --- .../main/scala/de/nowchess/chess/Main.scala | 2 +- .../chess/controller/GameController.scala | 76 +++++---- .../chess/controller/GameControllerTest.scala | 150 ++++++++++++++---- 3 files changed, 172 insertions(+), 56 deletions(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/Main.scala b/modules/core/src/main/scala/de/nowchess/chess/Main.scala index 3fb72e6..97ce515 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/Main.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/Main.scala @@ -8,5 +8,5 @@ import de.nowchess.chess.logic.GameHistory object Main { def main(args: Array[String]): Unit = println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.") - GameController.gameLoop(Board.initial, GameHistory.empty, Color.White) + GameController.gameLoop(Board.initial, GameHistory.empty, Color.White, 0) } diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index 4717430..5cf8ca6 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -1,7 +1,7 @@ package de.nowchess.chess.controller 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.view.Renderer @@ -11,15 +11,16 @@ import de.nowchess.chess.view.Renderer sealed trait MoveResult object MoveResult: - case object Quit extends MoveResult - case class InvalidFormat(raw: String) extends MoveResult - case object NoPiece extends MoveResult - case object WrongColor extends MoveResult - case object IllegalMove extends MoveResult - case class Moved(newBoard: Board, newHistory: GameHistory, 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 object Stalemate extends MoveResult + case object Quit extends MoveResult + case class InvalidFormat(raw: String) extends MoveResult + case object NoPiece extends MoveResult + case object WrongColor extends MoveResult + case object IllegalMove extends MoveResult + case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newHalfMoveClock: Int, 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 object Stalemate extends MoveResult + case object DrawClaimed extends MoveResult // --------------------------------------------------------------------------- // Controller @@ -30,10 +31,13 @@ object GameController: /** Pure function: interprets one raw input line against the current game context. * Has no I/O side effects — all output must be handled by the caller. */ - def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = + def processMove(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int, raw: String): MoveResult = raw.trim match case "quit" | "q" => MoveResult.Quit + case "draw" => + if halfMoveClock >= 50 then MoveResult.DrawClaimed + else MoveResult.InvalidFormat("draw") case trimmed => Parser.parseMove(trimmed) match case None => @@ -44,7 +48,7 @@ object GameController: MoveResult.NoPiece case Some(piece) if piece.color != turn => MoveResult.WrongColor - case Some(_) => + case Some(piece) => if !MoveValidator.isLegal(board, history, from, to) then MoveResult.IllegalMove else @@ -60,52 +64,68 @@ object GameController: val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn) (b.removed(capturedSq), board.pieceAt(capturedSq)) 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) GameRules.gameStatus(newBoard, newHistory, turn.opposite) match - case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite) - case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) + case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, newClock, turn.opposite) + case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, newClock, turn.opposite) case PositionStatus.Mated => MoveResult.Checkmate(turn) case PositionStatus.Drawn => MoveResult.Stalemate /** Thin I/O shell: renders the board, reads a line, delegates to processMove, * prints the outcome, and recurses until the game ends. */ - def gameLoop(board: Board, history: GameHistory, turn: Color): Unit = + def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int): Unit = println() print(Renderer.render(board)) - println(s"${turn.label}'s turn. Enter move: ") - val input = Option(StdIn.readLine()).getOrElse("quit").trim - processMove(board, history, turn, input) match + 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: ") + Option(StdIn.readLine()).getOrElse("quit").trim + 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 => 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) => println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.") - gameLoop(board, history, turn) + gameLoop(board, history, turn, halfMoveClock) case MoveResult.NoPiece => println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.") - gameLoop(board, history, turn) + gameLoop(board, history, turn, halfMoveClock) case MoveResult.WrongColor => println(s"That is not your piece.") - gameLoop(board, history, turn) + gameLoop(board, history, turn, halfMoveClock) case MoveResult.IllegalMove => println(s"Illegal move.") - gameLoop(board, history, turn) - case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => + gameLoop(board, history, turn, halfMoveClock) + case MoveResult.Moved(newBoard, newHistory, captured, newClock, newTurn) => val prevTurn = newTurn.opposite captured.foreach: cap => val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") - gameLoop(newBoard, newHistory, newTurn) - case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => + gameLoop(newBoard, newHistory, newTurn, newClock) + case MoveResult.MovedInCheck(newBoard, newHistory, captured, newClock, newTurn) => val prevTurn = newTurn.opposite captured.foreach: cap => val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") println(s"${newTurn.label} is in check!") - gameLoop(newBoard, newHistory, newTurn) + gameLoop(newBoard, newHistory, newTurn, newClock) case MoveResult.Checkmate(winner) => println(s"Checkmate! ${winner.label} wins.") - gameLoop(Board.initial, GameHistory.empty, Color.White) + gameLoop(Board.initial, GameHistory.empty, Color.White, 0) case MoveResult.Stalemate => println("Stalemate! The game is a draw.") - gameLoop(Board.initial, GameHistory.empty, Color.White) + gameLoop(Board.initial, GameHistory.empty, Color.White, 0) diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala index 8124005..df0cb87 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -11,11 +11,11 @@ import java.io.ByteArrayInputStream class GameControllerTest extends AnyFunSuite with Matchers: private def sq(f: File, r: Rank): Square = Square(f, r) - private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = - GameController.processMove(board, history, turn, raw) + private def processMove(board: Board, history: GameHistory, turn: Color, raw: String, halfMoveClock: Int = 0): MoveResult = + GameController.processMove(board, history, turn, halfMoveClock, raw) - private def gameLoop(board: Board, history: GameHistory, turn: Color): Unit = - GameController.gameLoop(board, history, turn) + private def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int = 0): Unit = + GameController.gameLoop(board, history, turn, halfMoveClock) private def castlingRights(history: GameHistory, color: Color): CastlingRights = 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"): 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.R2)) shouldBe None captured shouldBe None @@ -63,7 +63,7 @@ class GameControllerTest extends AnyFunSuite with Matchers: sq(File.H, Rank.R8) -> Piece.WhiteKing )) 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) newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) newTurn shouldBe Color.Black @@ -133,7 +133,7 @@ class GameControllerTest extends AnyFunSuite with Matchers: sq(File.H, Rank.R8) -> Piece.BlackKing )) 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") 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 )) 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.F, Rank.R1)) shouldBe Some(Piece.WhiteRook) 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 )) 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.D, Rank.R1)) shouldBe Some(Piece.WhiteRook) 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 )) processMove(b, GameHistory.empty, Color.White, "e1g1") match - case MoveResult.Moved(_, newHistory, _, _) => + case MoveResult.Moved(_, newHistory, _, _, _) => castlingRights(newHistory, Color.White) shouldBe CastlingRights.None 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 )) 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).queenSide shouldBe true - case MoveResult.MovedInCheck(_, newHistory, _, _) => + case MoveResult.MovedInCheck(_, newHistory, _, _, _) => castlingRights(newHistory, Color.White).kingSide shouldBe false castlingRights(newHistory, Color.White).queenSide shouldBe true 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 )) processMove(b, GameHistory.empty, Color.White, "e1e2") match - case MoveResult.Moved(_, newHistory, _, _) => + case MoveResult.Moved(_, newHistory, _, _, _) => castlingRights(newHistory, Color.White) shouldBe CastlingRights.None 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 )) processMove(b, GameHistory.empty, Color.Black, "h2h1") match - case MoveResult.Moved(_, newHistory, _, _) => + case MoveResult.Moved(_, newHistory, _, _, _) => castlingRights(newHistory, Color.White).kingSide shouldBe false - case MoveResult.MovedInCheck(_, newHistory, _, _) => + case MoveResult.MovedInCheck(_, newHistory, _, _, _) => castlingRights(newHistory, Color.White).kingSide shouldBe false 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 )) processMove(b, GameHistory.empty, Color.Black, "e8e7") match - case MoveResult.Moved(_, newHistory, _, _) => + case MoveResult.Moved(_, newHistory, _, _, _) => castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None - case MoveResult.MovedInCheck(_, newHistory, _, _) => + case MoveResult.MovedInCheck(_, newHistory, _, _, _) => castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None 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 )) 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).kingSide shouldBe true - case MoveResult.MovedInCheck(_, newHistory, _, _) => + case MoveResult.MovedInCheck(_, newHistory, _, _, _) => castlingRights(newHistory, Color.Black).queenSide shouldBe false castlingRights(newHistory, Color.Black).kingSide shouldBe true 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 )) 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).queenSide shouldBe true - case MoveResult.MovedInCheck(_, newHistory, _, _) => + case MoveResult.MovedInCheck(_, newHistory, _, _, _) => castlingRights(newHistory, Color.Black).kingSide shouldBe false castlingRights(newHistory, Color.Black).queenSide shouldBe true 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 )) processMove(b, GameHistory.empty, Color.Black, "a2a1") match - case MoveResult.Moved(_, newHistory, _, _) => + case MoveResult.Moved(_, newHistory, _, _, _) => castlingRights(newHistory, Color.White).queenSide shouldBe false - case MoveResult.MovedInCheck(_, newHistory, _, _) => + case MoveResult.MovedInCheck(_, newHistory, _, _, _) => castlingRights(newHistory, Color.White).queenSide shouldBe false 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 )) 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 - 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.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed captured shouldBe Some(Piece.BlackPawn) @@ -394,10 +394,106 @@ class GameControllerTest extends AnyFunSuite with Matchers: Square(File.E, Rank.R1) -> Piece.WhiteKing )) 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 - 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.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed captured shouldBe Some(Piece.WhitePawn) 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"