feat: add AlphaBetaSearch and bot implementation with difficulty levels

This commit is contained in:
2026-04-07 12:33:07 +02:00
committed by Janis
parent 767d3051a7
commit a247eb3d0d
15 changed files with 876 additions and 0 deletions
@@ -0,0 +1,139 @@
package de.nowchess.bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class AlphaBetaSearch(
rules: RuleSet = DefaultRules,
tt: TranspositionTable = TranspositionTable()
):
private val INF = Int.MaxValue / 2
private val MAX_QUIESCENCE_PLY = 64
/** Return the best move for the side to move, searching to maxDepth plies.
* Uses iterative deepening: searches depth 1, 2, ..., maxDepth. */
def bestMove(context: GameContext, maxDepth: Int): Option[Move] =
tt.clear()
var bestSoFar: Option[Move] = None
for depth <- 1 to maxDepth do
val (_, move) = search(context, depth, ply = 0, alpha = -INF, beta = INF)
move.foreach(m => bestSoFar = Some(m))
bestSoFar
/** Negamax alpha-beta search returning (score, best move). */
private def search(
context: GameContext,
depth: Int,
ply: Int,
alpha: Int,
beta: Int
): (Int, Option[Move]) =
val hash = ZobristHash.hash(context)
// TT probe
tt.probe(hash).foreach { entry =>
if entry.depth >= depth then
entry.flag match
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)
case TTFlag.Upper =>
val newBeta = math.min(beta, entry.score)
if alpha >= newBeta then
return (entry.score, entry.bestMove)
}
// Terminal node check
val legalMoves = rules.allLegalMoves(context)
if legalMoves.isEmpty then
val score = if rules.isCheckmate(context) then
-(Evaluation.CHECKMATE_SCORE - ply)
else
Evaluation.DRAW_SCORE
return (score, None)
if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then
return (Evaluation.DRAW_SCORE, None)
// Leaf node: call quiescence
if depth == 0 then
return (quiescence(context, ply, alpha, beta), None)
// Get TT best move for ordering
val ttBest = tt.probe(hash).flatMap(_.bestMove)
// Order moves
val ordered = MoveOrdering.sort(context, legalMoves, ttBest)
var bestMove: Option[Move] = None
var bestScore = -INF
var a = alpha
for move <- ordered do
val child = rules.applyMove(context)(move)
val (rawScore, _) = search(child, depth - 1, ply + 1, -beta, -a)
val score = -rawScore
if score > bestScore then
bestScore = score
bestMove = Some(move)
a = math.max(a, score)
if a >= beta then
// Beta cutoff: store as lower bound
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)
/** Quiescence search: only captures until position is quiet. */
private def quiescence(
context: GameContext,
ply: Int,
alpha: Int,
beta: Int
): Int =
// Stand-pat: evaluate current position
val standPat = Evaluation.evaluate(context)
if standPat >= beta then
return beta
var a = math.max(alpha, standPat)
// Guard against infinite quiescence
if ply >= MAX_QUIESCENCE_PLY then
return standPat
// Generate only captures
val allMoves = rules.allLegalMoves(context)
val captures = allMoves.filter(isCapture)
val ordered = MoveOrdering.sort(context, captures, None)
for move <- ordered do
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
/** Predicate: is a move a capture (including promotions)? */
private def isCapture(move: Move): Boolean = move.moveType match
case MoveType.Normal(true) => true
case MoveType.EnPassant => true
case MoveType.Promotion(_) => true
case _ => false
@@ -0,0 +1,7 @@
package de.nowchess.bot
enum BotDifficulty:
case Easy
case Medium
case Hard
case Expert
@@ -0,0 +1,25 @@
package de.nowchess.bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class ClassicalBot(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules
) extends Bot:
private val search: AlphaBetaSearch = AlphaBetaSearch(rules)
override val name: String = s"ClassicalBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] =
val depth = depthForDifficulty(difficulty)
search.bestMove(context, depth)
private def depthForDifficulty(d: BotDifficulty): Int = d match
case BotDifficulty.Easy => 2
case BotDifficulty.Medium => 3
case BotDifficulty.Hard => 4
case BotDifficulty.Expert => 5
@@ -0,0 +1,122 @@
package de.nowchess.bot
import de.nowchess.api.board.{Color, PieceType, Square}
import de.nowchess.api.game.GameContext
object Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
// Material values in centipawns
private val materialValue: Map[PieceType, Int] = Map(
PieceType.Pawn -> 100,
PieceType.Knight -> 320,
PieceType.Bishop -> 330,
PieceType.Rook -> 500,
PieceType.Queen -> 900,
PieceType.King -> 20_000
)
private val TEMPO_BONUS: Int = 10
// Piece-square tables (Simplified Evaluation Function, Michniewski)
// Indexed by squareIndex = rank.ordinal * 8 + file.ordinal
// White's perspective: rank 0 = home (r1), rank 7 = back rank (r8)
// Black is vertically mirrored
private val pawnTable: Array[Int] = Array(
0, 0, 0, 0, 0, 0, 0, 0,
50, 50, 50, 50, 50, 50, 50, 50,
10, 10, 20, 30, 30, 20, 10, 10,
5, 5, 10, 25, 25, 10, 5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, -5, -10, 0, 0, -10, -5, 5,
5, 10, 10, -20, -20, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0
)
private val knightTable: Array[Int] = Array(
-50, -40, -30, -30, -30, -30, -40, -50,
-40, -20, 0, 0, 0, 0, -20, -40,
-30, 0, 10, 15, 15, 10, 0, -30,
-30, 5, 15, 20, 20, 15, 5, -30,
-30, 0, 15, 20, 20, 15, 0, -30,
-30, 5, 10, 15, 15, 10, 5, -30,
-40, -20, 0, 5, 5, 0, -20, -40,
-50, -40, -30, -30, -30, -30, -40, -50
)
private val bishopTable: Array[Int] = Array(
-20, -10, -10, -10, -10, -10, -10, -20,
-10, 0, 0, 0, 0, 0, 0, -10,
-10, 0, 5, 10, 10, 5, 0, -10,
-10, 5, 5, 10, 10, 5, 5, -10,
-10, 0, 10, 10, 10, 10, 0, -10,
-10, 10, 10, 10, 10, 10, 10, -10,
-10, 5, 0, 0, 0, 0, 5, -10,
-20, -10, -10, -10, -10, -10, -10, -20
)
private val rookTable: Array[Int] = Array(
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10, 10, 10, 10, 10, 5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
0, 0, 0, 5, 5, 0, 0, 0
)
private val queenTable: Array[Int] = Array(
-20, -10, -10, -5, -5, -10, -10, -20,
-10, 0, 0, 0, 0, 0, 0, -10,
-10, 0, 5, 5, 5, 5, 0, -10,
-5, 0, 5, 5, 5, 5, 0, -5,
0, 0, 5, 5, 5, 5, 0, -5,
-10, 5, 5, 5, 5, 5, 0, -10,
-10, 0, 5, 0, 0, 0, 0, -10,
-20, -10, -10, -5, -5, -10, -10, -20
)
private val kingMidgameTable: Array[Int] = Array(
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-20, -30, -30, -40, -40, -30, -30, -20,
-10, -20, -20, -20, -20, -20, -20, -10,
20, 20, 0, 0, 0, 0, 20, 20,
20, 30, 10, 0, 0, 10, 30, 20
)
/** Evaluate the position from the perspective of context.turn.
* Positive = good for context.turn. */
def evaluate(context: GameContext): Int =
val material = materialAndPositional(context)
material + TEMPO_BONUS
private def materialAndPositional(context: GameContext): Int =
var score = 0
for (square, piece) <- context.board.pieces do
val value = materialValue(piece.pieceType) + squareBonus(piece.pieceType, piece.color, square)
if piece.color == context.turn then
score += value
else
score -= value
score
private def squareBonus(pieceType: PieceType, color: Color, sq: Square): Int =
val table = pieceType match
case PieceType.Pawn => pawnTable
case PieceType.Knight => knightTable
case PieceType.Bishop => bishopTable
case PieceType.Rook => rookTable
case PieceType.Queen => queenTable
case PieceType.King => kingMidgameTable
val rankIdx = if color == Color.White then sq.rank.ordinal else 7 - sq.rank.ordinal
val fileIdx = sq.file.ordinal
val squareIdx = rankIdx * 8 + fileIdx
table(squareIdx)
@@ -0,0 +1,66 @@
package de.nowchess.bot
import de.nowchess.api.board.PieceType
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
object MoveOrdering:
/** Score a single move for ordering (higher = search first). */
def score(context: GameContext, move: Move, ttBestMove: Option[Move]): 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 _ =>
0 // Quiet move
/** Sort moves: TT best move first, then by score descending. */
def sort(context: GameContext, moves: List[Move], ttBestMove: Option[Move]): List[Move] =
moves.sortBy(m => -score(context, m, ttBestMove))
/** MVV-LVA score: (victim value * 10) - attacker value.
* Higher score = better trade (most valuable victim captured by least valuable attacker). */
private def mvvLva(context: GameContext, move: Move): Int =
val victim = victimValue(context, move)
val attacker = attackerValue(context, move)
(victim * 10) - attacker
/** 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
/** 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 _ => 0
@@ -0,0 +1,32 @@
package de.nowchess.bot
import de.nowchess.api.move.Move
enum TTFlag:
case Exact // Score is exact
case Lower // Score is a lower bound
case Upper // Score is an upper bound
final case class TTEntry(
hash: Long,
depth: Int,
score: Int,
flag: TTFlag,
bestMove: Option[Move]
)
final class TranspositionTable(val sizePow2: Int = 20):
private val size = 1 << sizePow2
private val mask = size - 1L
private var table: Array[Option[TTEntry]] = Array.fill(size)(None)
def probe(hash: Long): Option[TTEntry] =
val index = (hash & mask).toInt
table(index).filter(_.hash == hash)
def store(entry: TTEntry): Unit =
val index = (entry.hash & mask).toInt
table(index) = Some(entry)
def clear(): Unit =
table = Array.fill(size)(None)
@@ -0,0 +1,60 @@
package de.nowchess.bot
import de.nowchess.api.board.{Color, File, PieceType, Square}
import de.nowchess.api.game.GameContext
import scala.util.Random
object ZobristHash:
// 768 entries: 64 squares * 12 piece variants (2 colors * 6 piece types)
private val pieceRands: Array[Long] = Array.ofDim(768)
// Side-to-move: XOR when Black to move
private val sideToMoveRand: Long = Random(0x1BADB002L).nextLong()
// 4 entries: White kingside, White queenside, Black kingside, Black queenside
private val castlingRands: Array[Long] = Array.ofDim(4)
// 8 entries: one per file (a-h)
private val enPassantRands: Array[Long] = Array.ofDim(8)
// Initialize all random values using a seeded RNG for reproducibility
locally:
val rng = Random(0x1BADB002L)
for i <- 0 until 768 do
pieceRands(i) = rng.nextLong()
for i <- 0 until 4 do
castlingRands(i) = rng.nextLong()
for i <- 0 until 8 do
enPassantRands(i) = rng.nextLong()
/** Compute a 64-bit Zobrist hash for a GameContext. */
def hash(context: GameContext): Long =
var h = 0L
// Hash board pieces
for (square, piece) <- context.board.pieces do
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
val randIndex = squareIndex * 12 + pieceIndex
h ^= pieceRands(randIndex)
// Hash side to move
if context.turn == Color.Black then
h ^= sideToMoveRand
// Hash castling rights
if context.castlingRights.whiteKingSide then
h ^= castlingRands(0)
if context.castlingRights.whiteQueenSide then
h ^= castlingRands(1)
if context.castlingRights.blackKingSide then
h ^= castlingRands(2)
if context.castlingRights.blackQueenSide then
h ^= castlingRands(3)
// Hash en-passant square
context.enPassantSquare.foreach(sq => h ^= enPassantRands(sq.file.ordinal))
h
@@ -0,0 +1,10 @@
package de.nowchess.bot
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class BotControllerTest extends AnyFunSuite with Matchers:
test("BotController can be instantiated"):
// BotController is an object with a private map, just test instantiation
BotController should not be null
@@ -0,0 +1,14 @@
package de.nowchess.bot
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class BotDifficultyTest extends AnyFunSuite with Matchers:
test("all difficulty values are defined"):
val difficulties = BotDifficulty.values
difficulties should contain(BotDifficulty.Easy)
difficulties should contain(BotDifficulty.Medium)
difficulties should contain(BotDifficulty.Hard)
difficulties should contain(BotDifficulty.Expert)
difficulties should have length 4
@@ -0,0 +1,70 @@
package de.nowchess.bot
import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.rules.RuleSet
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import de.nowchess.rules.sets.DefaultRules
class ClassicalBotTest extends AnyFunSuite with Matchers:
test("name returns expected format"):
val botEasy = ClassicalBot(BotDifficulty.Easy)
botEasy.name should include("ClassicalBot")
botEasy.name should include("Easy")
val botMedium = ClassicalBot(BotDifficulty.Medium)
botMedium.name should include("Medium")
test("nextMove on initial position returns a move"):
val bot = ClassicalBot(BotDifficulty.Easy)
val move = bot.nextMove(GameContext.initial)
move should not be None
test("nextMove returns None for position with no legal moves"):
val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List()
def legalMoves(context: GameContext)(square: Square) = List()
def allLegalMoves(context: GameContext) = List()
def isCheck(context: GameContext) = false
def isCheckmate(context: GameContext) = true
def isStalemate(context: GameContext) = false
def isInsufficientMaterial(context: GameContext) = false
def isFiftyMoveRule(context: GameContext) = false
def applyMove(context: GameContext)(move: Move) = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial)
move should be(None)
test("all BotDifficulty values work"):
BotDifficulty.values.foreach { difficulty =>
val bot = ClassicalBot(difficulty)
val move = bot.nextMove(GameContext.initial)
// All difficulties should return a move on the initial position
move should not be None
}
test("custom RuleSet injection works"):
val moveToReturn = Move(
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R2),
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R4),
de.nowchess.api.move.MoveType.Normal()
)
val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square) = List()
def legalMoves(context: GameContext)(square: Square) = List()
def allLegalMoves(context: GameContext) = List(moveToReturn)
def isCheck(context: GameContext) = false
def isCheckmate(context: GameContext) = false
def isStalemate(context: GameContext) = false
def isInsufficientMaterial(context: GameContext) = false
def isFiftyMoveRule(context: GameContext) = false
def applyMove(context: GameContext)(move: Move) = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial)
move should be(Some(moveToReturn))
@@ -0,0 +1,52 @@
package de.nowchess.bot
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.Board
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class EvaluationTest extends AnyFunSuite with Matchers:
test("initial position evaluates to tempo bonus"):
val eval = Evaluation.evaluate(GameContext.initial)
eval should equal(10) // TEMPO_BONUS only
test("remove white queen gives negative evaluation"):
val initial = GameContext.initial
val board = initial.board
val emptySquare = Square(File.D, Rank.R1)
val boardWithoutQueen = board.pieces.filter((sq, _) => sq != emptySquare)
val newContext = initial.withBoard(Board(boardWithoutQueen))
val eval = Evaluation.evaluate(newContext)
eval should be < 0
test("remove black queen gives positive evaluation"):
val initial = GameContext.initial
val board = initial.board
val emptySquare = Square(File.D, Rank.R8)
val boardWithoutQueen = board.pieces.filter((sq, _) => sq != emptySquare)
val newContext = initial.withBoard(Board(boardWithoutQueen))
val eval = Evaluation.evaluate(newContext)
eval should be > 0
test("different piece-square bonuses are applied"):
// Knight on d4 (center) vs knight on a1 (corner) - center should be better
val knightD4Board = Board(Map(Square(File.D, Rank.R4) -> Piece.WhiteKnight))
val knightA1Board = Board(Map(Square(File.A, Rank.R1) -> Piece.WhiteKnight))
val knightD4 = GameContext.initial.withBoard(knightD4Board)
val knightA1 = GameContext.initial.withBoard(knightA1Board)
val eval1 = Evaluation.evaluate(knightD4)
val eval2 = Evaluation.evaluate(knightA1)
eval1 should be > eval2 // d4 (center) is better than a1 (corner) for knight
test("all piece types are in material map"):
PieceType.values.length should be > 0
// Just verify evaluate works with all piece types
val eval = Evaluation.evaluate(GameContext.initial)
eval should not be (Evaluation.CHECKMATE_SCORE)
test("CHECKMATE_SCORE and DRAW_SCORE are accessible"):
Evaluation.CHECKMATE_SCORE should equal(10_000_000)
Evaluation.DRAW_SCORE should equal(0)
@@ -0,0 +1,161 @@
package de.nowchess.bot
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveOrderingTest extends AnyFunSuite with Matchers:
test("queen capture ranks higher than rook capture"):
val board = Board(Map(
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
Square(File.E, Rank.R5) -> Piece.BlackQueen,
Square(File.E, Rank.R6) -> Piece.BlackRook
))
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val queenCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
val rookCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6), MoveType.Normal(true))
val queenScore = MoveOrdering.score(context, queenCapture, None)
val rookScore = MoveOrdering.score(context, rookCapture, None)
queenScore should be > rookScore
test("quiet move ranks lower than capture"):
val board = Board(Map(
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
Square(File.E, Rank.R5) -> Piece.BlackPawn
))
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 captureScore = MoveOrdering.score(context, capture, None)
val quietScore = MoveOrdering.score(context, quiet, None)
captureScore should be > quietScore
test("TT best move ranks first"):
val board = Board(Map(
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
Square(File.E, Rank.R5) -> Piece.BlackPawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn
))
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val bestMove = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
val otherCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
val bestScore = MoveOrdering.score(context, bestMove, Some(bestMove))
val otherScore = MoveOrdering.score(context, otherCapture, Some(bestMove))
bestScore should equal(Int.MaxValue)
otherScore should be < bestScore
test("promotion to queen ranks high"):
val board = Board(Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn
))
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val promotionQueen = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
val promotionKnight = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight))
val queenScore = MoveOrdering.score(context, promotionQueen, None)
val knightScore = MoveOrdering.score(context, promotionKnight, None)
queenScore should be > knightScore
queenScore should be > 100_000 // Queen promotion score is > 100_000
test("en passant is treated as capture"):
val board = Board(Map(
Square(File.E, Rank.R5) -> Piece.WhitePawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn
))
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 epScore = MoveOrdering.score(context, epCapture, None)
val quietScore = MoveOrdering.score(context, quiet, None)
epScore should be > quietScore
test("sort returns moves ordered by score"):
val board = Board(Map(
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
Square(File.E, Rank.R5) -> Piece.BlackPawn,
Square(File.D, Rank.R5) -> Piece.BlackRook
))
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
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
)
val sorted = MoveOrdering.sort(context, moves, None)
// Rook capture should be first (higher victim value)
sorted.head.to should equal(Square(File.D, Rank.R5))
// Pawn capture should be second
sorted(1).to should equal(Square(File.E, Rank.R5))
// Quiet should be last
sorted.last.to should equal(Square(File.E, Rank.R6))
test("castling move is quiet (not capture)"):
val board = Board(Map(
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.H, Rank.R1) -> Piece.WhiteRook
))
val context = GameContext.initial.withBoard(board)
val castleMove = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
val score = MoveOrdering.score(context, castleMove, None)
score should equal(0) // Quiet move
test("all MoveType variants are handled in victimValue"):
val board = Board(Map(
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.H, Rank.R1) -> Piece.WhiteRook,
Square(File.E, Rank.R2) -> Piece.WhitePawn
))
val context = GameContext.initial.withBoard(board)
// Test castling queenside - should have victim value 0
val castleQs = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
val scoreQs = MoveOrdering.score(context, castleQs, None)
scoreQs should equal(0)
test("attackerValue covers all piece types"):
val board = Board(Map(
Square(File.A, Rank.R1) -> Piece.WhiteRook,
Square(File.B, Rank.R1) -> Piece.WhiteKnight,
Square(File.C, Rank.R1) -> Piece.WhiteBishop,
Square(File.D, Rank.R1) -> Piece.WhiteQueen,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.F, Rank.R2) -> Piece.WhitePawn
))
val context = GameContext.initial.withBoard(board)
// Create captures with each piece type
val rookCapture = Move(Square(File.A, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val knightCapture = Move(Square(File.B, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val bishopCapture = Move(Square(File.C, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val queenCapture = Move(Square(File.D, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val kingCapture = Move(Square(File.E, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val pawnCapture = Move(Square(File.F, Rank.R2), Square(File.A, Rank.R8), MoveType.Normal(true))
// Just verify all are scored without error
MoveOrdering.score(context, rookCapture, None) should be >= 0
MoveOrdering.score(context, knightCapture, None) should be >= 0
MoveOrdering.score(context, bishopCapture, None) should be >= 0
MoveOrdering.score(context, queenCapture, None) should be >= 0
MoveOrdering.score(context, kingCapture, None) should be >= 0
MoveOrdering.score(context, pawnCapture, None) should be >= 0
test("promotion capture is distinct from quiet promotion"):
val board = Board(Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn,
Square(File.D, Rank.R8) -> Piece.BlackPawn
))
val context = GameContext.initial.withBoard(board)
// Promotion with capture
val promotionWithCapture = Move(Square(File.E, Rank.R7), Square(File.D, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
// Regular queen promotion (no capture)
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
@@ -0,0 +1,72 @@
package de.nowchess.bot
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.move.{Move, MoveType}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class TranspositionTableTest extends AnyFunSuite with Matchers:
test("probe on empty table returns None"):
val tt = TranspositionTable(sizePow2 = 4)
tt.probe(12345L) should be(None)
test("store then probe returns the stored entry"):
val tt = TranspositionTable(sizePow2 = 4)
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val entry = TTEntry(hash = 12345L, depth = 3, score = 50, flag = TTFlag.Exact, bestMove = Some(move))
tt.store(entry)
val retrieved = tt.probe(12345L)
retrieved should be(Some(entry))
test("probe returns None when hash differs (collision guard)"):
val tt = TranspositionTable(sizePow2 = 4)
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val entry = TTEntry(hash = 12345L, depth = 3, score = 50, flag = TTFlag.Exact, bestMove = Some(move))
tt.store(entry)
tt.probe(54321L) should be(None)
test("clear removes all entries"):
val tt = TranspositionTable(sizePow2 = 4)
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val entry = TTEntry(hash = 12345L, depth = 3, score = 50, flag = TTFlag.Exact, bestMove = Some(move))
tt.store(entry)
tt.probe(12345L) should be(Some(entry))
tt.clear()
tt.probe(12345L) should be(None)
test("all TTFlag values store and retrieve correctly"):
val tt = TranspositionTable(sizePow2 = 4)
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
TTFlag.values.foreach { flag =>
val entry = TTEntry(hash = 12345L + flag.ordinal, depth = 2, score = 100, flag = flag, bestMove = Some(move))
tt.store(entry)
val retrieved = tt.probe(12345L + flag.ordinal)
retrieved.map(_.flag) should be(Some(flag))
}
test("bestMove = None roundtrips"):
val tt = TranspositionTable(sizePow2 = 4)
val entry = TTEntry(hash = 99999L, depth = 1, score = 0, flag = TTFlag.Upper, bestMove = None)
tt.store(entry)
val retrieved = tt.probe(99999L)
retrieved.map(_.bestMove) should be(Some(None))
test("always-replace overwrites at same slot"):
val tt = TranspositionTable(sizePow2 = 4)
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val move2 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val entry1 = TTEntry(hash = 12345L, depth = 2, score = 50, flag = TTFlag.Exact, bestMove = Some(move1))
val entry2 = TTEntry(hash = 12345L, depth = 3, score = 100, flag = TTFlag.Lower, bestMove = Some(move2))
tt.store(entry1)
tt.probe(12345L).map(_.score) should be(Some(50))
tt.store(entry2)
tt.probe(12345L).map(_.score) should be(Some(100))
test("size is 1 << sizePow2"):
val tt = TranspositionTable(sizePow2 = 4)
(1 << 4) should equal(16)
val tt2 = TranspositionTable(sizePow2 = 10)
(1 << 10) should equal(1024)
@@ -0,0 +1,45 @@
package de.nowchess.bot
import de.nowchess.api.board.{Board, Color, CastlingRights, File, Piece, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class ZobristHashTest extends AnyFunSuite with Matchers:
test("hash is deterministic"):
val hash1 = ZobristHash.hash(GameContext.initial)
val hash2 = ZobristHash.hash(GameContext.initial)
hash1 should equal(hash2)
test("hash differs after a pawn move"):
val initial = GameContext.initial
// Move pawn from e2 to e4
val board = initial.board.pieces
val newBoard = board.removed(Square(File.E, Rank.R2)).updated(Square(File.E, Rank.R4), Piece.WhitePawn)
val afterMove = initial.withBoard(Board(newBoard)).withTurn(Color.Black)
val hash1 = ZobristHash.hash(initial)
val hash2 = ZobristHash.hash(afterMove)
hash1 should not equal hash2
test("hash includes castling rights"):
val ctx1 = GameContext.initial
val ctx2 = ctx1.withCastlingRights(CastlingRights.None)
val hash1 = ZobristHash.hash(ctx1)
val hash2 = ZobristHash.hash(ctx2)
hash1 should not equal hash2
test("hash includes en-passant square"):
val ctx1 = GameContext.initial
val ctx2 = ctx1.withEnPassantSquare(Some(Square(File.E, Rank.R3)))
val hash1 = ZobristHash.hash(ctx1)
val hash2 = ZobristHash.hash(ctx2)
hash1 should not equal hash2
test("hash includes side to move"):
val ctx1 = GameContext.initial
val ctx2 = ctx1.withTurn(Color.Black)
val hash1 = ZobristHash.hash(ctx1)
val hash2 = ZobristHash.hash(ctx2)
hash1 should not equal hash2
+1
View File
@@ -41,6 +41,7 @@ dependencies {
implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:rule"))
implementation(project(":modules:bot"))
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")