feat: Implement threefold repetition detection and update game rules
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-16 21:00:32 +02:00
parent 5aa1691b32
commit f8d2858d98
21 changed files with 137 additions and 21633 deletions
@@ -19,20 +19,17 @@ class GameEngine(
val ruleSet: RuleSet = DefaultRules,
val participants: Map[Color, Participant] = Map(Color.White -> Human, Color.Black -> Human),
) 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). */
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
@SuppressWarnings(Array("DisableSyntax.var"))
private var pendingPromotion: Option[PendingPromotion] = None
private implicit val ec: ExecutionContext = ExecutionContext.global
/** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
// Synchronized accessors for current state
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
@@ -67,11 +64,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.",
),
)
@@ -87,11 +88,11 @@ class GameEngine(
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
),
)
case Some((from, to)) =>
handleParsedMove(from, to)
case Some((from, to, promotionPiece: Option[PromotionPiece])) =>
handleParsedMove(from, to, promotionPiece)
}
private def handleParsedMove(from: Square, to: Square): Unit =
private def handleParsedMove(from: Square, to: Square, promotionPiece: Option[PromotionPiece]): Unit =
currentContext.board.pieceAt(from) match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
@@ -104,11 +105,13 @@ class GameEngine(
candidates match
case Nil =>
notifyObservers(InvalidMoveEvent(currentContext, "Illegal move."))
case moves if isPromotionMove(piece, to) =>
// Multiple moves (one per promotion piece) — ask user to choose
val contextBefore = currentContext
pendingPromotion = Some(PendingPromotion(from, to, contextBefore))
notifyObservers(PromotionRequiredEvent(currentContext, from, to))
case _ if isPromotionMove(piece, to) =>
if promotionPiece.isEmpty then
notifyObservers(InvalidMoveEvent(currentContext, "Promotion piece required: append q, r, b, or n to the move."))
else
candidates.find(_.moveType == MoveType.Promotion(promotionPiece.get)) match
case None => notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion: no matching legal move."))
case Some(move) => executeMove(move)
case move :: _ =>
executeMove(move)
@@ -118,21 +121,6 @@ class GameEngine(
to.rank.ordinal == promoRank
}
/** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true.
*/
def completePromotion(piece: PromotionPiece): Unit = synchronized {
pendingPromotion match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
case Some(pending) =>
pendingPromotion = None
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
// Verify it's actually legal
val legal = ruleSet.legalMoves(currentContext)(pending.from)
if legal.contains(move) then executeMove(move)
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
}
/** Undo the last move. */
def undo(): Unit = synchronized(performUndo())
@@ -154,11 +142,10 @@ class GameEngine(
private def replayGame(ctx: GameContext): Either[String, Unit] =
val savedContext = currentContext
currentContext = GameContext.initial
pendingPromotion = None
invoker.clear()
if ctx.moves.isEmpty then
currentContext = ctx
currentContext = ctx.copy(initialBoard = ctx.board)
Right(())
else replayMoves(ctx.moves, savedContext)
@@ -170,14 +157,13 @@ class GameEngine(
result
private def applyReplayMove(move: Move): Either[String, Unit] =
handleParsedMove(move.from, move.to)
move.moveType match
case MoveType.Promotion(pp) if pendingPromotion.isDefined =>
completePromotion(pp)
Right(())
case MoveType.Promotion(_) =>
Left(s"Promotion required for move ${move.from}${move.to}")
case _ => Right(())
val legal = ruleSet.legalMoves(currentContext)(move.from)
val candidate = move.moveType match
case MoveType.Promotion(pp) => legal.find(m => m.to == move.to && m.moveType == MoveType.Promotion(pp))
case _ => legal.find(_.to == move.to)
candidate match
case None => Left("Illegal move.")
case Some(lm) => executeMove(lm); Right(())
/** Export the current game context using the provided exporter. */
def exportGame(exporter: GameContextExport): String = synchronized {
@@ -186,7 +172,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
invoker.clear()
notifyObservers(BoardResetEvent(currentContext))
}
@@ -243,10 +233,7 @@ class GameEngine(
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
// Request bot move if it's the opponent bot's turn
if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then
() // Game is over, don't request bot move
if ruleSet.isThreefoldRepetition(currentContext) then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
else requestBotMoveIfNeeded()
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
@@ -336,7 +323,7 @@ class GameEngine(
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
notifyObservers(CheckmateEvent(currentContext, winner))
else if ruleSet.isStalemate(currentContext) then notifyObservers(StalemateEvent(currentContext))
else if ruleSet.isStalemate(currentContext) then notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
}
private def performUndo(): Unit =