Compare commits

...

11 Commits

Author SHA1 Message Date
LQ63 88758c1cd1 docs(50-move-rule): 50 move docs
Build & Test (NowChessSystems) TeamCity build failed
Removed 50 move docs from this repo
2026-04-01 10:05:41 +02:00
LQ63 aad765c6c1 docs(50-move-rule): 50 move docs
Removed 50 move docs from this repo
2026-04-01 10:05:41 +02:00
LQ63 41b8b0441c docs(50-move-rule): 50 move docs
Removed 50 move docs from this repo
2026-04-01 10:05:39 +02:00
LQ63 5e22924053 fix: replace .get with pattern match in FenExporterTest halfMoveClock round-trip
Build & Test (NowChessSystems) TeamCity build failed
2026-03-31 23:28:22 +02:00
LQ63 97c3ff5e67 feat: NCS-11 implement 50-move rule draw claim and observer events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:24:58 +02:00
LQ63 b8f5c8eb77 feat: NCS-11 propagate half-move clock flags through GameController
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:18:36 +02:00
LQ63 5a4fcb1b55 feat: NCS-11 derive PGN termination marker from Result header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:13:10 +02:00
LQ63 83c7d3a46b feat: NCS-11 add halfMoveClock to GameHistory with addMove reset flags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:02:14 +02:00
LQ63 7855f0c136 docs: add 50-move rule implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:54:18 +02:00
LQ63 f61ffce22a docs: extend 50-move rule spec with FEN and PGN integration 2026-03-31 22:46:29 +02:00
LQ63 381c3f06a1 docs: add 50-move rule design spec for NCS-11 2026-03-31 22:42:20 +02:00
10 changed files with 203 additions and 12 deletions
@@ -63,7 +63,8 @@ object GameController:
case PromotionPiece.Bishop => PieceType.Bishop case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Knight => PieceType.Knight case PromotionPiece.Knight => PieceType.Knight
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType)) 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) toMoveResult(newBoard, newHistory, captured, turn)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -91,7 +92,9 @@ 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 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) toMoveResult(newBoard, newHistory, captured, turn)
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult = private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
@@ -67,6 +67,19 @@ class GameEngine(
case "redo" => case "redo" =>
performRedo() 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 "" => case "" =>
val event = InvalidMoveEvent( val event = InvalidMoveEvent(
currentBoard, currentBoard,
@@ -109,6 +122,8 @@ class GameEngine(
invoker.execute(updatedCmd) invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn) updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, 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) => case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
// Move succeeded with check // Move succeeded with check
@@ -117,6 +132,8 @@ class GameEngine(
updateGameState(newBoard, newHistory, newTurn) updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn) emitMoveEvent(from.toString, to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn)) notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) => case MoveResult.Checkmate(winner) =>
// Move resulted in checkmate // Move resulted in checkmate
@@ -11,21 +11,36 @@ case class HistoryMove(
promotionPiece: Option[PromotionPiece] = None promotionPiece: Option[PromotionPiece] = None
) )
/** Complete game history: ordered list of moves. */ /** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
case class GameHistory(moves: List[HistoryMove] = List.empty): *
* @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 = def addMove(move: HistoryMove): GameHistory =
GameHistory(moves :+ move) GameHistory(moves :+ move, halfMoveClock + 1)
def addMove(from: Square, to: Square): GameHistory =
addMove(HistoryMove(from, to, None))
/** 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( def addMove(
from: Square, from: Square,
to: Square, to: Square,
castleSide: Option[CastleSide] = None, castleSide: Option[CastleSide] = None,
promotionPiece: Option[PromotionPiece] = None promotionPiece: Option[PromotionPiece] = None,
wasPawnMove: Boolean = false,
wasCapture: Boolean = false
): GameHistory = ): 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: object GameHistory:
val empty: GameHistory = GameHistory() val empty: GameHistory = GameHistory()
@@ -22,7 +22,8 @@ object PgnExporter:
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr" else s"$moveNum. $whiteMoveStr $blackMoveStr"
moveLines.mkString(" ") + " *" val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
if headerLines.isEmpty then moveText if headerLines.isEmpty then moveText
else if moveText.isEmpty then headerLines else if moveText.isEmpty then headerLines
@@ -67,6 +67,20 @@ case class BoardResetEvent(
turn: Color turn: Color
) extends GameEvent ) 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. */ /** Observer trait: implement to receive game state updates. */
trait Observer: trait Observer:
def onGameEvent(event: GameEvent): Unit def onGameEvent(event: GameEvent): Unit
@@ -464,3 +464,39 @@ class GameControllerTest extends AnyFunSuite with Matchers:
PromotionPiece.Knight, Color.White PromotionPiece.Knight, Color.White
) )
result should be (MoveResult.Stalemate) 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 scala.collection.mutable
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory 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.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -302,6 +302,47 @@ class GameEngineTest extends AnyFunSuite with Matchers:
engine.board shouldBe boardAfterSecondMove engine.board shouldBe boardAfterSecondMove
engine.turn shouldBe Color.White 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 // Mock Observer for testing
private class MockObserver extends Observer: private class MockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]() val events = mutable.ListBuffer[GameEvent]()
@@ -69,3 +69,36 @@ class GameHistoryTest extends AnyFunSuite with Matchers:
newHistory.moves should have length 1 newHistory.moves should have length 1
newHistory.moves.head.castleSide should be (None) newHistory.moves.head.castleSide should be (None)
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) 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) val fen = FenExporter.gameStateToFen(gameState)
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3" 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 include ("e2e4")
pgn should not include ("=") 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 *"