From 1b5759828b7a780cf2c06928cc68743f0b86d521 Mon Sep 17 00:00:00 2001 From: Janis Date: Fri, 10 Apr 2026 17:25:12 +0200 Subject: [PATCH] feat: enhance AlphaBetaSearch with incremental search hash updates and improve move ordering logic --- CLAUDE.md | 2 + .../nowchess/bot/logic/AlphaBetaSearch.scala | 259 ++++++++---------- .../de/nowchess/bot/logic/MoveOrdering.scala | 180 ++++++++---- .../de/nowchess/bot/util/ZobristHash.scala | 83 +++++- .../de/nowchess/bot/MoveOrderingTest.scala | 12 +- .../de/nowchess/bot/ZobristHashTest.scala | 41 ++- 6 files changed, 359 insertions(+), 218 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 81f9b3f..b5ab597 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,8 @@ Try to stick to these commands for consistency. - **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens to events, not polling. GameEngine never imports UI code. - **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as a black box; rules don't know about the rest of core. - **Polyglot hash must follow spec index layout:** piece keys use interleaved mapping `(pieceType * 2 + colorBit)` with black=0/white=1, castling keys are `768..771`, en-passant file keys are `772..779` and are XORed only if side-to-move has a pawn that can capture en passant, side-to-move key is `780` for white. +- **Alpha-beta uses sequential PV search by default:** parallel split was disabled because fixed-window futures removed pruning effectiveness; correctness and pruning quality take priority over speculative parallelism. +- **Search hash is updated incrementally per move:** bot search now updates Zobrist keys from parent hash with move deltas instead of recomputing piece scans at every node. ## Rules 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 0846a7f..ba974a4 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 @@ -9,16 +9,11 @@ import de.nowchess.bot.util.ZobristHash import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules -import java.util.concurrent.ForkJoinPool -import java.util.concurrent.atomic.AtomicReference -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.{ExecutionContext, Future} - final class AlphaBetaSearch( rules: RuleSet = DefaultRules, tt: TranspositionTable = TranspositionTable(), weights: Weights, - numThreads: Int = Runtime.getRuntime().availableProcessors() + numThreads: Int = Runtime.getRuntime.availableProcessors ): private val INF = Int.MaxValue / 2 @@ -28,20 +23,21 @@ final class AlphaBetaSearch( private val ASPIRATION_DELTA_MAX = 150 private val TIME_CHECK_FREQUENCY = 1000 private val FUTILITY_MARGIN = 100 - private val PARALLEL_DEPTH_THRESHOLD = 4 + private val CHECK_EXTENSION = 1 @volatile private var timeStartMs = 0L @volatile private var timeLimitMs = 0L @volatile private var nodeCount = 0 private val ordering = MoveOrdering.OrderingContext() - private val threadPool = new ForkJoinPool(numThreads) - implicit private val executionContext: ExecutionContext = ExecutionContext.fromExecutor(threadPool) /** Return the best move for the side to move, searching to maxDepth plies. * Uses iterative deepening with aspiration windows. */ def bestMove(context: GameContext, maxDepth: Int): Option[Move] = tt.clear() ordering.clear() + timeStartMs = System.currentTimeMillis + timeLimitMs = Long.MaxValue / 4 + nodeCount = 0 var bestSoFar: Option[Move] = None var prevScore = 0 var aspWindow = ASPIRATION_DELTA @@ -49,7 +45,8 @@ final class AlphaBetaSearch( val (alpha, beta) = if depth == 1 then (-INF, INF) else (prevScore - aspWindow, prevScore + aspWindow) - val (score, move) = searchWithAspiration(context, depth, alpha, beta, aspWindow) + val rootHash = ZobristHash.hash(context) + val (score, move) = searchWithAspiration(context, depth, alpha, beta, aspWindow, rootHash) prevScore = score move.foreach(m => bestSoFar = Some(m)) aspWindow = ASPIRATION_DELTA @@ -60,7 +57,7 @@ final class AlphaBetaSearch( def bestMoveWithTime(context: GameContext, timeBudgetMs: Long): Option[Move] = tt.clear() ordering.clear() - timeStartMs = System.currentTimeMillis() + timeStartMs = System.currentTimeMillis timeLimitMs = timeBudgetMs nodeCount = 0 var bestSoFar: Option[Move] = None @@ -68,38 +65,40 @@ final class AlphaBetaSearch( var depth = 1 var aspWindow = ASPIRATION_DELTA - while !isOutOfTime() do + while !isOutOfTime do val (alpha, beta) = if depth == 1 then (-INF, INF) else (prevScore - aspWindow, prevScore + aspWindow) - val (score, move) = searchWithAspiration(context, depth, alpha, beta, aspWindow) - val elapsed = System.currentTimeMillis() - timeStartMs + val rootHash = ZobristHash.hash(context) + val (score, move) = searchWithAspiration(context, depth, alpha, beta, aspWindow, rootHash) prevScore = score - move.foreach { m => - bestSoFar = Some(m) - println(f"[Depth $depth%2d | ${elapsed}%5dms | ${nodeCount}%7d nodes] best=${m.from}->${m.to} score=$score") - } + move match + case Some(m) => + bestSoFar = Some(m) + case None => aspWindow = ASPIRATION_DELTA depth += 1 bestSoFar - private def isOutOfTime(): Boolean = - System.currentTimeMillis() - timeStartMs >= timeLimitMs + private def isOutOfTime: Boolean = + System.currentTimeMillis - timeStartMs >= timeLimitMs private def searchWithAspiration( context: GameContext, depth: Int, alpha: Int, beta: Int, - initialWindow: Int + initialWindow: Int, + rootHash: Long ): (Int, Option[Move]) = var currentAlpha = alpha var currentBeta = beta var window = initialWindow var attempt = 0 + val repetitions = Map(rootHash -> 1) while attempt < 3 && attempt < depth do - val (score, move) = search(context, depth, 0, currentAlpha, currentBeta) + val (score, move) = search(context, depth, 0, currentAlpha, currentBeta, rootHash, repetitions) if score > currentAlpha && score < currentBeta then return (score, move) if score <= currentAlpha then @@ -110,7 +109,7 @@ final class AlphaBetaSearch( window = math.min(window * 2, ASPIRATION_DELTA_MAX) attempt += 1 - search(context, depth, 0, -INF, INF) + search(context, depth, 0, -INF, INF, rootHash, repetitions) private def hasNonPawnMaterial(context: GameContext): Boolean = context.board.pieces.values.exists { piece => @@ -126,11 +125,17 @@ final class AlphaBetaSearch( context: GameContext, depth: Int, ply: Int, - beta: Int + beta: Int, + repetitions: Map[Long, Int] ): Option[Int] = val nullCtx = nullMoveContext(context) + val nullHash = ZobristHash.hash(nullCtx) + val nullRepetitions = repetitions.updatedWith(nullHash) { + case Some(v) => Some(v + 1) + case None => Some(1) + } val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R) - val (score, _) = search(nullCtx, reductionDepth, ply + 1, -beta, -beta + 1) + val (score, _) = search(nullCtx, reductionDepth, ply + 1, -beta, -beta + 1, nullHash, nullRepetitions) if -score >= beta then Some(beta) else None /** Negamax alpha-beta search returning (score, best move). */ @@ -139,30 +144,30 @@ final class AlphaBetaSearch( depth: Int, ply: Int, alpha: Int, - beta: Int + beta: Int, + hash: Long, + repetitions: Map[Long, Int] ): (Int, Option[Move]) = // Periodic time check nodeCount += 1 - if nodeCount % TIME_CHECK_FREQUENCY == 0 && isOutOfTime() then + if nodeCount % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then return (weights.evaluate(context), None) - val hash = ZobristHash.hash(context) + if repetitions.getOrElse(hash, 0) >= 3 then + return (weights.DRAW_SCORE, None) // TT probe - tt.probe(hash).foreach { entry => - if entry.depth >= depth then + tt.probe(hash) match + case Some(entry) if entry.depth >= depth => entry.flag match - case TTFlag.Exact => - return (entry.score, entry.bestMove) + case TTFlag.Exact => return (entry.score, entry.bestMove) case TTFlag.Lower => val newAlpha = math.max(alpha, entry.score) - if newAlpha >= beta then - return (entry.score, entry.bestMove) + if newAlpha >= beta then return (entry.score, entry.bestMove) case TTFlag.Upper => val newBeta = math.min(beta, entry.score) - if alpha >= newBeta then - return (entry.score, entry.bestMove) - } + if alpha >= newBeta then return (entry.score, entry.bestMove) + case _ => // Terminal node check val legalMoves = rules.allLegalMoves(context) @@ -182,9 +187,9 @@ final class AlphaBetaSearch( // Null move pruning if depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context) then - tryNullMove(context, depth, ply, beta).foreach { score => - return (score, None) - } + tryNullMove(context, depth, ply, beta, repetitions) match + case Some(score) => return (score, None) + case None => // Get TT best move for ordering val ttBest = tt.probe(hash).flatMap(_.bestMove) @@ -192,11 +197,7 @@ final class AlphaBetaSearch( // Order moves val ordered = MoveOrdering.sort(context, legalMoves, ttBest, ply, ordering) - // Use parallel search if depth >= threshold and not root - if depth >= PARALLEL_DEPTH_THRESHOLD && ply > 0 then - return searchParallel(context, depth, ply, alpha, beta, ordered, hash) - else - return searchSequential(context, depth, ply, alpha, beta, ordered, hash) + searchSequential(context, depth, ply, alpha, beta, ordered, hash, repetitions) private def searchSequential( context: GameContext, @@ -205,126 +206,72 @@ final class AlphaBetaSearch( alpha: Int, beta: Int, ordered: List[Move], - hash: Long + hash: Long, + repetitions: Map[Long, Int] ): (Int, Option[Move]) = var bestMove: Option[Move] = None var bestScore = -INF var a = alpha var moveNumber = 0 + var cutoff = false - for move <- ordered do + var idx = 0 + while idx < ordered.length && !cutoff do + val move = ordered(idx) + idx += 1 moveNumber += 1 - val isQuiet = !isCapture(move) && + val isQuiet = !isCapture(context, move) && move.moveType != MoveType.CastleKingside && move.moveType != MoveType.CastleQueenside // Futility pruning at frontier nodes: if static eval + margin is still below alpha, skip quiet moves - if depth == 1 && isQuiet && moveNumber > 2 then + val pruneByFutility = if depth == 1 && isQuiet && moveNumber > 2 then val staticEval = weights.evaluate(context) - if staticEval + FUTILITY_MARGIN < alpha then - moveNumber += 1 + staticEval + FUTILITY_MARGIN < alpha + else false - val child = rules.applyMove(context)(move) - val reduction = if moveNumber > 4 && depth >= 3 && isQuiet then 1 else 0 - - val score = if reduction > 0 then - val (reducedScore, _) = search(child, depth - 1 - reduction, ply + 1, -a - 1, -a) - val s = -reducedScore - if s > a then - val (fullScore, _) = search(child, depth - 1, ply + 1, -beta, -a) - -fullScore - else s - else - val (rawScore, _) = search(child, depth - 1, ply + 1, -beta, -a) - -rawScore - - if score > bestScore then - bestScore = score - bestMove = Some(move) - - a = math.max(a, score) - - // Track history heuristic - if isQuiet then - val fromIdx = move.from.rank.ordinal * 8 + move.from.file.ordinal - val toIdx = move.to.rank.ordinal * 8 + move.to.file.ordinal - ordering.addHistory(fromIdx, toIdx, depth * depth) - - if a >= beta then - // Record killer move - if isQuiet then - ordering.addKillerMove(ply, move) - tt.store(TTEntry(hash, depth, bestScore, TTFlag.Lower, bestMove)) - return (bestScore, bestMove) - - // No cutoff: determine flag - val flag = - if bestScore <= alpha then TTFlag.Upper - else TTFlag.Exact - tt.store(TTEntry(hash, depth, bestScore, flag, bestMove)) - (bestScore, bestMove) - - private def searchParallel( - context: GameContext, - depth: Int, - ply: Int, - alpha: Int, - beta: Int, - ordered: List[Move], - hash: Long - ): (Int, Option[Move]) = - val results = new AtomicReference[(Int, Option[Move], Boolean)]((-INF, None, false)) - val windowRef = new AtomicReference((alpha, beta)) - - val moveScores = ordered.zipWithIndex.map { case (move, moveIdx) => - Future { - val isQuiet = !isCapture(move) && - move.moveType != MoveType.CastleKingside && - move.moveType != MoveType.CastleQueenside + if !pruneByFutility then val child = rules.applyMove(context)(move) - val reduction = if moveIdx > 4 && depth >= 3 && isQuiet then 1 else 0 + val childHash = ZobristHash.nextHash(context, hash, move, child) + val childRepetitions = repetitions.updatedWith(childHash) { + case Some(v) => Some(v + 1) + case None => Some(1) + } + 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 (reducedScore, _) = search(child, depth - 1 - reduction, ply + 1, -beta, -alpha) + val reducedDepth = math.max(0, depth - 1 - reduction + extension) + val (reducedScore, _) = search(child, reducedDepth, ply + 1, -a - 1, -a, childHash, childRepetitions) val s = -reducedScore - if s > alpha then - val (fullScore, _) = search(child, depth - 1, ply + 1, -beta, -alpha) + if s > a then + val fullDepth = math.max(0, depth - 1 + extension) + val (fullScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childHash, childRepetitions) -fullScore else s else - val (rawScore, _) = search(child, depth - 1, ply + 1, -beta, -alpha) + val fullDepth = math.max(0, depth - 1 + extension) + val (rawScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childHash, childRepetitions) -rawScore - // Track history heuristic - if isQuiet then - val fromIdx = move.from.rank.ordinal * 8 + move.from.file.ordinal - val toIdx = move.to.rank.ordinal * 8 + move.to.file.ordinal - ordering.addHistory(fromIdx, toIdx, depth * depth) - - (move, score, isQuiet) - } - } - - var bestMove: Option[Move] = None - var bestScore = -INF - var cutoffFound = false - - for future <- moveScores do - if !cutoffFound then - val (move, score, isQuiet) = scala.concurrent.Await.result(future, scala.concurrent.duration.Duration.Inf) - if score > bestScore then bestScore = score bestMove = Some(move) - if bestScore >= beta then - if isQuiet then - ordering.addKillerMove(ply, move) - cutoffFound = true + a = math.max(a, score) + + if a >= beta then + if isQuiet then + val fromIdx = move.from.rank.ordinal * 8 + move.from.file.ordinal + val toIdx = move.to.rank.ordinal * 8 + move.to.file.ordinal + ordering.addHistory(fromIdx, toIdx, depth * depth) + ordering.addKillerMove(ply, move) + cutoff = true - // No cutoff: determine flag val flag = - if bestScore <= alpha then TTFlag.Upper + if cutoff then TTFlag.Lower + else if bestScore <= alpha then TTFlag.Upper else TTFlag.Exact tt.store(TTEntry(hash, depth, bestScore, flag, bestMove)) (bestScore, bestMove) @@ -336,33 +283,43 @@ final class AlphaBetaSearch( alpha: Int, beta: Int ): Int = - // Stand-pat: evaluate current position - val standPat = weights.evaluate(context) - if standPat >= beta then + val inCheck = rules.isCheck(context) + val standPat = if inCheck then -INF else weights.evaluate(context) + if !inCheck && standPat >= beta then return beta - var a = math.max(alpha, standPat) + var a = if inCheck then alpha else math.max(alpha, standPat) // Guard against infinite quiescence if ply >= MAX_QUIESCENCE_PLY then - return standPat + return if inCheck then weights.evaluate(context) else standPat - // Generate only captures + // Generate captures, or all evasions when side-to-move is in check. val allMoves = rules.allLegalMoves(context) - val captures = allMoves.filter(isCapture) - val ordered = MoveOrdering.sort(context, captures, None) + val tacticalMoves = if inCheck then allMoves else allMoves.filter(m => isCapture(context, m)) - for move <- ordered do + if inCheck && tacticalMoves.isEmpty then + return -(weights.CHECKMATE_SCORE - ply) + + val ordered = MoveOrdering.sort(context, tacticalMoves, None) + + var cutoff = false + var idx = 0 + while idx < ordered.length && !cutoff do + val move = ordered(idx) + idx += 1 val child = rules.applyMove(context)(move) val score = -quiescence(child, ply + 1, -beta, -a) if score >= beta then - return beta - a = math.max(a, score) + a = beta + cutoff = true + else + a = math.max(a, score) a - /** Predicate: is a move a capture (including promotions)? */ - private def isCapture(move: Move): Boolean = move.moveType match + /** Predicate: context-aware capture classification. */ + private def isCapture(context: GameContext, move: Move): Boolean = move.moveType match case MoveType.Normal(true) => true case MoveType.EnPassant => true - case MoveType.Promotion(_) => true + case MoveType.Promotion(_) => context.board.pieceAt(move.to).exists(_.color != context.turn) case _ => false 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 876c7d4..653b321 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 @@ -1,18 +1,16 @@ package de.nowchess.bot.logic -import de.nowchess.api.board.PieceType +import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square} import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import scala.annotation.tailrec import scala.collection.mutable object MoveOrdering: class OrderingContext: - // Killer moves: 2 per ply (depth) to track moves that caused cutoffs private val killerMoves = mutable.Map[Int, List[Move]]() - - // History heuristic: tracks how often a move improved alpha private val historyTable = mutable.Map[(Int, Int), Int]() def addKillerMove(ply: Int, move: Move): Unit = @@ -34,7 +32,6 @@ object MoveOrdering: killerMoves.clear() historyTable.clear() - /** Score a single move for ordering (higher = search first). */ def score( context: GameContext, move: Move, @@ -42,30 +39,16 @@ object MoveOrdering: ply: Int = 0, ordering: OrderingContext = new OrderingContext() ): Int = - if ttBestMove.exists(m => m.from == move.from && m.to == move.to) then - Int.MaxValue // TT best move always first - else - move.moveType match - case MoveType.Promotion(PromotionPiece.Queen) => - 1_000_000 // Queen promotion is always good - case MoveType.Normal(true) => - 100_000 + mvvLva(context, move) // Capture - case MoveType.EnPassant => - 100_000 + mvvLva(context, move) // En passant is a pawn capture - case MoveType.Promotion(_) => - 50_000 + mvvLva(context, move) // Minor/rook/bishop promotion - case _ => - scoreQuietMove(move, ply, ordering) // Quiet move with history/killer heuristic + if ttBestMove.exists(m => m.from == move.from && m.to == move.to) then Int.MaxValue + else move.moveType match + case MoveType.Promotion(PromotionPiece.Queen) => + 1_000_000 + promotionCaptureBonus(context, move) + case MoveType.Normal(true) | MoveType.EnPassant => + captureScore(context, move) + case MoveType.Promotion(_) => + 50_000 + promotionCaptureBonus(context, move) + case _ => scoreQuietMove(move, ply, ordering) - private def scoreQuietMove(move: Move, ply: Int, ordering: OrderingContext): Int = - val isKiller = ordering.getKillerMoves(ply).exists(k => k.from == move.from && k.to == move.to) - val fromIdx = move.from.rank.ordinal * 8 + move.from.file.ordinal - val toIdx = move.to.rank.ordinal * 8 + move.to.file.ordinal - val history = ordering.getHistory(fromIdx, toIdx) - if isKiller then 10_000 + (history / 10) - else history / 10 - - /** Sort moves: TT best move first, then by score descending. */ def sort( context: GameContext, moves: List[Move], @@ -75,40 +58,121 @@ object MoveOrdering: ): List[Move] = moves.sortBy(m => -score(context, m, ttBestMove, ply, ordering)) - /** MVV-LVA score: (victim value * 10) - attacker value. - * Higher score = better trade (most valuable victim captured by least valuable attacker). */ + private def scoreQuietMove(move: Move, ply: Int, ordering: OrderingContext): Int = + val isKiller = ordering.getKillerMoves(ply).exists(k => k.from == move.from && k.to == move.to) + val fromIdx = move.from.rank.ordinal * 8 + move.from.file.ordinal + val toIdx = move.to.rank.ordinal * 8 + move.to.file.ordinal + val history = ordering.getHistory(fromIdx, toIdx) + if isKiller then 10_000 + (history / 10) else history / 10 + + private def promotionCaptureBonus(context: GameContext, move: Move): Int = + if isCapture(context, move) then captureScore(context, move) else 0 + + private def captureScore(context: GameContext, move: Move): Int = + val see = staticExchange(context, move) + val seeBias = if see >= 0 then 20_000 else -20_000 + 100_000 + mvvLva(context, move) + seeBias + see + private def mvvLva(context: GameContext, move: Move): Int = - val victim = victimValue(context, move) - val attacker = attackerValue(context, move) - (victim * 10) - attacker + (victimValue(context, move) * 10) - attackerValue(context, move) - /** Material value of the attacking piece. */ private def attackerValue(context: GameContext, move: Move): Int = - context.board.pieceAt(move.from) match - case Some(piece) => - piece.pieceType match - case PieceType.Pawn => 1 - case PieceType.Knight => 3 - case PieceType.Bishop => 3 - case PieceType.Rook => 5 - case PieceType.Queen => 9 - case PieceType.King => 200 // King never captures, but include for completeness - case None => 0 + context.board.pieceAt(move.from).map(pieceValue).getOrElse(0) - /** Material value of the captured piece. */ private def victimValue(context: GameContext, move: Move): Int = move.moveType match - case MoveType.Normal(true) => - context.board.pieceAt(move.to) match - case Some(piece) => - piece.pieceType match - case PieceType.Pawn => 1 - case PieceType.Knight => 3 - case PieceType.Bishop => 3 - case PieceType.Rook => 5 - case PieceType.Queen => 9 - case PieceType.King => 200 - case None => 0 - case MoveType.EnPassant => 1 // En passant captures a pawn - case MoveType.Promotion(_) => 0 // Promotion is not a capture (destination is empty) + case MoveType.Normal(true) => context.board.pieceAt(move.to).map(pieceValue).getOrElse(0) + case MoveType.EnPassant => 1 + case MoveType.Promotion(_) => context.board.pieceAt(move.to).map(pieceValue).getOrElse(0) case _ => 0 + + private def pieceValue(piece: Piece): Int = piece.pieceType match + case PieceType.Pawn => 1 + case PieceType.Knight => 3 + case PieceType.Bishop => 3 + case PieceType.Rook => 5 + case PieceType.Queen => 9 + case PieceType.King => 200 + + private def isCapture(context: GameContext, move: Move): Boolean = move.moveType match + case MoveType.Normal(true) => true + case MoveType.EnPassant => true + case MoveType.Promotion(_) => context.board.pieceAt(move.to).exists(_.color != context.turn) + case _ => false + + private def staticExchange(context: GameContext, move: Move): Int = + if !isCapture(context, move) then 0 + else + val target = move.to + val initialGain = victimValue(context, move) + movedPieceAfterMove(context, move).fold(initialGain) { moved => + val boardAfterMove = applySeeMove(context.board, context, move, moved) + exchangeGain(boardAfterMove, target, context.turn.opposite, pieceValue(moved), Vector(initialGain)) + } + + private def movedPieceAfterMove(context: GameContext, move: Move): Option[Piece] = + move.moveType match + case MoveType.Promotion(pp) => Some(Piece(context.turn, promotionPieceType(pp))) + case _ => context.board.pieceAt(move.from) + + @tailrec + private def exchangeGain(board: Board, target: Square, side: Color, capturedValue: Int, gains: Vector[Int]): Int = + leastValuableAttacker(board, target, side) match + case None => resolveGain(gains) + case Some((from, attacker)) => + val nextGain = capturedValue - gains.last + val nextBoard = board.removed(from).updated(target, attacker) + exchangeGain(nextBoard, target, side.opposite, pieceValue(attacker), gains :+ nextGain) + + private def resolveGain(gains: Vector[Int]): Int = + (gains.length - 2 to 0 by -1).foldLeft(gains) { (acc, idx) => + acc.updated(idx, math.max(acc(idx), -acc(idx + 1))) + }.head + + private def applySeeMove(board: Board, context: GameContext, move: Move, moved: Piece): Board = + move.moveType match + case MoveType.EnPassant => + val capturedSquare = Square(move.to.file, move.from.rank) + board.removed(move.from).removed(capturedSquare).updated(move.to, moved) + case _ => board.removed(move.from).updated(move.to, moved) + + private def leastValuableAttacker(board: Board, target: Square, color: Color): Option[(Square, Piece)] = + board.pieces.collect { + case (sq, piece) if piece.color == color && attacksSquare(board, sq, target, piece) => (sq, piece) + }.toList.sortBy { case (_, piece) => pieceValue(piece) }.headOption + + private def attacksSquare(board: Board, from: Square, target: Square, piece: Piece): Boolean = + val df = target.file.ordinal - from.file.ordinal + val dr = target.rank.ordinal - from.rank.ordinal + piece.pieceType match + case PieceType.Pawn => + val dir = if piece.color == Color.White then 1 else -1 + dr == dir && math.abs(df) == 1 + case PieceType.Knight => + val adf = math.abs(df) + val adr = math.abs(dr) + (adf == 1 && adr == 2) || (adf == 2 && adr == 1) + case PieceType.Bishop => clearLine(board, from, target, df, dr, diagonal = true) + case PieceType.Rook => clearLine(board, from, target, df, dr, diagonal = false) + case PieceType.Queen => + clearLine(board, from, target, df, dr, diagonal = true) || + clearLine(board, from, target, df, dr, diagonal = false) + case PieceType.King => math.abs(df) <= 1 && math.abs(dr) <= 1 + + 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)) + + @tailrec + private def pathClear(board: Board, from: Square, target: Square, stepF: Int, stepR: Int): Boolean = + from.offset(stepF, stepR) match + case None => false + case Some(next) if next == target => true + case Some(next) => board.pieceAt(next).isEmpty && pathClear(board, next, target, stepF, stepR) + + private def promotionPieceType(piece: PromotionPiece): PieceType = piece match + case PromotionPiece.Knight => PieceType.Knight + case PromotionPiece.Bishop => PieceType.Bishop + case PromotionPiece.Rook => PieceType.Rook + case PromotionPiece.Queen => PieceType.Queen diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala b/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala index 3685f08..be82e84 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala @@ -1,7 +1,8 @@ package de.nowchess.bot.util -import de.nowchess.api.board.{Color, File, PieceType, Square} +import de.nowchess.api.board.{Color, Piece, PieceType, Rank, Square} import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import scala.util.Random @@ -59,3 +60,83 @@ object ZobristHash: context.enPassantSquare.foreach(sq => h ^= enPassantRands(sq.file.ordinal)) h + + def nextHash(context: GameContext, currentHash: Long, move: Move, nextContext: GameContext): Long = + var h = currentHash + h ^= sideToMoveRand + h = toggleCastling(h, context, nextContext) + h = toggleEnPassant(h, context, nextContext) + + move.moveType match + case MoveType.CastleKingside | MoveType.CastleQueenside => + h = applyCastleDelta(h, context.turn, move.moveType == MoveType.CastleKingside) + case MoveType.EnPassant => + h = applyEnPassantDelta(h, context, move) + case MoveType.Promotion(piece) => + h = applyPromotionDelta(h, context, move, piece) + case MoveType.Normal(_) => + h = applyNormalDelta(h, context, move) + + h + + private def applyNormalDelta(h0: Long, context: GameContext, move: Move): Long = + context.board.pieceAt(move.from).fold(h0) { mover => + var h = h0 ^ pieceKey(move.from, mover) + context.board.pieceAt(move.to).foreach(captured => h ^= pieceKey(move.to, captured)) + h ^ pieceKey(move.to, mover) + } + + private def applyPromotionDelta(h0: Long, context: GameContext, move: Move, promoted: PromotionPiece): Long = + context.board.pieceAt(move.from).fold(h0) { pawn => + var h = h0 ^ pieceKey(move.from, pawn) + context.board.pieceAt(move.to).foreach(captured => h ^= pieceKey(move.to, captured)) + h ^ pieceKey(move.to, Piece(context.turn, promotedPieceType(promoted))) + } + + private def applyEnPassantDelta(h0: Long, context: GameContext, move: Move): Long = + context.board.pieceAt(move.from).fold(h0) { pawn => + val capturedSquare = Square(move.to.file, move.from.rank) + var h = h0 ^ pieceKey(move.from, pawn) + context.board.pieceAt(capturedSquare).foreach(captured => h ^= pieceKey(capturedSquare, captured)) + h ^ pieceKey(move.to, pawn) + } + + private def applyCastleDelta(h0: Long, color: Color, kingside: Boolean): Long = + val rank = if color == Color.White then Rank.R1 else Rank.R8 + val (kingFrom, kingTo, rookFrom, rookTo) = + if kingside then + (Square(de.nowchess.api.board.File.E, rank), Square(de.nowchess.api.board.File.G, rank), + Square(de.nowchess.api.board.File.H, rank), Square(de.nowchess.api.board.File.F, rank)) + else + (Square(de.nowchess.api.board.File.E, rank), Square(de.nowchess.api.board.File.C, rank), + Square(de.nowchess.api.board.File.A, rank), Square(de.nowchess.api.board.File.D, rank)) + val king = Piece(color, PieceType.King) + val rook = Piece(color, PieceType.Rook) + h0 ^ pieceKey(kingFrom, king) ^ pieceKey(kingTo, king) ^ pieceKey(rookFrom, rook) ^ pieceKey(rookTo, rook) + + private def promotedPieceType(promotion: PromotionPiece): PieceType = promotion match + case PromotionPiece.Knight => PieceType.Knight + case PromotionPiece.Bishop => PieceType.Bishop + case PromotionPiece.Rook => PieceType.Rook + case PromotionPiece.Queen => PieceType.Queen + + private def toggleCastling(h0: Long, before: GameContext, after: GameContext): Long = + var h = h0 + if before.castlingRights.whiteKingSide != after.castlingRights.whiteKingSide then h ^= castlingRands(0) + if before.castlingRights.whiteQueenSide != after.castlingRights.whiteQueenSide then h ^= castlingRands(1) + if before.castlingRights.blackKingSide != after.castlingRights.blackKingSide then h ^= castlingRands(2) + if before.castlingRights.blackQueenSide != after.castlingRights.blackQueenSide then h ^= castlingRands(3) + h + + private def toggleEnPassant(h0: Long, before: GameContext, after: GameContext): Long = + var h = h0 + before.enPassantSquare.foreach(sq => h ^= enPassantRands(sq.file.ordinal)) + after.enPassantSquare.foreach(sq => h ^= enPassantRands(sq.file.ordinal)) + h + + private def pieceKey(square: Square, piece: Piece): Long = + val squareIndex = square.rank.ordinal * 8 + square.file.ordinal + val colorIndex = if piece.color == Color.White then 0 else 1 + val pieceIndex = colorIndex * 6 + piece.pieceType.ordinal + pieceRands(squareIndex * 12 + pieceIndex) + 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 488f116..59525b5 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala @@ -1,6 +1,6 @@ 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.logic.MoveOrdering @@ -30,7 +30,7 @@ class MoveOrderingTest extends AnyFunSuite with Matchers: )) val context = GameContext.initial.withBoard(board).withTurn(Color.White) val capture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true)) - val quiet = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(false)) + val quiet = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5)) val captureScore = MoveOrdering.score(context, capture, None) val quietScore = MoveOrdering.score(context, quiet, None) @@ -71,7 +71,7 @@ class MoveOrderingTest extends AnyFunSuite with Matchers: )) val context = GameContext.initial.withBoard(board).withTurn(Color.White) val epCapture = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant) - val quiet = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal(false)) + val quiet = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6)) val epScore = MoveOrdering.score(context, epCapture, None) val quietScore = MoveOrdering.score(context, quiet, None) @@ -87,7 +87,7 @@ class MoveOrderingTest extends AnyFunSuite with Matchers: val moves = List( Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true)), // Rook capture Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true)), // Pawn capture - Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6), MoveType.Normal(false)) // Quiet + Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6)) // Quiet ) val sorted = MoveOrdering.sort(context, moves, None) // Rook capture should be first (higher victim value) @@ -157,6 +157,4 @@ class MoveOrderingTest extends AnyFunSuite with Matchers: val quietPromotion = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)) val score1 = MoveOrdering.score(context, promotionWithCapture, None) val score2 = MoveOrdering.score(context, quietPromotion, None) - // Both should score high, but let's just verify they're scored - score1 should be > 0 - score2 should be > 0 + score1 should be > score2 diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala index a68406d..e183143 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala @@ -2,8 +2,9 @@ package de.nowchess.bot import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square} import de.nowchess.api.game.GameContext -import de.nowchess.api.move.{Move, MoveType} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.bot.util.ZobristHash +import de.nowchess.rules.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -44,3 +45,41 @@ class ZobristHashTest extends AnyFunSuite with Matchers: val hash1 = ZobristHash.hash(ctx1) val hash2 = ZobristHash.hash(ctx2) hash1 should not equal hash2 + + test("nextHash matches recomputed hash for a normal move"): + val context = GameContext.initial + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) + val next = DefaultRules.applyMove(context)(move) + val incremental = ZobristHash.nextHash(context, ZobristHash.hash(context), move, next) + incremental should equal(ZobristHash.hash(next)) + + test("nextHash matches recomputed hash for promotion and castling"): + val promotionBoard = Board(Map( + Square(File.E, Rank.R7) -> Piece.WhitePawn, + Square(File.E, Rank.R1) -> Piece.WhiteKing, + Square(File.H, Rank.R1) -> Piece.WhiteRook, + Square(File.E, Rank.R8) -> Piece.BlackKing + )) + val promotionContext = GameContext.initial + .withBoard(promotionBoard) + .withTurn(Color.White) + .withCastlingRights(CastlingRights.All) + val promotionMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)) + val promotionNext = DefaultRules.applyMove(promotionContext)(promotionMove) + val promotionHash = ZobristHash.nextHash(promotionContext, ZobristHash.hash(promotionContext), promotionMove, promotionNext) + promotionHash should equal(ZobristHash.hash(promotionNext)) + + val castleBoard = Board(Map( + Square(File.E, Rank.R1) -> Piece.WhiteKing, + Square(File.H, Rank.R1) -> Piece.WhiteRook, + Square(File.E, Rank.R8) -> Piece.BlackKing + )) + val castleContext = GameContext.initial + .withBoard(castleBoard) + .withTurn(Color.White) + .withCastlingRights(CastlingRights(whiteKingSide = true, whiteQueenSide = false, blackKingSide = false, blackQueenSide = false)) + val castleMove = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside) + val castleNext = DefaultRules.applyMove(castleContext)(castleMove) + val castleHash = ZobristHash.nextHash(castleContext, ZobristHash.hash(castleContext), castleMove, castleNext) + castleHash should equal(ZobristHash.hash(castleNext)) +