feat: implement bot functionality with difficulty levels and integrate Polyglot opening book

This commit is contained in:
2026-04-07 14:54:27 +02:00
committed by Janis
parent 3b945da958
commit 8e208b8a25
14 changed files with 1150 additions and 53 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import glob,re import glob,re
mods=['api','core','io','rule','ui'] mods=['api','core','io','rule','ui', 'bot']
tot=0 tot=0
for m in mods: for m in mods:
s=0 s=0
Binary file not shown.
@@ -1,5 +1,6 @@
package de.nowchess.bot package de.nowchess.bot
import de.nowchess.api.board.PieceType
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}
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
@@ -12,17 +13,85 @@ final class AlphaBetaSearch(
private val INF = Int.MaxValue / 2 private val INF = Int.MaxValue / 2
private val MAX_QUIESCENCE_PLY = 64 private val MAX_QUIESCENCE_PLY = 64
private val NULL_MOVE_R = 2
private val ASPIRATION_DELTA = 50
private val TIME_CHECK_FREQUENCY = 1000
@volatile private var timeStartMs = 0L
@volatile private var timeLimitMs = 0L
@volatile private var nodeCount = 0
/** 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: searches depth 1, 2, ..., maxDepth. */ * 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()
var bestSoFar: Option[Move] = None var bestSoFar: Option[Move] = None
var prevScore = 0
for depth <- 1 to maxDepth do for depth <- 1 to maxDepth do
val (_, move) = search(context, depth, ply = 0, alpha = -INF, beta = INF) val (alpha, beta) =
if depth == 1 then (-INF, INF)
else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA)
val (score, move) = searchWithAspiration(context, depth, alpha, beta)
prevScore = score
move.foreach(m => bestSoFar = Some(m)) move.foreach(m => bestSoFar = Some(m))
bestSoFar bestSoFar
/** Return the best move for the side to move within a time budget (ms).
* Uses iterative deepening, stopping when time runs out. */
def bestMoveWithTime(context: GameContext, timeBudgetMs: Long): Option[Move] =
tt.clear()
timeStartMs = System.currentTimeMillis()
timeLimitMs = timeBudgetMs
nodeCount = 0
var bestSoFar: Option[Move] = None
var prevScore = 0
var depth = 1
while !isOutOfTime() do
val (alpha, beta) =
if depth == 1 then (-INF, INF)
else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA)
val (score, move) = searchWithAspiration(context, depth, alpha, beta)
prevScore = score
move.foreach(m => bestSoFar = Some(m))
depth += 1
bestSoFar
private def isOutOfTime(): Boolean =
System.currentTimeMillis() - timeStartMs >= timeLimitMs
private def searchWithAspiration(
context: GameContext,
depth: Int,
alpha: Int,
beta: Int
): (Int, Option[Move]) =
val (score, move) = search(context, depth, 0, alpha, beta)
if score <= alpha then search(context, depth, 0, -INF, beta)
else if score >= beta then search(context, depth, 0, alpha, INF)
else (score, move)
private def hasNonPawnMaterial(context: GameContext): Boolean =
context.board.pieces.values.exists { piece =>
piece.color == context.turn &&
piece.pieceType != PieceType.Pawn &&
piece.pieceType != PieceType.King
}
private def nullMoveContext(context: GameContext): GameContext =
context.withTurn(context.turn.opposite).withEnPassantSquare(None)
private def tryNullMove(
context: GameContext,
depth: Int,
ply: Int,
beta: Int
): Option[Int] =
val nullCtx = nullMoveContext(context)
val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R)
val (score, _) = search(nullCtx, reductionDepth, ply + 1, -beta, -beta + 1)
if -score >= beta then Some(beta) else None
/** Negamax alpha-beta search returning (score, best move). */ /** Negamax alpha-beta search returning (score, best move). */
private def search( private def search(
context: GameContext, context: GameContext,
@@ -31,6 +100,11 @@ final class AlphaBetaSearch(
alpha: Int, alpha: Int,
beta: Int beta: Int
): (Int, Option[Move]) = ): (Int, Option[Move]) =
// Periodic time check
nodeCount += 1
if nodeCount % TIME_CHECK_FREQUENCY == 0 && isOutOfTime() then
return (Evaluation.evaluate(context), None)
val hash = ZobristHash.hash(context) val hash = ZobristHash.hash(context)
// TT probe // TT probe
@@ -65,21 +139,42 @@ final class AlphaBetaSearch(
if depth == 0 then if depth == 0 then
return (quiescence(context, ply, alpha, beta), None) return (quiescence(context, ply, alpha, beta), None)
// Null move pruning
if depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context) then
tryNullMove(context, depth, ply, beta).foreach { score =>
return (score, 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)
// Order moves // Order moves
val ordered = MoveOrdering.sort(context, legalMoves, ttBest) val ordered = MoveOrdering.sort(context, legalMoves, ttBest)
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
for move <- ordered do for move <- ordered do
moveNumber += 1
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 (rawScore, _) = search(child, depth - 1, ply + 1, -beta, -a) val reduction = if moveNumber > 4 && depth >= 3 && isQuiet then 1 else 0
val score = -rawScore
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 if score > bestScore then
bestScore = score bestScore = score
@@ -88,7 +183,6 @@ final class AlphaBetaSearch(
a = math.max(a, score) a = math.max(a, score)
if a >= beta then if a >= beta then
// Beta cutoff: store as lower bound
tt.store(TTEntry(hash, depth, bestScore, TTFlag.Lower, bestMove)) tt.store(TTEntry(hash, depth, bestScore, TTFlag.Lower, bestMove))
return (bestScore, bestMove) return (bestScore, bestMove)
@@ -128,7 +222,6 @@ final class AlphaBetaSearch(
if score >= beta then if score >= beta then
return beta return beta
a = math.max(a, score) a = math.max(a, score)
a a
/** Predicate: is a move a capture (including promotions)? */ /** Predicate: is a move a capture (including promotions)? */
@@ -1,7 +1,28 @@
package de.nowchess.bot package de.nowchess.bot
object BotController { object BotController {
private var bots: Map[String, Bot] = Map.empty private var bots: Map[String, Bot] = Map.empty
// Register standard bots
locally {
val easyBot = ClassicalBot(BotDifficulty.Easy)
val mediumBot = ClassicalBot(BotDifficulty.Medium)
val hardBot = ClassicalBot(BotDifficulty.Hard)
val expertBot = ClassicalBot(BotDifficulty.Expert)
bots = Map(
"easy" -> easyBot,
"medium" -> mediumBot,
"hard" -> hardBot,
"expert" -> expertBot
)
}
/** Get a bot by name. */
def getBot(name: String): Option[Bot] = bots.get(name.toLowerCase)
/** List all available bot names. */
def listBots: List[String] = bots.keys.toList.sorted
} }
@@ -7,19 +7,15 @@ import de.nowchess.rules.sets.DefaultRules
final class ClassicalBot( final class ClassicalBot(
difficulty: BotDifficulty, difficulty: BotDifficulty,
rules: RuleSet = DefaultRules rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None
) extends Bot: ) extends Bot:
private val search: AlphaBetaSearch = AlphaBetaSearch(rules) private val search: AlphaBetaSearch = AlphaBetaSearch(rules)
private val TIME_BUDGET_MS = 1000L
override val name: String = s"ClassicalBot(${difficulty.toString})" override val name: String = s"ClassicalBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] = override def nextMove(context: GameContext): Option[Move] =
val depth = depthForDifficulty(difficulty) book.flatMap(_.probe(context))
search.bestMove(context, depth) .orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS))
private def depthForDifficulty(d: BotDifficulty): Int = d match
case BotDifficulty.Easy => 2
case BotDifficulty.Medium => 3
case BotDifficulty.Hard => 4
case BotDifficulty.Expert => 5
@@ -8,15 +8,9 @@ object Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000 val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0 val DRAW_SCORE: Int = 0
// Material values in centipawns // Material values in centipawns (indexed by PieceType.ordinal: Pawn=0, Knight=1, Bishop=2, Rook=3, Queen=4, King=5)
private val materialValue: Map[PieceType, Int] = Map( private val mgMaterial = Array(100, 325, 335, 500, 900, 20_000)
PieceType.Pawn -> 100, private val egMaterial = Array(110, 310, 310, 530, 1_000, 20_000)
PieceType.Knight -> 320,
PieceType.Bishop -> 330,
PieceType.Rook -> 500,
PieceType.Queen -> 900,
PieceType.King -> 20_000
)
private val TEMPO_BONUS: Int = 10 private val TEMPO_BONUS: Int = 10
@@ -25,7 +19,7 @@ object Evaluation:
// White's perspective: rank 0 = home (r1), rank 7 = back rank (r8) // White's perspective: rank 0 = home (r1), rank 7 = back rank (r8)
// Black is vertically mirrored // Black is vertically mirrored
private val pawnTable: Array[Int] = Array( private val mgPawnTable: Array[Int] = Array(
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50,
10, 10, 20, 30, 30, 20, 10, 10, 10, 10, 20, 30, 30, 20, 10, 10,
@@ -36,7 +30,18 @@ object Evaluation:
0, 0, 0, 0, 0, 0, 0, 0 0, 0, 0, 0, 0, 0, 0, 0
) )
private val knightTable: Array[Int] = Array( private val egPawnTable: Array[Int] = Array(
0, 0, 0, 0, 0, 0, 0, 0,
70, 70, 70, 70, 70, 70, 70, 70,
40, 40, 40, 40, 40, 40, 40, 40,
30, 30, 30, 30, 30, 30, 30, 30,
20, 20, 20, 20, 20, 20, 20, 20,
10, 10, 10, 10, 10, 10, 10, 10,
5, 5, 5, 5, 5, 5, 5, 5,
0, 0, 0, 0, 0, 0, 0, 0
)
private val mgKnightTable: Array[Int] = Array(
-50, -40, -30, -30, -30, -30, -40, -50, -50, -40, -30, -30, -30, -30, -40, -50,
-40, -20, 0, 0, 0, 0, -20, -40, -40, -20, 0, 0, 0, 0, -20, -40,
-30, 0, 10, 15, 15, 10, 0, -30, -30, 0, 10, 15, 15, 10, 0, -30,
@@ -47,7 +52,18 @@ object Evaluation:
-50, -40, -30, -30, -30, -30, -40, -50 -50, -40, -30, -30, -30, -30, -40, -50
) )
private val bishopTable: Array[Int] = Array( private val egKnightTable: Array[Int] = Array(
-30, -20, -10, -10, -10, -10, -20, -30,
-20, 0, 5, 5, 5, 5, 0, -20,
-10, 5, 15, 20, 20, 15, 5, -10,
-10, 5, 20, 25, 25, 20, 5, -10,
-10, 5, 20, 25, 25, 20, 5, -10,
-10, 5, 15, 20, 20, 15, 5, -10,
-20, 0, 5, 5, 5, 5, 0, -20,
-30, -20, -10, -10, -10, -10, -20, -30
)
private val mgBishopTable: Array[Int] = Array(
-20, -10, -10, -10, -10, -10, -10, -20, -20, -10, -10, -10, -10, -10, -10, -20,
-10, 0, 0, 0, 0, 0, 0, -10, -10, 0, 0, 0, 0, 0, 0, -10,
-10, 0, 5, 10, 10, 5, 0, -10, -10, 0, 5, 10, 10, 5, 0, -10,
@@ -58,7 +74,18 @@ object Evaluation:
-20, -10, -10, -10, -10, -10, -10, -20 -20, -10, -10, -10, -10, -10, -10, -20
) )
private val rookTable: Array[Int] = Array( private val egBishopTable: Array[Int] = Array(
-20, -10, -5, -5, -5, -5, -10, -20,
-10, 0, 5, 5, 5, 5, 0, -10,
-5, 5, 10, 10, 10, 10, 5, -5,
-5, 5, 10, 15, 15, 10, 5, -5,
-5, 5, 10, 15, 15, 10, 5, -5,
-5, 5, 10, 10, 10, 10, 5, -5,
-10, 0, 5, 5, 5, 5, 0, -10,
-20, -10, -5, -5, -5, -5, -10, -20
)
private val mgRookTable: Array[Int] = Array(
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10, 10, 10, 10, 10, 5, 5, 10, 10, 10, 10, 10, 10, 5,
-5, 0, 0, 0, 0, 0, 0, -5, -5, 0, 0, 0, 0, 0, 0, -5,
@@ -69,7 +96,18 @@ object Evaluation:
0, 0, 0, 5, 5, 0, 0, 0 0, 0, 0, 5, 5, 0, 0, 0
) )
private val queenTable: Array[Int] = Array( private val egRookTable: 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 mgQueenTable: Array[Int] = Array(
-20, -10, -10, -5, -5, -10, -10, -20, -20, -10, -10, -5, -5, -10, -10, -20,
-10, 0, 0, 0, 0, 0, 0, -10, -10, 0, 0, 0, 0, 0, 0, -10,
-10, 0, 5, 5, 5, 5, 0, -10, -10, 0, 5, 5, 5, 5, 0, -10,
@@ -80,7 +118,18 @@ object Evaluation:
-20, -10, -10, -5, -5, -10, -10, -20 -20, -10, -10, -5, -5, -10, -10, -20
) )
private val kingMidgameTable: Array[Int] = Array( private val egQueenTable: Array[Int] = Array(
-15, -10, -8, -5, -5, -8, -10, -15,
-10, 0, 3, 5, 5, 3, 0, -10,
-8, 3, 10, 10, 10, 10, 3, -8,
-5, 5, 10, 15, 15, 10, 5, -5,
-5, 5, 10, 15, 15, 10, 5, -5,
-8, 3, 10, 10, 10, 10, 3, -8,
-10, 0, 3, 5, 5, 3, 0, -10,
-15, -10, -8, -5, -5, -8, -10, -15
)
private val mgKingTable: 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,
-30, -40, -40, -50, -50, -40, -40, -30, -30, -40, -40, -50, -50, -40, -40, -30,
@@ -91,32 +140,173 @@ object Evaluation:
20, 30, 10, 0, 0, 10, 30, 20 20, 30, 10, 0, 0, 10, 30, 20
) )
private val egKingTable: Array[Int] = Array(
-50, -40, -30, -20, -20, -30, -40, -50,
-30, -20, -10, 0, 0, -10, -20, -30,
-30, -10, 20, 30, 30, 20, -10, -30,
-30, -10, 30, 40, 40, 30, -10, -30,
-30, -10, 30, 40, 40, 30, -10, -30,
-30, -10, 20, 30, 30, 20, -10, -30,
-30, -30, 0, 0, 0, 0, -30, -30,
-50, -30, -30, -30, -30, -30, -30, -50
)
private val phaseWeight: Map[PieceType, Int] = Map(
PieceType.Knight -> 1,
PieceType.Bishop -> 1,
PieceType.Rook -> 2,
PieceType.Queen -> 4
)
private val maxPhase = 24 // 4*4 + 4*2 + 4*1 + 4*1
private val passedPawnBonus: Array[Int] = Array(0, 5, 10, 20, 35, 60, 100, 0)
// Pawn structure penalties
private val doubledMg = -10
private val doubledEg = -25
private val isolatedMg = -15
private val isolatedEg = -20
/** Evaluate the position from the perspective of context.turn. /** Evaluate the position from the perspective of context.turn.
* Positive = good for context.turn. */ * Positive = good for context.turn. */
def evaluate(context: GameContext): Int = def evaluate(context: GameContext): Int =
val material = materialAndPositional(context) val phase = gamePhase(context.board)
material + TEMPO_BONUS val material = materialAndPositional(context, phase)
val structure = pawnStructure(context, phase)
val bonuses = positionalBonuses(context, phase)
material + structure + bonuses + TEMPO_BONUS
private def materialAndPositional(context: GameContext): Int = private def gamePhase(board: de.nowchess.api.board.Board): Int =
var score = 0 val phase = board.pieces.values.foldLeft(0) { (acc, piece) =>
acc + phaseWeight.getOrElse(piece.pieceType, 0)
}
math.min(phase, maxPhase)
private def taper(mg: Int, eg: Int, phase: Int): Int =
(mg * phase + eg * (maxPhase - phase)) / maxPhase
private def materialAndPositional(context: GameContext, phase: Int): Int =
var mg = 0
var eg = 0
for (square, piece) <- context.board.pieces do for (square, piece) <- context.board.pieces do
val value = materialValue(piece.pieceType) + squareBonus(piece.pieceType, piece.color, square) val (psqMg, psqEg) = squareBonus(piece.pieceType, piece.color, square)
if piece.color == context.turn then val pieceMg = mgMaterial(piece.pieceType.ordinal) + psqMg
score += value val pieceEg = egMaterial(piece.pieceType.ordinal) + psqEg
else val sign = if piece.color == context.turn then 1 else -1
score -= value mg += sign * pieceMg
score eg += sign * pieceEg
taper(mg, eg, phase)
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
private def squareBonus(pieceType: PieceType, color: Color, sq: Square): (Int, Int) =
val rankIdx = if color == Color.White then sq.rank.ordinal else 7 - sq.rank.ordinal val rankIdx = if color == Color.White then sq.rank.ordinal else 7 - sq.rank.ordinal
val fileIdx = sq.file.ordinal val fileIdx = sq.file.ordinal
val squareIdx = rankIdx * 8 + fileIdx val squareIdx = rankIdx * 8 + fileIdx
table(squareIdx)
pieceType match
case PieceType.Pawn => (mgPawnTable(squareIdx), egPawnTable(squareIdx))
case PieceType.Knight => (mgKnightTable(squareIdx), egKnightTable(squareIdx))
case PieceType.Bishop => (mgBishopTable(squareIdx), egBishopTable(squareIdx))
case PieceType.Rook => (mgRookTable(squareIdx), egRookTable(squareIdx))
case PieceType.Queen => (mgQueenTable(squareIdx), egQueenTable(squareIdx))
case PieceType.King => (mgKingTable(squareIdx), egKingTable(squareIdx))
private def pawnStructure(context: GameContext, phase: Int): Int =
val friendlyPawns = context.board.pieces.filter((_, p) => p.color == context.turn && p.pieceType == PieceType.Pawn)
val enemyPawns = context.board.pieces.filter((_, p) => p.color != context.turn && p.pieceType == PieceType.Pawn)
var mg = 0
var eg = 0
// Group pawns by file
val friendlyByFile = friendlyPawns.groupMap(s => s._1.file.ordinal)(s => s._1.rank.ordinal)
val enemyByFile = enemyPawns.groupMap(s => s._1.file.ordinal)(s => s._1.rank.ordinal)
// Doubled pawn penalty for friendly pawns
for (file, ranks) <- friendlyByFile do
if ranks.size > 1 then
val doubledCount = ranks.size - 1
mg += doubledCount * doubledMg
eg += doubledCount * doubledEg
// Isolated pawn penalty for friendly pawns
for (file, _) <- friendlyByFile do
val hasAdjacentFriendly = (file - 1 to file + 1).filter(f => f >= 0 && f < 8 && f != file).exists(friendlyByFile.contains)
if !hasAdjacentFriendly then
val pawnsOnFile = friendlyByFile(file).size
mg += pawnsOnFile * isolatedMg
eg += pawnsOnFile * isolatedEg
// Same for enemy pawns (subtract from score)
var enemyMg = 0
var enemyEg = 0
for (file, ranks) <- enemyByFile do
if ranks.size > 1 then
val doubledCount = ranks.size - 1
enemyMg += doubledCount * doubledMg
enemyEg += doubledCount * doubledEg
for (file, _) <- enemyByFile do
val hasAdjacentEnemy = (file - 1 to file + 1).filter(f => f >= 0 && f < 8 && f != file).exists(enemyByFile.contains)
if !hasAdjacentEnemy then
val pawnsOnFile = enemyByFile(file).size
enemyMg += pawnsOnFile * isolatedMg
enemyEg += pawnsOnFile * isolatedEg
taper(mg - enemyMg, eg - enemyEg, phase)
private def positionalBonuses(context: GameContext, phase: Int): Int =
var score = 0
for (sq, piece) <- context.board.pieces do
val bonus = piece.pieceType match
case PieceType.Pawn =>
if isPassedPawn(context.board, sq, piece.color) then
passedPawnBonus(sq.rank.ordinal)
else 0
case PieceType.Rook =>
rookOpenFileBonus(context.board, sq, piece.color)
case PieceType.King =>
kingShieldBonus(context.board, sq, piece.color, phase)
case _ => 0
if piece.color == context.turn then score += bonus
else score -= bonus
score
private def isPassedPawn(board: de.nowchess.api.board.Board, sq: Square, color: Color): Boolean =
val enemyColor = color.opposite
val pawnRank = sq.rank.ordinal
val fileRange = (sq.file.ordinal - 1 to sq.file.ordinal + 1).filter(f => f >= 0 && f < 8)
val rankCheck = if color == Color.White then (r: Int) => r > pawnRank else (r: Int) => r < pawnRank
board.pieces.forall { (enemySq, enemyPiece) =>
!(enemyPiece.color == enemyColor &&
enemyPiece.pieceType == PieceType.Pawn &&
fileRange.contains(enemySq.file.ordinal) &&
rankCheck(enemySq.rank.ordinal))
}
private def rookOpenFileBonus(board: de.nowchess.api.board.Board, rookSq: Square, color: Color): Int =
val hasFriendlyPawn = board.pieces.exists { (sq, piece) =>
piece.color == color && piece.pieceType == PieceType.Pawn && sq.file == rookSq.file
}
val hasEnemyPawn = board.pieces.exists { (sq, piece) =>
piece.color != color && piece.pieceType == PieceType.Pawn && sq.file == rookSq.file
}
if !hasFriendlyPawn && !hasEnemyPawn then 20 // open file
else if !hasFriendlyPawn then 10 // semi-open file
else 0
private def kingShieldBonus(board: de.nowchess.api.board.Board, kingSq: Square, color: Color, phase: Int): Int =
val shieldRankDelta = if color == Color.White then 1 else -1
val shieldFiles = (kingSq.file.ordinal - 1 to kingSq.file.ordinal + 1).filter(f => f >= 0 && f < 8)
val shieldRank = kingSq.rank.ordinal + shieldRankDelta
if shieldRank < 0 || shieldRank > 7 then 0
else
val rawBonus = board.pieces.count { (sq, piece) =>
piece.color == color &&
piece.pieceType == PieceType.Pawn &&
shieldFiles.contains(sq.file.ordinal) &&
sq.rank.ordinal == shieldRank
} * 10
(rawBonus * phase) / maxPhase
@@ -0,0 +1,144 @@
package de.nowchess.bot
import de.nowchess.api.board.{Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import java.io.{DataInputStream, FileInputStream}
import scala.collection.mutable
import scala.util.Random
/** Reads a Polyglot opening book (.bin file) and probes it for moves.
*
* Polyglot books are binary files with 16-byte big-endian records:
* - key: 8 bytes (Long) — Zobrist hash of the position
* - move: 2 bytes (Short) — packed as (to_file | to_rank | from_file | from_rank | promotion)
* - weight: 2 bytes (Short) — move weight (higher = preferred)
* - learn: 4 bytes (Int) — learning data (unused)
*/
final class PolyglotBook(path: String):
private val entries: Map[Long, Vector[BookEntry]] =
try
loadBookFile(path)
catch
case e: Exception =>
// Gracefully fail: return empty map if book cannot be loaded
// This allows the bot to work even if the book file is missing
scala.collection.immutable.Map.empty
/** Probe the book for a move in the given position. Returns a weighted random move, or None if not in book. */
def probe(context: GameContext): Option[Move] =
val hash = PolyglotHash.hash(context)
entries.get(hash).flatMap { bookEntries =>
if bookEntries.isEmpty then None
else
print(s"Book hit: ${bookEntries.length} entries for hash ${hash.toHexString}. ")
val entry = weightedRandom(bookEntries)
decodeMove(entry.move, context)
}
private def loadBookFile(path: String): Map[Long, Vector[BookEntry]] =
val input = DataInputStream(FileInputStream(path))
try
val result = mutable.Map[Long, Vector[BookEntry]]()
while input.available() > 0 do
val key = input.readLong()
val move = input.readShort()
val weight = input.readShort()
val learn = input.readInt()
val entry = BookEntry(key, move, weight)
result.updateWith(key) {
case Some(entries) => Some(entries :+ entry)
case None => Some(Vector(entry))
}
result.toMap
finally
input.close()
/** Decode a packed Polyglot move short into an Option[Move].
*
* Bit layout of the move Short:
* - bits 0-2: to_file (0-7)
* - bits 3-5: to_rank (0-7)
* - bits 6-8: from_file (0-7)
* - bits 9-11: from_rank (0-7)
* - bits 12-14: promotion piece (0=none, 1=knight, 2=bishop, 3=rook, 4=queen)
*/
private def decodeMove(raw: Short, context: GameContext): Option[Move] =
val toFile = (raw & 0x07).toInt
val toRank = ((raw >> 3) & 0x07).toInt
val fromFile = ((raw >> 6) & 0x07).toInt
val fromRank = ((raw >> 9) & 0x07).toInt
val promotionBits = ((raw >> 12) & 0x07).toInt
// Bounds check
if toFile > 7 || toRank > 7 || fromFile > 7 || fromRank > 7 then
return None
val from = Square(File.values(fromFile), Rank.values(fromRank))
val to = Square(File.values(toFile), Rank.values(toRank))
// Check for castling: in Polyglot, castling is encoded as king-to-rook-square
// e.g. e1 (from_rank=0, from_file=4) to h1 (to_file=7, to_rank=0) is white kingside
val isCastling = isKingMove(context, from)
if isCastling && isRookSquare(to, context) then
return Some(decodeCastling(from, to))
// Handle promotion
val moveType = if promotionBits > 0 then
val promotionPiece = promotionBits match
case 1 => PromotionPiece.Knight
case 2 => PromotionPiece.Bishop
case 3 => PromotionPiece.Rook
case 4 => PromotionPiece.Queen
case _ => return None
MoveType.Promotion(promotionPiece)
else
// Check if it's a capture
val isCapture = context.board.pieces.contains(to)
MoveType.Normal(isCapture)
Some(Move(from, to, moveType))
private def isKingMove(context: GameContext, square: Square): Boolean =
context.board.pieces.get(square).exists { piece =>
piece.pieceType == PieceType.King
}
private def isRookSquare(square: Square, context: GameContext): Boolean =
context.board.pieces.get(square).exists { piece =>
piece.pieceType == PieceType.Rook
}
/** Decode castling from king-to-rook square to the standard move.
*
* Polyglot encodes castling as:
* - e1→h1 = White kingside (move to g1)
* - e1→a1 = White queenside (move to c1)
* - e8→h8 = Black kingside (move to g8)
* - e8→a8 = Black queenside (move to c8)
*/
private def decodeCastling(from: Square, to: Square): Move =
if to.file == File.H then
Move(from, Square(File.G, to.rank), MoveType.CastleKingside)
else if to.file == File.A then
Move(from, Square(File.C, to.rank), MoveType.CastleQueenside)
else
// Fallback (should not happen in a valid book)
Move(from, to, MoveType.Normal())
/** Select a weighted random move from the list of book entries. */
private def weightedRandom(entries: Vector[BookEntry]): BookEntry =
if entries.length == 1 then
entries(0)
else
val totalWeight = entries.map(_.weight).sum
var pick = Random.nextInt(totalWeight.max(1))
var idx = 0
while idx < entries.length && pick >= entries(idx).weight do
pick -= entries(idx).weight
idx += 1
entries(idx.min(entries.length - 1))
private case class BookEntry(key: Long, move: Short, weight: Int)
@@ -0,0 +1,249 @@
package de.nowchess.bot
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
object PolyglotHash:
/** 781-entry Zobrist random table from the Polyglot spec. */
private val Random: Array[Long] = Array(
0x9D39247E33776D41L, 0x2AF7398005AAA5C7L, 0x44DB015024623547L, 0x9C15F73E62A76AE2L,
0x75834465489C0C89L, 0x3290AC3A203001BFL, 0x0FBBAD1F61042279L, 0xE83A908FF2FB60CAL,
0x0D7E765D58755C10L, 0x1A083822CEAFE02DL, 0x9605D5F0E25EC3B0L, 0xD021FF5CD13A2ED5L,
0x40BDF15D4A672E32L, 0x011355146FD56395L, 0x5DB4832046F3D9E5L, 0x239F8B2D7FF719CCL,
0x05D1A1AE85B49AA1L, 0x679F848F6E8FC971L, 0x7449BBFF801FED0BL, 0x7D11CDB1C3B7ADF0L,
0x82C7709E781EB7CCL, 0xF3218F1C9510786CL, 0x331478F3AF51BBE6L, 0x4BB38DE5E7219443L,
0xAA649C6EBCFD50FCL, 0x8DBD98A352AFD40BL, 0x87D2074B81D79217L, 0x19F3C751D3E92AE1L,
0xB4AB30F062B19ABFL, 0x7B0500AC42047AC4L, 0xC9452CA81A09D85DL, 0x24AA6C514DA27500L,
0x4C9F34427501B447L, 0x14A68FD73C910841L, 0xA71B9B83461CBD93L, 0x03488B95B0F1850FL,
0x637B2B34FF93C040L, 0x09D1BC9A3DD90A94L, 0x3575668334A1DD3BL, 0x735E2B97A4C45A23L,
0x18727070F1BD400BL, 0x1FCBACD259BF02E7L, 0xD310A7C2CE9B6555L, 0xBF983FE0FE5D8244L,
0x9F74D14F7454A824L, 0x51EBDC4AB9BA3035L, 0x5C82C505DB9AB0FAL, 0xFCF7FE8A3430B241L,
0x3253A729B9BA3DDEL, 0x8C74C368081B3075L, 0xB9BC6C87167C33E7L, 0x7EF48F2B83024E20L,
0x11D505D4C351BD7FL, 0x6568FCA92C76A243L, 0x4DE0B0F40F32A7B8L, 0x96D693460CC37E5DL,
0x42E240CB63689F2FL, 0x6D2BDCDAE2919661L, 0x42880B0236E4D951L, 0x5F0F4A5898171BB6L,
0x39F890F579F92F88L, 0x93C5B5F47356388BL, 0x63DC359D8D231B78L, 0xEC16CA8AEA98AD76L,
0x5355F900C2A82DC7L, 0x07FB9F855A997142L, 0x5093417AA8A7ED5EL, 0x7BCBC38DA25A7F3CL,
0x19FC8A768CF4B6D4L, 0x637A7780DECFC0D9L, 0x8249A47AEE0E41F7L, 0x79AD695501E7D1E8L,
0x14ACBAF4777D5776L, 0xF145B6BECCDEA195L, 0xDABF2AC8201752FCL, 0x24C3C94DF9C8D3F6L,
0xBB6E2924F03912EAL, 0x0CE26C0B95C980D9L, 0xA49CD132BFBF7CC4L, 0xE99D662AF4243939L,
0x27E6AD7891165C3FL, 0x8535F040B9744FF1L, 0x54B3F4FA5F40D873L, 0x72B12C32127FED2BL,
0xEE954D3C7B411F47L, 0x9A85AC909A24EAA1L, 0x70AC4CD9F04F21F5L, 0xF9B89D3E99A075C2L,
0x87B3E2B2B5C907B1L, 0xA366E5B8C54F48B8L, 0xAE4A9346CC3F7CF2L, 0x1920C04D47267BBDL,
0x87BF02C6B49E2AE9L, 0x092237AC237F3859L, 0xFF07F64EF8ED14D0L, 0x8DE8DCA9F03CC54EL,
0x9C1633264DB49C89L, 0xB3F22C3D0B0B38EDL, 0x390E5FB44D01144BL, 0x5BFEA5B4712768E9L,
0x1E1032911FA78984L, 0x9A74ACB964E78CB3L, 0x4F80F7A035DAFB04L, 0x6304D09A0B3738C4L,
0x2171E64683023A08L, 0x5B9B63EB9CEFF80CL, 0x506AACF489889342L, 0x1881AFC9A3A701D6L,
0x6503080440750644L, 0xDFD395339CDBF4A7L, 0xEF927DBCF00C20F2L, 0x7B32F7D1E03680ECL,
0xB9FD7620E7316243L, 0x05A7E8A57DB91B77L, 0xB5889C6E15630A75L, 0x4A750A09CE9573F7L,
0xCF464CEC899A2F8AL, 0xF538639CE705B824L, 0x3C79A0FF5580EF7FL, 0xEDE6C87F8477609DL,
0x799E81F05BC93F31L, 0x86536B8CF3428A8CL, 0x97D7374C60087B73L, 0xA246637CFF328532L,
0x043FCAE60CC0EBA0L, 0x920E449535DD359EL, 0x70EB093B15B290CCL, 0x73A1921916591CBDL,
0x56436C9FE1A1AA8DL, 0xEFAC4B70633B8F81L, 0xBB215798D45DF7AFL, 0x45F20042F24F1768L,
0x930F80F4E8EB7462L, 0xFF6712FFCFD75EA1L, 0xAE623FD67468AA70L, 0xDD2C5BC84BC8D8FCL,
0x7EED120D54CF2DD9L, 0x22FE545401165F1CL, 0xC91800E98FB99929L, 0x808BD68E6AC10365L,
0xDEC468145B7605F6L, 0x1BEDE3A3AEF53302L, 0x43539603D6C55602L, 0xAA969B5C691CCB7AL,
0xA87832D392EFEE56L, 0x65942C7B3C7E11AEL, 0xDED2D633CAD004F6L, 0x21F08570F420E565L,
0xB415938D7DA94E3CL, 0x91B859E59ECB6350L, 0x10CFF333E0ED804AL, 0x28AED140BE0BB7DDL,
0xC5CC1D89724FA456L, 0x5648F680F11A2741L, 0x2D255069F0B7DAB3L, 0x9BC5A38EF729ABD4L,
0xEF2F054308F6A2BCL, 0xAF2042F5CC5C2858L, 0x480412BAB7F5BE2AL, 0xAEF3AF4A563DFE43L,
0x19AFE59AE451497FL, 0x52593803DFF1E840L, 0xF4F076E65F2CE6F0L, 0x11379625747D5AF3L,
0xBCE5D2248682C115L, 0x9DA4243DE836994FL, 0x066F70B33FE09017L, 0x4DC4DE189B671A1CL,
0x51039AB7712457C3L, 0xC07A3F80C31FB4B4L, 0xB46EE9C5E64A6E7CL, 0xB3819A42ABE61C87L,
0x21A007933A522A20L, 0x2DF16F761598AA4FL, 0x763C4A1371B368FDL, 0xF793C46702E086A0L,
0xD7288E012AEB8D31L, 0xDE336A2A4BC1C44BL, 0x0BF692B38D079F23L, 0x2C604A7A177326B3L,
0x4850E73E03EB6064L, 0xCFC447F1E53C8E1BL, 0xB05CA3F564268D99L, 0x9AE182C8BC9474E8L,
0xA4FC4BD4FC5558CAL, 0xE755178D58FC4E76L, 0x69B97DB1A4C03DFEL, 0xF9B5B7C4ACC67C96L,
0xFC6A82D64B8655FBL, 0x9C684CB6C4D24417L, 0x8EC97D2917456ED0L, 0x6703DF9D2924E97EL,
0xC547F57E42A7444EL, 0x78E37644E7CAD29EL, 0xFE9A44E9362F05FAL, 0x08BD35CC38336615L,
0x9315E5EB3A129ACEL, 0x94061B871E04DF75L, 0xDF1D9F9D784BA010L, 0x3BBA57B68871B59DL,
0xD2B7ADEEDED1F73FL, 0xF7A255D83BC373F8L, 0xD7F4F2448C0CEB81L, 0xD95BE88CD210FFA7L,
0x336F52F8FF4728E7L, 0xA74049DAC312AC71L, 0xA2F61BB6E437FDB5L, 0x4F2A5CB07F6A35B3L,
0x87D380BDA5BF7859L, 0x16B9F7E06C453A21L, 0x7BA2484C8A0FD54EL, 0xF3A678CAD9A2E38CL,
0x39B0BF7DDE437BA2L, 0xFCAF55C1BF8A4424L, 0x18FCF680573FA594L, 0x4C0563B89F495AC3L,
0x40E087931A00930DL, 0x8CFFA9412EB642C1L, 0x68CA39053261169FL, 0x7A1EE967D27579E2L,
0x9D1D60E5076F5B6FL, 0x3810E399B6F65BA2L, 0x32095B6D4AB5F9B1L, 0x35CAB62109DD038AL,
0xA90B24499FCFAFB1L, 0x77A225A07CC2C6BDL, 0x513E5E634C70E331L, 0x4361C0CA3F692F12L,
0xD941ACA44B20A45BL, 0x528F7C8602C5807BL, 0x52AB92BEB9613989L, 0x9D1DFA2EFC557F73L,
0x722FF175F572C348L, 0x1D1260A51107FE97L, 0x7A249A57EC0C9BA2L, 0x04208FE9E8F7F2D6L,
0x5A110C6058B920A0L, 0x0CD9A497658A5698L, 0x56FD23C8F9715A4CL, 0x284C847B9D887AAEL,
0x04FEABFBBDB619CBL, 0x742E1E651C60BA83L, 0x9A9632E65904AD3CL, 0x881B82A13B51B9E2L,
0x506E6744CD974924L, 0xB0183DB56FFC6A79L, 0x0ED9B915C66ED37EL, 0x5E11E86D5873D484L,
0xF678647E3519AC6EL, 0x1B85D488D0F20CC5L, 0xDAB9FE6525D89021L, 0x0D151D86ADB73615L,
0xA865A54EDCC0F019L, 0x93C42566AEF98FFBL, 0x99E7AFEABE000731L, 0x48CBFF086DDF285AL,
0x7F9B6AF1EBF78BAFL, 0x58627E1A149BBA21L, 0x2CD16E2ABD791E33L, 0xD363EFF5F0977996L,
0x0CE2A38C344A6EEDL, 0x1A804AADB9CFA741L, 0x907F30421D78C5DEL, 0x501F65EDB3034D07L,
0x37624AE5A48FA6E9L, 0x957BAF61700CFF4EL, 0x3A6C27934E31188AL, 0xD49503536ABCA345L,
0x088E049589C432E0L, 0xF943AEE7FEBF21B8L, 0x6C3B8E3E336139D3L, 0x364F6FFA464EE52EL,
0xD60F6DCEDC314222L, 0x56963B0DCA418FC0L, 0x16F50EDF91E513AFL, 0xEF1955914B609F93L,
0x565601C0364E3228L, 0xECB53939887E8175L, 0xBAC7A9A18531294BL, 0xB344C470397BBA52L,
0x65D34954DAF3CEBDL, 0xB4B81B3FA97511E2L, 0xB422061193D6F6A7L, 0x071582401C38434DL,
0x7A13F18BBEDC4FF5L, 0xBC4097B116C524D2L, 0x59B97885E2F2EA28L, 0x99170A5DC3115544L,
0x6F423357E7C6A9F9L, 0x325928EE6E6F8794L, 0xD0E4366228B03343L, 0x565C31F7DE89EA27L,
0x30F5611484119414L, 0xD873DB391292ED4FL, 0x7BD94E1D8E17DEBCL, 0xC7D9F16864A76E94L,
0x947AE053EE56E63CL, 0xC8C93882F9475F5FL, 0x3A9BF55BA91F81CAL, 0xD9A11FBB3D9808E4L,
0x0FD22063EDC29FCAL, 0xB3F256D8ACA0B0B9L, 0xB03031A8B4516E84L, 0x35DD37D5871448AFL,
0xE9F6082B05542E4EL, 0xEBFAFA33D7254B59L, 0x9255ABB50D532280L, 0xB9AB4CE57F2D34F3L,
0x693501D628297551L, 0xC62C58F97DD949BFL, 0xCD454F8F19C5126AL, 0xBBE83F4ECC2BDECBL,
0xDC842B7E2819E230L, 0xBA89142E007503B8L, 0xA3BC941D0A5061CBL, 0xE9F6760E32CD8021L,
0x09C7E552BC76492FL, 0x852F54934DA55CC9L, 0x8107FCCF064FCF56L, 0x098954D51FFF6580L,
0x23B70EDB1955C4BFL, 0xC330DE426430F69DL, 0x4715ED43E8A45C0AL, 0xA8D7E4DAB780A08DL,
0x0572B974F03CE0BBL, 0xB57D2E985E1419C7L, 0xE8D9ECBE2CF3D73FL, 0x2FE4B17170E59750L,
0x11317BA87905E790L, 0x7FBF21EC8A1F45ECL, 0x1725CABFCB045B00L, 0x964E915CD5E2B207L,
0x3E2B8BCBF016D66DL, 0xBE7444E39328A0ACL, 0xF85B2B4FBCDE44B7L, 0x49353FEA39BA63B1L,
0x1DD01AAFCD53486AL, 0x1FCA8A92FD719F85L, 0xFC7C95D827357AFAL, 0x18A6A990C8B35EBDL,
0xCCCB7005C6B9C28DL, 0x3BDBB92C43B17F26L, 0xAA70B5B4F89695A2L, 0xE94C39A54A98307FL,
0xB7A0B174CFF6F36EL, 0xD4DBA84729AF48ADL, 0x2E18BC1AD9704A68L, 0x2DE0966DAF2F8B1CL,
0xB9C11D5B1E43A07EL, 0x64972D68DEE33360L, 0x94628D38D0C20584L, 0xDBC0D2B6AB90A559L,
0xD2733C4335C6A72FL, 0x7E75D99D94A70F4DL, 0x6CED1983376FA72BL, 0x97FCAACBF030BC24L,
0x7B77497B32503B12L, 0x8547EDDFB81CCB94L, 0x79999CDFF70902CBL, 0xCFFE1939438E9B24L,
0x829626E3892D95D7L, 0x92FAE24291F2B3F1L, 0x63E22C147B9C3403L, 0xC678B6D860284A1CL,
0x5873888850659AE7L, 0x0981DCD296A8736DL, 0x9F65789A6509A440L, 0x9FF38FED72E9052FL,
0xE479EE5B9930578CL, 0xE7F28ECD2D49EECDL, 0x56C074A581EA17FEL, 0x5544F7D774B14AEFL,
0x7B3F0195FC6F290FL, 0x12153635B2C0CF57L, 0x7F5126DBBA5E0CA7L, 0x7A76956C3EAFB413L,
0x3D5774A11D31AB39L, 0x8A1B083821F40CB4L, 0x7B4A38E32537DF62L, 0x950113646D1D6E03L,
0x4DA8979A0041E8A9L, 0x3BC36E078F7515D7L, 0x5D0A12F27AD310D1L, 0x7F9D1A2E1EBE1327L,
0xDA3A361B1C5157B1L, 0xDCDD7D20903D0C25L, 0x36833336D068F707L, 0xCE68341F79893389L,
0xAB9090168DD05F34L, 0x43954B3252DC25E5L, 0xB438C2B67F98E5E9L, 0x10DCD78E3851A492L,
0xDBC27AB5447822BFL, 0x9B3CDB65F82CA382L, 0xB67B7896167B4C84L, 0xBFCED1B0048EAC50L,
0xA9119B60369FFEBDL, 0x1FFF7AC80904BF45L, 0xAC12FB171817EEE7L, 0xAF08DA9177DDA93DL,
0x1B0CAB936E65C744L, 0xB559EB1D04E5E932L, 0xC37B45B3F8D6F2BAL, 0xC3A9DC228CAAC9E9L,
0xF3B8B6675A6507FFL, 0x9FC477DE4ED681DAL, 0x67378D8ECCEF96CBL, 0x6DD856D94D259236L,
0xA319CE15B0B4DB31L, 0x073973751F12DD5EL, 0x8A8E849EB32781A5L, 0xE1925C71285279F5L,
0x74C04BF1790C0EFEL, 0x4DDA48153C94938AL, 0x9D266D6A1CC0542CL, 0x7440FB816508C4FEL,
0x13328503DF48229FL, 0xD6BF7BAEE43CAC40L, 0x4838D65F6EF6748FL, 0x1E152328F3318DEAL,
0x8F8419A348F296BFL, 0x72C8834A5957B511L, 0xD7A023A73260B45CL, 0x94EBC8ABCFB56DAEL,
0x9FC10D0F989993E0L, 0xDE68A2355B93CAE6L, 0xA44CFE79AE538BBEL, 0x9D1D84FCCE371425L,
0x51D2B1AB2DDFB636L, 0x2FD7E4B9E72CD38CL, 0x65CA5B96B7552210L, 0xDD69A0D8AB3B546DL,
0x604D51B25FBF70E2L, 0x73AA8A564FB7AC9EL, 0x1A8C1E992B941148L, 0xAAC40A2703D9BEA0L,
0x764DBEAE7FA4F3A6L, 0x1E99B96E70A9BE8BL, 0x2C5E9DEB57EF4743L, 0x3A938FEE32D29981L,
0x26E6DB8FFDF5ADFEL, 0x469356C504EC9F9DL, 0xC8763C5B08D1908CL, 0x3F6C6AF859D80055L,
0x7F7CC39420A3A545L, 0x9BFB227EBDF4C5CEL, 0x89039D79D6FC5C5CL, 0x8FE88B57305E2AB6L,
0xA09E8C8C35AB96DEL, 0xFA7E393983325753L, 0xD6B6D0ECC617C699L, 0xDFEA21EA9E7557E3L,
0xB67C1FA481680AF8L, 0xCA1E3785A9E724E5L, 0x1CFC8BED0D681639L, 0xD18D8549D140CAEAL,
0x4ED0FE7E9DC91335L, 0xE4DBF0634473F5D2L, 0x1761F93A44D5AEFEL, 0x53898E4C3910DA55L,
0x734DE8181F6EC39AL, 0x2680B122BAA28D97L, 0x298AF231C85BAFABL, 0x7983EED3740847D5L,
0x66C1A2A1A60CD889L, 0x9E17E49642A3E4C1L, 0xEDB454E7BADC0805L, 0x50B704CAB602C329L,
0x4CC317FB9CDDD023L, 0x66B4835D9EAFEA22L, 0x219B97E26FFC81BDL, 0x261E4E4C0A333A9DL,
0x1FE2CCA76517DB90L, 0xD7504DFA8816EDBBL, 0xB9571FA04DC089C8L, 0x1DDC0325259B27DEL,
0xCF3F4688801EB9AAL, 0xF4F5D05C10CAB243L, 0x38B6525C21A42B0EL, 0x36F60E2BA4FA6800L,
0xEB3593803173E0CEL, 0x9C4CD6257C5A3603L, 0xAF0C317D32ADAA8AL, 0x258E5A80C7204C4BL,
0x8B889D624D44885DL, 0xF4D14597E660F855L, 0xD4347F66EC8941C3L, 0xE699ED85B0DFB40DL,
0x2472F6207C2D0484L, 0xC2A1E7B5B459AEB5L, 0xAB4F6451CC1D45ECL, 0x63767572AE3D6174L,
0xA59E0BD101731A28L, 0x116D0016CB948F09L, 0x2CF9C8CA052F6E9FL, 0x0B090A7560A968E3L,
0xABEEDDB2DDE06FF1L, 0x58EFC10B06A2068DL, 0xC6E57A78FBD986E0L, 0x2EAB8CA63CE802D7L,
0x14A195640116F336L, 0x7C0828DD624EC390L, 0xD74BBE77E6116AC7L, 0x804456AF10F5FB53L,
0xEBE9EA2ADF4321C7L, 0x03219A39EE587A30L, 0x49787FEF17AF9924L, 0xA1E9300CD8520548L,
0x5B45E522E4B1B4EFL, 0xB49C3B3995091A36L, 0xD4490AD526F14431L, 0x12A8F216AF9418C2L,
0x001F837CC7350524L, 0x1877B51E57A764D5L, 0xA2853B80F17F58EEL, 0x993E1DE72D36D310L,
0xB3598080CE64A656L, 0x252F59CF0D9F04BBL, 0xD23C8E176D113600L, 0x1BDA0492E7E4586EL,
0x21E0BD5026C619BFL, 0x3B097ADAF088F94EL, 0x8D14DEDB30BE846EL, 0xF95CFFA23AF5F6F4L,
0x3871700761B3F743L, 0xCA672B91E9E4FA16L, 0x64C8E531BFF53B55L, 0x241260ED4AD1E87DL,
0x106C09B972D2E822L, 0x7FBA195410E5CA30L, 0x7884D9BC6CB569D8L, 0x0647DFEDCD894A29L,
0x63573FF03E224774L, 0x4FC8E9560F91B123L, 0x1DB956E450275779L, 0xB8D91274B9E9D4FBL,
0xA2EBEE47E2FBFCE1L, 0xD9F1F30CCD97FB09L, 0xEFED53D75FD64E6BL, 0x2E6D02C36017F67FL,
0xA9AA4D20DB084E9BL, 0xB64BE8D8B25396C1L, 0x70CB6AF7C2D5BCF0L, 0x98F076A4F7A2322EL,
0xBF84470805E69B5FL, 0x94C3251F06F90CF3L, 0x3E003E616A6591E9L, 0xB925A6CD0421AFF3L,
0x61BDD1307C66E300L, 0xBF8D5108E27E0D48L, 0x240AB57A8B888B20L, 0xFC87614BAF287E07L,
0xEF02CDD06FFDB432L, 0xA1082C0466DF6C0AL, 0x8215E577001332C8L, 0xD39BB9C3A48DB6CFL,
0x2738259634305C14L, 0x61CF4F94C97DF93DL, 0x1B6BACA2AE4E125BL, 0x758F450C88572E0BL,
0x959F587D507A8359L, 0xB063E962E045F54DL, 0x60E8ED72C0DFF5D1L, 0x7B64978555326F9FL,
0xFD080D236DA814BAL, 0x8C90FD9B083F4558L, 0x106F72FE81E2C590L, 0x7976033A39F7D952L,
0xA4EC0132764CA04BL, 0x733EA705FAE4FA77L, 0xB4D8F77BC3E56167L, 0x9E21F4F903B33FD9L,
0x9D765E419FB69F6DL, 0xD30C088BA61EA5EFL, 0x5D94337FBFAF7F5BL, 0x1A4E4822EB4D7A59L,
0x6FFE73E81B637FB3L, 0xDDF957BC36D8B9CAL, 0x64D0E29EEA8838B3L, 0x08DD9BDFD96B9F63L,
0x087E79E5A57D1D13L, 0xE328E230E3E2B3FBL, 0x1C2559E30F0946BEL, 0x720BF5F26F4D2EAAL,
0xB0774D261CC609DBL, 0x443F64EC5A371195L, 0x4112CF68649A260EL, 0xD813F2FAB7F5C5CAL,
0x660D3257380841EEL, 0x59AC2C7873F910A3L, 0xE846963877671A17L, 0x93B633ABFA3469F8L,
0xC0C0F5A60EF4CDCFL, 0xCAF21ECD4377B28CL, 0x57277707199B8175L, 0x506C11B9D90E8B1DL,
0xD83CC2687A19255FL, 0x4A29C6465A314CD1L, 0xED2DF21216235097L, 0xB5635C95FF7296E2L,
0x22AF003AB672E811L, 0x52E762596BF68235L, 0x9AEBA33AC6ECC6B0L, 0x944F6DE09134DFB6L,
0x6C47BEC883A7DE39L, 0x6AD047C430A12104L, 0xA5B1CFDBA0AB4067L, 0x7C45D833AFF07862L,
0x5092EF950A16DA0BL, 0x9338E69C052B8E7BL, 0x455A4B4CFE30E3F5L, 0x6B02E63195AD0CF8L,
0x6B17B224BAD6BF27L, 0xD1E0CCD25BB9C169L, 0xDE0C89A556B9AE70L, 0x50065E535A213CF6L,
0x9C1169FA2777B874L, 0x78EDEFD694AF1EEDL, 0x6DC93D9526A50E68L, 0xEE97F453F06791EDL,
0x32AB0EDB696703D3L, 0x3A6853C7E70757A7L, 0x31865CED6120F37DL, 0x67FEF95D92607890L,
0x1F2B1D1F15F6DC9CL, 0xB69E38A8965C6B65L, 0xAA9119FF184CCCF4L, 0xF43C732873F24C13L,
0xFB4A3D794A9A80D2L, 0x3550C2321FD6109CL, 0x371F77E76BB8417EL, 0x6BFA9AAE5EC05779L,
0xCD04F3FF001A4778L, 0xE3273522064480CAL, 0x9F91508BFFCFC14AL, 0x049A7F41061A9E60L,
0xFCB6BE43A9F2FE9BL, 0x08DE8A1C7797DA9BL, 0x8F9887E6078735A1L, 0xB5B4071DBFC73A66L,
0x230E343DFBA08D33L, 0x43ED7F5A0FAE657DL, 0x3A88A0FBBCB05C63L, 0x21874B8B4D2DBC4FL,
0x1BDEA12E35F6A8C9L, 0x53C065C6C8E63528L, 0xE34A1D250E7A8D6BL, 0xD6B04D3B7651DD7EL,
0x5E90277E7CB39E2DL, 0x2C046F22062DC67DL, 0xB10BB459132D0A26L, 0x3FA9DDFB67E2F199L,
0x0E09B88E1914F7AFL, 0x10E8B35AF3EEAB37L, 0x9EEDECA8E272B933L, 0xD4C718BC4AE8AE5FL,
0x81536D601170FC20L, 0x91B534F885818A06L, 0xEC8177F83F900978L, 0x190E714FADA5156EL,
0xB592BF39B0364963L, 0x89C350C893AE7DC1L, 0xAC042E70F8B383F2L, 0xB49B52E587A1EE60L,
0xFB152FE3FF26DA89L, 0x3E666E6F69AE2C15L, 0x3B544EBE544C19F9L, 0xE805A1E290CF2456L,
0x24B33C9D7ED25117L, 0xE74733427B72F0C1L, 0x0A804D18B7097475L, 0x57E3306D881EDB4FL,
0x4AE7D6A36EB5DBCBL, 0x2D8D5432157064C8L, 0xD1E649DE1E7F268BL, 0x8A328A1CEDFE552CL,
0x07A3AEC79624C7DAL, 0x84547DDC3E203C94L, 0x990A98FD5071D263L, 0x1A4FF12616EEFC89L,
0xF6F7FD1431714200L, 0x30C05B1BA332F41CL, 0x8D2636B81555A786L, 0x46C9FEB55D120902L,
0xCCEC0A73B49C9921L, 0x4E9D2827355FC492L, 0x19EBB029435DCB0FL, 0x4659D2B743848A2CL,
0x963EF2C96B33BE31L, 0x74F85198B05A2E7DL, 0x5A0F544DD2B1FB18L, 0x03727073C2E134B1L,
0xC7F6AA2DE59AEA61L, 0x352787BAA0D7C22FL, 0x9853EAB63B5E0B35L, 0xABBDCDD7ED5C0860L,
0xCF05DAF5AC8D77B0L, 0x49CAD48CEBF4A71EL, 0x7A4C10EC2158C4A6L, 0xD9E92AA246BF719EL,
0x13AE978D09FE5557L, 0x730499AF921549FFL, 0x4E4B705B92903BA4L, 0xFF577222C14F0A3AL,
0x55B6344CF97AAFAEL, 0xB862225B055B6960L, 0xCAC09AFBDDD2CDB4L, 0xDAF8E9829FE96B5FL,
0xB5FDFC5D3132C498L, 0x310CB380DB6F7503L, 0xE87FBB46217A360EL, 0x2102AE466EBB1148L,
0xF8549E1A3AA5E00DL, 0x07A69AFDCC42261AL, 0xC4C118BFE78FEAAEL, 0xF9F4892ED96BD438L,
0x1AF3DBE25D8F45DAL, 0xF5B4B0B0D2DEEEB4L, 0x962ACEEFA82E1C84L, 0x046E3ECAAF453CE9L,
0xF05D129681949A4CL, 0x964781CE734B3C84L, 0x9C2ED44081CE5FBDL, 0x522E23F3925E319EL,
0x177E00F9FC32F791L, 0x2BC60A63A6F3B3F2L, 0x222BBFAE61725606L, 0x486289DDCC3D6780L,
0x7DC7785B8EFDFC80L, 0x8AF38731C02BA980L, 0x1FAB64EA29A2DDF7L, 0xE4D9429322CD065AL,
0x9DA058C67844F20CL, 0x24C0E332B70019B0L, 0x233003B5A6CFE6ADL, 0xD586BD01C5C217F6L,
0x5E5637885F29BC2BL, 0x7EBA726D8C94094BL, 0x0A56A5F0BFE39272L, 0xD79476A84EE20D06L,
0x9E4C1269BAA4BF37L, 0x17EFEE45B0DEE640L, 0x1D95B0A5FCF90BC6L, 0x93CBE0B699C2585DL,
0x65FA4F227A2B6D79L, 0xD5F9E858292504D5L, 0xC2B5A03F71471A6FL, 0x59300222B4561E00L,
0xCE2F8642CA0712DCL, 0x7CA9723FBB2E8988L, 0x2785338347F2BA08L, 0xC61BB3A141E50E8CL,
0x150F361DAB9DEC26L, 0x9F6A419D382595F4L, 0x64A53DC924FE7AC9L, 0x142DE49FFF7A7C3DL,
0x0C335248857FA9E7L, 0x0A9C32D5EAE45305L, 0xE6C42178C4BBB92EL, 0x71F1CE2490D20B07L,
0xF1BCC3D275AFE51AL, 0xE728E8C83C334074L, 0x96FBF83A12884624L, 0x81A1549FD6573DA5L,
0x5FA7867CAF35E149L, 0x56986E2EF3ED091BL, 0x917F1DD5F8886C61L, 0xD20D8C88C8FFE65FL,
0x31D71DCE64B2C310L, 0xF165B587DF898190L, 0xA57E6339DD2CF3A0L, 0x1EF6E6DBB1961EC9L,
0x70CC73D90BC26E24L, 0xE21A6B35DF0C3AD7L, 0x003A93D8B2806962L, 0x1C99DED33CB890A1L,
0xCF3145DE0ADD4289L, 0xD0E4427A5514FB72L, 0x77C621CC9FB3A483L, 0x67A34DAC4356550BL,
0xF8D626AAAF278509L
)
def hash(context: GameContext): Long =
var h = 0L
// Piece hashes
for (sq, piece) <- context.board.pieces do
val idx = pieceIndex(piece) * 64 + squareIndex(sq)
h ^= Random(idx)
// Side to move (XOR if White is to move)
if context.turn == Color.White then
h ^= Random(768)
// Castling rights
if context.castlingRights.whiteKingSide then
h ^= Random(769)
if context.castlingRights.whiteQueenSide then
h ^= Random(770)
if context.castlingRights.blackKingSide then
h ^= Random(771)
if context.castlingRights.blackQueenSide then
h ^= Random(772)
// En passant (by file only)
context.enPassantSquare.foreach { sq =>
h ^= Random(773 + sq.file.ordinal)
}
h
private def pieceIndex(piece: Piece): Int =
val colorIdx = if piece.color == Color.Black then 0 else 1
val typeIdx = piece.pieceType match
case PieceType.Pawn => 0
case PieceType.Knight => 1
case PieceType.Bishop => 2
case PieceType.Rook => 3
case PieceType.Queen => 4
case PieceType.King => 5
colorIdx * 6 + typeIdx
private def squareIndex(sq: Square): Int =
sq.file.ordinal + 8 * sq.rank.ordinal
@@ -0,0 +1,155 @@
package de.nowchess.bot
import de.nowchess.api.board.{Color, File, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{DataOutputStream, FileOutputStream}
import java.nio.file.Files
import scala.util.Using
class PolyglotBookTest extends AnyFunSuite with Matchers:
test("Book probe returns None for non-existent file"):
val book = PolyglotBook("/nonexistent/path/book.bin")
book.probe(GameContext.initial) shouldEqual None
test("Book probe returns None when position not in book"):
val tempFile = Files.createTempFile("test_book", ".bin")
try
// Write a single entry with a different key
Using(DataOutputStream(FileOutputStream(tempFile.toFile))) { dos =>
dos.writeLong(12345L) // some random key
dos.writeShort(0) // move
dos.writeShort(100) // weight
dos.writeInt(0) // learn
}.get
val book = PolyglotBook(tempFile.toString)
book.probe(GameContext.initial) shouldEqual None
finally
Files.delete(tempFile)
test("Book returns a move when position is in book"):
val tempFile = Files.createTempFile("test_book", ".bin")
try
val ctx = GameContext.initial
val hash = PolyglotHash.hash(ctx)
// Write an entry: e2-e4 (normal move, non-capture)
// from_file=4, from_rank=1, to_file=4, to_rank=3, promotion=0
val move: Short = (4 | (3 << 3) | (4 << 6) | (1 << 9)).toShort
Using(DataOutputStream(FileOutputStream(tempFile.toFile))) { dos =>
dos.writeLong(hash)
dos.writeShort(move)
dos.writeShort(100) // weight
dos.writeInt(0)
}.get
val book = PolyglotBook(tempFile.toString)
val result = book.probe(ctx)
result shouldNot be(None)
result.get.from shouldEqual Square(File.E, Rank.R2)
result.get.to shouldEqual Square(File.E, Rank.R4)
finally
Files.delete(tempFile)
test("Weighted random sampling works"):
val tempFile = Files.createTempFile("test_book", ".bin")
try
val ctx = GameContext.initial
val hash = PolyglotHash.hash(ctx)
// Two moves: e2-e4 with high weight, d2-d4 with low weight
val moveE4: Short = (4 | (3 << 3) | (4 << 6) | (1 << 9)).toShort
val moveD4: Short = (3 | (3 << 3) | (3 << 6) | (1 << 9)).toShort
Using(DataOutputStream(FileOutputStream(tempFile.toFile))) { dos =>
dos.writeLong(hash)
dos.writeShort(moveE4)
dos.writeShort(900) // high weight
dos.writeInt(0)
dos.writeLong(hash)
dos.writeShort(moveD4)
dos.writeShort(100) // low weight
dos.writeInt(0)
}.get
val book = PolyglotBook(tempFile.toString)
// Sample multiple times; high-weight move should be picked more often
val samples = (0 until 100).map(_ => book.probe(ctx)).flatten
samples.length should be > 0
val e4Count = samples.count(m => m.from == Square(File.E, Rank.R2) && m.to == Square(File.E, Rank.R4))
val d4Count = samples.count(m => m.from == Square(File.D, Rank.R2) && m.to == Square(File.D, Rank.R4))
// With 900:100 weight ratio, e4 should appear more frequently
e4Count should be > d4Count
finally
Files.delete(tempFile)
test("ClassicalBot without book falls back to search"):
val ctx = GameContext.initial
val bot = ClassicalBot(BotDifficulty.Easy) // no book
val move = bot.nextMove(ctx)
move shouldNot be(None)
// The move should be legal
val allLegalMoves = DefaultRules.allLegalMoves(ctx)
allLegalMoves should contain(move.get)
test("ClassicalBot with book uses book move"):
val tempFile = Files.createTempFile("test_book", ".bin")
try
val ctx = GameContext.initial
val hash = PolyglotHash.hash(ctx)
// e2-e4
val moveE4: Short = (4 | (3 << 3) | (4 << 6) | (1 << 9)).toShort
Using(DataOutputStream(FileOutputStream(tempFile.toFile))) { dos =>
dos.writeLong(hash)
dos.writeShort(moveE4)
dos.writeShort(100)
dos.writeInt(0)
}.get
val book = PolyglotBook(tempFile.toString)
val botWithBook = ClassicalBot(BotDifficulty.Easy, book = Some(book))
val move = botWithBook.nextMove(ctx)
// Book should return e2-e4
move shouldEqual Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))
finally
Files.delete(tempFile)
test("Promotion moves are decoded correctly"):
val tempFile = Files.createTempFile("test_book", ".bin")
try
val ctx = GameContext.initial
val hash = PolyglotHash.hash(ctx)
// Pawn promotion: a7-a8=Q
// from_file=0, from_rank=6, to_file=0, to_rank=7, promotion=4 (queen)
val promoteMove: Short = (0 | (7 << 3) | (0 << 6) | (6 << 9) | (4 << 12)).toShort
Using(DataOutputStream(FileOutputStream(tempFile.toFile))) { dos =>
dos.writeLong(hash)
dos.writeShort(promoteMove)
dos.writeShort(100)
dos.writeInt(0)
}.get
val book = PolyglotBook(tempFile.toString)
val move = book.probe(ctx)
move shouldNot be(None)
move.get.moveType match
case MoveType.Promotion(piece) => piece shouldEqual PromotionPiece.Queen
case _ => fail("Expected promotion move")
finally
Files.delete(tempFile)
@@ -0,0 +1,53 @@
package de.nowchess.bot
import de.nowchess.api.board.{Color, File, Rank, Square}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class PolyglotHashTest extends AnyFunSuite with Matchers:
test("Initial position produces consistent hash"):
val ctx = GameContext.initial
val hash1 = PolyglotHash.hash(ctx)
val hash2 = PolyglotHash.hash(ctx)
hash1 shouldEqual hash2
test("Hash changes when turn changes"):
val ctx = GameContext.initial
val hash1 = PolyglotHash.hash(ctx)
val ctxBlackTurn = ctx.withTurn(Color.Black)
val hash2 = PolyglotHash.hash(ctxBlackTurn)
hash1 should not equal hash2
test("Hash changes when castling rights change"):
val ctx = GameContext.initial
val hash1 = PolyglotHash.hash(ctx)
val noCastling = ctx.withCastlingRights(
de.nowchess.api.board.CastlingRights(false, false, false, false)
)
val hash2 = PolyglotHash.hash(noCastling)
hash1 should not equal hash2
test("Hash changes when en passant square changes"):
val ctx = GameContext.initial
val hash1 = PolyglotHash.hash(ctx)
val withEP = ctx.withEnPassantSquare(Some(Square(File.E, Rank.R3)))
val hash2 = PolyglotHash.hash(withEP)
hash1 should not equal hash2
test("Different en passant files produce different hashes"):
val ctx = GameContext.initial
val epFileE = ctx.withEnPassantSquare(Some(Square(File.E, Rank.R3)))
val epFileD = ctx.withEnPassantSquare(Some(Square(File.D, Rank.R3)))
val hash1 = PolyglotHash.hash(epFileE)
val hash2 = PolyglotHash.hash(epFileD)
hash1 should not equal hash2
test("Removing en passant changes hash"):
val ctx = GameContext.initial
val withEP = ctx.withEnPassantSquare(Some(Square(File.E, Rank.R3)))
val hash1 = PolyglotHash.hash(withEP)
val noEP = withEP.withEnPassantSquare(None)
val hash2 = PolyglotHash.hash(noEP)
hash1 should not equal hash2
@@ -9,6 +9,8 @@ import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.io.{GameContextExport, GameContextImport} import de.nowchess.io.{GameContextExport, GameContextImport}
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
import de.nowchess.bot.Bot
import scala.concurrent.{Future, ExecutionContext}
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the /** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents. * injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
@@ -31,9 +33,28 @@ class GameEngine(
@SuppressWarnings(Array("DisableSyntax.var")) @SuppressWarnings(Array("DisableSyntax.var"))
private var pendingPromotion: Option[PendingPromotion] = None private var pendingPromotion: Option[PendingPromotion] = None
/** Optional opponent bot and the color it plays. */
private var opponentBot: Option[Bot] = None
private var opponentColor: Option[Color] = None
private implicit val ec: ExecutionContext = ExecutionContext.global
/** True if a pawn promotion move is pending and needs a piece choice. */ /** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined) def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
/** Set an opponent bot to play against.
* The bot will play as the given color and auto-play moves after the opponent moves.
*/
def setOpponentBot(bot: Bot, color: Color): Unit = synchronized {
opponentBot = Some(bot)
opponentColor = Some(color)
}
/** Clear the opponent bot. */
def clearOpponentBot(): Unit = synchronized {
opponentBot = None
opponentColor = None
}
// Synchronized accessors for current state // Synchronized accessors for current state
def board: Board = synchronized(currentContext.board) def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn) def turn: Color = synchronized(currentContext.turn)
@@ -252,6 +273,12 @@ class GameEngine(
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if ruleSet.isThreefoldRepetition(currentContext) then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext)) if ruleSet.isThreefoldRepetition(currentContext) then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
// Request bot move if it's the opponent bot's turn
if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then
() // Game is over, don't request bot move
else
requestBotMoveIfNeeded()
private def translateMoveToNotation(move: Move, boardBefore: Board): String = private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match move.moveType match
case MoveType.CastleKingside => "O-O" case MoveType.CastleKingside => "O-O"
@@ -301,6 +328,53 @@ class GameEngine(
case _ => case _ =>
context.board.pieceAt(move.to) context.board.pieceAt(move.to)
/** Request a move from the opponent bot if it's their turn.
* Spawns an async task to avoid blocking the engine.
*/
private def requestBotMoveIfNeeded(): Unit =
(opponentBot, opponentColor) match
case (Some(bot), Some(color)) if currentContext.turn == color =>
Future {
bot.nextMove(currentContext) match
case Some(move) => applyBotMove(move, color)
case None => handleBotNoMove()
}
case _ => () // No bot or not bot's turn
private def applyBotMove(move: Move, color: Color): Unit =
synchronized {
if currentContext.turn == color then
val from = move.from
val to = move.to
currentContext.board.pieceAt(from) match
case Some(piece) if piece.color == color =>
val legal = ruleSet.legalMoves(currentContext)(from)
legal.find(m => m.to == to && m.moveType == move.moveType) match
case Some(legalMove) =>
val isPromotion = move.moveType match
case MoveType.Promotion(_) => true
case _ => false
if isPromotion then
move.moveType match
case MoveType.Promotion(pp) => completePromotion(pp)
case _ => ()
else
executeMove(legalMove)
case None =>
notifyObservers(InvalidMoveEvent(currentContext, s"Bot move ${from}${to} is illegal"))
case _ =>
notifyObservers(InvalidMoveEvent(currentContext, "Bot move has invalid source square"))
}
private def handleBotNoMove(): Unit =
synchronized {
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
notifyObservers(CheckmateEvent(currentContext, winner))
else if ruleSet.isStalemate(currentContext) then
notifyObservers(StalemateEvent(currentContext))
}
private def performUndo(): Unit = private def performUndo(): Unit =
if invoker.canUndo then if invoker.canUndo then
val cmd = invoker.history(invoker.getCurrentIndex) val cmd = invoker.history(invoker.getCurrentIndex)
@@ -0,0 +1,114 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.Color
import de.nowchess.api.game.GameContext
import de.nowchess.bot.{BotDifficulty, ClassicalBot, BotController}
import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import scala.concurrent.duration.*
import scala.concurrent.Await
class GameEngineWithBotTest extends AnyFunSuite with Matchers:
test("GameEngine can play against a ClassicalBot"):
val engine = GameEngine(GameContext.initial, DefaultRules)
val bot = ClassicalBot(BotDifficulty.Easy)
// Set White (human) vs Black (bot)
engine.setOpponentBot(bot, Color.Black)
// Collect events
var moveCount = 0
var checkmateDetected = false
var gameEnded = false
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent =>
moveCount += 1
case _: CheckmateEvent =>
checkmateDetected = true
gameEnded = true
case _: StalemateEvent =>
gameEnded = true
case _ => ()
engine.subscribe(observer)
// Play a few moves: e2e4, then let the bot respond
engine.processUserInput("e2e4")
// Wait a bit for the bot to respond asynchronously
Thread.sleep(5000)
// White should have moved, then Black (bot) should have responded
moveCount should be >= 2
engine.clearOpponentBot()
test("BotController can list and retrieve bots"):
val bots = BotController.listBots
bots should contain("easy")
bots should contain("medium")
bots should contain("hard")
bots should contain("expert")
BotController.getBot("easy") should not be None
BotController.getBot("medium") should not be None
BotController.getBot("hard") should not be None
BotController.getBot("expert") should not be None
BotController.getBot("unknown") should be(None)
test("GameEngine handles bot with different difficulty"):
val engine = GameEngine(GameContext.initial, DefaultRules)
val hardBot = BotController.getBot("hard").get
engine.setOpponentBot(hardBot, Color.Black)
engine.turn should equal(Color.White)
var movesMade = 0
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => movesMade += 1
case _ => ()
engine.subscribe(observer)
// White moves
engine.processUserInput("d2d4")
Thread.sleep(500) // Wait for bot response
// At least white moved, possibly black also responded
movesMade should be >= 1
engine.clearOpponentBot()
test("GameEngine plays valid bot moves"):
val engine = GameEngine(GameContext.initial, DefaultRules)
val bot = ClassicalBot(BotDifficulty.Easy)
engine.setOpponentBot(bot, Color.Black)
var moveCount = 0
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => moveCount += 1
case _ => ()
engine.subscribe(observer)
// Play a normal move
engine.processUserInput("e2e4")
Thread.sleep(1000)
// The game should have progressed with at least one move
moveCount should be >= 1
// Game should not be ended (checkmate/stalemate)
engine.context.moves.nonEmpty should be(true)
engine.clearOpponentBot()
+1
View File
@@ -64,6 +64,7 @@ dependencies {
implementation(project(":modules:rule")) implementation(project(":modules:rule"))
implementation(project(":modules:api")) implementation(project(":modules:api"))
implementation(project(":modules:io")) implementation(project(":modules:io"))
implementation(project(":modules:bot"))
// ScalaFX dependencies // ScalaFX dependencies
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}") implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
@@ -1,5 +1,7 @@
package de.nowchess.ui package de.nowchess.ui
import de.nowchess.api.board.Color.Black
import de.nowchess.bot.{BotDifficulty, ClassicalBot, PolyglotBook}
import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.engine.GameEngine
import de.nowchess.ui.terminal.TerminalUI import de.nowchess.ui.terminal.TerminalUI
import de.nowchess.ui.gui.ChessGUILauncher import de.nowchess.ui.gui.ChessGUILauncher
@@ -12,6 +14,11 @@ object Main:
// Create the core game engine (single source of truth) // Create the core game engine (single source of truth)
val engine = new GameEngine() val engine = new GameEngine()
val book = PolyglotBook("/home/janis/Workspaces/IntelliJ/NowChess/NowChessSystems/modules/bot/codekiddy.bin")
engine.setOpponentBot(ClassicalBot(BotDifficulty.Easy, book = Some(book)), Black);
// Launch ScalaFX GUI in separate thread // Launch ScalaFX GUI in separate thread
ChessGUILauncher.launch(engine) ChessGUILauncher.launch(engine)