feat: enhance AlphaBetaSearch with incremental search hash updates and improve move ordering logic

This commit is contained in:
2026-04-10 17:25:12 +02:00
parent 75286a8773
commit 1b5759828b
6 changed files with 359 additions and 218 deletions
+2
View File
@@ -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. - **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. - **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. - **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 ## Rules
@@ -9,16 +9,11 @@ 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
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( final class AlphaBetaSearch(
rules: RuleSet = DefaultRules, rules: RuleSet = DefaultRules,
tt: TranspositionTable = TranspositionTable(), tt: TranspositionTable = TranspositionTable(),
weights: Weights, weights: Weights,
numThreads: Int = Runtime.getRuntime().availableProcessors() numThreads: Int = Runtime.getRuntime.availableProcessors
): ):
private val INF = Int.MaxValue / 2 private val INF = Int.MaxValue / 2
@@ -28,20 +23,21 @@ final class AlphaBetaSearch(
private val ASPIRATION_DELTA_MAX = 150 private val ASPIRATION_DELTA_MAX = 150
private val TIME_CHECK_FREQUENCY = 1000 private val TIME_CHECK_FREQUENCY = 1000
private val FUTILITY_MARGIN = 100 private val FUTILITY_MARGIN = 100
private val PARALLEL_DEPTH_THRESHOLD = 4 private val CHECK_EXTENSION = 1
@volatile private var timeStartMs = 0L @volatile private var timeStartMs = 0L
@volatile private var timeLimitMs = 0L @volatile private var timeLimitMs = 0L
@volatile private var nodeCount = 0 @volatile private var nodeCount = 0
private val ordering = MoveOrdering.OrderingContext() 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. /** Return the best move for the side to move, searching to maxDepth plies.
* Uses iterative deepening with aspiration windows. */ * Uses iterative deepening with aspiration windows. */
def bestMove(context: GameContext, maxDepth: Int): Option[Move] = def bestMove(context: GameContext, maxDepth: Int): Option[Move] =
tt.clear() tt.clear()
ordering.clear() ordering.clear()
timeStartMs = System.currentTimeMillis
timeLimitMs = Long.MaxValue / 4
nodeCount = 0
var bestSoFar: Option[Move] = None var bestSoFar: Option[Move] = None
var prevScore = 0 var prevScore = 0
var aspWindow = ASPIRATION_DELTA var aspWindow = ASPIRATION_DELTA
@@ -49,7 +45,8 @@ final class AlphaBetaSearch(
val (alpha, beta) = val (alpha, beta) =
if depth == 1 then (-INF, INF) if depth == 1 then (-INF, INF)
else (prevScore - aspWindow, prevScore + aspWindow) 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 prevScore = score
move.foreach(m => bestSoFar = Some(m)) move.foreach(m => bestSoFar = Some(m))
aspWindow = ASPIRATION_DELTA aspWindow = ASPIRATION_DELTA
@@ -60,7 +57,7 @@ final class AlphaBetaSearch(
def bestMoveWithTime(context: GameContext, timeBudgetMs: Long): Option[Move] = def bestMoveWithTime(context: GameContext, timeBudgetMs: Long): Option[Move] =
tt.clear() tt.clear()
ordering.clear() ordering.clear()
timeStartMs = System.currentTimeMillis() timeStartMs = System.currentTimeMillis
timeLimitMs = timeBudgetMs timeLimitMs = timeBudgetMs
nodeCount = 0 nodeCount = 0
var bestSoFar: Option[Move] = None var bestSoFar: Option[Move] = None
@@ -68,38 +65,40 @@ final class AlphaBetaSearch(
var depth = 1 var depth = 1
var aspWindow = ASPIRATION_DELTA var aspWindow = ASPIRATION_DELTA
while !isOutOfTime() do while !isOutOfTime do
val (alpha, beta) = val (alpha, beta) =
if depth == 1 then (-INF, INF) if depth == 1 then (-INF, INF)
else (prevScore - aspWindow, prevScore + aspWindow) else (prevScore - aspWindow, prevScore + aspWindow)
val (score, move) = searchWithAspiration(context, depth, alpha, beta, aspWindow) val rootHash = ZobristHash.hash(context)
val elapsed = System.currentTimeMillis() - timeStartMs val (score, move) = searchWithAspiration(context, depth, alpha, beta, aspWindow, rootHash)
prevScore = score prevScore = score
move.foreach { m => move match
bestSoFar = Some(m) case Some(m) =>
println(f"[Depth $depth%2d | ${elapsed}%5dms | ${nodeCount}%7d nodes] best=${m.from}->${m.to} score=$score") bestSoFar = Some(m)
} case None =>
aspWindow = ASPIRATION_DELTA aspWindow = ASPIRATION_DELTA
depth += 1 depth += 1
bestSoFar bestSoFar
private def isOutOfTime(): Boolean = private def isOutOfTime: Boolean =
System.currentTimeMillis() - timeStartMs >= timeLimitMs System.currentTimeMillis - timeStartMs >= timeLimitMs
private def searchWithAspiration( private def searchWithAspiration(
context: GameContext, context: GameContext,
depth: Int, depth: Int,
alpha: Int, alpha: Int,
beta: Int, beta: Int,
initialWindow: Int initialWindow: Int,
rootHash: Long
): (Int, Option[Move]) = ): (Int, Option[Move]) =
var currentAlpha = alpha var currentAlpha = alpha
var currentBeta = beta var currentBeta = beta
var window = initialWindow var window = initialWindow
var attempt = 0 var attempt = 0
val repetitions = Map(rootHash -> 1)
while attempt < 3 && attempt < depth do 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 if score > currentAlpha && score < currentBeta then
return (score, move) return (score, move)
if score <= currentAlpha then if score <= currentAlpha then
@@ -110,7 +109,7 @@ final class AlphaBetaSearch(
window = math.min(window * 2, ASPIRATION_DELTA_MAX) window = math.min(window * 2, ASPIRATION_DELTA_MAX)
attempt += 1 attempt += 1
search(context, depth, 0, -INF, INF) search(context, depth, 0, -INF, INF, rootHash, repetitions)
private def hasNonPawnMaterial(context: GameContext): Boolean = private def hasNonPawnMaterial(context: GameContext): Boolean =
context.board.pieces.values.exists { piece => context.board.pieces.values.exists { piece =>
@@ -126,11 +125,17 @@ final class AlphaBetaSearch(
context: GameContext, context: GameContext,
depth: Int, depth: Int,
ply: Int, ply: Int,
beta: Int beta: Int,
repetitions: Map[Long, Int]
): Option[Int] = ): Option[Int] =
val nullCtx = nullMoveContext(context) 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 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 if -score >= beta then Some(beta) else None
/** Negamax alpha-beta search returning (score, best move). */ /** Negamax alpha-beta search returning (score, best move). */
@@ -139,30 +144,30 @@ final class AlphaBetaSearch(
depth: Int, depth: Int,
ply: Int, ply: Int,
alpha: Int, alpha: Int,
beta: Int beta: Int,
hash: Long,
repetitions: Map[Long, Int]
): (Int, Option[Move]) = ): (Int, Option[Move]) =
// Periodic time check // Periodic time check
nodeCount += 1 nodeCount += 1
if nodeCount % TIME_CHECK_FREQUENCY == 0 && isOutOfTime() then if nodeCount % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then
return (weights.evaluate(context), None) 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
tt.probe(hash).foreach { entry => tt.probe(hash) match
if entry.depth >= depth then case Some(entry) if entry.depth >= depth =>
entry.flag match entry.flag match
case TTFlag.Exact => case TTFlag.Exact => return (entry.score, entry.bestMove)
return (entry.score, entry.bestMove)
case TTFlag.Lower => case TTFlag.Lower =>
val newAlpha = math.max(alpha, entry.score) val newAlpha = math.max(alpha, entry.score)
if newAlpha >= beta then if newAlpha >= beta then return (entry.score, entry.bestMove)
return (entry.score, entry.bestMove)
case TTFlag.Upper => case TTFlag.Upper =>
val newBeta = math.min(beta, entry.score) val newBeta = math.min(beta, entry.score)
if alpha >= newBeta then if alpha >= newBeta then return (entry.score, entry.bestMove)
return (entry.score, entry.bestMove) case _ =>
}
// Terminal node check // Terminal node check
val legalMoves = rules.allLegalMoves(context) val legalMoves = rules.allLegalMoves(context)
@@ -182,9 +187,9 @@ final class AlphaBetaSearch(
// Null move pruning // Null move pruning
if depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context) then if depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context) then
tryNullMove(context, depth, ply, beta).foreach { score => tryNullMove(context, depth, ply, beta, repetitions) match
return (score, None) case Some(score) => return (score, None)
} case None =>
// Get TT best move for ordering // Get TT best move for ordering
val ttBest = tt.probe(hash).flatMap(_.bestMove) val ttBest = tt.probe(hash).flatMap(_.bestMove)
@@ -192,11 +197,7 @@ final class AlphaBetaSearch(
// Order moves // Order moves
val ordered = MoveOrdering.sort(context, legalMoves, ttBest, ply, ordering) val ordered = MoveOrdering.sort(context, legalMoves, ttBest, ply, ordering)
// Use parallel search if depth >= threshold and not root searchSequential(context, depth, ply, alpha, beta, ordered, hash, repetitions)
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)
private def searchSequential( private def searchSequential(
context: GameContext, context: GameContext,
@@ -205,126 +206,72 @@ final class AlphaBetaSearch(
alpha: Int, alpha: Int,
beta: Int, beta: Int,
ordered: List[Move], ordered: List[Move],
hash: Long hash: Long,
repetitions: Map[Long, Int]
): (Int, Option[Move]) = ): (Int, Option[Move]) =
var bestMove: Option[Move] = None var bestMove: Option[Move] = None
var bestScore = -INF var bestScore = -INF
var a = alpha var a = alpha
var moveNumber = 0 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 moveNumber += 1
val isQuiet = !isCapture(move) && val isQuiet = !isCapture(context, move) &&
move.moveType != MoveType.CastleKingside && move.moveType != MoveType.CastleKingside &&
move.moveType != MoveType.CastleQueenside move.moveType != MoveType.CastleQueenside
// Futility pruning at frontier nodes: if static eval + margin is still below alpha, skip quiet moves // 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) val staticEval = weights.evaluate(context)
if staticEval + FUTILITY_MARGIN < alpha then staticEval + FUTILITY_MARGIN < alpha
moveNumber += 1 else false
val child = rules.applyMove(context)(move) if !pruneByFutility then
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
val child = rules.applyMove(context)(move) 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 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 val s = -reducedScore
if s > alpha then if s > a then
val (fullScore, _) = search(child, depth - 1, ply + 1, -beta, -alpha) val fullDepth = math.max(0, depth - 1 + extension)
val (fullScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childHash, childRepetitions)
-fullScore -fullScore
else s else s
else 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 -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 if score > bestScore then
bestScore = score bestScore = score
bestMove = Some(move) bestMove = Some(move)
if bestScore >= beta then a = math.max(a, score)
if isQuiet then
ordering.addKillerMove(ply, move) if a >= beta then
cutoffFound = true 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 = val flag =
if bestScore <= alpha then TTFlag.Upper if cutoff then TTFlag.Lower
else if bestScore <= alpha then TTFlag.Upper
else TTFlag.Exact else TTFlag.Exact
tt.store(TTEntry(hash, depth, bestScore, flag, bestMove)) tt.store(TTEntry(hash, depth, bestScore, flag, bestMove))
(bestScore, bestMove) (bestScore, bestMove)
@@ -336,33 +283,43 @@ final class AlphaBetaSearch(
alpha: Int, alpha: Int,
beta: Int beta: Int
): Int = ): Int =
// Stand-pat: evaluate current position val inCheck = rules.isCheck(context)
val standPat = weights.evaluate(context) val standPat = if inCheck then -INF else weights.evaluate(context)
if standPat >= beta then if !inCheck && standPat >= beta then
return beta return beta
var a = math.max(alpha, standPat) var a = if inCheck then alpha else math.max(alpha, standPat)
// Guard against infinite quiescence // Guard against infinite quiescence
if ply >= MAX_QUIESCENCE_PLY then 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 allMoves = rules.allLegalMoves(context)
val captures = allMoves.filter(isCapture) val tacticalMoves = if inCheck then allMoves else allMoves.filter(m => isCapture(context, m))
val ordered = MoveOrdering.sort(context, captures, None)
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 child = rules.applyMove(context)(move)
val score = -quiescence(child, ply + 1, -beta, -a) val score = -quiescence(child, ply + 1, -beta, -a)
if score >= beta then if score >= beta then
return beta a = beta
a = math.max(a, score) cutoff = true
else
a = math.max(a, score)
a a
/** Predicate: is a move a capture (including promotions)? */ /** Predicate: context-aware capture classification. */
private def isCapture(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
case MoveType.EnPassant => true case MoveType.EnPassant => true
case MoveType.Promotion(_) => true case MoveType.Promotion(_) => context.board.pieceAt(move.to).exists(_.color != context.turn)
case _ => false case _ => false
@@ -1,18 +1,16 @@
package de.nowchess.bot.logic 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.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import scala.annotation.tailrec
import scala.collection.mutable import scala.collection.mutable
object MoveOrdering: object MoveOrdering:
class OrderingContext: class OrderingContext:
// Killer moves: 2 per ply (depth) to track moves that caused cutoffs
private val killerMoves = mutable.Map[Int, List[Move]]() 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]() private val historyTable = mutable.Map[(Int, Int), Int]()
def addKillerMove(ply: Int, move: Move): Unit = def addKillerMove(ply: Int, move: Move): Unit =
@@ -34,7 +32,6 @@ object MoveOrdering:
killerMoves.clear() killerMoves.clear()
historyTable.clear() historyTable.clear()
/** Score a single move for ordering (higher = search first). */
def score( def score(
context: GameContext, context: GameContext,
move: Move, move: Move,
@@ -42,30 +39,16 @@ object MoveOrdering:
ply: Int = 0, ply: Int = 0,
ordering: OrderingContext = new OrderingContext() ordering: OrderingContext = new OrderingContext()
): Int = ): Int =
if ttBestMove.exists(m => m.from == move.from && m.to == move.to) then if ttBestMove.exists(m => m.from == move.from && m.to == move.to) then Int.MaxValue
Int.MaxValue // TT best move always first else move.moveType match
else case MoveType.Promotion(PromotionPiece.Queen) =>
move.moveType match 1_000_000 + promotionCaptureBonus(context, move)
case MoveType.Promotion(PromotionPiece.Queen) => case MoveType.Normal(true) | MoveType.EnPassant =>
1_000_000 // Queen promotion is always good captureScore(context, move)
case MoveType.Normal(true) => case MoveType.Promotion(_) =>
100_000 + mvvLva(context, move) // Capture 50_000 + promotionCaptureBonus(context, move)
case MoveType.EnPassant => case _ => scoreQuietMove(move, ply, ordering)
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
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( def sort(
context: GameContext, context: GameContext,
moves: List[Move], moves: List[Move],
@@ -75,40 +58,121 @@ object MoveOrdering:
): List[Move] = ): List[Move] =
moves.sortBy(m => -score(context, m, ttBestMove, ply, ordering)) moves.sortBy(m => -score(context, m, ttBestMove, ply, ordering))
/** MVV-LVA score: (victim value * 10) - attacker value. private def scoreQuietMove(move: Move, ply: Int, ordering: OrderingContext): Int =
* Higher score = better trade (most valuable victim captured by least valuable attacker). */ 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 = private def mvvLva(context: GameContext, move: Move): Int =
val victim = victimValue(context, move) (victimValue(context, move) * 10) - attackerValue(context, move)
val attacker = attackerValue(context, move)
(victim * 10) - attacker
/** Material value of the attacking piece. */
private def attackerValue(context: GameContext, move: Move): Int = private def attackerValue(context: GameContext, move: Move): Int =
context.board.pieceAt(move.from) match context.board.pieceAt(move.from).map(pieceValue).getOrElse(0)
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
/** Material value of the captured piece. */
private def victimValue(context: GameContext, move: Move): Int = private def victimValue(context: GameContext, move: Move): Int =
move.moveType match move.moveType match
case MoveType.Normal(true) => case MoveType.Normal(true) => context.board.pieceAt(move.to).map(pieceValue).getOrElse(0)
context.board.pieceAt(move.to) match case MoveType.EnPassant => 1
case Some(piece) => case MoveType.Promotion(_) => context.board.pieceAt(move.to).map(pieceValue).getOrElse(0)
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 _ => 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
@@ -1,7 +1,8 @@
package de.nowchess.bot.util 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.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import scala.util.Random import scala.util.Random
@@ -59,3 +60,83 @@ object ZobristHash:
context.enPassantSquare.foreach(sq => h ^= enPassantRands(sq.file.ordinal)) context.enPassantSquare.foreach(sq => h ^= enPassantRands(sq.file.ordinal))
h 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)
@@ -1,6 +1,6 @@
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.logic.MoveOrdering 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 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 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 captureScore = MoveOrdering.score(context, capture, None)
val quietScore = MoveOrdering.score(context, quiet, 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 context = GameContext.initial.withBoard(board).withTurn(Color.White)
val epCapture = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant) 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 epScore = MoveOrdering.score(context, epCapture, None)
val quietScore = MoveOrdering.score(context, quiet, None) val quietScore = MoveOrdering.score(context, quiet, None)
@@ -87,7 +87,7 @@ class MoveOrderingTest extends AnyFunSuite with Matchers:
val moves = List( 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.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.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) val sorted = MoveOrdering.sort(context, moves, None)
// Rook capture should be first (higher victim value) // 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 quietPromotion = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
val score1 = MoveOrdering.score(context, promotionWithCapture, None) val score1 = MoveOrdering.score(context, promotionWithCapture, None)
val score2 = MoveOrdering.score(context, quietPromotion, None) val score2 = MoveOrdering.score(context, quietPromotion, None)
// Both should score high, but let's just verify they're scored score1 should be > score2
score1 should be > 0
score2 should be > 0
@@ -2,8 +2,9 @@ package de.nowchess.bot
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square} import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, 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, PromotionPiece}
import de.nowchess.bot.util.ZobristHash import de.nowchess.bot.util.ZobristHash
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
@@ -44,3 +45,41 @@ class ZobristHashTest extends AnyFunSuite with Matchers:
val hash1 = ZobristHash.hash(ctx1) val hash1 = ZobristHash.hash(ctx1)
val hash2 = ZobristHash.hash(ctx2) val hash2 = ZobristHash.hash(ctx2)
hash1 should not equal hash2 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))