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:
@@ -488,3 +488,39 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
PromotionPiece.Knight, Color.White
|
||||
)
|
||||
result should be (MoveResult.Stalemate)
|
||||
|
||||
// ──── half-move clock propagation ────────────────────────────────────
|
||||
|
||||
test("processMove: non-pawn non-capture increments halfMoveClock"):
|
||||
// g1f3 is a knight move — not a pawn, not a capture
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 1
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: pawn move resets halfMoveClock to 0"):
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 0
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: capture resets halfMoveClock to 0"):
|
||||
// White pawn on e5, Black pawn on d6 — exd6 is a capture
|
||||
val board = Board(Map(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val history = GameHistory(halfMoveClock = 10)
|
||||
processMove(board, history, Color.White, "e5d6") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 0
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: clock carries from previous history on non-pawn non-capture"):
|
||||
val history = GameHistory(halfMoveClock = 5)
|
||||
processMove(Board.initial, history, Color.White, "g1f3") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 6
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -302,6 +302,47 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
||||
engine.board shouldBe boardAfterSecondMove
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ──── 50-move rule ───────────────────────────────────────────────────
|
||||
|
||||
test("GameEngine: 'draw' rejected when halfMoveClock < 100"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("draw")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine: 'draw' accepted and fires DrawClaimedEvent when halfMoveClock >= 100"):
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("draw")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[DrawClaimedEvent]
|
||||
|
||||
test("GameEngine: state resets to initial after draw claimed"):
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
|
||||
engine.processUserInput("draw")
|
||||
engine.board shouldBe Board.initial
|
||||
engine.history shouldBe GameHistory.empty
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine: FiftyMoveRuleAvailableEvent fired when move brings clock to 100"):
|
||||
// Start at clock 99; a knight move (non-pawn, non-capture) increments to 100
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 99))
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("g1f3") // knight move on initial board
|
||||
// Should receive MoveExecutedEvent AND FiftyMoveRuleAvailableEvent
|
||||
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe true
|
||||
|
||||
test("GameEngine: FiftyMoveRuleAvailableEvent not fired when clock is below 100 after move"):
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 5))
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("g1f3")
|
||||
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe false
|
||||
|
||||
// Mock Observer for testing
|
||||
private class MockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
@@ -69,3 +69,36 @@ class GameHistoryTest extends AnyFunSuite with Matchers:
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.castleSide should be (None)
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
|
||||
// ──── half-move clock ────────────────────────────────────────────────
|
||||
|
||||
test("halfMoveClock starts at 0"):
|
||||
GameHistory.empty.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock increments on a non-pawn non-capture move"):
|
||||
val h = GameHistory.empty.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
h.halfMoveClock shouldBe 1
|
||||
|
||||
test("halfMoveClock resets to 0 on a pawn move"):
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true)
|
||||
h.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock resets to 0 on a capture"):
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasCapture = true)
|
||||
h.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock resets to 0 when both wasPawnMove and wasCapture are true"):
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasPawnMove = true, wasCapture = true)
|
||||
h.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock carries across multiple moves"):
|
||||
val h = GameHistory.empty
|
||||
.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) // +1 → 1
|
||||
.addMove(sq(File.G, Rank.R8), sq(File.F, Rank.R6)) // +1 → 2
|
||||
.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true) // reset → 0
|
||||
.addMove(sq(File.B, Rank.R1), sq(File.C, Rank.R3)) // +1 → 1
|
||||
h.halfMoveClock shouldBe 1
|
||||
|
||||
test("GameHistory can be initialised with a non-zero halfMoveClock"):
|
||||
val h = GameHistory(halfMoveClock = 42)
|
||||
h.halfMoveClock shouldBe 42
|
||||
|
||||
@@ -67,3 +67,22 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
)
|
||||
val fen = FenExporter.gameStateToFen(gameState)
|
||||
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
||||
|
||||
test("halfMoveClock round-trips through FEN export and import"):
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.notation.FenParser
|
||||
val history = GameHistory(halfMoveClock = 42)
|
||||
val gameState = GameState(
|
||||
piecePlacement = FenExporter.boardToFen(de.nowchess.api.board.Board.initial),
|
||||
activeColor = Color.White,
|
||||
castlingWhite = CastlingRights.Both,
|
||||
castlingBlack = CastlingRights.Both,
|
||||
enPassantTarget = None,
|
||||
halfMoveClock = history.halfMoveClock,
|
||||
fullMoveNumber = 1,
|
||||
status = GameStatus.InProgress
|
||||
)
|
||||
val fen = FenExporter.gameStateToFen(gameState)
|
||||
FenParser.parseFen(fen) match
|
||||
case Some(gs) => gs.halfMoveClock shouldBe 42
|
||||
case None => fail("FEN parsing failed")
|
||||
|
||||
@@ -100,3 +100,15 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
pgn should include ("e2e4")
|
||||
pgn should not include ("=")
|
||||
}
|
||||
|
||||
test("exportGame uses Result header as termination marker"):
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val pgn = PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), history)
|
||||
pgn should endWith("1/2-1/2")
|
||||
|
||||
test("exportGame with no Result header still uses * as default"):
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||
pgn shouldBe "1. e2e4 *"
|
||||
|
||||
Reference in New Issue
Block a user