feat: enhance AlphaBetaSearch with incremental search hash updates and improve move ordering logic
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user