feat: Implement move repetition handling for bots and update search logic to exclude repeated moves
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user