From 25b0c9f2fb0e93c44005af7a42f5421b4f0d20a4 Mon Sep 17 00:00:00 2001 From: Janis Date: Mon, 13 Apr 2026 22:32:23 +0200 Subject: [PATCH] feat: Implement move repetition handling for bots and update search logic to exclude repeated moves --- .../de/nowchess/bot/BotMoveRepetition.scala | 20 +++ .../de/nowchess/bot/bots/ClassicalBot.scala | 6 +- .../de/nowchess/bot/bots/HybridBot.scala | 9 +- .../scala/de/nowchess/bot/bots/NNUEBot.scala | 8 +- .../nowchess/bot/logic/AlphaBetaSearch.scala | 49 +++++-- .../de/nowchess/bot/AlphaBetaSearchTest.scala | 127 ++++++++++-------- .../de/nowchess/bot/ClassicalBotTest.scala | 61 ++++++--- 7 files changed, 187 insertions(+), 93 deletions(-) create mode 100644 modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala diff --git a/modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala b/modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala new file mode 100644 index 0000000..f50e8f5 --- /dev/null +++ b/modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala @@ -0,0 +1,20 @@ +package de.nowchess.bot + +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move + +object BotMoveRepetition: + + private val maxConsecutiveMoves = 3 + + def blockedMoves(context: GameContext): Set[Move] = repeatedMove(context).toSet + + def repeatedMove(context: GameContext): Option[Move] = + context.moves.takeRight(maxConsecutiveMoves) match + case first :: second :: third :: Nil if first == second && second == third => Some(first) + case _ => None + + def filterAllowed(context: GameContext, moves: List[Move]): List[Move] = + val blocked = blockedMoves(context) + moves.filterNot(blocked.contains) + diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala index 835d0a5..a91a497 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala @@ -5,7 +5,7 @@ import de.nowchess.api.move.Move import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.util.PolyglotBook -import de.nowchess.bot.{Bot, BotDifficulty} +import de.nowchess.bot.{Bot, BotDifficulty, BotMoveRepetition} import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules @@ -21,6 +21,8 @@ final class ClassicalBot( override val name: String = s"ClassicalBot(${difficulty.toString})" override def nextMove(context: GameContext): Option[Move] = + val blockedMoves = BotMoveRepetition.blockedMoves(context) book .flatMap(_.probe(context)) - .orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS)) + .filterNot(blockedMoves.contains) + .orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS, blockedMoves)) diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala index 8ef6507..0161470 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala @@ -6,7 +6,7 @@ import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable} import de.nowchess.bot.util.PolyglotBook -import de.nowchess.bot.{Bot, BotDifficulty, Config} +import de.nowchess.bot.{Bot, BotDifficulty, BotMoveRepetition, Config} import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules @@ -21,10 +21,11 @@ final class HybridBot( override val name: String = s"HybridBot(${difficulty.toString})" override def nextMove(context: GameContext): Option[Move] = - book.flatMap(_.probe(context)).orElse(searchWithVeto(context)) + val blockedMoves = BotMoveRepetition.blockedMoves(context) + book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse(searchWithVeto(context, blockedMoves)) - private def searchWithVeto(context: GameContext): Option[Move] = - search.bestMoveWithTime(context, Config.TIME_LIMIT_MS).map { move => + private def searchWithVeto(context: GameContext, blockedMoves: Set[Move]): Option[Move] = + search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move => val next = rules.applyMove(context)(move) val staticNnue = EvaluationNNUE.evaluate(next) val classical = EvaluationClassic.evaluate(next) diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala index 1906a7a..04cc85c 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala @@ -5,7 +5,7 @@ import de.nowchess.api.move.Move import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.util.{PolyglotBook, ZobristHash} -import de.nowchess.bot.{Bot, BotDifficulty} +import de.nowchess.bot.{Bot, BotDifficulty, BotMoveRepetition} import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules @@ -20,15 +20,17 @@ final class NNUEBot( override val name: String = s"NNUEBot(${difficulty.toString})" override def nextMove(context: GameContext): Option[Move] = + val blockedMoves = BotMoveRepetition.blockedMoves(context) book .flatMap(_.probe(context)) + .filterNot(blockedMoves.contains) .orElse { - val moves = rules.allLegalMoves(context) + val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context)) if moves.isEmpty then None else val scored = batchEvaluateRoot(context, moves) val bestMove = scored.maxBy(_._2)._1 - search.bestMoveWithTime(context, allocateTime(scored)).orElse(Some(bestMove)) + search.bestMoveWithTime(context, allocateTime(scored), blockedMoves).orElse(Some(bestMove)) } /** Evaluate all root moves shallowly via incremental NNUE accumulator updates. Returns (move, score) pairs with score diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala index 216c313..bc0360c 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala @@ -35,6 +35,9 @@ final class AlphaBetaSearch( * windows. */ def bestMove(context: GameContext, maxDepth: Int): Option[Move] = + bestMove(context, maxDepth, Set.empty) + + def bestMove(context: GameContext, maxDepth: Int, excludedRootMoves: Set[Move]): Option[Move] = tt.clear() ordering.clear() weights.initAccumulator(context) @@ -46,7 +49,15 @@ final class AlphaBetaSearch( .foldLeft((None: Option[Move], 0)) { case ((bestSoFar, prevScore), depth) => val (alpha, beta) = if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA) - val (score, move) = searchWithAspiration(context, depth, alpha, beta, ASPIRATION_DELTA, rootHash) + val (score, move) = searchWithAspiration( + context, + depth, + alpha, + beta, + ASPIRATION_DELTA, + rootHash, + excludedRootMoves, + ) (move.orElse(bestSoFar), score) } ._1 @@ -55,6 +66,9 @@ final class AlphaBetaSearch( * runs out. */ def bestMoveWithTime(context: GameContext, timeBudgetMs: Long): Option[Move] = + bestMoveWithTime(context, timeBudgetMs, Set.empty) + + def bestMoveWithTime(context: GameContext, timeBudgetMs: Long, excludedRootMoves: Set[Move]): Option[Move] = tt.clear() ordering.clear() weights.initAccumulator(context) @@ -69,7 +83,15 @@ final class AlphaBetaSearch( else val (alpha, beta) = if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA) - val (score, move) = searchWithAspiration(context, depth, alpha, beta, ASPIRATION_DELTA, rootHash) + val (score, move) = searchWithAspiration( + context, + depth, + alpha, + beta, + ASPIRATION_DELTA, + rootHash, + excludedRootMoves, + ) loop(move.orElse(bestSoFar), score, depth + 1) loop(None, 0, 1) @@ -84,14 +106,15 @@ final class AlphaBetaSearch( beta: Int, initialWindow: Int, rootHash: Long, + excludedRootMoves: Set[Move], ): (Int, Option[Move]) = val repetitions = Map(rootHash -> 1) @scala.annotation.tailrec def loop(currentAlpha: Int, currentBeta: Int, window: Int, attempt: Int): (Int, Option[Move]) = - if attempt >= 3 || attempt >= depth then search(context, depth, 0, -INF, INF, rootHash, repetitions) + if attempt >= 3 || attempt >= depth then search(context, depth, 0, -INF, INF, rootHash, repetitions, excludedRootMoves) else - val (score, move) = search(context, depth, 0, currentAlpha, currentBeta, rootHash, repetitions) + val (score, move) = search(context, depth, 0, currentAlpha, currentBeta, rootHash, repetitions, excludedRootMoves) if score > currentAlpha && score < currentBeta then (score, move) else if score <= currentAlpha then loop(score - window, currentBeta, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1) @@ -115,6 +138,7 @@ final class AlphaBetaSearch( ply: Int, beta: Int, repetitions: Map[Long, Int], + excludedRootMoves: Set[Move], ): Option[Int] = val nullCtx = nullMoveContext(context) val nullHash = ZobristHash.hash(nullCtx) @@ -124,7 +148,7 @@ final class AlphaBetaSearch( } val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R) weights.copyAccumulator(ply, ply + 1) - val (score, _) = search(nullCtx, reductionDepth, ply + 1, -beta, -beta + 1, nullHash, nullRepetitions) + val (score, _) = search(nullCtx, reductionDepth, ply + 1, -beta, -beta + 1, nullHash, nullRepetitions, excludedRootMoves) if -score >= beta then Some(beta) else None /** Negamax alpha-beta search returning (score, best move). */ @@ -136,6 +160,7 @@ final class AlphaBetaSearch( beta: Int, hash: Long, repetitions: Map[Long, Int], + excludedRootMoves: Set[Move], ): (Int, Option[Move]) = val count = nodeCount.incrementAndGet() if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then (weights.evaluateAccumulator(ply, context, hash), None) @@ -160,13 +185,13 @@ final class AlphaBetaSearch( else val nullResult = Option .when(depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context)) { - tryNullMove(context, depth, ply, beta, repetitions) + tryNullMove(context, depth, ply, beta, repetitions, excludedRootMoves) } .flatten nullResult.map((_, None)).getOrElse { val ttBest = tt.probe(hash).flatMap(_.bestMove) val ordered = MoveOrdering.sort(context, legalMoves, ttBest, ply, ordering) - searchSequential(context, depth, ply, alpha, beta, ordered, hash, repetitions) + searchSequential(context, depth, ply, alpha, beta, ordered, hash, repetitions, excludedRootMoves) } } @@ -179,6 +204,7 @@ final class AlphaBetaSearch( ordered: List[Move], hash: Long, repetitions: Map[Long, Int], + excludedRootMoves: Set[Move], ): (Int, Option[Move]) = @scala.annotation.tailrec def loop( @@ -191,13 +217,14 @@ final class AlphaBetaSearch( if idx >= ordered.length then (bestMove, bestScore, false) else val move = ordered(idx) + val skipRootMove = ply == 0 && excludedRootMoves.contains(move) val isQuiet = !isCapture(context, move) && move.moveType != MoveType.CastleKingside && move.moveType != MoveType.CastleQueenside val pruneByFutility = depth == 1 && isQuiet && moveNumber > 2 && weights.evaluateAccumulator(ply, context, hash) + FUTILITY_MARGIN < alpha - if pruneByFutility then loop(idx + 1, bestMove, bestScore, a, moveNumber + 1) + if skipRootMove || pruneByFutility then loop(idx + 1, bestMove, bestScore, a, moveNumber + 1) else val child = rules.applyMove(context)(move) val childHash = ZobristHash.nextHash(context, hash, move, child) @@ -213,16 +240,16 @@ final class AlphaBetaSearch( val score = if reduction > 0 then val reducedDepth = math.max(0, depth - 1 - reduction + extension) - val (reducedScore, _) = search(child, reducedDepth, ply + 1, -a - 1, -a, childHash, childRepetitions) + val (reducedScore, _) = search(child, reducedDepth, ply + 1, -a - 1, -a, childHash, childRepetitions, excludedRootMoves) val s = -reducedScore if s > a then val fullDepth = math.max(0, depth - 1 + extension) - val (fullScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childHash, childRepetitions) + val (fullScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childHash, childRepetitions, excludedRootMoves) -fullScore else s else val fullDepth = math.max(0, depth - 1 + extension) - val (rawScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childHash, childRepetitions) + val (rawScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childHash, childRepetitions, excludedRootMoves) -rawScore val newBestScore = math.max(bestScore, score) diff --git a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala index 22abc03..7c51412 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala @@ -26,18 +26,35 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers: val move = search.bestMove(context, maxDepth = 1) move should not be None + test("bestMoveWithTime skips excluded root moves"): + val blockedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()) + val stubRules = new RuleSet: + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = List(blockedMove) + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context + + val search = AlphaBetaSearch(stubRules, weights = EvaluationClassic) + val move = search.bestMoveWithTime(GameContext.initial, 1000L, Set(blockedMove)) + move should be(None) + test("bestMove returns None for initial position has no legal moves"): // Use a stub RuleSet that returns empty legal moves val stubRules = new RuleSet: - def candidateMoves(context: GameContext)(square: Square) = List() - def legalMoves(context: GameContext)(square: Square) = List() - def allLegalMoves(context: GameContext) = List() - def isCheck(context: GameContext) = false - def isCheckmate(context: GameContext) = true - def isStalemate(context: GameContext) = false - def isInsufficientMaterial(context: GameContext) = false - def isFiftyMoveRule(context: GameContext) = false - def applyMove(context: GameContext)(move: Move) = context + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = Nil + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = true + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context val search = AlphaBetaSearch(stubRules, weights = EvaluationClassic) val move = search.bestMove(GameContext.initial, maxDepth = 2) @@ -76,15 +93,15 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers: test("stalemate position returns score 0"): // Create a stalemate stub: white to move, no legal moves, not checkmate val stalematRules = new RuleSet: - def candidateMoves(context: GameContext)(square: Square) = List() - def legalMoves(context: GameContext)(square: Square) = List() - def allLegalMoves(context: GameContext) = List() - def isCheck(context: GameContext) = false - def isCheckmate(context: GameContext) = false - def isStalemate(context: GameContext) = true - def isInsufficientMaterial(context: GameContext) = false - def isFiftyMoveRule(context: GameContext) = false - def applyMove(context: GameContext)(move: Move) = context + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = Nil + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = true + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context val search = AlphaBetaSearch(stalematRules, weights = EvaluationClassic) val move = search.bestMove(GameContext.initial, maxDepth = 1) @@ -92,15 +109,15 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers: test("insufficient material returns score 0"): val insufficientRules = new RuleSet: - def candidateMoves(context: GameContext)(square: Square) = List() - def legalMoves(context: GameContext)(square: Square) = List() - def allLegalMoves(context: GameContext) = List() - def isCheck(context: GameContext) = false - def isCheckmate(context: GameContext) = false - def isStalemate(context: GameContext) = false - def isInsufficientMaterial(context: GameContext) = true - def isFiftyMoveRule(context: GameContext) = false - def applyMove(context: GameContext)(move: Move) = context + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = Nil + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = true + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context val search = AlphaBetaSearch(insufficientRules, weights = EvaluationClassic) val move = search.bestMove(GameContext.initial, maxDepth = 1) @@ -108,15 +125,15 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers: test("fifty move rule returns score 0"): val fiftyMoveRules = new RuleSet: - def candidateMoves(context: GameContext)(square: Square) = List() - def legalMoves(context: GameContext)(square: Square) = List() - def allLegalMoves(context: GameContext) = List() - def isCheck(context: GameContext) = false - def isCheckmate(context: GameContext) = false - def isStalemate(context: GameContext) = false - def isInsufficientMaterial(context: GameContext) = false - def isFiftyMoveRule(context: GameContext) = true - def applyMove(context: GameContext)(move: Move) = context + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = Nil + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = true + def applyMove(context: GameContext)(move: Move): GameContext = context val search = AlphaBetaSearch(fiftyMoveRules, weights = EvaluationClassic) val move = search.bestMove(GameContext.initial, maxDepth = 1) @@ -134,32 +151,32 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers: val captureMove = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true)) val rulesWithCapture = new RuleSet: - def candidateMoves(context: GameContext)(square: Square) = List() - def legalMoves(context: GameContext)(square: Square) = List() - def allLegalMoves(context: GameContext) = List(captureMove) - def isCheck(context: GameContext) = false - def isCheckmate(context: GameContext) = false - def isStalemate(context: GameContext) = false - def isInsufficientMaterial(context: GameContext) = false - def isFiftyMoveRule(context: GameContext) = false - def applyMove(context: GameContext)(move: Move) = context + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = List(captureMove) + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context val search = AlphaBetaSearch(rulesWithCapture, weights = EvaluationClassic) val move = search.bestMove(context, maxDepth = 1) move should be(Some(captureMove)) test("non-capture moves are not included in quiescence"): - val quietMove = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(false)) + val quietMove = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal()) val rulesQuiet = new RuleSet: - def candidateMoves(context: GameContext)(square: Square) = List() - def legalMoves(context: GameContext)(square: Square) = List() - def allLegalMoves(context: GameContext) = List(quietMove) - def isCheck(context: GameContext) = false - def isCheckmate(context: GameContext) = false - def isStalemate(context: GameContext) = false - def isInsufficientMaterial(context: GameContext) = false - def isFiftyMoveRule(context: GameContext) = false - def applyMove(context: GameContext)(move: Move) = context + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = List(quietMove) + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context val search = AlphaBetaSearch(rulesQuiet, weights = EvaluationClassic) val move = search.bestMove(GameContext.initial, maxDepth = 1) diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala index e00099c..a82a4e4 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala @@ -3,6 +3,7 @@ package de.nowchess.bot import de.nowchess.api.board.Square import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move +import de.nowchess.api.move.MoveType import de.nowchess.bot.bots.ClassicalBot import de.nowchess.rules.RuleSet import org.scalatest.funsuite.AnyFunSuite @@ -26,15 +27,15 @@ class ClassicalBotTest extends AnyFunSuite with Matchers: test("nextMove returns None for position with no legal moves"): val stubRules = new RuleSet: - def candidateMoves(context: GameContext)(square: Square) = List() - def legalMoves(context: GameContext)(square: Square) = List() - def allLegalMoves(context: GameContext) = List() - def isCheck(context: GameContext) = false - def isCheckmate(context: GameContext) = true - def isStalemate(context: GameContext) = false - def isInsufficientMaterial(context: GameContext) = false - def isFiftyMoveRule(context: GameContext) = false - def applyMove(context: GameContext)(move: Move) = context + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = Nil + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = true + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context val bot = ClassicalBot(BotDifficulty.Easy, stubRules) val move = bot.nextMove(GameContext.initial) @@ -56,16 +57,40 @@ class ClassicalBotTest extends AnyFunSuite with Matchers: ) val stubRules = new RuleSet: - def candidateMoves(context: GameContext)(square: Square) = List() - def legalMoves(context: GameContext)(square: Square) = List() - def allLegalMoves(context: GameContext) = List(moveToReturn) - def isCheck(context: GameContext) = false - def isCheckmate(context: GameContext) = false - def isStalemate(context: GameContext) = false - def isInsufficientMaterial(context: GameContext) = false - def isFiftyMoveRule(context: GameContext) = false - def applyMove(context: GameContext)(move: Move) = context + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = List(moveToReturn) + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context val bot = ClassicalBot(BotDifficulty.Easy, stubRules) val move = bot.nextMove(GameContext.initial) move should be(Some(moveToReturn)) + + test("nextMove skips a move repeated three times in a row"): + val repeatedMove = 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), + MoveType.Normal(), + ) + + val stubRules = new RuleSet: + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = List(repeatedMove) + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context + + val context = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove)) + val bot = ClassicalBot(BotDifficulty.Easy, stubRules) + + bot.nextMove(context) should be(None) +