fix(bot): include quiet promotions in quiescence search
Build & Test (NowChessSystems) TeamCity build finished

Quiescence tactical filter only flagged capture-promotions, so a quiet
queening on an empty back-rank square was treated as non-tactical and
skipped at the search horizon. A bot could therefore miss a winning
promotion sitting exactly at the horizon and play another move. All bots
(Classical/NNUE/Hybrid) share AlphaBetaSearch and were affected.

Treat every promotion as tactical so quiescence always expands it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Janis Eccarius
2026-06-29 19:18:31 +02:00
parent 4a397eed7f
commit 4938560014
2 changed files with 25 additions and 1 deletions
@@ -468,5 +468,5 @@ final class AlphaBetaSearch(
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 MoveType.Promotion(_) => true
case _ => false
@@ -253,6 +253,30 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
val search = AlphaBetaSearch(promoCaptureRules, weights = EvaluationClassic)
search.bestMove(ctx, maxDepth = 1) should be(Some(promoCapture))
test("quiet promotion is treated as tactical in quiescence"):
// Pawn pushes to an empty back-rank square (no capture). Must still be searched in
// quiescence so a bot does not skip queening at the search horizon.
val quietPromo = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
val board = Board(
Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn,
),
)
val ctx = GameContext.initial.withBoard(board).withTurn(Color.White)
val quietPromoRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(quietPromo)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(quietPromoRules, weights = EvaluationClassic)
search.bestMove(ctx, maxDepth = 1) should be(Some(quietPromo))
test("draw when isInsufficientMaterial with legal moves present"):
val legalMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val drawRules = new RuleSet: