feat: add AlphaBetaSearch and bot implementation with difficulty levels
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user