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.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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user