feat: Implement move repetition handling for bots and update search logic to exclude repeated moves

This commit is contained in:
2026-04-13 22:32:23 +02:00
parent d6758ed8ec
commit 25b0c9f2fb
7 changed files with 187 additions and 93 deletions
@@ -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)
@@ -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))
@@ -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)
@@ -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
@@ -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)
@@ -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)
@@ -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)