diff --git a/build.gradle.kts b/build.gradle.kts index 2429731..372a95f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,7 @@ val versions = mapOf( "SCALAFX" to "21.0.0-R32", "JAVAFX" to "21.0.1", "JUNIT_BOM" to "5.13.4", + "ONNXRUNTIME" to "1.19.2", "SCALA_PARSER_COMBINATORS" to "2.4.0", "FASTPARSE" to "3.0.2", "JACKSON" to "2.17.2", diff --git a/modules/bot/build.gradle.kts b/modules/bot/build.gradle.kts index 9dfcd68..8ac4edb 100644 --- a/modules/bot/build.gradle.kts +++ b/modules/bot/build.gradle.kts @@ -1,6 +1,6 @@ plugins { id("scala") - id("org.scoverage") version "8.1" + id("org.scoverage") } group = "de.nowchess" @@ -19,6 +19,23 @@ scala { 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 { @@ -41,9 +58,9 @@ dependencies { implementation(project(":modules:api")) implementation(project(":modules:io")) 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.scalatest:scalatest_3:${versions["SCALATEST"]!!}") testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala index a4cc841..3a9147a 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala @@ -3,6 +3,7 @@ package de.nowchess.bot.bots import de.nowchess.api.bot.Bot import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move +import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable} @@ -15,9 +16,12 @@ final class HybridBot( difficulty: BotDifficulty, rules: RuleSet = DefaultRules, book: Option[PolyglotBook] = None, + nnueEvaluation: Evaluation = EvaluationNNUE, + classicalEvaluation: Evaluation = EvaluationClassic, + vetoReporter: String => Unit = println(_), ) extends Bot: - private val search = AlphaBetaSearch(rules, TranspositionTable(), EvaluationClassic) + private val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation) 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] = 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) + val staticNnue = nnueEvaluation.evaluate(next) + val classical = classicalEvaluation.evaluate(next) val diff = (classical - staticNnue).abs 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)", ) move diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala index 3d846a2..6cf01f5 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala @@ -187,7 +187,7 @@ object EvaluationClassic extends Evaluation: (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)) => val bonus = piece.pieceType match case PieceType.Pawn => @@ -281,30 +281,44 @@ object EvaluationClassic extends Evaluation: taper(mg, eg, phase) private def rookAndBishopBonuses(context: GameContext, phase: Int): Int = - val friendlyBishops = - context.board.pieces.filter((_, p) => p.color == context.turn && p.pieceType == PieceType.Bishop) - val enemyBishops = context.board.pieces.filter((_, p) => p.color != context.turn && p.pieceType == PieceType.Bishop) + val (baseMg, baseEg) = bishopPairBase(context) + val (rookMg, rookEg) = rookOn7thDelta(context) + taper(baseMg + rookMg, baseEg + rookEg, phase) - val friendlyHasPair = - friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) && - friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1) - val enemyHasPair = - enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) && - enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1) + private def bishopPairBase(context: GameContext): (Int, Int) = + val friendlyHasPair = hasBishopPair(context, context.turn) + val enemyHasPair = hasBishopPair(context, context.turn.opposite) + val mg = pairDelta(friendlyHasPair, enemyHasPair, bishopPairMg) + val eg = pairDelta(friendlyHasPair, enemyHasPair, bishopPairEg) + (mg, eg) - val baseMg = (if friendlyHasPair then bishopPairMg else 0) - (if enemyHasPair then bishopPairMg else 0) - val baseEg = (if friendlyHasPair then bishopPairEg else 0) - (if enemyHasPair then bishopPairEg else 0) - - 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) + private def hasBishopPair(context: GameContext, color: Color): Boolean = + val bishopSquares = context.board.pieces.collect { + case (sq, piece) if piece.color == color && piece.pieceType == PieceType.Bishop => sq } - 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 = 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 = 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 case PieceType.Knight => 300 case PieceType.Bishop => 300 case PieceType.Rook => 500 case PieceType.Queen => 900 - case _ => 0 + case PieceType.Pawn => 0 + case PieceType.King => 0 ) else sum } diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala index 49a8a2e..fbf005c 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala @@ -4,7 +4,6 @@ import de.nowchess.api.board.PieceType import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType} import de.nowchess.bot.ai.Evaluation -import de.nowchess.bot.logic.{MoveOrdering, TTEntry, TTFlag, TranspositionTable} import de.nowchess.bot.util.ZobristHash import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules @@ -31,6 +30,14 @@ final class AlphaBetaSearch( private val nodeCount = AtomicInteger(0) 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 * windows. */ @@ -111,14 +118,14 @@ final class AlphaBetaSearch( val state = SearchState(rootHash, 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, state, excludedRootMoves) + def loop(currentAlpha: Int, currentBeta: Int, delta: Int, attempt: Int): (Int, Option[Move]) = + if attempt >= 3 || attempt >= depth then search(context, depth, 0, Window(-INF, INF), state, excludedRootMoves) 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) else if score <= currentAlpha then - loop(score - window, currentBeta, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1) - else loop(currentAlpha, score + window, 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 + delta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1) loop(alpha, beta, initialWindow, 0) @@ -144,7 +151,7 @@ final class AlphaBetaSearch( val nullState = state.advance(ZobristHash.hash(nullCtx)) 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, nullState, excludedRootMoves) + val (score, _) = search(nullCtx, reductionDepth, ply + 1, Window(-beta, -beta + 1), nullState, excludedRootMoves) if -score >= beta then Some(beta) else None /** Negamax alpha-beta search returning (score, best move). */ @@ -152,116 +159,169 @@ final class AlphaBetaSearch( context: GameContext, depth: Int, ply: Int, - alpha: Int, - beta: Int, + window: Window, state: SearchState, excludedRootMoves: Set[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() - if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then (weights.evaluateAccumulator(ply, context, state.hash), None) - else if state.repetitions.getOrElse(state.hash, 0) >= 3 then (weights.DRAW_SCORE, None) + immediateSearchResult(params, count).getOrElse { + 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 - val ttCutoff = tt.probe(state.hash).filter(_.depth >= depth).flatMap { entry => - entry.flag match - case TTFlag.Exact => Some((entry.score, entry.bestMove)) - case TTFlag.Lower => - val newAlpha = math.max(alpha, entry.score) - Option.when(newAlpha >= beta)((entry.score, entry.bestMove)) - case TTFlag.Upper => - val newBeta = math.min(beta, entry.score) - Option.when(alpha >= newBeta)((entry.score, entry.bestMove)) - } - ttCutoff.getOrElse { - val legalMoves = rules.allLegalMoves(context) - if legalMoves.isEmpty then - (if rules.isCheckmate(context) then -(weights.CHECKMATE_SCORE - ply) else weights.DRAW_SCORE, None) - else if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then (weights.DRAW_SCORE, None) - else if depth == 0 then (quiescence(context, ply, alpha, beta, state.hash), None) - else - val nullResult = Option - .when(depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context)) { - tryNullMove(context, depth, ply, beta, state, excludedRootMoves) - } - .flatten - nullResult.map((_, None)).getOrElse { - val ttBest = tt.probe(state.hash).flatMap(_.bestMove) - val ordered = MoveOrdering.sort(context, legalMoves, ttBest, ply, ordering) - searchSequential(context, depth, ply, alpha, beta, ordered, state, excludedRootMoves) - } - } + val (rs, _) = search(child, math.max(0, params.depth - 1 + extension), params.ply + 1, Window(betaNeg, -a), childState, params.excludedRootMoves) + -rs + + private def evalSingleMove( + move: Move, + moveNumber: Int, + a: Int, + params: SearchParams, + ): Option[(Int, Boolean)] = + val skipRoot = params.ply == 0 && params.excludedRootMoves.contains(move) + val isQuiet = isQuietMove(params.context, move) + val futility = params.depth == 1 && isQuiet && moveNumber > 2 && + weights.evaluateAccumulator(params.ply, params.context, params.state.hash) + FUTILITY_MARGIN < params.window.alpha + if skipRoot || futility then None + else + val child = rules.applyMove(params.context)(move) + val childHash = ZobristHash.nextHash(params.context, params.state.hash, move, child) + weights.pushAccumulator(params.ply + 1, move, params.context, child) + val childState = params.state.advance(childHash) + val extension = if rules.isCheck(child) then CHECK_EXTENSION else 0 + val reduction = if moveNumber > 4 && params.depth >= 3 && isQuiet then 1 else 0 + Some((scoreMove(child, childState, params, extension, reduction, a), isQuiet)) + + private def recordCutoff(move: Move, depth: Int, ply: Int): Unit = + 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) + + @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( context: GameContext, depth: Int, ply: Int, - alpha: Int, - beta: Int, + window: Window, ordered: List[Move], state: SearchState, excludedRootMoves: Set[Move], ): (Int, Option[Move]) = - @scala.annotation.tailrec - def loop( - 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 params = SearchParams(context, depth, ply, window, state, excludedRootMoves) + val (bestMove, bestScore, cutoff) = searchLoop(0, 0, LoopAcc(None, -INF, window.alpha), params, ordered) val flag = if cutoff then TTFlag.Lower - else if bestScore <= alpha then TTFlag.Upper + else if bestScore <= window.alpha then TTFlag.Upper else TTFlag.Exact tt.store(TTEntry(state.hash, depth, bestScore, flag, bestMove)) (bestScore, bestMove) @@ -274,35 +334,45 @@ final class AlphaBetaSearch( beta: Int, hash: Long, ): Int = - val inCheck = rules.isCheck(context) - val standPat = if inCheck then -INF else weights.evaluateAccumulator(ply, context, hash) + quiescenceNode(QuiescenceNode(context, ply, alpha, beta, 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 - val a0 = if inCheck then alpha else math.max(alpha, standPat) - - if ply >= MAX_QUIESCENCE_PLY then if inCheck then weights.evaluateAccumulator(ply, context, hash) else standPat + val moves = tacticalMoves(node.context, inCheck) + if inCheck && moves.isEmpty then -(weights.CHECKMATE_SCORE - node.ply) else - val allMoves = rules.allLegalMoves(context) - val tacticalMoves = if inCheck then allMoves else allMoves.filter(m => isCapture(context, m)) + val ordered = MoveOrdering.sort(node.context, moves, None) + 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) - else - val ordered = MoveOrdering.sort(context, tacticalMoves, None) + private def quiescenceAtDepthLimit(node: QuiescenceNode, inCheck: Boolean, standPat: Int): Int = + if inCheck then weights.evaluateAccumulator(node.ply, node.context, node.hash) else standPat - @scala.annotation.tailrec - def loop(idx: Int, a: Int): Int = - if idx >= ordered.length then a - 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)) + private def tacticalMoves(context: GameContext, inCheck: Boolean): List[Move] = + val allMoves = rules.allLegalMoves(context) + if inCheck then allMoves else allMoves.filter(m => isCapture(context, m)) - 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 case MoveType.Normal(true) => true diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala b/modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala index 14d5a18..47e29f9 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala @@ -107,7 +107,7 @@ object MoveOrdering: val target = move.to val initialGain = victimValue(context, move) 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)) } @@ -132,7 +132,7 @@ object MoveOrdering: } .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 case MoveType.EnPassant => 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 = val valid = if diagonal then math.abs(df) == math.abs(dr) && df != 0 else (df == 0 && dr != 0) || (dr == 0 && df != 0) - if !valid then false - else pathClear(board, from, target, Integer.compare(df, 0), Integer.compare(dr, 0)) + valid && pathClear(board, from, target, Integer.compare(df, 0), Integer.compare(dr, 0)) @tailrec private def pathClear(board: Board, from: Square, target: Square, stepF: Int, stepR: Int): Boolean = diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala b/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala index b4221e1..b92bf73 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala @@ -1,7 +1,21 @@ package de.nowchess.bot.logic +import de.nowchess.api.game.GameContext 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]): def advance(nextHash: Long): SearchState = SearchState(nextHash, repetitions.updatedWith(nextHash) { diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala b/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala index b6fb3e4..48f03eb 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala @@ -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. */ def probe(context: GameContext): Option[Move] = val hash = PolyglotHash.hash(context) - println(f"0x${hash}%016X") + println(f"0x$hash%016X") entries.get(hash).flatMap { bookEntries => if bookEntries.isEmpty then None else @@ -49,7 +49,7 @@ final class PolyglotBook(path: String): val key = input.readLong() val move = input.readShort() val weight = input.readShort() - val learn = input.readInt() + input.readInt() // learning data (unused) val entry = BookEntry(key, move, weight) result.updateWith(key) { diff --git a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala index 50a72e7..1b3f7a4 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala @@ -1,8 +1,9 @@ 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.move.{Move, MoveType, PromotionPiece} +import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.rules.RuleSet @@ -12,6 +13,11 @@ import de.nowchess.rules.sets.DefaultRules 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"): val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic) 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 val search = AlphaBetaSearch(drawRules, weights = EvaluationClassic) 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)) + diff --git a/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala index 76bb2fb..f655059 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala @@ -3,13 +3,17 @@ package de.nowchess.bot import de.nowchess.api.board.{File, Rank, Square} import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType} +import de.nowchess.bot.ai.Evaluation 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.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite 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: 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 bot = HybridBot(BotDifficulty.Easy, onlyMoveRules) 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)) + diff --git a/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala index f52e0c2..9ce4e4f 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala @@ -9,6 +9,16 @@ import org.scalatest.matchers.should.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"): val board = Board( Map( @@ -197,3 +207,37 @@ class MoveOrderingTest extends AnyFunSuite with Matchers: MoveOrdering.score(context, knightPromo, None) should be > 0 MoveOrdering.score(context, bishopPromo, 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) + diff --git a/modules/ui/build.gradle.kts b/modules/ui/build.gradle.kts index 43aa3bd..2a930c2 100644 --- a/modules/ui/build.gradle.kts +++ b/modules/ui/build.gradle.kts @@ -23,11 +23,7 @@ scala { scoverage { scoverageVersion.set(versions["SCOVERAGE"]!!) - excludedPackages.set(listOf( - "de.nowchess.ui.gui", - "de.nowchess.ui.terminal", - "de.nowchess.ui.Main", - )) + excludedPackages.set(listOf("de\\.nowchess\\.ui\\..*")) } application {