feat: NCS-13 Implement Threefold Repetition
Build & Test (NowChessSystems) TeamCity build finished

This commit is contained in:
2026-04-15 09:47:09 +02:00
parent b2e62dc60c
commit b1684ad3bb
14 changed files with 205 additions and 4 deletions
Regular → Executable
View File
@@ -5,4 +5,5 @@ enum DrawReason:
case Stalemate case Stalemate
case InsufficientMaterial case InsufficientMaterial
case FiftyMoveRule case FiftyMoveRule
case ThreefoldRepetition
case Agreement case Agreement
@@ -13,6 +13,7 @@ case class GameContext(
halfMoveClock: Int, halfMoveClock: Int,
moves: List[Move], moves: List[Move],
result: Option[GameResult] = None, result: Option[GameResult] = None,
initialBoard: Board = Board.initial,
): ):
/** Create new context with updated board. */ /** Create new context with updated board. */
def withBoard(newBoard: Board): GameContext = copy(board = newBoard) def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
@@ -17,8 +17,13 @@ class GameEngine(
val initialContext: GameContext = GameContext.initial, val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules, val ruleSet: RuleSet = DefaultRules,
) extends Observable: ) extends Observable:
// Ensure that initialBoard is set correctly for threefold repetition detection
private val contextWithInitialBoard = if initialContext.moves.isEmpty && initialContext.board != initialContext.initialBoard then
initialContext.copy(initialBoard = initialContext.board)
else
initialContext
@SuppressWarnings(Array("DisableSyntax.var")) @SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = initialContext private var currentContext: GameContext = contextWithInitialBoard
private val invoker = new CommandInvoker() private val invoker = new CommandInvoker()
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */ /** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
@@ -63,11 +68,15 @@ class GameEngine(
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule))) currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
invoker.clear() invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule)) notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else else
notifyObservers( notifyObservers(
InvalidMoveEvent( InvalidMoveEvent(
currentContext, currentContext,
"Draw cannot be claimed: the 50-move rule has not been triggered.", "Draw cannot be claimed: neither the 50-move rule nor threefold repetition has been triggered.",
), ),
) )
@@ -154,7 +163,7 @@ class GameEngine(
invoker.clear() invoker.clear()
if ctx.moves.isEmpty then if ctx.moves.isEmpty then
currentContext = ctx currentContext = ctx.copy(initialBoard = ctx.board)
Right(()) Right(())
else replayMoves(ctx.moves, savedContext) else replayMoves(ctx.moves, savedContext)
@@ -182,7 +191,11 @@ class GameEngine(
/** Load an arbitrary board position, clearing all history and undo/redo state. */ /** Load an arbitrary board position, clearing all history and undo/redo state. */
def loadPosition(newContext: GameContext): Unit = synchronized { def loadPosition(newContext: GameContext): Unit = synchronized {
currentContext = newContext val contextWithInitialBoard = if newContext.moves.isEmpty then
newContext.copy(initialBoard = newContext.board)
else
newContext
currentContext = contextWithInitialBoard
pendingPromotion = None pendingPromotion = None
invoker.clear() invoker.clear()
notifyObservers(BoardResetEvent(currentContext)) notifyObservers(BoardResetEvent(currentContext))
@@ -237,6 +250,7 @@ class GameEngine(
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext)) else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if ruleSet.isThreefoldRepetition(currentContext) then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
private def translateMoveToNotation(move: Move, boardBefore: Board): String = private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match move.moveType match
@@ -56,6 +56,11 @@ case class FiftyMoveRuleAvailableEvent(
context: GameContext, context: GameContext,
) extends GameEvent ) extends GameEvent
/** Fired after any move where the same position occurs for the third time — threefold repetition is now claimable. */
case class ThreefoldRepetitionAvailableEvent(
context: GameContext,
) extends GameEvent
/** Fired when a move is undone, carrying PGN notation of the reversed move. */ /** Fired when a move is undone, carrying PGN notation of the reversed move. */
case class MoveUndoneEvent( case class MoveUndoneEvent(
context: GameContext, context: GameContext,
@@ -98,6 +98,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
def isStalemate(context: GameContext): Boolean = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move) def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
val engine = new GameEngine(ruleSet = permissiveRules) val engine = new GameEngine(ruleSet = permissiveRules)
@@ -119,6 +120,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
def isStalemate(context: GameContext): Boolean = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context def applyMove(context: GameContext)(move: Move): GameContext = context
val engine = new GameEngine(ruleSet = noLegalMoves) val engine = new GameEngine(ruleSet = noLegalMoves)
@@ -221,3 +221,75 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
evt.isDefined shouldBe true evt.isDefined shouldBe true
evt.get.reason shouldBe DrawReason.InsufficientMaterial evt.get.reason shouldBe DrawReason.InsufficientMaterial
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.InsufficientMaterial)) engine.context.result shouldBe Some(GameResult.Draw(DrawReason.InsufficientMaterial))
// ── Threefold Repetition ──────────────────────────────────────────
test("draw command rejected when neither 50-move rule nor threefold repetition available"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
observer.clear()
engine.processUserInput("draw")
observer.hasEvent[InvalidMoveEvent] shouldBe true
test("threefold repetition fires ThreefoldRepetitionAvailableEvent after 8-move shuffle"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Both knights shuffle home: initial position occurs 3 times on move 8 (Ng8)
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
engine.processUserInput("f6g8")
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
observer.clear()
engine.processUserInput("f6g8") // 3rd occurrence of initial position
observer.hasEvent[ThreefoldRepetitionAvailableEvent] shouldBe true
engine.context.result shouldBe None // claimable, not automatic
test("draw claim via threefold repetition ends game with DrawEvent"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
engine.processUserInput("f6g8")
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
engine.processUserInput("f6g8") // threefold now available
observer.clear()
engine.processUserInput("draw")
val evt = observer.getEvent[DrawEvent]
evt.isDefined shouldBe true
evt.get.reason shouldBe DrawReason.ThreefoldRepetition
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.ThreefoldRepetition))
test("loadPosition with non-empty moves preserves context as-is"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Build a context that already has a move in its history
val move = de.nowchess.api.move.Move(
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R2),
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R4),
)
val ctxWithMove = de.nowchess.api.game.GameContext.initial.withMove(move)
engine.loadPosition(ctxWithMove)
observer.hasEvent[BoardResetEvent] shouldBe true
@@ -173,6 +173,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
DefaultRules.isInsufficientMaterial(context) DefaultRules.isInsufficientMaterial(context)
def isFiftyMoveRule(context: GameContext): Boolean = def isFiftyMoveRule(context: GameContext): Boolean =
DefaultRules.isFiftyMoveRule(context) DefaultRules.isFiftyMoveRule(context)
def isThreefoldRepetition(context: GameContext): Boolean =
DefaultRules.isThreefoldRepetition(context)
def applyMove(context: GameContext)(move: Move): GameContext = def applyMove(context: GameContext)(move: Move): GameContext =
DefaultRules.applyMove(context)(move) DefaultRules.applyMove(context)(move)
@@ -32,6 +32,9 @@ trait RuleSet:
/** True if halfMoveClock >= 100 (50-move rule). */ /** True if halfMoveClock >= 100 (50-move rule). */
def isFiftyMoveRule(context: GameContext): Boolean def isFiftyMoveRule(context: GameContext): Boolean
/** True if the same position has occurred 3 times (including current position). */
def isThreefoldRepetition(context: GameContext): Boolean
/** Apply a legal move to produce the next game context. Handles all special move types: castling, en passant, /** Apply a legal move to produce the next game context. Handles all special move types: castling, en passant,
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history. * promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
*/ */
@@ -11,6 +11,14 @@ import scala.annotation.tailrec
*/ */
object DefaultRules extends RuleSet: object DefaultRules extends RuleSet:
/** Represents a position for threefold repetition (board state + turn + castling + en passant). */
private case class Position(
board: Board,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
)
// ── Direction vectors ────────────────────────────────────────────── // ── Direction vectors ──────────────────────────────────────────────
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1)) private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1)) private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
@@ -62,6 +70,46 @@ object DefaultRules extends RuleSet:
override def isFiftyMoveRule(context: GameContext): Boolean = override def isFiftyMoveRule(context: GameContext): Boolean =
context.halfMoveClock >= 100 context.halfMoveClock >= 100
override def isThreefoldRepetition(context: GameContext): Boolean =
val currentPosition = Position(
board = context.board,
turn = context.turn,
castlingRights = context.castlingRights,
enPassantSquare = context.enPassantSquare,
)
countPositionOccurrences(context, currentPosition) >= 3
private def countPositionOccurrences(context: GameContext, targetPosition: Position): Int =
try
var count = 0
var tempCtx = GameContext(
board = context.initialBoard,
turn = Color.White,
castlingRights = CastlingRights.Initial,
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
initialBoard = context.initialBoard,
)
var tempPos = Position(tempCtx.board, tempCtx.turn, tempCtx.castlingRights, tempCtx.enPassantSquare)
if tempPos == targetPosition then count += 1
for move <- context.moves do
tempCtx = applyMove(tempCtx)(move)
tempPos = Position(
board = tempCtx.board,
turn = tempCtx.turn,
castlingRights = tempCtx.castlingRights,
enPassantSquare = tempCtx.enPassantSquare,
)
if tempPos == targetPosition then count += 1
count
catch
case _: Exception =>
// If replay fails, conservatively count only the current position (never triggers a draw)
1
// ── Sliding pieces (Bishop, Rook, Queen) ─────────────────────────── // ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
private def slidingMoves( private def slidingMoves(
@@ -175,3 +175,48 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
// White is in check; only moves that block or move the king are legal // White is in check; only moves that block or move the king are legal
moves.nonEmpty shouldBe true moves.nonEmpty shouldBe true
// ── Threefold Repetition ─────────────────────────────────────────
test("threefold repetition returns false for initial position with no moves"):
val context = GameContext.initial
rules.isThreefoldRepetition(context) shouldBe false
test("threefold repetition returns false after single move"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val ctx1 = rules.applyMove(context)(move1)
rules.isThreefoldRepetition(ctx1) shouldBe false
test("threefold repetition detects repeated position after back-and-forth moves"):
// Both knights shuffle back and forth: initial position (White to move) occurs 3 times
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val nf3 = Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3))
val nf6 = Move(Square(File.G, Rank.R8), Square(File.F, Rank.R6))
val ng1 = Move(Square(File.F, Rank.R3), Square(File.G, Rank.R1))
val ng8 = Move(Square(File.F, Rank.R6), Square(File.G, Rank.R8))
// After 8 moves the starting position (White to move, both knights home) has occurred 3 times
val ctx = List(nf3, nf6, ng1, ng8, nf3, nf6, ng1, ng8)
.foldLeft(context)(rules.applyMove(_)(_))
rules.isThreefoldRepetition(ctx) shouldBe true
test("threefold repetition catch block returns false for inconsistent context"):
// A context whose moves cannot be replayed from initialBoard (forces the catch path)
val m = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6)) // e5→e6, no pawn there in initial board
val brokenCtx = GameContext(
board = Board.initial,
turn = Color.White,
castlingRights = CastlingRights.Initial,
enPassantSquare = None,
halfMoveClock = 0,
moves = List(m),
initialBoard = Board.initial,
)
// Replay will fail → catch returns 1 → 1 >= 3 is false
rules.isThreefoldRepetition(brokenCtx) shouldBe false
@@ -36,6 +36,7 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
case DrawReason.Stalemate => "Stalemate! The game is a draw." case DrawReason.Stalemate => "Stalemate! The game is a draw."
case DrawReason.InsufficientMaterial => "Draw by insufficient material." case DrawReason.InsufficientMaterial => "Draw by insufficient material."
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule." case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
case DrawReason.ThreefoldRepetition => "Draw by threefold repetition."
case DrawReason.Agreement => "Draw by agreement." case DrawReason.Agreement => "Draw by agreement."
showAlert(AlertType.Information, "Game Over", msg) showAlert(AlertType.Information, "Game Over", msg)
@@ -52,6 +53,9 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
case e: FiftyMoveRuleAvailableEvent => case e: FiftyMoveRuleAvailableEvent =>
boardView.showMessage("50-move rule is now available — type 'draw' to claim.") boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
case e: ThreefoldRepetitionAvailableEvent =>
boardView.showMessage("Threefold repetition is now available — type 'draw' to claim.")
case e: MoveUndoneEvent => case e: MoveUndoneEvent =>
boardView.updateBoard(e.context.board, e.context.turn) boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage(s"↶ Undo: ${e.pgnNotation}") boardView.showMessage(s"↶ Undo: ${e.pgnNotation}")
@@ -50,6 +50,7 @@ class TerminalUI(engine: GameEngine) extends Observer:
case DrawReason.Stalemate => "Stalemate! The game is a draw." case DrawReason.Stalemate => "Stalemate! The game is a draw."
case DrawReason.InsufficientMaterial => "Draw by insufficient material." case DrawReason.InsufficientMaterial => "Draw by insufficient material."
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule." case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
case DrawReason.ThreefoldRepetition => "Draw by threefold repetition."
case DrawReason.Agreement => "Draw by agreement." case DrawReason.Agreement => "Draw by agreement."
println(msg) println(msg)
println() println()
@@ -70,6 +71,9 @@ class TerminalUI(engine: GameEngine) extends Observer:
case _: FiftyMoveRuleAvailableEvent => case _: FiftyMoveRuleAvailableEvent =>
println("50-move rule is now available — type 'draw' to claim.") println("50-move rule is now available — type 'draw' to claim.")
case _: ThreefoldRepetitionAvailableEvent =>
println("Threefold repetition is now available — type 'draw' to claim.")
case e: PgnLoadedEvent => case e: PgnLoadedEvent =>
println("PGN loaded successfully.") println("PGN loaded successfully.")
println() println()
Regular → Executable
View File