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>
This commit was merged in pull request #9.
This commit is contained in:
@@ -63,7 +63,8 @@ object GameController:
|
||||
case PromotionPiece.Bishop => PieceType.Bishop
|
||||
case PromotionPiece.Knight => PieceType.Knight
|
||||
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
||||
val newHistory = history.addMove(from, to, None, Some(piece))
|
||||
// Promotion is always a pawn move → clock resets
|
||||
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
|
||||
toMoveResult(newBoard, newHistory, captured, turn)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,7 +92,9 @@ object GameController:
|
||||
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
||||
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
||||
else (b, cap)
|
||||
val newHistory = history.addMove(from, to, castleOpt)
|
||||
val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
|
||||
val wasCapture = captured.isDefined
|
||||
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture)
|
||||
toMoveResult(newBoard, newHistory, captured, turn)
|
||||
|
||||
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
|
||||
|
||||
@@ -67,6 +67,19 @@ class GameEngine(
|
||||
case "redo" =>
|
||||
performRedo()
|
||||
|
||||
case "draw" =>
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
invoker.clear()
|
||||
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard, currentHistory, currentTurn,
|
||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
||||
))
|
||||
|
||||
case "" =>
|
||||
val event = InvalidMoveEvent(
|
||||
currentBoard,
|
||||
@@ -77,70 +90,65 @@ class GameEngine(
|
||||
notifyObservers(event)
|
||||
|
||||
case moveInput =>
|
||||
// Try to parse as a move
|
||||
Parser.parseMove(moveInput) match
|
||||
case None =>
|
||||
val event = InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard, currentHistory, currentTurn,
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||
)
|
||||
notifyObservers(event)
|
||||
|
||||
))
|
||||
case Some((from, to)) =>
|
||||
// Create a move command with current state snapshot
|
||||
val cmd = MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
previousBoard = Some(currentBoard),
|
||||
previousHistory = Some(currentHistory),
|
||||
previousTurn = Some(currentTurn)
|
||||
)
|
||||
|
||||
// Execute the move through GameController
|
||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
||||
handleFailedMove(moveInput)
|
||||
|
||||
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)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, 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)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
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)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
||||
|
||||
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)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
||||
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
||||
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
|
||||
handleParsedMove(from, to, moveInput)
|
||||
}
|
||||
|
||||
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
|
||||
val cmd = MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
previousBoard = Some(currentBoard),
|
||||
previousHistory = Some(currentHistory),
|
||||
previousTurn = Some(currentTurn)
|
||||
)
|
||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
||||
handleFailedMove(moveInput)
|
||||
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
||||
|
||||
case MoveResult.Stalemate =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
||||
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
||||
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
|
||||
|
||||
/** Undo the last move. */
|
||||
def undo(): Unit = synchronized {
|
||||
performUndo()
|
||||
|
||||
@@ -11,21 +11,36 @@ case class HistoryMove(
|
||||
promotionPiece: Option[PromotionPiece] = None
|
||||
)
|
||||
|
||||
/** Complete game history: ordered list of moves. */
|
||||
case class GameHistory(moves: List[HistoryMove] = List.empty):
|
||||
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
|
||||
*
|
||||
* @param moves moves played so far, oldest first
|
||||
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
|
||||
*/
|
||||
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
|
||||
|
||||
/** Add a raw HistoryMove record. Clock increments by 1.
|
||||
* Use the coordinate overload when you know whether the move is a pawn move or capture.
|
||||
*/
|
||||
def addMove(move: HistoryMove): GameHistory =
|
||||
GameHistory(moves :+ move)
|
||||
|
||||
def addMove(from: Square, to: Square): GameHistory =
|
||||
addMove(HistoryMove(from, to, None))
|
||||
GameHistory(moves :+ move, halfMoveClock + 1)
|
||||
|
||||
/** Add a move by coordinates.
|
||||
*
|
||||
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
|
||||
* @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
|
||||
*
|
||||
* If neither flag is set the clock increments by 1.
|
||||
*/
|
||||
def addMove(
|
||||
from: Square,
|
||||
to: Square,
|
||||
castleSide: Option[CastleSide] = None,
|
||||
promotionPiece: Option[PromotionPiece] = None
|
||||
promotionPiece: Option[PromotionPiece] = None,
|
||||
wasPawnMove: Boolean = false,
|
||||
wasCapture: Boolean = false
|
||||
): GameHistory =
|
||||
addMove(HistoryMove(from, to, castleSide, promotionPiece))
|
||||
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
|
||||
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
|
||||
|
||||
object GameHistory:
|
||||
val empty: GameHistory = GameHistory()
|
||||
|
||||
@@ -22,7 +22,8 @@ object PgnExporter:
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
|
||||
moveLines.mkString(" ") + " *"
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
|
||||
if headerLines.isEmpty then moveText
|
||||
else if moveText.isEmpty then headerLines
|
||||
|
||||
@@ -67,6 +67,20 @@ case class BoardResetEvent(
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||
case class FiftyMoveRuleAvailableEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
||||
case class DrawClaimedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
trait Observer:
|
||||
def onGameEvent(event: GameEvent): Unit
|
||||
|
||||
Reference in New Issue
Block a user