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.
|
- **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))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user