feat: implement bot functionality with difficulty levels and integrate Polyglot opening book
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import glob,re
|
||||
mods=['api','core','io','rule','ui']
|
||||
mods=['api','core','io','rule','ui', 'bot']
|
||||
tot=0
|
||||
for m in mods:
|
||||
s=0
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
package de.nowchess.bot
|
||||
|
||||
import de.nowchess.api.board.PieceType
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType}
|
||||
import de.nowchess.rules.RuleSet
|
||||
@@ -12,17 +13,85 @@ final class AlphaBetaSearch(
|
||||
|
||||
private val INF = Int.MaxValue / 2
|
||||
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.
|
||||
* Uses iterative deepening: searches depth 1, 2, ..., maxDepth. */
|
||||
* Uses iterative deepening with aspiration windows. */
|
||||
def bestMove(context: GameContext, maxDepth: Int): Option[Move] =
|
||||
tt.clear()
|
||||
var bestSoFar: Option[Move] = None
|
||||
var prevScore = 0
|
||||
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))
|
||||
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). */
|
||||
private def search(
|
||||
context: GameContext,
|
||||
@@ -31,6 +100,11 @@ final class AlphaBetaSearch(
|
||||
alpha: Int,
|
||||
beta: Int
|
||||
): (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)
|
||||
|
||||
// TT probe
|
||||
@@ -65,21 +139,42 @@ final class AlphaBetaSearch(
|
||||
if depth == 0 then
|
||||
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
|
||||
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
|
||||
var moveNumber = 0
|
||||
|
||||
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 (rawScore, _) = search(child, depth - 1, ply + 1, -beta, -a)
|
||||
val score = -rawScore
|
||||
val reduction = if moveNumber > 4 && depth >= 3 && isQuiet then 1 else 0
|
||||
|
||||
val score = if reduction > 0 then
|
||||
val (reducedScore, _) = search(child, depth - 1 - reduction, ply + 1, -a - 1, -a)
|
||||
val s = -reducedScore
|
||||
if s > a then
|
||||
val (fullScore, _) = search(child, depth - 1, ply + 1, -beta, -a)
|
||||
-fullScore
|
||||
else s
|
||||
else
|
||||
val (rawScore, _) = search(child, depth - 1, ply + 1, -beta, -a)
|
||||
-rawScore
|
||||
|
||||
if score > bestScore then
|
||||
bestScore = score
|
||||
@@ -88,7 +183,6 @@ final class AlphaBetaSearch(
|
||||
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)
|
||||
|
||||
@@ -128,7 +222,6 @@ final class AlphaBetaSearch(
|
||||
if score >= beta then
|
||||
return beta
|
||||
a = math.max(a, score)
|
||||
|
||||
a
|
||||
|
||||
/** Predicate: is a move a capture (including promotions)? */
|
||||
|
||||
@@ -4,4 +4,25 @@ object BotController {
|
||||
|
||||
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(
|
||||
difficulty: BotDifficulty,
|
||||
rules: RuleSet = DefaultRules
|
||||
rules: RuleSet = DefaultRules,
|
||||
book: Option[PolyglotBook] = None
|
||||
) extends Bot:
|
||||
|
||||
private val search: AlphaBetaSearch = AlphaBetaSearch(rules)
|
||||
private val TIME_BUDGET_MS = 1000L
|
||||
|
||||
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
|
||||
book.flatMap(_.probe(context))
|
||||
.orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS))
|
||||
|
||||
@@ -8,15 +8,9 @@ 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
|
||||
)
|
||||
// Material values in centipawns (indexed by PieceType.ordinal: Pawn=0, Knight=1, Bishop=2, Rook=3, Queen=4, King=5)
|
||||
private val mgMaterial = Array(100, 325, 335, 500, 900, 20_000)
|
||||
private val egMaterial = Array(110, 310, 310, 530, 1_000, 20_000)
|
||||
|
||||
private val TEMPO_BONUS: Int = 10
|
||||
|
||||
@@ -25,7 +19,7 @@ object Evaluation:
|
||||
// White's perspective: rank 0 = home (r1), rank 7 = back rank (r8)
|
||||
// Black is vertically mirrored
|
||||
|
||||
private val pawnTable: Array[Int] = Array(
|
||||
private val mgPawnTable: 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,
|
||||
@@ -36,7 +30,18 @@ object Evaluation:
|
||||
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,
|
||||
-40, -20, 0, 0, 0, 0, -20, -40,
|
||||
-30, 0, 10, 15, 15, 10, 0, -30,
|
||||
@@ -47,7 +52,18 @@ object Evaluation:
|
||||
-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,
|
||||
-10, 0, 0, 0, 0, 0, 0, -10,
|
||||
-10, 0, 5, 10, 10, 5, 0, -10,
|
||||
@@ -58,7 +74,18 @@ object Evaluation:
|
||||
-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,
|
||||
5, 10, 10, 10, 10, 10, 10, 5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
@@ -69,7 +96,18 @@ object Evaluation:
|
||||
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,
|
||||
-10, 0, 0, 0, 0, 0, 0, -10,
|
||||
-10, 0, 5, 5, 5, 5, 0, -10,
|
||||
@@ -80,7 +118,18 @@ object Evaluation:
|
||||
-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,
|
||||
@@ -91,32 +140,173 @@ object Evaluation:
|
||||
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.
|
||||
* Positive = good for context.turn. */
|
||||
def evaluate(context: GameContext): Int =
|
||||
val material = materialAndPositional(context)
|
||||
material + TEMPO_BONUS
|
||||
val phase = gamePhase(context.board)
|
||||
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 =
|
||||
var score = 0
|
||||
private def gamePhase(board: de.nowchess.api.board.Board): Int =
|
||||
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
|
||||
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 (psqMg, psqEg) = squareBonus(piece.pieceType, piece.color, square)
|
||||
val pieceMg = mgMaterial(piece.pieceType.ordinal) + psqMg
|
||||
val pieceEg = egMaterial(piece.pieceType.ordinal) + psqEg
|
||||
val sign = if piece.color == context.turn then 1 else -1
|
||||
mg += sign * pieceMg
|
||||
eg += sign * pieceEg
|
||||
taper(mg, eg, phase)
|
||||
|
||||
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 fileIdx = sq.file.ordinal
|
||||
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.rules.RuleSet
|
||||
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
|
||||
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||
@@ -31,9 +33,28 @@ class GameEngine(
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
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. */
|
||||
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
|
||||
def board: Board = synchronized(currentContext.board)
|
||||
def turn: Color = synchronized(currentContext.turn)
|
||||
@@ -252,6 +273,12 @@ class GameEngine(
|
||||
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(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 =
|
||||
move.moveType match
|
||||
case MoveType.CastleKingside => "O-O"
|
||||
@@ -301,6 +328,53 @@ class GameEngine(
|
||||
case _ =>
|
||||
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 =
|
||||
if invoker.canUndo then
|
||||
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()
|
||||
@@ -64,6 +64,7 @@ dependencies {
|
||||
implementation(project(":modules:rule"))
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:io"))
|
||||
implementation(project(":modules:bot"))
|
||||
|
||||
// ScalaFX dependencies
|
||||
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.ui.terminal.TerminalUI
|
||||
import de.nowchess.ui.gui.ChessGUILauncher
|
||||
@@ -12,6 +14,11 @@ object Main:
|
||||
// Create the core game engine (single source of truth)
|
||||
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
|
||||
ChessGUILauncher.launch(engine)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user