feat: Refactor alpha-beta search to improve search parameters and quiescence handling
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -51,6 +51,7 @@ val versions = mapOf(
|
|||||||
"SCALAFX" to "21.0.0-R32",
|
"SCALAFX" to "21.0.0-R32",
|
||||||
"JAVAFX" to "21.0.1",
|
"JAVAFX" to "21.0.1",
|
||||||
"JUNIT_BOM" to "5.13.4",
|
"JUNIT_BOM" to "5.13.4",
|
||||||
|
"ONNXRUNTIME" to "1.19.2",
|
||||||
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
||||||
"FASTPARSE" to "3.0.2",
|
"FASTPARSE" to "3.0.2",
|
||||||
"JACKSON" to "2.17.2",
|
"JACKSON" to "2.17.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("scala")
|
id("scala")
|
||||||
id("org.scoverage") version "8.1"
|
id("org.scoverage")
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "de.nowchess"
|
group = "de.nowchess"
|
||||||
@@ -19,6 +19,23 @@ scala {
|
|||||||
|
|
||||||
scoverage {
|
scoverage {
|
||||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||||
|
excludedPackages.set(
|
||||||
|
listOf(
|
||||||
|
"de\\.nowchess\\.bot\\.bots\\.NNUEBot",
|
||||||
|
"de\\.nowchess\\.bot\\.bots\\.nnue\\..*",
|
||||||
|
"de\\.nowchess\\.bot\\.util\\.PolyglotBook",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
excludedFiles.set(
|
||||||
|
listOf(
|
||||||
|
".*NNUE\\.scala",
|
||||||
|
".*NNUEBot\\.scala",
|
||||||
|
".*NbaiLoader\\.scala",
|
||||||
|
".*NbaiMigrator\\.scala",
|
||||||
|
".*NbaiWriter\\.scala",
|
||||||
|
".*PolyglotBook\\.scala",
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<ScalaCompile> {
|
tasks.withType<ScalaCompile> {
|
||||||
@@ -41,9 +58,9 @@ dependencies {
|
|||||||
implementation(project(":modules:api"))
|
implementation(project(":modules:api"))
|
||||||
implementation(project(":modules:io"))
|
implementation(project(":modules:io"))
|
||||||
implementation(project(":modules:rule"))
|
implementation(project(":modules:rule"))
|
||||||
implementation("com.microsoft.onnxruntime:onnxruntime:1.19.2")
|
implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
|
||||||
|
|
||||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||||
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.nowchess.bot.bots
|
|||||||
import de.nowchess.api.bot.Bot
|
import de.nowchess.api.bot.Bot
|
||||||
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.bot.ai.Evaluation
|
||||||
import de.nowchess.bot.bots.classic.EvaluationClassic
|
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}
|
||||||
@@ -15,9 +16,12 @@ final class HybridBot(
|
|||||||
difficulty: BotDifficulty,
|
difficulty: BotDifficulty,
|
||||||
rules: RuleSet = DefaultRules,
|
rules: RuleSet = DefaultRules,
|
||||||
book: Option[PolyglotBook] = None,
|
book: Option[PolyglotBook] = None,
|
||||||
|
nnueEvaluation: Evaluation = EvaluationNNUE,
|
||||||
|
classicalEvaluation: Evaluation = EvaluationClassic,
|
||||||
|
vetoReporter: String => Unit = println(_),
|
||||||
) extends Bot:
|
) extends Bot:
|
||||||
|
|
||||||
private val search = AlphaBetaSearch(rules, TranspositionTable(), EvaluationClassic)
|
private val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
|
||||||
|
|
||||||
override val name: String = s"HybridBot(${difficulty.toString})"
|
override val name: String = s"HybridBot(${difficulty.toString})"
|
||||||
|
|
||||||
@@ -28,11 +32,11 @@ final class HybridBot(
|
|||||||
private def searchWithVeto(context: GameContext, blockedMoves: Set[Move]): Option[Move] =
|
private def searchWithVeto(context: GameContext, blockedMoves: Set[Move]): Option[Move] =
|
||||||
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).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 = nnueEvaluation.evaluate(next)
|
||||||
val classical = EvaluationClassic.evaluate(next)
|
val classical = classicalEvaluation.evaluate(next)
|
||||||
val diff = (classical - staticNnue).abs
|
val diff = (classical - staticNnue).abs
|
||||||
if diff > Config.VETO_THRESHOLD then
|
if diff > Config.VETO_THRESHOLD then
|
||||||
println(
|
vetoReporter(
|
||||||
f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
|
f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
|
||||||
)
|
)
|
||||||
move
|
move
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ object EvaluationClassic extends Evaluation:
|
|||||||
(mg + doubled * doubledMg + isolated * isolatedMg, eg + doubled * doubledEg + isolated * isolatedEg)
|
(mg + doubled * doubledMg + isolated * isolatedMg, eg + doubled * doubledEg + isolated * isolatedEg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def positionalBonuses(context: GameContext, phase: Int, isEg: Boolean = false): Int =
|
private def positionalBonuses(context: GameContext, phase: Int, isEg: Boolean): Int =
|
||||||
context.board.pieces.foldLeft(0) { case (score, (sq, piece)) =>
|
context.board.pieces.foldLeft(0) { case (score, (sq, piece)) =>
|
||||||
val bonus = piece.pieceType match
|
val bonus = piece.pieceType match
|
||||||
case PieceType.Pawn =>
|
case PieceType.Pawn =>
|
||||||
@@ -281,30 +281,44 @@ object EvaluationClassic extends Evaluation:
|
|||||||
taper(mg, eg, phase)
|
taper(mg, eg, phase)
|
||||||
|
|
||||||
private def rookAndBishopBonuses(context: GameContext, phase: Int): Int =
|
private def rookAndBishopBonuses(context: GameContext, phase: Int): Int =
|
||||||
val friendlyBishops =
|
val (baseMg, baseEg) = bishopPairBase(context)
|
||||||
context.board.pieces.filter((_, p) => p.color == context.turn && p.pieceType == PieceType.Bishop)
|
val (rookMg, rookEg) = rookOn7thDelta(context)
|
||||||
val enemyBishops = context.board.pieces.filter((_, p) => p.color != context.turn && p.pieceType == PieceType.Bishop)
|
taper(baseMg + rookMg, baseEg + rookEg, phase)
|
||||||
|
|
||||||
val friendlyHasPair =
|
private def bishopPairBase(context: GameContext): (Int, Int) =
|
||||||
friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) &&
|
val friendlyHasPair = hasBishopPair(context, context.turn)
|
||||||
friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1)
|
val enemyHasPair = hasBishopPair(context, context.turn.opposite)
|
||||||
val enemyHasPair =
|
val mg = pairDelta(friendlyHasPair, enemyHasPair, bishopPairMg)
|
||||||
enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) &&
|
val eg = pairDelta(friendlyHasPair, enemyHasPair, bishopPairEg)
|
||||||
enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1)
|
(mg, eg)
|
||||||
|
|
||||||
val baseMg = (if friendlyHasPair then bishopPairMg else 0) - (if enemyHasPair then bishopPairMg else 0)
|
private def hasBishopPair(context: GameContext, color: Color): Boolean =
|
||||||
val baseEg = (if friendlyHasPair then bishopPairEg else 0) - (if enemyHasPair then bishopPairEg else 0)
|
val bishopSquares = context.board.pieces.collect {
|
||||||
|
case (sq, piece) if piece.color == color && piece.pieceType == PieceType.Bishop => sq
|
||||||
val (mg, eg) = context.board.pieces.foldLeft((baseMg, baseEg)) { case ((mg, eg), (sq, piece)) =>
|
|
||||||
if piece.pieceType == PieceType.Rook then
|
|
||||||
val is7th = if piece.color == Color.White then sq.rank.ordinal == 6 else sq.rank.ordinal == 1
|
|
||||||
if is7th then
|
|
||||||
if piece.color == context.turn then (mg + rookOn7thMg, eg + rookOn7thEg)
|
|
||||||
else (mg - rookOn7thMg, eg - rookOn7thEg)
|
|
||||||
else (mg, eg)
|
|
||||||
else (mg, eg)
|
|
||||||
}
|
}
|
||||||
taper(mg, eg, phase)
|
bishopSquares.exists(isEvenSquare) && bishopSquares.exists(sq => !isEvenSquare(sq))
|
||||||
|
|
||||||
|
private def isEvenSquare(square: Square): Boolean =
|
||||||
|
(square.file.ordinal + square.rank.ordinal) % 2 == 0
|
||||||
|
|
||||||
|
private def pairDelta(friendlyHasPair: Boolean, enemyHasPair: Boolean, bonus: Int): Int =
|
||||||
|
(if friendlyHasPair then bonus else 0) - (if enemyHasPair then bonus else 0)
|
||||||
|
|
||||||
|
private def rookOn7thDelta(context: GameContext): (Int, Int) =
|
||||||
|
context.board.pieces.foldLeft((0, 0)) { case ((mg, eg), (sq, piece)) =>
|
||||||
|
rookOn7thContribution(piece, sq, context.turn).fold((mg, eg)) { case (dMg, dEg) =>
|
||||||
|
(mg + dMg, eg + dEg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def rookOn7thContribution(piece: de.nowchess.api.board.Piece, sq: Square, turn: Color): Option[(Int, Int)] =
|
||||||
|
Option.when(piece.pieceType == PieceType.Rook && isRookOn7th(piece.color, sq)) {
|
||||||
|
val sign = if piece.color == turn then 1 else -1
|
||||||
|
(sign * rookOn7thMg, sign * rookOn7thEg)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def isRookOn7th(color: Color, sq: Square): Boolean =
|
||||||
|
if color == Color.White then sq.rank.ordinal == 6 else sq.rank.ordinal == 1
|
||||||
|
|
||||||
private def endgameBonus(context: GameContext): Int =
|
private def endgameBonus(context: GameContext): Int =
|
||||||
val friendlyKing = context.board.pieces.find((_, p) => p.color == context.turn && p.pieceType == PieceType.King)
|
val friendlyKing = context.board.pieces.find((_, p) => p.color == context.turn && p.pieceType == PieceType.King)
|
||||||
@@ -334,13 +348,14 @@ object EvaluationClassic extends Evaluation:
|
|||||||
|
|
||||||
private def materialCount(context: GameContext, color: Color): Int =
|
private def materialCount(context: GameContext, color: Color): Int =
|
||||||
context.board.pieces.foldLeft(0) { case (sum, (_, piece)) =>
|
context.board.pieces.foldLeft(0) { case (sum, (_, piece)) =>
|
||||||
if piece.color == color && piece.pieceType != PieceType.King && piece.pieceType != PieceType.Pawn then
|
if piece.color == color then
|
||||||
sum + (piece.pieceType match
|
sum + (piece.pieceType match
|
||||||
case PieceType.Knight => 300
|
case PieceType.Knight => 300
|
||||||
case PieceType.Bishop => 300
|
case PieceType.Bishop => 300
|
||||||
case PieceType.Rook => 500
|
case PieceType.Rook => 500
|
||||||
case PieceType.Queen => 900
|
case PieceType.Queen => 900
|
||||||
case _ => 0
|
case PieceType.Pawn => 0
|
||||||
|
case PieceType.King => 0
|
||||||
)
|
)
|
||||||
else sum
|
else sum
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import de.nowchess.api.board.PieceType
|
|||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.move.{Move, MoveType}
|
import de.nowchess.api.move.{Move, MoveType}
|
||||||
import de.nowchess.bot.ai.Evaluation
|
import de.nowchess.bot.ai.Evaluation
|
||||||
import de.nowchess.bot.logic.{MoveOrdering, TTEntry, TTFlag, TranspositionTable}
|
|
||||||
import de.nowchess.bot.util.ZobristHash
|
import de.nowchess.bot.util.ZobristHash
|
||||||
import de.nowchess.rules.RuleSet
|
import de.nowchess.rules.RuleSet
|
||||||
import de.nowchess.rules.sets.DefaultRules
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
@@ -31,6 +30,14 @@ final class AlphaBetaSearch(
|
|||||||
private val nodeCount = AtomicInteger(0)
|
private val nodeCount = AtomicInteger(0)
|
||||||
private val ordering = MoveOrdering.OrderingContext()
|
private val ordering = MoveOrdering.OrderingContext()
|
||||||
|
|
||||||
|
private final case class QuiescenceNode(
|
||||||
|
context: GameContext,
|
||||||
|
ply: Int,
|
||||||
|
alpha: Int,
|
||||||
|
beta: Int,
|
||||||
|
hash: Long,
|
||||||
|
)
|
||||||
|
|
||||||
/** Return the best move for the side to move, searching to maxDepth plies. Uses iterative deepening with aspiration
|
/** Return the best move for the side to move, searching to maxDepth plies. Uses iterative deepening with aspiration
|
||||||
* windows.
|
* windows.
|
||||||
*/
|
*/
|
||||||
@@ -111,14 +118,14 @@ final class AlphaBetaSearch(
|
|||||||
val state = SearchState(rootHash, Map(rootHash -> 1))
|
val state = SearchState(rootHash, 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, delta: Int, attempt: Int): (Int, Option[Move]) =
|
||||||
if attempt >= 3 || attempt >= depth then search(context, depth, 0, -INF, INF, state, excludedRootMoves)
|
if attempt >= 3 || attempt >= depth then search(context, depth, 0, Window(-INF, INF), state, excludedRootMoves)
|
||||||
else
|
else
|
||||||
val (score, move) = search(context, depth, 0, currentAlpha, currentBeta, state, excludedRootMoves)
|
val (score, move) = search(context, depth, 0, Window(currentAlpha, currentBeta), state, 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 - delta, currentBeta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
|
||||||
else loop(currentAlpha, score + window, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1)
|
else loop(currentAlpha, score + delta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
|
||||||
|
|
||||||
loop(alpha, beta, initialWindow, 0)
|
loop(alpha, beta, initialWindow, 0)
|
||||||
|
|
||||||
@@ -144,7 +151,7 @@ final class AlphaBetaSearch(
|
|||||||
val nullState = state.advance(ZobristHash.hash(nullCtx))
|
val nullState = state.advance(ZobristHash.hash(nullCtx))
|
||||||
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, nullState, excludedRootMoves)
|
val (score, _) = search(nullCtx, reductionDepth, ply + 1, Window(-beta, -beta + 1), nullState, 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). */
|
||||||
@@ -152,116 +159,169 @@ final class AlphaBetaSearch(
|
|||||||
context: GameContext,
|
context: GameContext,
|
||||||
depth: Int,
|
depth: Int,
|
||||||
ply: Int,
|
ply: Int,
|
||||||
alpha: Int,
|
window: Window,
|
||||||
beta: Int,
|
|
||||||
state: SearchState,
|
state: SearchState,
|
||||||
excludedRootMoves: Set[Move],
|
excludedRootMoves: Set[Move],
|
||||||
): (Int, Option[Move]) =
|
): (Int, Option[Move]) =
|
||||||
|
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
|
||||||
|
searchNode(params)
|
||||||
|
|
||||||
|
private def searchNode(params: SearchParams): (Int, Option[Move]) =
|
||||||
val count = nodeCount.incrementAndGet()
|
val count = nodeCount.incrementAndGet()
|
||||||
if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then (weights.evaluateAccumulator(ply, context, state.hash), None)
|
immediateSearchResult(params, count).getOrElse {
|
||||||
else if state.repetitions.getOrElse(state.hash, 0) >= 3 then (weights.DRAW_SCORE, None)
|
val legalMoves = rules.allLegalMoves(params.context)
|
||||||
|
terminalSearchResult(params, legalMoves).getOrElse(searchDeeper(params, legalMoves))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def immediateSearchResult(
|
||||||
|
params: SearchParams,
|
||||||
|
count: Int,
|
||||||
|
): Option[(Int, Option[Move])] =
|
||||||
|
if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then
|
||||||
|
Some((weights.evaluateAccumulator(params.ply, params.context, params.state.hash), None))
|
||||||
|
else if params.state.repetitions.getOrElse(params.state.hash, 0) >= 3 then Some((weights.DRAW_SCORE, None))
|
||||||
|
else ttCutoff(params)
|
||||||
|
|
||||||
|
private def ttCutoff(params: SearchParams): Option[(Int, Option[Move])] =
|
||||||
|
tt.probe(params.state.hash).filter(_.depth >= params.depth).flatMap { entry =>
|
||||||
|
entry.flag match
|
||||||
|
case TTFlag.Exact => Some((entry.score, entry.bestMove))
|
||||||
|
case TTFlag.Lower =>
|
||||||
|
val newAlpha = math.max(params.window.alpha, entry.score)
|
||||||
|
Option.when(newAlpha >= params.window.beta)((entry.score, entry.bestMove))
|
||||||
|
case TTFlag.Upper =>
|
||||||
|
val newBeta = math.min(params.window.beta, entry.score)
|
||||||
|
Option.when(params.window.alpha >= newBeta)((entry.score, entry.bestMove))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def terminalSearchResult(
|
||||||
|
params: SearchParams,
|
||||||
|
legalMoves: List[Move],
|
||||||
|
): Option[(Int, Option[Move])] =
|
||||||
|
if legalMoves.isEmpty then
|
||||||
|
Some((if rules.isCheckmate(params.context) then -(weights.CHECKMATE_SCORE - params.ply) else weights.DRAW_SCORE, None))
|
||||||
|
else if rules.isInsufficientMaterial(params.context) || rules.isFiftyMoveRule(params.context) then Some((weights.DRAW_SCORE, None))
|
||||||
|
else if params.depth == 0 then
|
||||||
|
Some((quiescence(params.context, params.ply, params.window.alpha, params.window.beta, params.state.hash), None))
|
||||||
|
else None
|
||||||
|
|
||||||
|
private def searchDeeper(
|
||||||
|
params: SearchParams,
|
||||||
|
legalMoves: List[Move],
|
||||||
|
): (Int, Option[Move]) =
|
||||||
|
val nullResult =
|
||||||
|
Option.when(canTryNullMove(params))(tryNullMove(params.context, params.depth, params.ply, params.window.beta, params.state, params.excludedRootMoves)).flatten
|
||||||
|
|
||||||
|
nullResult.map((_, None)).getOrElse {
|
||||||
|
val ttBest = tt.probe(params.state.hash).flatMap(_.bestMove)
|
||||||
|
val ordered = MoveOrdering.sort(params.context, legalMoves, ttBest, params.ply, ordering)
|
||||||
|
searchSequential(
|
||||||
|
params.context,
|
||||||
|
params.depth,
|
||||||
|
params.ply,
|
||||||
|
params.window,
|
||||||
|
ordered,
|
||||||
|
params.state,
|
||||||
|
params.excludedRootMoves,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def canTryNullMove(params: SearchParams): Boolean =
|
||||||
|
params.depth >= 3 &&
|
||||||
|
!rules.isCheck(params.context) &&
|
||||||
|
hasNonPawnMaterial(params.context)
|
||||||
|
|
||||||
|
private def isQuietMove(context: GameContext, move: Move): Boolean =
|
||||||
|
!isCapture(context, move) &&
|
||||||
|
move.moveType != MoveType.CastleKingside &&
|
||||||
|
move.moveType != MoveType.CastleQueenside
|
||||||
|
|
||||||
|
private def scoreMove(
|
||||||
|
child: GameContext,
|
||||||
|
childState: SearchState,
|
||||||
|
params: SearchParams,
|
||||||
|
extension: Int,
|
||||||
|
reduction: Int,
|
||||||
|
a: Int,
|
||||||
|
): Int =
|
||||||
|
val betaNeg = -params.window.beta
|
||||||
|
if reduction > 0 then
|
||||||
|
val (rs, _) = search(child, math.max(0, params.depth - 1 - reduction + extension), params.ply + 1, Window(-a - 1, -a), childState, params.excludedRootMoves)
|
||||||
|
val s = -rs
|
||||||
|
if s > a then
|
||||||
|
val (fs, _) = search(child, math.max(0, params.depth - 1 + extension), params.ply + 1, Window(betaNeg, -a), childState, params.excludedRootMoves)
|
||||||
|
-fs
|
||||||
|
else s
|
||||||
else
|
else
|
||||||
val ttCutoff = tt.probe(state.hash).filter(_.depth >= depth).flatMap { entry =>
|
val (rs, _) = search(child, math.max(0, params.depth - 1 + extension), params.ply + 1, Window(betaNeg, -a), childState, params.excludedRootMoves)
|
||||||
entry.flag match
|
-rs
|
||||||
case TTFlag.Exact => Some((entry.score, entry.bestMove))
|
|
||||||
case TTFlag.Lower =>
|
private def evalSingleMove(
|
||||||
val newAlpha = math.max(alpha, entry.score)
|
move: Move,
|
||||||
Option.when(newAlpha >= beta)((entry.score, entry.bestMove))
|
moveNumber: Int,
|
||||||
case TTFlag.Upper =>
|
a: Int,
|
||||||
val newBeta = math.min(beta, entry.score)
|
params: SearchParams,
|
||||||
Option.when(alpha >= newBeta)((entry.score, entry.bestMove))
|
): Option[(Int, Boolean)] =
|
||||||
}
|
val skipRoot = params.ply == 0 && params.excludedRootMoves.contains(move)
|
||||||
ttCutoff.getOrElse {
|
val isQuiet = isQuietMove(params.context, move)
|
||||||
val legalMoves = rules.allLegalMoves(context)
|
val futility = params.depth == 1 && isQuiet && moveNumber > 2 &&
|
||||||
if legalMoves.isEmpty then
|
weights.evaluateAccumulator(params.ply, params.context, params.state.hash) + FUTILITY_MARGIN < params.window.alpha
|
||||||
(if rules.isCheckmate(context) then -(weights.CHECKMATE_SCORE - ply) else weights.DRAW_SCORE, None)
|
if skipRoot || futility then None
|
||||||
else if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then (weights.DRAW_SCORE, None)
|
else
|
||||||
else if depth == 0 then (quiescence(context, ply, alpha, beta, state.hash), None)
|
val child = rules.applyMove(params.context)(move)
|
||||||
else
|
val childHash = ZobristHash.nextHash(params.context, params.state.hash, move, child)
|
||||||
val nullResult = Option
|
weights.pushAccumulator(params.ply + 1, move, params.context, child)
|
||||||
.when(depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context)) {
|
val childState = params.state.advance(childHash)
|
||||||
tryNullMove(context, depth, ply, beta, state, excludedRootMoves)
|
val extension = if rules.isCheck(child) then CHECK_EXTENSION else 0
|
||||||
}
|
val reduction = if moveNumber > 4 && params.depth >= 3 && isQuiet then 1 else 0
|
||||||
.flatten
|
Some((scoreMove(child, childState, params, extension, reduction, a), isQuiet))
|
||||||
nullResult.map((_, None)).getOrElse {
|
|
||||||
val ttBest = tt.probe(state.hash).flatMap(_.bestMove)
|
private def recordCutoff(move: Move, depth: Int, ply: Int): Unit =
|
||||||
val ordered = MoveOrdering.sort(context, legalMoves, ttBest, ply, ordering)
|
ordering.addHistory(
|
||||||
searchSequential(context, depth, ply, alpha, beta, ordered, state, excludedRootMoves)
|
move.from.rank.ordinal * 8 + move.from.file.ordinal,
|
||||||
}
|
move.to.rank.ordinal * 8 + move.to.file.ordinal,
|
||||||
}
|
depth * depth,
|
||||||
|
)
|
||||||
|
ordering.addKillerMove(ply, move)
|
||||||
|
|
||||||
|
@scala.annotation.tailrec
|
||||||
|
private def searchLoop(
|
||||||
|
idx: Int,
|
||||||
|
moveNumber: Int,
|
||||||
|
acc: LoopAcc,
|
||||||
|
params: SearchParams,
|
||||||
|
ordered: List[Move],
|
||||||
|
): (Option[Move], Int, Boolean) =
|
||||||
|
if idx >= ordered.length then (acc.bestMove, acc.bestScore, false)
|
||||||
|
else
|
||||||
|
val move = ordered(idx)
|
||||||
|
evalSingleMove(move, moveNumber, acc.a, params) match
|
||||||
|
case None => searchLoop(idx + 1, moveNumber + 1, acc, params, ordered)
|
||||||
|
case Some((score, isQuiet)) =>
|
||||||
|
val newAcc = LoopAcc(
|
||||||
|
if score > acc.bestScore then Some(move) else acc.bestMove,
|
||||||
|
math.max(acc.bestScore, score),
|
||||||
|
math.max(acc.a, score),
|
||||||
|
)
|
||||||
|
if newAcc.a >= params.window.beta then
|
||||||
|
if isQuiet then recordCutoff(move, params.depth, params.ply)
|
||||||
|
(newAcc.bestMove, newAcc.bestScore, true)
|
||||||
|
else searchLoop(idx + 1, moveNumber + 1, newAcc, params, ordered)
|
||||||
|
|
||||||
private def searchSequential(
|
private def searchSequential(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
depth: Int,
|
depth: Int,
|
||||||
ply: Int,
|
ply: Int,
|
||||||
alpha: Int,
|
window: Window,
|
||||||
beta: Int,
|
|
||||||
ordered: List[Move],
|
ordered: List[Move],
|
||||||
state: SearchState,
|
state: SearchState,
|
||||||
excludedRootMoves: Set[Move],
|
excludedRootMoves: Set[Move],
|
||||||
): (Int, Option[Move]) =
|
): (Int, Option[Move]) =
|
||||||
@scala.annotation.tailrec
|
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
|
||||||
def loop(
|
val (bestMove, bestScore, cutoff) = searchLoop(0, 0, LoopAcc(None, -INF, window.alpha), params, ordered)
|
||||||
idx: Int,
|
|
||||||
bestMove: Option[Move],
|
|
||||||
bestScore: Int,
|
|
||||||
a: Int,
|
|
||||||
moveNumber: Int,
|
|
||||||
): (Option[Move], Int, Boolean) =
|
|
||||||
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, state.hash) + FUTILITY_MARGIN < alpha
|
|
||||||
|
|
||||||
if skipRootMove || pruneByFutility then loop(idx + 1, bestMove, bestScore, a, moveNumber + 1)
|
|
||||||
else
|
|
||||||
val child = rules.applyMove(context)(move)
|
|
||||||
val childHash = ZobristHash.nextHash(context, state.hash, move, child)
|
|
||||||
weights.pushAccumulator(ply + 1, move, context, child)
|
|
||||||
val childState = state.advance(childHash)
|
|
||||||
val givesCheck = rules.isCheck(child)
|
|
||||||
val extension = if givesCheck then CHECK_EXTENSION else 0
|
|
||||||
val reduction = if moveNumber > 4 && depth >= 3 && isQuiet then 1 else 0
|
|
||||||
|
|
||||||
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, childState, 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, childState, excludedRootMoves)
|
|
||||||
-fullScore
|
|
||||||
else s
|
|
||||||
else
|
|
||||||
val fullDepth = math.max(0, depth - 1 + extension)
|
|
||||||
val (rawScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childState, excludedRootMoves)
|
|
||||||
-rawScore
|
|
||||||
|
|
||||||
val newBestScore = math.max(bestScore, score)
|
|
||||||
val newBestMove = if score > bestScore then Some(move) else bestMove
|
|
||||||
val newA = math.max(a, score)
|
|
||||||
|
|
||||||
if newA >= beta then
|
|
||||||
if isQuiet then
|
|
||||||
ordering.addHistory(
|
|
||||||
move.from.rank.ordinal * 8 + move.from.file.ordinal,
|
|
||||||
move.to.rank.ordinal * 8 + move.to.file.ordinal,
|
|
||||||
depth * depth,
|
|
||||||
)
|
|
||||||
ordering.addKillerMove(ply, move)
|
|
||||||
(newBestMove, newBestScore, true)
|
|
||||||
else loop(idx + 1, newBestMove, newBestScore, newA, moveNumber + 1)
|
|
||||||
|
|
||||||
val (bestMove, bestScore, cutoff) = loop(0, None, -INF, alpha, 0)
|
|
||||||
val flag =
|
val flag =
|
||||||
if cutoff then TTFlag.Lower
|
if cutoff then TTFlag.Lower
|
||||||
else if bestScore <= alpha then TTFlag.Upper
|
else if bestScore <= window.alpha then TTFlag.Upper
|
||||||
else TTFlag.Exact
|
else TTFlag.Exact
|
||||||
tt.store(TTEntry(state.hash, depth, bestScore, flag, bestMove))
|
tt.store(TTEntry(state.hash, depth, bestScore, flag, bestMove))
|
||||||
(bestScore, bestMove)
|
(bestScore, bestMove)
|
||||||
@@ -274,35 +334,45 @@ final class AlphaBetaSearch(
|
|||||||
beta: Int,
|
beta: Int,
|
||||||
hash: Long,
|
hash: Long,
|
||||||
): Int =
|
): Int =
|
||||||
val inCheck = rules.isCheck(context)
|
quiescenceNode(QuiescenceNode(context, ply, alpha, beta, hash))
|
||||||
val standPat = if inCheck then -INF else weights.evaluateAccumulator(ply, context, hash)
|
|
||||||
|
|
||||||
if !inCheck && standPat >= beta then beta
|
private def quiescenceNode(node: QuiescenceNode): Int =
|
||||||
|
val inCheck = rules.isCheck(node.context)
|
||||||
|
val standPat = if inCheck then -INF else weights.evaluateAccumulator(node.ply, node.context, node.hash)
|
||||||
|
|
||||||
|
if !inCheck && standPat >= node.beta then node.beta
|
||||||
|
else if node.ply >= MAX_QUIESCENCE_PLY then quiescenceAtDepthLimit(node, inCheck, standPat)
|
||||||
else
|
else
|
||||||
val a0 = if inCheck then alpha else math.max(alpha, standPat)
|
val moves = tacticalMoves(node.context, inCheck)
|
||||||
|
if inCheck && moves.isEmpty then -(weights.CHECKMATE_SCORE - node.ply)
|
||||||
if ply >= MAX_QUIESCENCE_PLY then if inCheck then weights.evaluateAccumulator(ply, context, hash) else standPat
|
|
||||||
else
|
else
|
||||||
val allMoves = rules.allLegalMoves(context)
|
val ordered = MoveOrdering.sort(node.context, moves, None)
|
||||||
val tacticalMoves = if inCheck then allMoves else allMoves.filter(m => isCapture(context, m))
|
val a0 = if inCheck then node.alpha else math.max(node.alpha, standPat)
|
||||||
|
quiescenceLoop(node, ordered, 0, a0)
|
||||||
|
|
||||||
if inCheck && tacticalMoves.isEmpty then -(weights.CHECKMATE_SCORE - ply)
|
private def quiescenceAtDepthLimit(node: QuiescenceNode, inCheck: Boolean, standPat: Int): Int =
|
||||||
else
|
if inCheck then weights.evaluateAccumulator(node.ply, node.context, node.hash) else standPat
|
||||||
val ordered = MoveOrdering.sort(context, tacticalMoves, None)
|
|
||||||
|
|
||||||
@scala.annotation.tailrec
|
private def tacticalMoves(context: GameContext, inCheck: Boolean): List[Move] =
|
||||||
def loop(idx: Int, a: Int): Int =
|
val allMoves = rules.allLegalMoves(context)
|
||||||
if idx >= ordered.length then a
|
if inCheck then allMoves else allMoves.filter(m => isCapture(context, m))
|
||||||
else
|
|
||||||
val move = ordered(idx)
|
|
||||||
val child = rules.applyMove(context)(move)
|
|
||||||
val childHash = ZobristHash.nextHash(context, hash, move, child)
|
|
||||||
weights.pushAccumulator(ply + 1, move, context, child)
|
|
||||||
val score = -quiescence(child, ply + 1, -beta, -a, childHash)
|
|
||||||
if score >= beta then beta
|
|
||||||
else loop(idx + 1, math.max(a, score))
|
|
||||||
|
|
||||||
loop(0, a0)
|
@scala.annotation.tailrec
|
||||||
|
private def quiescenceLoop(
|
||||||
|
node: QuiescenceNode,
|
||||||
|
ordered: List[Move],
|
||||||
|
idx: Int,
|
||||||
|
a: Int,
|
||||||
|
): Int =
|
||||||
|
if idx >= ordered.length then a
|
||||||
|
else
|
||||||
|
val move = ordered(idx)
|
||||||
|
val child = rules.applyMove(node.context)(move)
|
||||||
|
val childHash = ZobristHash.nextHash(node.context, node.hash, move, child)
|
||||||
|
weights.pushAccumulator(node.ply + 1, move, node.context, child)
|
||||||
|
val score = -quiescence(child, node.ply + 1, -node.beta, -a, childHash)
|
||||||
|
if score >= node.beta then node.beta
|
||||||
|
else quiescenceLoop(node, ordered, idx + 1, math.max(a, score))
|
||||||
|
|
||||||
private def isCapture(context: GameContext, move: Move): Boolean = move.moveType match
|
private def isCapture(context: GameContext, move: Move): Boolean = move.moveType match
|
||||||
case MoveType.Normal(true) => true
|
case MoveType.Normal(true) => true
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ object MoveOrdering:
|
|||||||
val target = move.to
|
val target = move.to
|
||||||
val initialGain = victimValue(context, move)
|
val initialGain = victimValue(context, move)
|
||||||
movedPieceAfterMove(context, move).fold(initialGain) { moved =>
|
movedPieceAfterMove(context, move).fold(initialGain) { moved =>
|
||||||
val boardAfterMove = applySeeMove(context.board, context, move, moved)
|
val boardAfterMove = applySeeMove(context.board, move, moved)
|
||||||
exchangeGain(boardAfterMove, target, context.turn.opposite, pieceValue(moved), Vector(initialGain))
|
exchangeGain(boardAfterMove, target, context.turn.opposite, pieceValue(moved), Vector(initialGain))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ object MoveOrdering:
|
|||||||
}
|
}
|
||||||
.head
|
.head
|
||||||
|
|
||||||
private def applySeeMove(board: Board, context: GameContext, move: Move, moved: Piece): Board =
|
private def applySeeMove(board: Board, move: Move, moved: Piece): Board =
|
||||||
move.moveType match
|
move.moveType match
|
||||||
case MoveType.EnPassant =>
|
case MoveType.EnPassant =>
|
||||||
val capturedSquare = Square(move.to.file, move.from.rank)
|
val capturedSquare = Square(move.to.file, move.from.rank)
|
||||||
@@ -169,8 +169,7 @@ object MoveOrdering:
|
|||||||
private def clearLine(board: Board, from: Square, target: Square, df: Int, dr: Int, diagonal: Boolean): Boolean =
|
private def clearLine(board: Board, from: Square, target: Square, df: Int, dr: Int, diagonal: Boolean): Boolean =
|
||||||
val valid =
|
val valid =
|
||||||
if diagonal then math.abs(df) == math.abs(dr) && df != 0 else (df == 0 && dr != 0) || (dr == 0 && df != 0)
|
if diagonal then math.abs(df) == math.abs(dr) && df != 0 else (df == 0 && dr != 0) || (dr == 0 && df != 0)
|
||||||
if !valid then false
|
valid && pathClear(board, from, target, Integer.compare(df, 0), Integer.compare(dr, 0))
|
||||||
else pathClear(board, from, target, Integer.compare(df, 0), Integer.compare(dr, 0))
|
|
||||||
|
|
||||||
@tailrec
|
@tailrec
|
||||||
private def pathClear(board: Board, from: Square, target: Square, stepF: Int, stepR: Int): Boolean =
|
private def pathClear(board: Board, from: Square, target: Square, stepF: Int, stepR: Int): Boolean =
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
package de.nowchess.bot.logic
|
package de.nowchess.bot.logic
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.move.Move
|
import de.nowchess.api.move.Move
|
||||||
|
|
||||||
|
final case class Window(alpha: Int, beta: Int)
|
||||||
|
|
||||||
|
final case class LoopAcc(bestMove: Option[Move], bestScore: Int, a: Int)
|
||||||
|
|
||||||
|
final case class SearchParams(
|
||||||
|
context: GameContext,
|
||||||
|
depth: Int,
|
||||||
|
ply: Int,
|
||||||
|
window: Window,
|
||||||
|
state: SearchState,
|
||||||
|
excludedRootMoves: Set[Move],
|
||||||
|
)
|
||||||
|
|
||||||
final case class SearchState(hash: Long, repetitions: Map[Long, Int]):
|
final case class SearchState(hash: Long, repetitions: Map[Long, Int]):
|
||||||
def advance(nextHash: Long): SearchState =
|
def advance(nextHash: Long): SearchState =
|
||||||
SearchState(nextHash, repetitions.updatedWith(nextHash) {
|
SearchState(nextHash, repetitions.updatedWith(nextHash) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ final class PolyglotBook(path: String):
|
|||||||
/** Probe the book for a move in the given position. Returns a weighted random move, or None if not in book. */
|
/** Probe the book for a move in the given position. Returns a weighted random move, or None if not in book. */
|
||||||
def probe(context: GameContext): Option[Move] =
|
def probe(context: GameContext): Option[Move] =
|
||||||
val hash = PolyglotHash.hash(context)
|
val hash = PolyglotHash.hash(context)
|
||||||
println(f"0x${hash}%016X")
|
println(f"0x$hash%016X")
|
||||||
entries.get(hash).flatMap { bookEntries =>
|
entries.get(hash).flatMap { bookEntries =>
|
||||||
if bookEntries.isEmpty then None
|
if bookEntries.isEmpty then None
|
||||||
else
|
else
|
||||||
@@ -49,7 +49,7 @@ final class PolyglotBook(path: String):
|
|||||||
val key = input.readLong()
|
val key = input.readLong()
|
||||||
val move = input.readShort()
|
val move = input.readShort()
|
||||||
val weight = input.readShort()
|
val weight = input.readShort()
|
||||||
val learn = input.readInt()
|
input.readInt() // learning data (unused)
|
||||||
|
|
||||||
val entry = BookEntry(key, move, weight)
|
val entry = BookEntry(key, move, weight)
|
||||||
result.updateWith(key) {
|
result.updateWith(key) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package de.nowchess.bot
|
package de.nowchess.bot
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.bot.ai.Evaluation
|
||||||
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.rules.RuleSet
|
import de.nowchess.rules.RuleSet
|
||||||
@@ -12,6 +13,11 @@ import de.nowchess.rules.sets.DefaultRules
|
|||||||
|
|
||||||
class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
private object ZeroEval extends Evaluation:
|
||||||
|
val CHECKMATE_SCORE: Int = 1_000_000
|
||||||
|
val DRAW_SCORE: Int = 0
|
||||||
|
def evaluate(context: GameContext): Int = 0
|
||||||
|
|
||||||
test("bestMove on initial position returns a move"):
|
test("bestMove on initial position returns a move"):
|
||||||
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||||
val move = search.bestMove(GameContext.initial, maxDepth = 2)
|
val move = search.bestMove(GameContext.initial, maxDepth = 2)
|
||||||
@@ -260,3 +266,72 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
|||||||
def applyMove(context: GameContext)(move: Move): GameContext = context
|
def applyMove(context: GameContext)(move: Move): GameContext = context
|
||||||
val search = AlphaBetaSearch(drawRules, weights = EvaluationClassic)
|
val search = AlphaBetaSearch(drawRules, weights = EvaluationClassic)
|
||||||
search.bestMove(GameContext.initial, maxDepth = 2) should be(None)
|
search.bestMove(GameContext.initial, maxDepth = 2) should be(None)
|
||||||
|
|
||||||
|
test("repetition cutoff is reached on forced self-loop positions"):
|
||||||
|
// Use a no-op move from an empty square so nextHash alternates between a tiny set of hashes.
|
||||||
|
// This forces repetition counts >= 3 and exercises immediateSearchResult's repetition cutoff.
|
||||||
|
val loopMove = Move(Square(File.A, Rank.R3), Square(File.A, Rank.R4), MoveType.Normal())
|
||||||
|
val loopRules = new RuleSet:
|
||||||
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(loopMove)
|
||||||
|
def legalMoves(context: GameContext)(square: Square): List[Move] = List(loopMove)
|
||||||
|
def allLegalMoves(context: GameContext): List[Move] = List(loopMove)
|
||||||
|
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 isThreefoldRepetition(context: GameContext): Boolean = false
|
||||||
|
def applyMove(context: GameContext)(move: Move): GameContext = context
|
||||||
|
|
||||||
|
val search = AlphaBetaSearch(loopRules, weights = ZeroEval)
|
||||||
|
search.bestMove(GameContext.initial, maxDepth = 8) should be(Some(loopMove))
|
||||||
|
|
||||||
|
test("quiescence returns checkmate score when side is in check and has no tactical moves"):
|
||||||
|
val rootMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||||
|
val capMove = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true))
|
||||||
|
val qRules = new RuleSet:
|
||||||
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
|
||||||
|
def legalMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
|
||||||
|
def allLegalMoves(context: GameContext): List[Move] =
|
||||||
|
context.moves.length match
|
||||||
|
case 0 => List(rootMove)
|
||||||
|
case 1 => List(capMove)
|
||||||
|
case _ => Nil
|
||||||
|
def isCheck(context: GameContext): Boolean =
|
||||||
|
context.moves.length >= 2
|
||||||
|
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 isThreefoldRepetition(context: GameContext): Boolean = false
|
||||||
|
def applyMove(context: GameContext)(move: Move): GameContext =
|
||||||
|
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
|
||||||
|
|
||||||
|
val search = AlphaBetaSearch(qRules, weights = ZeroEval)
|
||||||
|
search.bestMove(GameContext.initial, maxDepth = 1) should be(Some(rootMove))
|
||||||
|
|
||||||
|
test("quiescence depth-limit in-check branch is exercised"):
|
||||||
|
val rootMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||||
|
val capMove = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true))
|
||||||
|
var firstChildCheckCall = true
|
||||||
|
val deepQRules = new RuleSet:
|
||||||
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
|
||||||
|
def legalMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
|
||||||
|
def allLegalMoves(context: GameContext): List[Move] =
|
||||||
|
if context.moves.isEmpty then List(rootMove) else List(capMove)
|
||||||
|
def isCheck(context: GameContext): Boolean =
|
||||||
|
if context.moves.length == 1 && firstChildCheckCall then
|
||||||
|
firstChildCheckCall = false
|
||||||
|
false
|
||||||
|
else context.moves.nonEmpty
|
||||||
|
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 isThreefoldRepetition(context: GameContext): Boolean = false
|
||||||
|
def applyMove(context: GameContext)(move: Move): GameContext =
|
||||||
|
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
|
||||||
|
|
||||||
|
val search = AlphaBetaSearch(deepQRules, weights = ZeroEval)
|
||||||
|
search.bestMove(GameContext.initial, maxDepth = 1) should be(Some(rootMove))
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ package de.nowchess.bot
|
|||||||
import de.nowchess.api.board.{File, Rank, Square}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.move.{Move, MoveType}
|
import de.nowchess.api.move.{Move, MoveType}
|
||||||
|
import de.nowchess.bot.ai.Evaluation
|
||||||
import de.nowchess.bot.bots.HybridBot
|
import de.nowchess.bot.bots.HybridBot
|
||||||
import de.nowchess.bot.util.PolyglotBook
|
import de.nowchess.bot.util.{PolyglotBook, PolyglotHash}
|
||||||
import de.nowchess.rules.RuleSet
|
import de.nowchess.rules.RuleSet
|
||||||
import de.nowchess.rules.sets.DefaultRules
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
import java.io.{DataOutputStream, FileOutputStream}
|
||||||
|
import java.nio.file.Files
|
||||||
|
import scala.util.Using
|
||||||
|
|
||||||
class HybridBotTest extends AnyFunSuite with Matchers:
|
class HybridBotTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("HybridBot name includes difficulty"):
|
test("HybridBot name includes difficulty"):
|
||||||
@@ -60,3 +64,97 @@ class HybridBotTest extends AnyFunSuite with Matchers:
|
|||||||
val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
|
val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
|
||||||
val bot = HybridBot(BotDifficulty.Easy, onlyMoveRules)
|
val bot = HybridBot(BotDifficulty.Easy, onlyMoveRules)
|
||||||
bot.nextMove(ctx) should be(None)
|
bot.nextMove(ctx) should be(None)
|
||||||
|
|
||||||
|
test("HybridBot uses book move when available"):
|
||||||
|
val tempFile = Files.createTempFile("hybrid_book", ".bin")
|
||||||
|
try
|
||||||
|
val ctx = GameContext.initial
|
||||||
|
val hash = PolyglotHash.hash(ctx)
|
||||||
|
val e2e4: Short = (4 | (3 << 3) | (4 << 6) | (1 << 9)).toShort
|
||||||
|
|
||||||
|
Using(DataOutputStream(FileOutputStream(tempFile.toFile))) { dos =>
|
||||||
|
dos.writeLong(hash)
|
||||||
|
dos.writeShort(e2e4)
|
||||||
|
dos.writeShort(100)
|
||||||
|
dos.writeInt(0)
|
||||||
|
}.get
|
||||||
|
|
||||||
|
val book = PolyglotBook(tempFile.toString)
|
||||||
|
val bot = HybridBot(BotDifficulty.Easy, book = Some(book))
|
||||||
|
bot.nextMove(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
||||||
|
finally Files.deleteIfExists(tempFile)
|
||||||
|
|
||||||
|
test("HybridBot reports veto when classical and NNUE differ above threshold"):
|
||||||
|
val forcedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||||
|
val oneMoveRules = new RuleSet:
|
||||||
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
|
||||||
|
def legalMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
|
||||||
|
def allLegalMoves(context: GameContext): List[Move] = List(forcedMove)
|
||||||
|
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 isThreefoldRepetition(context: GameContext): Boolean = false
|
||||||
|
def applyMove(context: GameContext)(move: Move): GameContext =
|
||||||
|
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
|
||||||
|
|
||||||
|
object LowNnue extends Evaluation:
|
||||||
|
val CHECKMATE_SCORE: Int = 10_000_000
|
||||||
|
val DRAW_SCORE: Int = 0
|
||||||
|
def evaluate(context: GameContext): Int = 0
|
||||||
|
|
||||||
|
object HighClassic extends Evaluation:
|
||||||
|
val CHECKMATE_SCORE: Int = 10_000_000
|
||||||
|
val DRAW_SCORE: Int = 0
|
||||||
|
def evaluate(context: GameContext): Int = 10_000
|
||||||
|
|
||||||
|
var reported = false
|
||||||
|
val bot = HybridBot(
|
||||||
|
BotDifficulty.Easy,
|
||||||
|
rules = oneMoveRules,
|
||||||
|
nnueEvaluation = LowNnue,
|
||||||
|
classicalEvaluation = HighClassic,
|
||||||
|
vetoReporter = _ => reported = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
bot.nextMove(GameContext.initial) should be(Some(forcedMove))
|
||||||
|
reported should be(true)
|
||||||
|
|
||||||
|
test("HybridBot default veto reporter prints when threshold is exceeded"):
|
||||||
|
val forcedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||||
|
val oneMoveRules = new RuleSet:
|
||||||
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
|
||||||
|
def legalMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
|
||||||
|
def allLegalMoves(context: GameContext): List[Move] = List(forcedMove)
|
||||||
|
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 isThreefoldRepetition(context: GameContext): Boolean = false
|
||||||
|
def applyMove(context: GameContext)(move: Move): GameContext =
|
||||||
|
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
|
||||||
|
|
||||||
|
object LowNnue extends Evaluation:
|
||||||
|
val CHECKMATE_SCORE: Int = 10_000_000
|
||||||
|
val DRAW_SCORE: Int = 0
|
||||||
|
def evaluate(context: GameContext): Int = 0
|
||||||
|
|
||||||
|
object HighClassic extends Evaluation:
|
||||||
|
val CHECKMATE_SCORE: Int = 10_000_000
|
||||||
|
val DRAW_SCORE: Int = 0
|
||||||
|
def evaluate(context: GameContext): Int = 10_000
|
||||||
|
|
||||||
|
val bot = HybridBot(
|
||||||
|
BotDifficulty.Easy,
|
||||||
|
rules = oneMoveRules,
|
||||||
|
nnueEvaluation = LowNnue,
|
||||||
|
classicalEvaluation = HighClassic,
|
||||||
|
)
|
||||||
|
|
||||||
|
val printed = Console.withOut(new java.io.ByteArrayOutputStream()) {
|
||||||
|
bot.nextMove(GameContext.initial)
|
||||||
|
}
|
||||||
|
printed should be(Some(forcedMove))
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class MoveOrderingTest extends AnyFunSuite with Matchers:
|
class MoveOrderingTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
private val moveOrderingClass = Class.forName("de.nowchess.bot.logic.MoveOrdering$")
|
||||||
|
private val moveOrderingObject = moveOrderingClass.getField("MODULE$").get(null)
|
||||||
|
|
||||||
|
private def invokeMoveOrderingPrivate[T](methodPrefix: String, args: Seq[AnyRef]): T =
|
||||||
|
val method = moveOrderingClass.getDeclaredMethods
|
||||||
|
.find(m => (m.getName == methodPrefix || m.getName.startsWith(methodPrefix + "$")) && m.getParameterCount == args.length)
|
||||||
|
.getOrElse(throw RuntimeException(s"Method not found: $methodPrefix/${args.length}"))
|
||||||
|
method.setAccessible(true)
|
||||||
|
method.invoke(moveOrderingObject, args*).asInstanceOf[T]
|
||||||
|
|
||||||
test("queen capture ranks higher than rook capture"):
|
test("queen capture ranks higher than rook capture"):
|
||||||
val board = Board(
|
val board = Board(
|
||||||
Map(
|
Map(
|
||||||
@@ -197,3 +207,37 @@ class MoveOrderingTest extends AnyFunSuite with Matchers:
|
|||||||
MoveOrdering.score(context, knightPromo, None) should be > 0
|
MoveOrdering.score(context, knightPromo, None) should be > 0
|
||||||
MoveOrdering.score(context, bishopPromo, None) should be > 0
|
MoveOrdering.score(context, bishopPromo, None) should be > 0
|
||||||
MoveOrdering.score(context, rookPromo, None) should be > 0
|
MoveOrdering.score(context, rookPromo, None) should be > 0
|
||||||
|
|
||||||
|
test("negative SEE capture path is scored below neutral capture baseline"):
|
||||||
|
val board = Board(
|
||||||
|
Map(
|
||||||
|
Square(File.D, Rank.R4) -> Piece.WhiteQueen,
|
||||||
|
Square(File.D, Rank.R5) -> Piece.BlackPawn,
|
||||||
|
Square(File.D, Rank.R8) -> Piece.BlackRook,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||||
|
val move = Move(Square(File.D, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||||
|
|
||||||
|
MoveOrdering.score(context, move, None) should be < 100_000
|
||||||
|
|
||||||
|
test("private fallback branches in MoveOrdering are covered"):
|
||||||
|
val board = Board(Map(Square(File.E, Rank.R1) -> Piece.WhiteKing, Square(File.A, Rank.R8) -> Piece.BlackKing))
|
||||||
|
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||||
|
val castle = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||||
|
|
||||||
|
val victim = invokeMoveOrderingPrivate[Int]("victimValue", Seq[AnyRef](context, castle))
|
||||||
|
victim should be(0)
|
||||||
|
|
||||||
|
val capture = invokeMoveOrderingPrivate[Boolean]("isCapture", Seq[AnyRef](context, castle))
|
||||||
|
capture should be(false)
|
||||||
|
|
||||||
|
val see = invokeMoveOrderingPrivate[Int]("staticExchange", Seq[AnyRef](context, castle))
|
||||||
|
see should be(0)
|
||||||
|
|
||||||
|
val clear = invokeMoveOrderingPrivate[Boolean](
|
||||||
|
"pathClear",
|
||||||
|
Seq[AnyRef](board, Square(File.A, Rank.R1), Square(File.H, Rank.R1), Int.box(-1), Int.box(0)),
|
||||||
|
)
|
||||||
|
clear should be(false)
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ scala {
|
|||||||
|
|
||||||
scoverage {
|
scoverage {
|
||||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||||
excludedPackages.set(listOf(
|
excludedPackages.set(listOf("de\\.nowchess\\.ui\\..*"))
|
||||||
"de.nowchess.ui.gui",
|
|
||||||
"de.nowchess.ui.terminal",
|
|
||||||
"de.nowchess.ui.Main",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
application {
|
application {
|
||||||
|
|||||||
Reference in New Issue
Block a user