Compare commits

..

1 Commits

Author SHA1 Message Date
lq64 412ed986a9 feat: NCS-11 50-move rule (#9)
Build & Test (NowChessSystems) TeamCity build finished
Summary

  - Implements the FIDE 50-move draw rule: a player may claim a draw if no pawn move or capture has occurred in the last
   50 full moves (100 half-moves)
  - Draw is not automatic — the eligible player must claim it via a TUI menu shown at the start of their turn
  - halfMoveClock: Int is threaded through processMove and gameLoop; resets on pawn move, capture, or en passant;
  increments on all other moves

  Changes

  - GameController.scala: extended MoveResult.Moved and MoveResult.MovedInCheck with newHalfMoveClock: Int; added
  MoveResult.DrawClaimed; added halfMoveClock parameter to processMove and gameLoop; TUI menu shown when clock ≥ 100
  - Main.scala: initial gameLoop call passes halfMoveClock = 0
  - GameControllerTest.scala: updated all existing pattern matches; added 10 new tests covering clock reset, clock
  increment, draw claim, and TUI menu behaviour

  Test plan

  - processMove: 'draw' with halfMoveClock = 100 → DrawClaimed
  - processMove: 'draw' with halfMoveClock = 99 → InvalidFormat
  - Pawn move / capture / en passant → clock resets to 0
  - Quiet piece move → clock increments by 1
  - MovedInCheck carries updated clock
  - TUI menu appears when clock ≥ 100; option 1 claims draw, option 2 continues
  - No TUI menu when clock < 100
  - All 197 tests passing

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #9
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-04-01 10:36:24 +02:00
@@ -90,19 +90,17 @@ class GameEngine(
notifyObservers(event) notifyObservers(event)
case moveInput => case moveInput =>
// Try to parse as a move
Parser.parseMove(moveInput) match Parser.parseMove(moveInput) match
case None => case None =>
val event = InvalidMoveEvent( notifyObservers(InvalidMoveEvent(
currentBoard, currentBoard, currentHistory, currentTurn,
currentHistory,
currentTurn,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4." s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
) ))
notifyObservers(event)
case Some((from, to)) => case Some((from, to)) =>
// Create a move command with current state snapshot handleParsedMove(from, to, moveInput)
}
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
val cmd = MoveCommand( val cmd = MoveCommand(
from = from, from = from,
to = to, to = to,
@@ -110,14 +108,11 @@ class GameEngine(
previousHistory = Some(currentHistory), previousHistory = Some(currentHistory),
previousTurn = Some(currentTurn) previousTurn = Some(currentTurn)
) )
// Execute the move through GameController
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit => case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
handleFailedMove(moveInput) handleFailedMove(moveInput)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
// Move succeeded - store result and execute through invoker
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd) invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn) updateGameState(newBoard, newHistory, newTurn)
@@ -126,7 +121,6 @@ class GameEngine(
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
// Move succeeded with check
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd) invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn) updateGameState(newBoard, newHistory, newTurn)
@@ -136,7 +130,6 @@ class GameEngine(
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) => case MoveResult.Checkmate(winner) =>
// Move resulted in checkmate
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd) invoker.execute(updatedCmd)
currentBoard = Board.initial currentBoard = Board.initial
@@ -145,7 +138,6 @@ class GameEngine(
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner)) notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
case MoveResult.Stalemate => case MoveResult.Stalemate =>
// Move resulted in stalemate
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd) invoker.execute(updatedCmd)
currentBoard = Board.initial currentBoard = Board.initial
@@ -156,7 +148,6 @@ class GameEngine(
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) => case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn)) pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo)) notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
}
/** Undo the last move. */ /** Undo the last move. */
def undo(): Unit = synchronized { def undo(): Unit = synchronized {