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.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.PolyglotBook 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.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
@@ -21,6 +21,8 @@ final class ClassicalBot(
override val name: String = s"ClassicalBot(${difficulty.toString})" override val name: String = s"ClassicalBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] = override def nextMove(context: GameContext): Option[Move] =
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book book
.flatMap(_.probe(context)) .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.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable} import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
import de.nowchess.bot.util.PolyglotBook 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.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
@@ -21,10 +21,11 @@ final class HybridBot(
override val name: String = s"HybridBot(${difficulty.toString})" override val name: String = s"HybridBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] = 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] = private def searchWithVeto(context: GameContext, blockedMoves: Set[Move]): Option[Move] =
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS).map { move => search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
val next = rules.applyMove(context)(move) val next = rules.applyMove(context)(move)
val staticNnue = EvaluationNNUE.evaluate(next) val staticNnue = EvaluationNNUE.evaluate(next)
val classical = EvaluationClassic.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.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.{PolyglotBook, ZobristHash} 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.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
@@ -20,15 +20,17 @@ final class NNUEBot(
override val name: String = s"NNUEBot(${difficulty.toString})" override val name: String = s"NNUEBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] = override def nextMove(context: GameContext): Option[Move] =
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book book
.flatMap(_.probe(context)) .flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse { .orElse {
val moves = rules.allLegalMoves(context) val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context))
if moves.isEmpty then None if moves.isEmpty then None
else else
val scored = batchEvaluateRoot(context, moves) val scored = batchEvaluateRoot(context, moves)
val bestMove = scored.maxBy(_._2)._1 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 /** Evaluate all root moves shallowly via incremental NNUE accumulator updates. Returns (move, score) pairs with score
@@ -35,6 +35,9 @@ final class AlphaBetaSearch(
* windows. * windows.
*/ */
def bestMove(context: GameContext, maxDepth: Int): Option[Move] = 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() tt.clear()
ordering.clear() ordering.clear()
weights.initAccumulator(context) weights.initAccumulator(context)
@@ -46,7 +49,15 @@ final class AlphaBetaSearch(
.foldLeft((None: Option[Move], 0)) { case ((bestSoFar, prevScore), depth) => .foldLeft((None: Option[Move], 0)) { case ((bestSoFar, prevScore), depth) =>
val (alpha, beta) = val (alpha, beta) =
if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA) 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) (move.orElse(bestSoFar), score)
} }
._1 ._1
@@ -55,6 +66,9 @@ final class AlphaBetaSearch(
* runs out. * runs out.
*/ */
def bestMoveWithTime(context: GameContext, timeBudgetMs: Long): Option[Move] = 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() tt.clear()
ordering.clear() ordering.clear()
weights.initAccumulator(context) weights.initAccumulator(context)
@@ -69,7 +83,15 @@ final class AlphaBetaSearch(
else else
val (alpha, beta) = val (alpha, beta) =
if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA) 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(move.orElse(bestSoFar), score, depth + 1)
loop(None, 0, 1) loop(None, 0, 1)
@@ -84,14 +106,15 @@ final class AlphaBetaSearch(
beta: Int, beta: Int,
initialWindow: Int, initialWindow: Int,
rootHash: Long, rootHash: Long,
excludedRootMoves: Set[Move],
): (Int, Option[Move]) = ): (Int, Option[Move]) =
val repetitions = Map(rootHash -> 1) val repetitions = Map(rootHash -> 1)
@scala.annotation.tailrec @scala.annotation.tailrec
def loop(currentAlpha: Int, currentBeta: Int, window: Int, attempt: Int): (Int, Option[Move]) = 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 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) if score > currentAlpha && score < currentBeta then (score, move)
else if score <= currentAlpha then else if score <= currentAlpha then
loop(score - window, currentBeta, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1) loop(score - window, currentBeta, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1)
@@ -115,6 +138,7 @@ final class AlphaBetaSearch(
ply: Int, ply: Int,
beta: Int, beta: Int,
repetitions: Map[Long, Int], repetitions: Map[Long, Int],
excludedRootMoves: Set[Move],
): Option[Int] = ): Option[Int] =
val nullCtx = nullMoveContext(context) val nullCtx = nullMoveContext(context)
val nullHash = ZobristHash.hash(nullCtx) val nullHash = ZobristHash.hash(nullCtx)
@@ -124,7 +148,7 @@ final class AlphaBetaSearch(
} }
val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R) val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R)
weights.copyAccumulator(ply, ply + 1) 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 if -score >= beta then Some(beta) else None
/** Negamax alpha-beta search returning (score, best move). */ /** Negamax alpha-beta search returning (score, best move). */
@@ -136,6 +160,7 @@ final class AlphaBetaSearch(
beta: Int, beta: Int,
hash: Long, hash: Long,
repetitions: Map[Long, Int], repetitions: Map[Long, Int],
excludedRootMoves: Set[Move],
): (Int, Option[Move]) = ): (Int, Option[Move]) =
val count = nodeCount.incrementAndGet() val count = nodeCount.incrementAndGet()
if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then (weights.evaluateAccumulator(ply, context, hash), None) if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then (weights.evaluateAccumulator(ply, context, hash), None)
@@ -160,13 +185,13 @@ final class AlphaBetaSearch(
else else
val nullResult = Option val nullResult = Option
.when(depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context)) { .when(depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context)) {
tryNullMove(context, depth, ply, beta, repetitions) tryNullMove(context, depth, ply, beta, repetitions, excludedRootMoves)
} }
.flatten .flatten
nullResult.map((_, None)).getOrElse { nullResult.map((_, None)).getOrElse {
val ttBest = tt.probe(hash).flatMap(_.bestMove) val ttBest = tt.probe(hash).flatMap(_.bestMove)
val ordered = MoveOrdering.sort(context, legalMoves, ttBest, ply, ordering) 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], ordered: List[Move],
hash: Long, hash: Long,
repetitions: Map[Long, Int], repetitions: Map[Long, Int],
excludedRootMoves: Set[Move],
): (Int, Option[Move]) = ): (Int, Option[Move]) =
@scala.annotation.tailrec @scala.annotation.tailrec
def loop( def loop(
@@ -191,13 +217,14 @@ final class AlphaBetaSearch(
if idx >= ordered.length then (bestMove, bestScore, false) if idx >= ordered.length then (bestMove, bestScore, false)
else else
val move = ordered(idx) val move = ordered(idx)
val skipRootMove = ply == 0 && excludedRootMoves.contains(move)
val isQuiet = !isCapture(context, move) && val isQuiet = !isCapture(context, move) &&
move.moveType != MoveType.CastleKingside && move.moveType != MoveType.CastleKingside &&
move.moveType != MoveType.CastleQueenside move.moveType != MoveType.CastleQueenside
val pruneByFutility = depth == 1 && isQuiet && moveNumber > 2 && val pruneByFutility = depth == 1 && isQuiet && moveNumber > 2 &&
weights.evaluateAccumulator(ply, context, hash) + FUTILITY_MARGIN < alpha 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 else
val child = rules.applyMove(context)(move) val child = rules.applyMove(context)(move)
val childHash = ZobristHash.nextHash(context, hash, move, child) val childHash = ZobristHash.nextHash(context, hash, move, child)
@@ -213,16 +240,16 @@ final class AlphaBetaSearch(
val score = val score =
if reduction > 0 then if reduction > 0 then
val reducedDepth = math.max(0, depth - 1 - reduction + extension) 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 val s = -reducedScore
if s > a then if s > a then
val fullDepth = math.max(0, depth - 1 + extension) 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 -fullScore
else s else s
else else
val fullDepth = math.max(0, depth - 1 + extension) 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 -rawScore
val newBestScore = math.max(bestScore, score) val newBestScore = math.max(bestScore, score)
@@ -26,18 +26,35 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
val move = search.bestMove(context, maxDepth = 1) val move = search.bestMove(context, maxDepth = 1)
move should not be None 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"): test("bestMove returns None for initial position has no legal moves"):
// Use a stub RuleSet that returns empty legal moves // Use a stub RuleSet that returns empty legal moves
val stubRules = new RuleSet: val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List() def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square) = List() def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext) = List() def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext) = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext) = true def isCheckmate(context: GameContext): Boolean = true
def isStalemate(context: GameContext) = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext) = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext) = false def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move) = context def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(stubRules, weights = EvaluationClassic) val search = AlphaBetaSearch(stubRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 2) val move = search.bestMove(GameContext.initial, maxDepth = 2)
@@ -76,15 +93,15 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
test("stalemate position returns score 0"): test("stalemate position returns score 0"):
// Create a stalemate stub: white to move, no legal moves, not checkmate // Create a stalemate stub: white to move, no legal moves, not checkmate
val stalematRules = new RuleSet: val stalematRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List() def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square) = List() def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext) = List() def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext) = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext) = false def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext) = true def isStalemate(context: GameContext): Boolean = true
def isInsufficientMaterial(context: GameContext) = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext) = false def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move) = context def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(stalematRules, weights = EvaluationClassic) val search = AlphaBetaSearch(stalematRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1) val move = search.bestMove(GameContext.initial, maxDepth = 1)
@@ -92,15 +109,15 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
test("insufficient material returns score 0"): test("insufficient material returns score 0"):
val insufficientRules = new RuleSet: val insufficientRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List() def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square) = List() def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext) = List() def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext) = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext) = false def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext) = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext) = true def isInsufficientMaterial(context: GameContext): Boolean = true
def isFiftyMoveRule(context: GameContext) = false def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move) = context def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(insufficientRules, weights = EvaluationClassic) val search = AlphaBetaSearch(insufficientRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1) 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"): test("fifty move rule returns score 0"):
val fiftyMoveRules = new RuleSet: val fiftyMoveRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List() def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square) = List() def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext) = List() def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext) = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext) = false def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext) = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext) = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext) = true def isFiftyMoveRule(context: GameContext): Boolean = true
def applyMove(context: GameContext)(move: Move) = context def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(fiftyMoveRules, weights = EvaluationClassic) val search = AlphaBetaSearch(fiftyMoveRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1) 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 captureMove = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
val rulesWithCapture = new RuleSet: val rulesWithCapture = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List() def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square) = List() def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext) = List(captureMove) def allLegalMoves(context: GameContext): List[Move] = List(captureMove)
def isCheck(context: GameContext) = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext) = false def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext) = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext) = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext) = false def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move) = context def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(rulesWithCapture, weights = EvaluationClassic) val search = AlphaBetaSearch(rulesWithCapture, weights = EvaluationClassic)
val move = search.bestMove(context, maxDepth = 1) val move = search.bestMove(context, maxDepth = 1)
move should be(Some(captureMove)) move should be(Some(captureMove))
test("non-capture moves are not included in quiescence"): 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: val rulesQuiet = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List() def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square) = List() def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext) = List(quietMove) def allLegalMoves(context: GameContext): List[Move] = List(quietMove)
def isCheck(context: GameContext) = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext) = false def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext) = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext) = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext) = false def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move) = context def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(rulesQuiet, weights = EvaluationClassic) val search = AlphaBetaSearch(rulesQuiet, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1) 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.board.Square
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.api.move.MoveType
import de.nowchess.bot.bots.ClassicalBot import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
import org.scalatest.funsuite.AnyFunSuite 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"): test("nextMove returns None for position with no legal moves"):
val stubRules = new RuleSet: val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List() def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square) = List() def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext) = List() def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext) = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext) = true def isCheckmate(context: GameContext): Boolean = true
def isStalemate(context: GameContext) = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext) = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext) = false def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move) = context def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules) val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial) val move = bot.nextMove(GameContext.initial)
@@ -56,16 +57,40 @@ class ClassicalBotTest extends AnyFunSuite with Matchers:
) )
val stubRules = new RuleSet: val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List() def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square) = List() def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext) = List(moveToReturn) def allLegalMoves(context: GameContext): List[Move] = List(moveToReturn)
def isCheck(context: GameContext) = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext) = false def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext) = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext) = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext) = false def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move) = context def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules) val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial) val move = bot.nextMove(GameContext.initial)
move should be(Some(moveToReturn)) 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)