diff --git a/clean b/clean old mode 100644 new mode 100755 diff --git a/modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala b/modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala index df4bbc3..aa6bab6 100644 --- a/modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala +++ b/modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala @@ -5,4 +5,5 @@ enum DrawReason: case Stalemate case InsufficientMaterial case FiftyMoveRule + case ThreefoldRepetition case Agreement diff --git a/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala b/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala index cde9b01..7edfdb9 100644 --- a/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala +++ b/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala @@ -13,6 +13,7 @@ case class GameContext( halfMoveClock: Int, moves: List[Move], result: Option[GameResult] = None, + initialBoard: Board = Board.initial, ): /** Create new context with updated board. */ def withBoard(newBoard: Board): GameContext = copy(board = newBoard) diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 635a575..5159852 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -17,8 +17,13 @@ class GameEngine( val initialContext: GameContext = GameContext.initial, val ruleSet: RuleSet = DefaultRules, ) 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")) - private var currentContext: GameContext = initialContext + private var currentContext: GameContext = contextWithInitialBoard private val invoker = new CommandInvoker() /** 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))) invoker.clear() 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 notifyObservers( InvalidMoveEvent( 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() if ctx.moves.isEmpty then - currentContext = ctx + currentContext = ctx.copy(initialBoard = ctx.board) Right(()) else replayMoves(ctx.moves, savedContext) @@ -182,7 +191,11 @@ class GameEngine( /** Load an arbitrary board position, clearing all history and undo/redo state. */ 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 invoker.clear() notifyObservers(BoardResetEvent(currentContext)) @@ -237,6 +250,7 @@ class GameEngine( else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(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 = move.moveType match diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala index b824065..446b677 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala @@ -56,6 +56,11 @@ case class FiftyMoveRuleAvailableEvent( context: GameContext, ) 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. */ case class MoveUndoneEvent( context: GameContext, diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala index 902d392..185a6ad 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala @@ -98,6 +98,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: def isStalemate(context: GameContext): Boolean = false def isInsufficientMaterial(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) val engine = new GameEngine(ruleSet = permissiveRules) @@ -119,6 +120,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: def isStalemate(context: GameContext): Boolean = false def isInsufficientMaterial(context: GameContext): Boolean = false def isFiftyMoveRule(context: GameContext): Boolean = false + def isThreefoldRepetition(context: GameContext): Boolean = false def applyMove(context: GameContext)(move: Move): GameContext = context val engine = new GameEngine(ruleSet = noLegalMoves) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala index 7d2881d..11c888e 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala @@ -221,3 +221,75 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: evt.isDefined shouldBe true evt.get.reason shouldBe 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 diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index 5db8ae1..71df191 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -173,6 +173,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: DefaultRules.isInsufficientMaterial(context) def isFiftyMoveRule(context: GameContext): Boolean = DefaultRules.isFiftyMoveRule(context) + def isThreefoldRepetition(context: GameContext): Boolean = + DefaultRules.isThreefoldRepetition(context) def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala b/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala index e8a0b1d..f2622bd 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala @@ -32,6 +32,9 @@ trait RuleSet: /** True if halfMoveClock >= 100 (50-move rule). */ 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, * promotion. Updates castling rights, en passant square, half-move clock, turn, and move history. */ diff --git a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala index 5a4be1e..deec440 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala @@ -11,6 +11,14 @@ import scala.annotation.tailrec */ 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 ────────────────────────────────────────────── 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)) @@ -62,6 +70,46 @@ object DefaultRules extends RuleSet: override def isFiftyMoveRule(context: GameContext): Boolean = 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) ─────────────────────────── private def slidingMoves( diff --git a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala index e44525a..92d8d89 100644 --- a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala @@ -175,3 +175,48 @@ class DefaultRulesTest extends AnyFunSuite with Matchers: // White is in check; only moves that block or move the king are legal 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 diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala index 529b6e0..f89917c 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala @@ -36,6 +36,7 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer: case DrawReason.Stalemate => "Stalemate! The game is a draw." case DrawReason.InsufficientMaterial => "Draw by insufficient material." case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule." + case DrawReason.ThreefoldRepetition => "Draw by threefold repetition." case DrawReason.Agreement => "Draw by agreement." showAlert(AlertType.Information, "Game Over", msg) @@ -52,6 +53,9 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer: case e: FiftyMoveRuleAvailableEvent => 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 => boardView.updateBoard(e.context.board, e.context.turn) boardView.showMessage(s"↶ Undo: ${e.pgnNotation}") diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala index 07a52cd..63cd302 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala @@ -50,6 +50,7 @@ class TerminalUI(engine: GameEngine) extends Observer: case DrawReason.Stalemate => "Stalemate! The game is a draw." case DrawReason.InsufficientMaterial => "Draw by insufficient material." case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule." + case DrawReason.ThreefoldRepetition => "Draw by threefold repetition." case DrawReason.Agreement => "Draw by agreement." println(msg) println() @@ -70,6 +71,9 @@ class TerminalUI(engine: GameEngine) extends Observer: case _: FiftyMoveRuleAvailableEvent => 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 => println("PGN loaded successfully.") println() diff --git a/test b/test old mode 100644 new mode 100755