feat: NCS-41 Bot Platform (#33)

Co-authored-by: Janis <janis@nowchess.de>
Reviewed-on: #33
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
This commit is contained in:
2026-04-19 15:52:08 +02:00
committed by Janis
parent 5f4d33f3ca
commit 8744bee2dd
115 changed files with 8573 additions and 424 deletions
Binary file not shown.
@@ -0,0 +1,21 @@
package de.nowchess.bot
import de.nowchess.api.bot.Bot
import de.nowchess.bot.bots.ClassicalBot
object BotController {
private val bots: Map[String, Bot] = Map(
"easy" -> ClassicalBot(BotDifficulty.Easy),
"medium" -> ClassicalBot(BotDifficulty.Medium),
"hard" -> ClassicalBot(BotDifficulty.Hard),
"expert" -> ClassicalBot(BotDifficulty.Expert),
)
/** 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
}
@@ -0,0 +1,7 @@
package de.nowchess.bot
enum BotDifficulty:
case Easy
case Medium
case Hard
case Expert
@@ -0,0 +1,19 @@
package de.nowchess.bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
object BotMoveRepetition:
private val maxConsecutiveMoves = 3
def blockedMoves(context: GameContext): Set[Move] = repeatedMove(context).toSet
def repeatedMove(context: GameContext): Option[Move] =
context.moves.takeRight(maxConsecutiveMoves) match
case first :: second :: third :: Nil if first == second && second == third => Some(first)
case _ => None
def filterAllowed(context: GameContext, moves: List[Move]): List[Move] =
val blocked = blockedMoves(context)
moves.filterNot(blocked.contains)
@@ -0,0 +1,11 @@
package de.nowchess.bot
object Config:
/** Threshold in centipawns: if classical evaluation differs from NNUE by more than this, the move is vetoed (not
* accepted as a suggestion).
*/
val VETO_THRESHOLD: Int = 150
/** Time budget per move for iterative deepening (milliseconds). */
val TIME_LIMIT_MS: Long = 2000L
@@ -0,0 +1,29 @@
package de.nowchess.bot.ai
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
trait Evaluation:
def CHECKMATE_SCORE: Int
def DRAW_SCORE: Int
def evaluate(context: GameContext): Int
// ── Accumulator hooks ─────────────────────────────────────────────────────
// Default implementations fall back to full re-evaluation each call.
// Override in NNUE-capable evaluators for incremental L1 speedup.
/** Initialise the accumulator for the root position at ply 0. */
def initAccumulator(context: GameContext): Unit = ()
/** Copy parent ply's accumulator to childPly without move deltas (null-move). */
def copyAccumulator(parentPly: Int, childPly: Int): Unit = ()
/** Derive childPly's accumulator from parentPly by applying move deltas. */
def pushAccumulator(childPly: Int, move: Move, parent: GameContext, child: GameContext): Unit = ()
/** Evaluate from the pre-computed accumulator at ply, using hash for the eval cache. Falls back to full evaluate when
* not overridden.
*/
def evaluateAccumulator(ply: Int, context: GameContext, hash: Long): Int = evaluate(context)
@@ -0,0 +1,29 @@
package de.nowchess.bot.bots
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class ClassicalBot(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
) extends Bot:
private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationClassic)
private val TIME_BUDGET_MS = 1000L
override val name: String = s"ClassicalBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] =
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS, blockedMoves))
@@ -0,0 +1,43 @@
package de.nowchess.bot.bots
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class HybridBot(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
nnueEvaluation: Evaluation = EvaluationNNUE,
classicalEvaluation: Evaluation = EvaluationClassic,
vetoReporter: String => Unit = println(_),
) extends Bot:
private val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
override val name: String = s"HybridBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] =
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse(searchWithVeto(context, blockedMoves))
private def searchWithVeto(context: GameContext, blockedMoves: Set[Move]): Option[Move] =
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
val next = rules.applyMove(context)(move)
val staticNnue = nnueEvaluation.evaluate(next)
val classical = classicalEvaluation.evaluate(next)
val diff = (classical - staticNnue).abs
if diff > Config.VETO_THRESHOLD then
vetoReporter(
f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
)
move
}
@@ -0,0 +1,60 @@
package de.nowchess.bot.bots
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class NNUEBot(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
) extends Bot:
private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationNNUE)
override val name: String = s"NNUEBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] =
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse {
val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context))
if moves.isEmpty then None
else
val scored = batchEvaluateRoot(context, moves)
val bestMove = scored.maxBy(_._2)._1
search.bestMoveWithTime(context, allocateTime(scored), blockedMoves).orElse(Some(bestMove))
}
/** Evaluate all root moves shallowly via incremental NNUE accumulator updates. Returns (move, score) pairs with score
* from the root player's perspective.
*/
private def batchEvaluateRoot(context: GameContext, moves: List[Move]): List[(Move, Int)] =
EvaluationNNUE.initAccumulator(context)
val rootHash = ZobristHash.hash(context)
moves.map { move =>
val child = rules.applyMove(context)(move)
val childHash = ZobristHash.nextHash(context, rootHash, move, child)
EvaluationNNUE.pushAccumulator(1, move, context, child)
val score = -EvaluationNNUE.evaluateAccumulator(1, child, childHash)
(move, score)
}
/** Allocate more time for complex positions; less when one move clearly dominates. */
private def allocateTime(scored: List[(Move, Int)]): Long =
val moveCount = scored.length
if moveCount > 30 then 1500L
else if moveCount < 5 then 500L
else
val scores = scored.map(_._2)
val best = scores.max
val second = scores.filter(_ < best).maxOption.getOrElse(best)
if best - second > 200 then 600L else 1000L
@@ -0,0 +1,361 @@
package de.nowchess.bot.bots.classic
import de.nowchess.api.board.{Color, PieceType, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.bot.ai.Evaluation
object EvaluationClassic extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
// 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
// Piece-square tables (Simplified Evaluation Function, Michniewski)
// Indexed by squareIndex = rank.ordinal * 8 + file.ordinal
// White's perspective: rank 0 = home (r1), rank 7 = back rank (r8)
// Black is vertically mirrored
private val 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, 5, 5, 10, 25, 25, 10, 5, 5,
0, 0, 0, 20, 20, 0, 0, 0, 5, -5, -10, 0, 0, -10, -5, 5, 5, 10, 10, -20, -20, 10, 10, 5, 0, 0, 0, 0, 0, 0, 0, 0,
)
private val 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, -30, 5, 15,
20, 20, 15, 5, -30, -30, 0, 15, 20, 20, 15, 0, -30, -30, 5, 10, 15, 15, 10, 5, -30, -40, -20, 0, 5, 5, 0, -20, -40,
-50, -40, -30, -30, -30, -30, -40, -50,
)
private val 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, -10, 5, 5, 10, 10,
5, 5, -10, -10, 0, 10, 10, 10, 10, 0, -10, -10, 10, 10, 10, 10, 10, 10, -10, -10, 5, 0, 0, 0, 0, 5, -10, -20, -10,
-10, -10, -10, -10, -10, -20,
)
private val 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, -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 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, -5, 0, 5, 5, 5, 5, 0,
-5, 0, 0, 5, 5, 5, 5, 0, -5, -10, 5, 5, 5, 5, 5, 0, -10, -10, 0, 5, 0, 0, 0, 0, -10, -20, -10, -10, -5, -5, -10,
-10, -20,
)
private val 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, -20, -30, -30, -40, -40, -30, -30, -20, -10, -20, -20, -20, -20, -20,
-20, -10, 20, 20, 0, 0, 0, 0, 20, 20, 20, 30, 10, 0, 0, 10, 30, 20,
)
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)
private val egPassedPawnBonus: Array[Int] = Array(0, 20, 40, 80, 150, 250, 400, 0)
// Pawn structure penalties
private val doubledMg = -10
private val doubledEg = -25
private val isolatedMg = -15
private val isolatedEg = -20
// Mobility weights: centipawns per reachable square (indexed by PieceType.ordinal)
private val mobilityMg = Array(0, 4, 3, 2, 1, 0, 0)
private val mobilityEg = Array(0, 4, 3, 4, 2, 0, 0)
// Direction offsets for sliding pieces
private val diagonals = List((-1, -1), (-1, 1), (1, -1), (1, 1))
private val orthogonals = List((-1, 0), (1, 0), (0, -1), (0, 1))
private val knightOffsets = List((-2, -1), (-2, 1), (-1, -2), (-1, 2), (1, -2), (1, 2), (2, -1), (2, 1))
// Rook and bishop bonuses
private val bishopPairMg = 50
private val bishopPairEg = 70
private val rookOn7thMg = 20
private val rookOn7thEg = 10
/** Evaluate the position from the perspective of context.turn. Positive = good for context.turn.
*/
def evaluate(context: GameContext): Int =
val phase = gamePhase(context.board)
val isEg = isEndgame(phase)
val material = materialAndPositional(context, phase)
val structure = pawnStructure(context, phase)
val mobility = mobilityScore(context, phase)
val rookBishop = rookAndBishopBonuses(context, phase)
val bonuses = positionalBonuses(context, phase, isEg)
val egBonuses = if isEg then endgameBonus(context) else 0
material + structure + mobility + rookBishop + bonuses + egBonuses + TEMPO_BONUS
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 isEndgame(phase: Int): Boolean =
phase < 8 // Significantly reduced material indicates endgame
private def taper(mg: Int, eg: Int, phase: Int): Int =
(mg * phase + eg * (maxPhase - phase)) / maxPhase
private def materialAndPositional(context: GameContext, phase: Int): Int =
val (mg, eg) = context.board.pieces.foldLeft((0, 0)) { case ((mg, eg), (square, piece)) =>
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
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)
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)
val (fMg, fEg) = structureScore(friendlyByFile)
val (eMg, eEg) = structureScore(enemyByFile)
taper(fMg - eMg, fEg - eEg, phase)
private def structureScore(byFile: Map[Int, Iterable[Int]]): (Int, Int) =
byFile.foldLeft((0, 0)) { case ((mg, eg), (file, ranks)) =>
val doubled = (ranks.size - 1).max(0)
val hasAdjacent = (file - 1 to file + 1).filter(f => f >= 0 && f < 8 && f != file).exists(byFile.contains)
val isolated = if !hasAdjacent then ranks.size else 0
(mg + doubled * doubledMg + isolated * isolatedMg, eg + doubled * doubledEg + isolated * isolatedEg)
}
private def positionalBonuses(context: GameContext, phase: Int, isEg: Boolean): Int =
context.board.pieces.foldLeft(0) { case (score, (sq, piece)) =>
val bonus = piece.pieceType match
case PieceType.Pawn =>
if isPassedPawn(context.board, sq, piece.color) then
if isEg then egPassedPawnBonus(sq.rank.ordinal) else 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
}
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
private def slidingCount(
sq: Square,
board: de.nowchess.api.board.Board,
color: Color,
directions: List[(Int, Int)],
): Int =
directions.foldLeft(0) { case (total, (fileDelta, rankDelta)) =>
@scala.annotation.tailrec
def countRay(current: Option[Square], acc: Int): Int =
current match
case None => acc
case Some(target) =>
board.pieceAt(target) match
case Some(piece) if piece.color == color => acc
case Some(_) => acc + 1
case None => countRay(target.offset(fileDelta, rankDelta), acc + 1)
total + countRay(sq.offset(fileDelta, rankDelta), 0)
}
private def knightCount(sq: Square, board: de.nowchess.api.board.Board, color: Color): Int =
knightOffsets.count { case (fileDelta, rankDelta) =>
sq.offset(fileDelta, rankDelta).forall { target =>
board.pieceAt(target).forall(_.color != color)
}
}
private def mobilityScore(context: GameContext, phase: Int): Int =
val (mg, eg) = context.board.pieces.foldLeft((0, 0)) { case ((mg, eg), (sq, piece)) =>
val count = piece.pieceType match
case PieceType.Knight => knightCount(sq, context.board, piece.color)
case PieceType.Bishop => slidingCount(sq, context.board, piece.color, diagonals)
case PieceType.Rook => slidingCount(sq, context.board, piece.color, orthogonals)
case PieceType.Queen => slidingCount(sq, context.board, piece.color, diagonals ++ orthogonals)
case _ => 0
val pieceMg = count * mobilityMg(piece.pieceType.ordinal)
val pieceEg = count * mobilityEg(piece.pieceType.ordinal)
val sign = if piece.color == context.turn then 1 else -1
(mg + sign * pieceMg, eg + sign * pieceEg)
}
taper(mg, eg, phase)
private def rookAndBishopBonuses(context: GameContext, phase: Int): Int =
val (baseMg, baseEg) = bishopPairBase(context)
val (rookMg, rookEg) = rookOn7thDelta(context)
taper(baseMg + rookMg, baseEg + rookEg, phase)
private def bishopPairBase(context: GameContext): (Int, Int) =
val friendlyHasPair = hasBishopPair(context, context.turn)
val enemyHasPair = hasBishopPair(context, context.turn.opposite)
val mg = pairDelta(friendlyHasPair, enemyHasPair, bishopPairMg)
val eg = pairDelta(friendlyHasPair, enemyHasPair, bishopPairEg)
(mg, eg)
private def hasBishopPair(context: GameContext, color: Color): Boolean =
val bishopSquares = context.board.pieces.collect {
case (sq, piece) if piece.color == color && piece.pieceType == PieceType.Bishop => sq
}
bishopSquares.exists(isEvenSquare) && bishopSquares.exists(sq => !isEvenSquare(sq))
private def isEvenSquare(square: Square): Boolean =
(square.file.ordinal + square.rank.ordinal) % 2 == 0
private def pairDelta(friendlyHasPair: Boolean, enemyHasPair: Boolean, bonus: Int): Int =
(if friendlyHasPair then bonus else 0) - (if enemyHasPair then bonus else 0)
private def rookOn7thDelta(context: GameContext): (Int, Int) =
context.board.pieces.foldLeft((0, 0)) { case ((mg, eg), (sq, piece)) =>
rookOn7thContribution(piece, sq, context.turn).fold((mg, eg)) { case (dMg, dEg) =>
(mg + dMg, eg + dEg)
}
}
private def rookOn7thContribution(piece: de.nowchess.api.board.Piece, sq: Square, turn: Color): Option[(Int, Int)] =
Option.when(piece.pieceType == PieceType.Rook && isRookOn7th(piece.color, sq)) {
val sign = if piece.color == turn then 1 else -1
(sign * rookOn7thMg, sign * rookOn7thEg)
}
private def isRookOn7th(color: Color, sq: Square): Boolean =
if color == Color.White then sq.rank.ordinal == 6 else sq.rank.ordinal == 1
private def endgameBonus(context: GameContext): Int =
val friendlyKing = context.board.pieces.find((_, p) => p.color == context.turn && p.pieceType == PieceType.King)
val enemyKing = context.board.pieces.find((_, p) => p.color != context.turn && p.pieceType == PieceType.King)
val kingCentralBonus =
friendlyKing.fold(0)((kSq, _) => (8 - kingCentralizationDistance(kSq)) * 15) -
enemyKing.fold(0)((kSq, _) => (8 - kingCentralizationDistance(kSq)) * 15)
val friendlyMaterial = materialCount(context, context.turn)
val enemyMaterial = materialCount(context, context.turn.opposite)
val edgeBonus =
if friendlyMaterial > enemyMaterial then enemyKing.fold(0)((kSq, _) => (7 - kingEdgeDistance(kSq)) * 10)
else 0
kingCentralBonus + edgeBonus
private def kingCentralizationDistance(sq: Square): Int =
val fileFromCenter = (sq.file.ordinal - 3.5).abs.toInt
val rankFromCenter = (sq.rank.ordinal - 3.5).abs.toInt
math.max(fileFromCenter, rankFromCenter)
private def kingEdgeDistance(sq: Square): Int =
val fileFromEdge = math.min(sq.file.ordinal, 7 - sq.file.ordinal)
val rankFromEdge = math.min(sq.rank.ordinal, 7 - sq.rank.ordinal)
math.min(fileFromEdge, rankFromEdge)
private def materialCount(context: GameContext, color: Color): Int =
context.board.pieces.foldLeft(0) { case (sum, (_, piece)) =>
if piece.color == color then
sum + (piece.pieceType match
case PieceType.Knight => 300
case PieceType.Bishop => 300
case PieceType.Rook => 500
case PieceType.Queen => 900
case PieceType.Pawn => 0
case PieceType.King => 0
)
else sum
}
@@ -0,0 +1,31 @@
package de.nowchess.bot.bots.nnue
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.bot.ai.Evaluation
object EvaluationNNUE extends Evaluation:
private val nnue = NNUE(NbaiLoader.loadDefault())
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
/** Full-board evaluate — used as fallback and by non-search callers. */
def evaluate(context: GameContext): Int = nnue.evaluate(context)
// ── Accumulator hooks (incremental L1) ───────────────────────────────────
override def initAccumulator(context: GameContext): Unit =
nnue.initAccumulator(context.board)
override def copyAccumulator(parentPly: Int, childPly: Int): Unit =
nnue.copyAccumulator(parentPly, childPly)
override def pushAccumulator(childPly: Int, move: Move, parent: GameContext, child: GameContext): Unit =
// Use incremental updates, but recompute from scratch every 10 plies to prevent accumulation errors
if childPly % 10 == 0 then nnue.recomputeAccumulator(childPly, child.board)
else nnue.pushAccumulator(childPly, move, parent.board)
override def evaluateAccumulator(ply: Int, context: GameContext, hash: Long): Int =
nnue.evaluateAtPlyWithValidation(ply, context.turn, hash, context.board)
@@ -0,0 +1,231 @@
package de.nowchess.bot.bots.nnue
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
class NNUE(model: NbaiModel):
private val featureSize = model.layers(0).inputSize
private val accSize = model.layers(0).outputSize
private val validateAccum = sys.env.contains("NNUE_VALIDATE") // Enable with NNUE_VALIDATE=1
// Column-major L1 weights for cache-friendly sparse & incremental updates.
// l1WeightsT(featureIdx * accSize + outputIdx) = l1Weights(outputIdx * featureSize + featureIdx)
private val l1WeightsT: Array[Float] =
val w = model.weights(0).weights
val t = new Array[Float](featureSize * accSize)
for j <- 0 until featureSize; i <- 0 until accSize do t(j * accSize + i) = w(i * featureSize + j)
t
// ── Accumulator stack ────────────────────────────────────────────────────
private val MAX_PLY = 128
private val l1Stack: Array[Array[Float]] = Array.fill(MAX_PLY + 1)(new Array[Float](accSize))
// Shared evaluation buffers: index i holds the output of layers(i) (all except the scalar output layer).
private val evalBuffers: Array[Array[Float]] = model.layers.init.map(l => new Array[Float](l.outputSize))
// ── Eval cache ───────────────────────────────────────────────────────────
private val EVAL_CACHE_MASK = (1 << 18) - 1L
private val evalCacheHashes = new Array[Long](1 << 18)
private val evalCacheScores = new Array[Int](1 << 18)
// ── Feature helpers ──────────────────────────────────────────────────────
private def squareNum(sq: Square): Int = sq.rank.ordinal * 8 + sq.file.ordinal
private def featureIndex(piece: Piece, sqNum: Int): Int =
val colorOffset = if piece.color == Color.White then 6 else 0
(colorOffset + piece.pieceType.ordinal) * 64 + sqNum
private def addColumn(l1Pre: Array[Float], featureIdx: Int): Unit =
val offset = featureIdx * accSize
for i <- 0 until accSize do l1Pre(i) += l1WeightsT(offset + i)
private def subtractColumn(l1Pre: Array[Float], featureIdx: Int): Unit =
val offset = featureIdx * accSize
for i <- 0 until accSize do l1Pre(i) -= l1WeightsT(offset + i)
// ── Accumulator init ─────────────────────────────────────────────────────
def initAccumulator(board: Board): Unit =
System.arraycopy(model.weights(0).bias, 0, l1Stack(0), 0, accSize)
for (sq, piece) <- board.pieces do addColumn(l1Stack(0), featureIndex(piece, squareNum(sq)))
// ── Accumulator push (incremental updates) ───────────────────────────────
def pushAccumulator(childPly: Int, move: Move, board: Board): Unit =
System.arraycopy(l1Stack(childPly - 1), 0, l1Stack(childPly), 0, accSize)
val l1 = l1Stack(childPly)
move.moveType match
case MoveType.Normal(_) => applyNormalDelta(l1, move, board)
case MoveType.EnPassant => applyEnPassantDelta(l1, move, board)
case MoveType.CastleKingside | MoveType.CastleQueenside => applyCastleDelta(l1, move, board)
case MoveType.Promotion(p) => applyPromotionDelta(l1, move, p, board)
def copyAccumulator(parentPly: Int, childPly: Int): Unit =
System.arraycopy(l1Stack(parentPly), 0, l1Stack(childPly), 0, accSize)
def recomputeAccumulator(ply: Int, board: Board): Unit =
System.arraycopy(model.weights(0).bias, 0, l1Stack(ply), 0, accSize)
for (sq, piece) <- board.pieces do addColumn(l1Stack(ply), featureIndex(piece, squareNum(sq)))
def validateAccumulator(ply: Int, board: Board): Boolean =
// Compute what L1 should be from scratch
val expectedL1 = new Array[Float](accSize)
System.arraycopy(model.weights(0).bias, 0, expectedL1, 0, accSize)
for (sq, piece) <- board.pieces do addColumn(expectedL1, featureIndex(piece, squareNum(sq)))
// Compare with actual L1
val actual = l1Stack(ply)
val maxError =
(0 until accSize).foldLeft(0f) { (currentMax, i) =>
val error = math.abs(actual(i) - expectedL1(i))
math.max(currentMax, error)
}
maxError < 0.001f // Allow small floating-point errors
private def applyNormalDelta(l1: Array[Float], move: Move, board: Board): Unit =
// Extract source and destination square indices early
val fromNum = squareNum(move.from)
val toNum = squareNum(move.to)
// Get the moving piece
board.pieceAt(move.from).foreach { mover =>
subtractColumn(l1, featureIndex(mover, fromNum))
// If there's a capture, subtract the captured piece
board.pieceAt(move.to).foreach { cap =>
subtractColumn(l1, featureIndex(cap, toNum))
}
// Add the piece to its new location
addColumn(l1, featureIndex(mover, toNum))
}
private def applyEnPassantDelta(l1: Array[Float], move: Move, board: Board): Unit =
board.pieceAt(move.from).foreach { pawn =>
val capturedSq = Square(move.to.file, move.from.rank)
subtractColumn(l1, featureIndex(pawn, squareNum(move.from)))
board.pieceAt(capturedSq).foreach(cap => subtractColumn(l1, featureIndex(cap, squareNum(capturedSq))))
addColumn(l1, featureIndex(pawn, squareNum(move.to)))
}
private def applyCastleDelta(l1: Array[Float], move: Move, board: Board): Unit =
board.pieceAt(move.from).foreach { king =>
val rank = move.from.rank
val kingside = move.moveType == MoveType.CastleKingside
val (rookFrom, rookTo) =
if kingside then (Square(File.H, rank), Square(File.F, rank))
else (Square(File.A, rank), Square(File.D, rank))
val rook = Piece(king.color, PieceType.Rook)
subtractColumn(l1, featureIndex(king, squareNum(move.from)))
addColumn(l1, featureIndex(king, squareNum(move.to)))
subtractColumn(l1, featureIndex(rook, squareNum(rookFrom)))
addColumn(l1, featureIndex(rook, squareNum(rookTo)))
}
private def applyPromotionDelta(l1: Array[Float], move: Move, promo: PromotionPiece, board: Board): Unit =
board.pieceAt(move.from).foreach { pawn =>
val toNum = squareNum(move.to)
subtractColumn(l1, featureIndex(pawn, squareNum(move.from)))
board.pieceAt(move.to).foreach(cap => subtractColumn(l1, featureIndex(cap, toNum)))
addColumn(l1, featureIndex(Piece(pawn.color, promotedType(promo)), toNum))
}
private def promotedType(promo: PromotionPiece): PieceType = promo match
case PromotionPiece.Knight => PieceType.Knight
case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Rook => PieceType.Rook
case PromotionPiece.Queen => PieceType.Queen
// ── Evaluation from accumulator ──────────────────────────────────────────
def evaluateAtPly(ply: Int, turn: Color, hash: Long): Int =
val idx = (hash & EVAL_CACHE_MASK).toInt
if evalCacheHashes(idx) == hash then evalCacheScores(idx)
else
val score = runL2toOutput(l1Stack(ply), turn)
evalCacheHashes(idx) = hash
evalCacheScores(idx) = score
score
def evaluateAtPlyWithValidation(ply: Int, turn: Color, hash: Long, board: Board): Int =
// For debugging: validate that incremental accumulator matches recomputation
if validateAccum && ply > 0 && ply % 10 != 0 then
val isValid = validateAccumulator(ply, board)
if !isValid then System.err.println(s"WARNING: NNUE accumulator diverged at ply $ply")
evaluateAtPly(ply, turn, hash)
private def runL2toOutput(l1Pre: Array[Float], turn: Color): Int =
val l1ReLU = evalBuffers(0)
for i <- 0 until accSize do l1ReLU(i) = if l1Pre(i) > 0f then l1Pre(i) else 0f
val finalInput =
(1 until model.layers.length - 1).foldLeft(l1ReLU) { (input, i) =>
val lw = model.weights(i)
val out = evalBuffers(i)
val ld = model.layers(i)
runDenseReLU(input, ld.inputSize, lw.weights, lw.bias, out, ld.outputSize)
out
}
val lastIdx = model.layers.length - 1
val output = runOutputLayer(finalInput, model.layers(lastIdx).inputSize, model.weights(lastIdx))
scoreFromOutput(output, turn)
private def runDenseReLU(
input: Array[Float],
inSize: Int,
weights: Array[Float],
bias: Array[Float],
output: Array[Float],
outSize: Int,
): Unit =
for i <- 0 until outSize do
val sum = (0 until inSize).foldLeft(bias(i))((s, j) => s + input(j) * weights(i * inSize + j))
output(i) = if sum > 0f then sum else 0f
private def runOutputLayer(input: Array[Float], inSize: Int, lw: LayerWeights): Float =
(0 until inSize).foldLeft(lw.bias(0))((sum, j) => sum + input(j) * lw.weights(j))
private def scoreFromOutput(output: Float, turn: Color): Int =
val cp =
if math.abs(output) >= 0.9999f then if output > 0f then 20000 else -20000
else
val atanh = 0.5f * math.log((1f + output) / (1f - output)).toFloat
(300f * atanh).toInt
val cpFromTurn = if turn == Color.Black then -cp else cp
math.max(-20000, math.min(20000, cpFromTurn))
// ── Legacy full-board evaluate ────────────────────────────────────────────
private val legacyL1 = new Array[Float](accSize)
def evaluate(context: GameContext): Int =
System.arraycopy(model.weights(0).bias, 0, legacyL1, 0, accSize)
for (sq, piece) <- context.board.pieces do addColumn(legacyL1, featureIndex(piece, squareNum(sq)))
runL2toOutput(legacyL1, context.turn)
def benchmark(): Unit =
val context = GameContext.initial
val iterations = 1_000_000
for _ <- 0 until 10000 do evaluate(context)
val startNanos = System.nanoTime()
for _ <- 0 until iterations do evaluate(context)
val endNanos = System.nanoTime()
val totalNanos = endNanos - startNanos
val nanosPerEval = totalNanos.toDouble / iterations
println()
println("=" * 60)
println("NNUE BENCHMARK RESULTS")
println("=" * 60)
println(f"Iterations: $iterations%,d")
println(f"Total time: ${totalNanos / 1e9}%.2f seconds")
println(f"ns/eval: $nanosPerEval%.2f ns")
println(f"evals/second: ${1e9 / nanosPerEval}%.0f evals/s")
println("=" * 60)
println()
@@ -0,0 +1,52 @@
package de.nowchess.bot.bots.nnue
import java.io.InputStream
import java.nio.{ByteBuffer, ByteOrder}
import java.nio.charset.StandardCharsets
object NbaiLoader:
/** Little-endian encoding of ASCII bytes 'N','B','A','I'. */
val MAGIC: Int = 0x4942_414e
def load(stream: InputStream): NbaiModel =
val buf = ByteBuffer.wrap(stream.readAllBytes()).order(ByteOrder.LITTLE_ENDIAN)
checkHeader(buf)
val metadata = readMetadata(buf)
val descs = readLayerDescriptors(buf)
val weights = descs.map(_ => readLayerWeights(buf))
NbaiModel(metadata, descs, weights)
/** Tries /nnue_weights.nbai on the classpath; falls back to migrating /nnue_weights.bin. */
def loadDefault(): NbaiModel =
Option(getClass.getResourceAsStream("/nnue_weights.nbai")) match
case Some(s) =>
try load(s)
finally s.close()
case None => NbaiMigrator.migrateFromBin()
private def checkHeader(buf: ByteBuffer): Unit =
val magic = buf.getInt()
if magic != MAGIC then sys.error(s"Invalid NBAI magic: 0x${magic.toHexString}")
val version = buf.getShort() & 0xffff
if version != 1 then sys.error(s"Unsupported NBAI version: $version")
private def readMetadata(buf: ByteBuffer): NbaiMetadata =
val bytes = new Array[Byte](buf.getInt())
buf.get(bytes)
NbaiMetadata.fromJson(new String(bytes, StandardCharsets.UTF_8))
private def readLayerDescriptors(buf: ByteBuffer): Array[LayerDescriptor] =
Array.tabulate(buf.getShort() & 0xffff) { _ =>
val nameBytes = new Array[Byte](buf.get() & 0xff)
buf.get(nameBytes)
LayerDescriptor(new String(nameBytes, StandardCharsets.US_ASCII), buf.getInt(), buf.getInt())
}
private def readLayerWeights(buf: ByteBuffer): LayerWeights =
LayerWeights(readFloats(buf), readFloats(buf))
private def readFloats(buf: ByteBuffer): Array[Float] =
val arr = new Array[Float](buf.getInt())
for i <- arr.indices do arr(i) = buf.getFloat()
arr
@@ -0,0 +1,43 @@
package de.nowchess.bot.bots.nnue
import java.nio.{ByteBuffer, ByteOrder}
/** Converts the legacy nnue_weights.bin resource into an NbaiModel. Used as fallback when no .nbai file exists. */
object NbaiMigrator:
private val BinMagic = 0x4555_4e4e
private val BinVersion = 1
private val DefaultLayers: Array[LayerDescriptor] = Array(
LayerDescriptor("relu", 768, 1536),
LayerDescriptor("relu", 1536, 1024),
LayerDescriptor("relu", 1024, 512),
LayerDescriptor("relu", 512, 256),
LayerDescriptor("linear", 256, 1),
)
private val UnknownMetadata: NbaiMetadata =
NbaiMetadata(trainedBy = "unknown", trainedAt = "unknown", trainingDataCount = 0L, valLoss = 0.0, trainLoss = 0.0)
def migrateFromBin(): NbaiModel =
val stream = Option(getClass.getResourceAsStream("/nnue_weights.bin"))
.getOrElse(sys.error("Neither nnue_weights.nbai nor nnue_weights.bin found in resources"))
try
val buf = ByteBuffer.wrap(stream.readAllBytes()).order(ByteOrder.LITTLE_ENDIAN)
checkBinHeader(buf)
val weights = DefaultLayers.map(_ => readBinLayerWeights(buf))
NbaiModel(UnknownMetadata, DefaultLayers, weights)
finally stream.close()
private def checkBinHeader(buf: ByteBuffer): Unit =
val magic = buf.getInt()
if magic != BinMagic then sys.error(s"Invalid bin magic: 0x${magic.toHexString}")
val version = buf.getInt()
if version != BinVersion then sys.error(s"Unsupported bin version: $version")
private def readBinLayerWeights(buf: ByteBuffer): LayerWeights =
LayerWeights(readBinTensor(buf), readBinTensor(buf))
private def readBinTensor(buf: ByteBuffer): Array[Float] =
val shape = Array.tabulate(buf.getInt())(_ => buf.getInt())
Array.tabulate(shape.product)(_ => buf.getFloat())
@@ -0,0 +1,45 @@
package de.nowchess.bot.bots.nnue
/** Descriptor for a single dense layer stored in a .nbai file. */
case class LayerDescriptor(activation: String, inputSize: Int, outputSize: Int)
/** Training metadata embedded in every .nbai file. */
case class NbaiMetadata(
trainedBy: String,
trainedAt: String,
trainingDataCount: Long,
valLoss: Double,
trainLoss: Double,
):
def toJson: String =
s"""{
| "trainedBy": "$trainedBy",
| "trainedAt": "$trainedAt",
| "trainingDataCount": $trainingDataCount,
| "valLoss": $valLoss,
| "trainLoss": $trainLoss
|}""".stripMargin
object NbaiMetadata:
def fromJson(json: String): NbaiMetadata =
def str(key: String) = raw""""$key"\s*:\s*"([^"]*)"""".r.findFirstMatchIn(json).map(_.group(1)).getOrElse("")
def num(key: String) = raw""""$key"\s*:\s*([0-9.eE+\-]+)""".r.findFirstMatchIn(json).map(_.group(1)).getOrElse("0")
NbaiMetadata(
str("trainedBy"),
str("trainedAt"),
num("trainingDataCount").toLong,
num("valLoss").toDouble,
num("trainLoss").toDouble,
)
/** Weights and biases for a single layer. Weights are row-major: (outputSize × inputSize). */
case class LayerWeights(weights: Array[Float], bias: Array[Float])
/** A fully deserialized .nbai model ready to initialize NNUE. */
case class NbaiModel(
metadata: NbaiMetadata,
layers: Array[LayerDescriptor],
weights: Array[LayerWeights],
):
require(layers.length == weights.length, "Layer count must match weight count")
require(layers.length >= 2, "Model must have at least 2 layers")
@@ -0,0 +1,51 @@
package de.nowchess.bot.bots.nnue
import java.io.{ByteArrayOutputStream, OutputStream}
import java.nio.{ByteBuffer, ByteOrder}
import java.nio.charset.StandardCharsets
object NbaiWriter:
def write(model: NbaiModel, out: OutputStream): Unit =
val acc = new ByteArrayOutputStream()
writeHeader(acc)
writeMetadata(acc, model.metadata)
writeLayerDescriptors(acc, model.layers)
model.weights.foreach(lw => writeLayerWeights(acc, lw))
out.write(acc.toByteArray)
private def writeHeader(out: ByteArrayOutputStream): Unit =
val buf = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN)
buf.putInt(NbaiLoader.MAGIC)
buf.putShort(1.toShort)
out.write(buf.array())
private def writeMetadata(out: ByteArrayOutputStream, meta: NbaiMetadata): Unit =
val json = meta.toJson.getBytes(StandardCharsets.UTF_8)
val buf = ByteBuffer.allocate(4 + json.length).order(ByteOrder.LITTLE_ENDIAN)
buf.putInt(json.length)
buf.put(json)
out.write(buf.array())
private def writeLayerDescriptors(out: ByteArrayOutputStream, layers: Array[LayerDescriptor]): Unit =
val nameBytes = layers.map(_.activation.getBytes(StandardCharsets.US_ASCII))
val capacity = 2 + layers.indices.map(i => 1 + nameBytes(i).length + 8).sum
val buf = ByteBuffer.allocate(capacity).order(ByteOrder.LITTLE_ENDIAN)
buf.putShort(layers.length.toShort)
layers.zip(nameBytes).foreach { (l, nb) =>
buf.put(nb.length.toByte)
buf.put(nb)
buf.putInt(l.inputSize)
buf.putInt(l.outputSize)
}
out.write(buf.array())
private def writeLayerWeights(out: ByteArrayOutputStream, lw: LayerWeights): Unit =
writeFloats(out, lw.weights)
writeFloats(out, lw.bias)
private def writeFloats(out: ByteArrayOutputStream, floats: Array[Float]): Unit =
val buf = ByteBuffer.allocate(4 + floats.length * 4).order(ByteOrder.LITTLE_ENDIAN)
buf.putInt(floats.length)
floats.foreach(buf.putFloat)
out.write(buf.array())
@@ -0,0 +1,419 @@
package de.nowchess.bot.logic
import de.nowchess.api.board.PieceType
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.util.ZobristHash
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import java.util.concurrent.atomic.{AtomicInteger, AtomicLong}
final class AlphaBetaSearch(
rules: RuleSet = DefaultRules,
tt: TranspositionTable = TranspositionTable(),
weights: Evaluation,
numThreads: Int = Runtime.getRuntime.availableProcessors,
):
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 ASPIRATION_DELTA_MAX = 150
private val TIME_CHECK_FREQUENCY = 1000
private val FUTILITY_MARGIN = 100
private val CHECK_EXTENSION = 1
private val timeStartMs = AtomicLong(0L)
private val timeLimitMs = AtomicLong(0L)
private val nodeCount = AtomicInteger(0)
private val ordering = MoveOrdering.OrderingContext()
private final case class QuiescenceNode(
context: GameContext,
ply: Int,
alpha: Int,
beta: Int,
hash: Long,
)
/** Return the best move for the side to move, searching to maxDepth plies. Uses iterative deepening with aspiration
* windows.
*/
def bestMove(context: GameContext, maxDepth: Int): Option[Move] =
bestMove(context, maxDepth, Set.empty)
def bestMove(context: GameContext, maxDepth: Int, excludedRootMoves: Set[Move]): Option[Move] =
tt.clear()
ordering.clear()
weights.initAccumulator(context)
timeStartMs.set(System.currentTimeMillis)
timeLimitMs.set(Long.MaxValue / 4)
nodeCount.set(0)
val rootHash = ZobristHash.hash(context)
(1 to maxDepth)
.foldLeft((None: Option[Move], 0)) { case ((bestSoFar, prevScore), depth) =>
val (alpha, beta) =
if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA)
val (score, move) = searchWithAspiration(
context,
depth,
alpha,
beta,
ASPIRATION_DELTA,
rootHash,
excludedRootMoves,
)
(move.orElse(bestSoFar), score)
}
._1
/** 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] =
bestMoveWithTime(context, timeBudgetMs, Set.empty)
def bestMoveWithTime(context: GameContext, timeBudgetMs: Long, excludedRootMoves: Set[Move]): Option[Move] =
tt.clear()
ordering.clear()
weights.initAccumulator(context)
timeStartMs.set(System.currentTimeMillis)
timeLimitMs.set(timeBudgetMs)
nodeCount.set(0)
val rootHash = ZobristHash.hash(context)
@scala.annotation.tailrec
def loop(bestSoFar: Option[Move], prevScore: Int, depth: Int): Option[Move] =
if isOutOfTime then bestSoFar
else
val (alpha, beta) =
if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA)
val (score, move) = searchWithAspiration(
context,
depth,
alpha,
beta,
ASPIRATION_DELTA,
rootHash,
excludedRootMoves,
)
loop(move.orElse(bestSoFar), score, depth + 1)
loop(None, 0, 1)
private def isOutOfTime: Boolean =
System.currentTimeMillis - timeStartMs.get >= timeLimitMs.get
private def searchWithAspiration(
context: GameContext,
depth: Int,
alpha: Int,
beta: Int,
initialWindow: Int,
rootHash: Long,
excludedRootMoves: Set[Move],
): (Int, Option[Move]) =
val state = SearchState(rootHash, Map(rootHash -> 1))
@scala.annotation.tailrec
def loop(currentAlpha: Int, currentBeta: Int, delta: Int, attempt: Int): (Int, Option[Move]) =
if attempt >= 3 || attempt >= depth then search(context, depth, 0, Window(-INF, INF), state, excludedRootMoves)
else
val (score, move) = search(context, depth, 0, Window(currentAlpha, currentBeta), state, excludedRootMoves)
if score > currentAlpha && score < currentBeta then (score, move)
else if score <= currentAlpha then
loop(score - delta, currentBeta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
else loop(currentAlpha, score + delta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
loop(alpha, beta, initialWindow, 0)
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,
state: SearchState,
excludedRootMoves: Set[Move],
): Option[Int] =
val nullCtx = nullMoveContext(context)
val nullState = state.advance(ZobristHash.hash(nullCtx))
val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R)
weights.copyAccumulator(ply, ply + 1)
val (score, _) = search(nullCtx, reductionDepth, ply + 1, Window(-beta, -beta + 1), nullState, excludedRootMoves)
if -score >= beta then Some(beta) else None
/** Negamax alpha-beta search returning (score, best move). */
private def search(
context: GameContext,
depth: Int,
ply: Int,
window: Window,
state: SearchState,
excludedRootMoves: Set[Move],
): (Int, Option[Move]) =
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
searchNode(params)
private def searchNode(params: SearchParams): (Int, Option[Move]) =
val count = nodeCount.incrementAndGet()
immediateSearchResult(params, count).getOrElse {
val legalMoves = rules.allLegalMoves(params.context)
terminalSearchResult(params, legalMoves).getOrElse(searchDeeper(params, legalMoves))
}
private def immediateSearchResult(
params: SearchParams,
count: Int,
): Option[(Int, Option[Move])] =
if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then
Some((weights.evaluateAccumulator(params.ply, params.context, params.state.hash), None))
else if params.state.repetitions.getOrElse(params.state.hash, 0) >= 3 then Some((weights.DRAW_SCORE, None))
else ttCutoff(params)
private def ttCutoff(params: SearchParams): Option[(Int, Option[Move])] =
tt.probe(params.state.hash).filter(_.depth >= params.depth).flatMap { entry =>
entry.flag match
case TTFlag.Exact => Some((entry.score, entry.bestMove))
case TTFlag.Lower =>
val newAlpha = math.max(params.window.alpha, entry.score)
Option.when(newAlpha >= params.window.beta)((entry.score, entry.bestMove))
case TTFlag.Upper =>
val newBeta = math.min(params.window.beta, entry.score)
Option.when(params.window.alpha >= newBeta)((entry.score, entry.bestMove))
}
private def terminalSearchResult(
params: SearchParams,
legalMoves: List[Move],
): Option[(Int, Option[Move])] =
if legalMoves.isEmpty then
Some(
(
if rules.isCheckmate(params.context) then -(weights.CHECKMATE_SCORE - params.ply) else weights.DRAW_SCORE,
None,
),
)
else if rules.isInsufficientMaterial(params.context) || rules.isFiftyMoveRule(params.context) then
Some((weights.DRAW_SCORE, None))
else if params.depth == 0 then
Some((quiescence(params.context, params.ply, params.window.alpha, params.window.beta, params.state.hash), None))
else None
private def searchDeeper(
params: SearchParams,
legalMoves: List[Move],
): (Int, Option[Move]) =
val nullResult =
Option
.when(canTryNullMove(params))(
tryNullMove(
params.context,
params.depth,
params.ply,
params.window.beta,
params.state,
params.excludedRootMoves,
),
)
.flatten
nullResult.map((_, None)).getOrElse {
val ttBest = tt.probe(params.state.hash).flatMap(_.bestMove)
val ordered = MoveOrdering.sort(params.context, legalMoves, ttBest, params.ply, ordering)
searchSequential(
params.context,
params.depth,
params.ply,
params.window,
ordered,
params.state,
params.excludedRootMoves,
)
}
private def canTryNullMove(params: SearchParams): Boolean =
params.depth >= 3 &&
!rules.isCheck(params.context) &&
hasNonPawnMaterial(params.context)
private def isQuietMove(context: GameContext, move: Move): Boolean =
!isCapture(context, move) &&
move.moveType != MoveType.CastleKingside &&
move.moveType != MoveType.CastleQueenside
private def scoreMove(
child: GameContext,
childState: SearchState,
params: SearchParams,
extension: Int,
reduction: Int,
a: Int,
): Int =
val betaNeg = -params.window.beta
if reduction > 0 then
val (rs, _) = search(
child,
math.max(0, params.depth - 1 - reduction + extension),
params.ply + 1,
Window(-a - 1, -a),
childState,
params.excludedRootMoves,
)
val s = -rs
if s > a then
val (fs, _) = search(
child,
math.max(0, params.depth - 1 + extension),
params.ply + 1,
Window(betaNeg, -a),
childState,
params.excludedRootMoves,
)
-fs
else s
else
val (rs, _) = search(
child,
math.max(0, params.depth - 1 + extension),
params.ply + 1,
Window(betaNeg, -a),
childState,
params.excludedRootMoves,
)
-rs
private def evalSingleMove(
move: Move,
moveNumber: Int,
a: Int,
params: SearchParams,
): Option[(Int, Boolean)] =
val skipRoot = params.ply == 0 && params.excludedRootMoves.contains(move)
val isQuiet = isQuietMove(params.context, move)
val futility = params.depth == 1 && isQuiet && moveNumber > 2 &&
weights.evaluateAccumulator(params.ply, params.context, params.state.hash) + FUTILITY_MARGIN < params.window.alpha
if skipRoot || futility then None
else
val child = rules.applyMove(params.context)(move)
val childHash = ZobristHash.nextHash(params.context, params.state.hash, move, child)
weights.pushAccumulator(params.ply + 1, move, params.context, child)
val childState = params.state.advance(childHash)
val extension = if rules.isCheck(child) then CHECK_EXTENSION else 0
val reduction = if moveNumber > 4 && params.depth >= 3 && isQuiet then 1 else 0
Some((scoreMove(child, childState, params, extension, reduction, a), isQuiet))
private def recordCutoff(move: Move, depth: Int, ply: Int): Unit =
ordering.addHistory(
move.from.rank.ordinal * 8 + move.from.file.ordinal,
move.to.rank.ordinal * 8 + move.to.file.ordinal,
depth * depth,
)
ordering.addKillerMove(ply, move)
@scala.annotation.tailrec
private def searchLoop(
idx: Int,
moveNumber: Int,
acc: LoopAcc,
params: SearchParams,
ordered: List[Move],
): (Option[Move], Int, Boolean) =
if idx >= ordered.length then (acc.bestMove, acc.bestScore, false)
else
val move = ordered(idx)
evalSingleMove(move, moveNumber, acc.a, params) match
case None => searchLoop(idx + 1, moveNumber + 1, acc, params, ordered)
case Some((score, isQuiet)) =>
val newAcc = LoopAcc(
if score > acc.bestScore then Some(move) else acc.bestMove,
math.max(acc.bestScore, score),
math.max(acc.a, score),
)
if newAcc.a >= params.window.beta then
if isQuiet then recordCutoff(move, params.depth, params.ply)
(newAcc.bestMove, newAcc.bestScore, true)
else searchLoop(idx + 1, moveNumber + 1, newAcc, params, ordered)
private def searchSequential(
context: GameContext,
depth: Int,
ply: Int,
window: Window,
ordered: List[Move],
state: SearchState,
excludedRootMoves: Set[Move],
): (Int, Option[Move]) =
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
val (bestMove, bestScore, cutoff) = searchLoop(0, 0, LoopAcc(None, -INF, window.alpha), params, ordered)
val flag =
if cutoff then TTFlag.Lower
else if bestScore <= window.alpha then TTFlag.Upper
else TTFlag.Exact
tt.store(TTEntry(state.hash, depth, bestScore, flag, bestMove))
(bestScore, bestMove)
/** Quiescence search: only captures until position is quiet. */
private def quiescence(
context: GameContext,
ply: Int,
alpha: Int,
beta: Int,
hash: Long,
): Int =
quiescenceNode(QuiescenceNode(context, ply, alpha, beta, hash))
private def quiescenceNode(node: QuiescenceNode): Int =
val inCheck = rules.isCheck(node.context)
val standPat = if inCheck then -INF else weights.evaluateAccumulator(node.ply, node.context, node.hash)
if !inCheck && standPat >= node.beta then node.beta
else if node.ply >= MAX_QUIESCENCE_PLY then quiescenceAtDepthLimit(node, inCheck, standPat)
else
val moves = tacticalMoves(node.context, inCheck)
if inCheck && moves.isEmpty then -(weights.CHECKMATE_SCORE - node.ply)
else
val ordered = MoveOrdering.sort(node.context, moves, None)
val a0 = if inCheck then node.alpha else math.max(node.alpha, standPat)
quiescenceLoop(node, ordered, 0, a0)
private def quiescenceAtDepthLimit(node: QuiescenceNode, inCheck: Boolean, standPat: Int): Int =
if inCheck then weights.evaluateAccumulator(node.ply, node.context, node.hash) else standPat
private def tacticalMoves(context: GameContext, inCheck: Boolean): List[Move] =
val allMoves = rules.allLegalMoves(context)
if inCheck then allMoves else allMoves.filter(m => isCapture(context, m))
@scala.annotation.tailrec
private def quiescenceLoop(
node: QuiescenceNode,
ordered: List[Move],
idx: Int,
a: Int,
): Int =
if idx >= ordered.length then a
else
val move = ordered(idx)
val child = rules.applyMove(node.context)(move)
val childHash = ZobristHash.nextHash(node.context, node.hash, move, child)
weights.pushAccumulator(node.ply + 1, move, node.context, child)
val score = -quiescence(child, node.ply + 1, -node.beta, -a, childHash)
if score >= node.beta then node.beta
else quiescenceLoop(node, ordered, idx + 1, math.max(a, score))
private def isCapture(context: GameContext, move: Move): Boolean = move.moveType match
case MoveType.Normal(true) => true
case MoveType.EnPassant => true
case MoveType.Promotion(_) => context.board.pieceAt(move.to).exists(_.color != context.turn)
case _ => false
@@ -0,0 +1,177 @@
package de.nowchess.bot.logic
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import scala.annotation.tailrec
import scala.collection.mutable
object MoveOrdering:
class OrderingContext:
private val killerMoves = mutable.Map[Int, List[Move]]()
private val historyTable = mutable.Map[(Int, Int), Int]()
def addKillerMove(ply: Int, move: Move): Unit =
val current = killerMoves.getOrElse(ply, List())
if current.isEmpty || (current.head.from != move.from || current.head.to != move.to) then
killerMoves(ply) = (move :: current).take(2)
def getKillerMoves(ply: Int): List[Move] =
killerMoves.getOrElse(ply, List())
def addHistory(from: Int, to: Int, bonus: Int): Unit =
val key = (from, to)
historyTable(key) = historyTable.getOrElse(key, 0) + bonus
def getHistory(from: Int, to: Int): Int =
historyTable.getOrElse((from, to), 0)
def clear(): Unit =
killerMoves.clear()
historyTable.clear()
def score(
context: GameContext,
move: Move,
ttBestMove: Option[Move],
ply: Int = 0,
ordering: OrderingContext = new OrderingContext(),
): Int =
if ttBestMove.exists(m => m.from == move.from && m.to == move.to) then Int.MaxValue
else
move.moveType match
case MoveType.Promotion(PromotionPiece.Queen) =>
1_000_000 + promotionCaptureBonus(context, move)
case MoveType.Normal(true) | MoveType.EnPassant =>
captureScore(context, move)
case MoveType.Promotion(_) =>
50_000 + promotionCaptureBonus(context, move)
case _ => scoreQuietMove(move, ply, ordering)
def sort(
context: GameContext,
moves: List[Move],
ttBestMove: Option[Move],
ply: Int = 0,
ordering: OrderingContext = new OrderingContext(),
): List[Move] =
moves.sortBy(m => -score(context, m, ttBestMove, ply, ordering))
private def scoreQuietMove(move: Move, ply: Int, ordering: OrderingContext): Int =
val isKiller = ordering.getKillerMoves(ply).exists(k => k.from == move.from && k.to == move.to)
val fromIdx = move.from.rank.ordinal * 8 + move.from.file.ordinal
val toIdx = move.to.rank.ordinal * 8 + move.to.file.ordinal
val history = ordering.getHistory(fromIdx, toIdx)
if isKiller then 10_000 + (history / 10) else history / 10
private def promotionCaptureBonus(context: GameContext, move: Move): Int =
if isCapture(context, move) then captureScore(context, move) else 0
private def captureScore(context: GameContext, move: Move): Int =
val see = staticExchange(context, move)
val seeBias = if see >= 0 then 20_000 else -20_000
100_000 + mvvLva(context, move) + seeBias + see
private def mvvLva(context: GameContext, move: Move): Int =
(victimValue(context, move) * 10) - attackerValue(context, move)
private def attackerValue(context: GameContext, move: Move): Int =
context.board.pieceAt(move.from).map(pieceValue).getOrElse(0)
private def victimValue(context: GameContext, move: Move): Int =
move.moveType match
case MoveType.Normal(true) => context.board.pieceAt(move.to).map(pieceValue).getOrElse(0)
case MoveType.EnPassant => 1
case MoveType.Promotion(_) => context.board.pieceAt(move.to).map(pieceValue).getOrElse(0)
case _ => 0
private def pieceValue(piece: Piece): Int = piece.pieceType match
case PieceType.Pawn => 1
case PieceType.Knight => 3
case PieceType.Bishop => 3
case PieceType.Rook => 5
case PieceType.Queen => 9
case PieceType.King => 200
private def isCapture(context: GameContext, move: Move): Boolean = move.moveType match
case MoveType.Normal(true) => true
case MoveType.EnPassant => true
case MoveType.Promotion(_) => context.board.pieceAt(move.to).exists(_.color != context.turn)
case _ => false
private def staticExchange(context: GameContext, move: Move): Int =
if !isCapture(context, move) then 0
else
val target = move.to
val initialGain = victimValue(context, move)
movedPieceAfterMove(context, move).fold(initialGain) { moved =>
val boardAfterMove = applySeeMove(context.board, move, moved)
initialGain - seeGain(boardAfterMove, target, context.turn.opposite, pieceValue(moved))
}
private def movedPieceAfterMove(context: GameContext, move: Move): Option[Piece] =
move.moveType match
case MoveType.Promotion(pp) => Some(Piece(context.turn, promotionPieceType(pp)))
case _ => context.board.pieceAt(move.from)
private def seeGain(board: Board, target: Square, side: Color, currentValue: Int): Int =
leastValuableAttacker(board, target, side) match
case None => 0
case Some((from, attacker)) =>
val nextBoard = board.removed(from).updated(target, attacker)
val replyGain = seeGain(nextBoard, target, side.opposite, pieceValue(attacker))
math.max(0, currentValue - replyGain)
private def applySeeMove(board: Board, move: Move, moved: Piece): Board =
move.moveType match
case MoveType.EnPassant =>
val capturedSquare = Square(move.to.file, move.from.rank)
board.removed(move.from).removed(capturedSquare).updated(move.to, moved)
case _ => board.removed(move.from).updated(move.to, moved)
private def leastValuableAttacker(board: Board, target: Square, color: Color): Option[(Square, Piece)] =
board.pieces
.collect {
case (sq, piece) if piece.color == color && attacksSquare(board, sq, target, piece) => (sq, piece)
}
.toList
.sortBy { case (_, piece) => pieceValue(piece) }
.headOption
private def attacksSquare(board: Board, from: Square, target: Square, piece: Piece): Boolean =
val df = target.file.ordinal - from.file.ordinal
val dr = target.rank.ordinal - from.rank.ordinal
piece.pieceType match
case PieceType.Pawn =>
val dir = if piece.color == Color.White then 1 else -1
dr == dir && math.abs(df) == 1
case PieceType.Knight =>
val adf = math.abs(df)
val adr = math.abs(dr)
(adf == 1 && adr == 2) || (adf == 2 && adr == 1)
case PieceType.Bishop => clearLine(board, from, target, df, dr, diagonal = true)
case PieceType.Rook => clearLine(board, from, target, df, dr, diagonal = false)
case PieceType.Queen =>
clearLine(board, from, target, df, dr, diagonal = true) ||
clearLine(board, from, target, df, dr, diagonal = false)
case PieceType.King => math.abs(df) <= 1 && math.abs(dr) <= 1
private def clearLine(board: Board, from: Square, target: Square, df: Int, dr: Int, diagonal: Boolean): Boolean =
val valid =
if diagonal then math.abs(df) == math.abs(dr) && df != 0 else (df == 0 && dr != 0) || (dr == 0 && df != 0)
valid && pathClear(board, from, target, Integer.compare(df, 0), Integer.compare(dr, 0))
@tailrec
private def pathClear(board: Board, from: Square, target: Square, stepF: Int, stepR: Int): Boolean =
from.offset(stepF, stepR) match
case None => false
case Some(next) if next == target => true
case Some(next) => board.pieceAt(next).isEmpty && pathClear(board, next, target, stepF, stepR)
private def promotionPieceType(piece: PromotionPiece): PieceType = piece match
case PromotionPiece.Knight => PieceType.Knight
case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Rook => PieceType.Rook
case PromotionPiece.Queen => PieceType.Queen
@@ -0,0 +1,61 @@
package de.nowchess.bot.logic
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
final case class Window(alpha: Int, beta: Int)
final case class LoopAcc(bestMove: Option[Move], bestScore: Int, a: Int)
final case class SearchParams(
context: GameContext,
depth: Int,
ply: Int,
window: Window,
state: SearchState,
excludedRootMoves: Set[Move],
)
final case class SearchState(hash: Long, repetitions: Map[Long, Int]):
def advance(nextHash: Long): SearchState =
SearchState(
nextHash,
repetitions.updatedWith(nextHash) {
case Some(v) => Some(v + 1)
case None => Some(1)
},
)
enum TTFlag:
case Exact // Score is exact
case Lower // Score is a lower bound
case Upper // Score is an upper bound
final case class TTEntry(
hash: Long,
depth: Int,
score: Int,
flag: TTFlag,
bestMove: Option[Move],
)
final class TranspositionTable(val sizePow2: Int = 20):
private val size = 1 << sizePow2
private val mask = size - 1L
private val locks = Array.fill(size)(new Object())
private val table: Array[Option[TTEntry]] = Array.fill(size)(None)
def probe(hash: Long): Option[TTEntry] =
val index = (hash & mask).toInt
locks(index).synchronized {
table(index).filter(_.hash == hash)
}
def store(entry: TTEntry): Unit =
val index = (entry.hash & mask).toInt
locks(index).synchronized {
table(index) = Some(entry)
}
def clear(): Unit =
for i <- 0 until size do locks(i).synchronized { table(i) = None }
@@ -0,0 +1,137 @@
package de.nowchess.bot.util
import de.nowchess.api.board.*
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 {
val r = loadBookFile(path)
println(s"Book loaded successfully. ${r.size} entries found.")
r
} catch
case e: Exception =>
println(s"Error loading book: $e")
// 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)
println(f"0x$hash%016X")
entries.get(hash).flatMap { bookEntries =>
if bookEntries.isEmpty then None
else
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()
input.readInt() // learning data (unused)
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
val toRank = (raw >> 3) & 0x07
val fromFile = (raw >> 6) & 0x07
val fromRank = (raw >> 9) & 0x07
val promotionBits = (raw >> 12) & 0x07
if toFile > 7 || toRank > 7 || fromFile > 7 || fromRank > 7 then None
else
val from = Square(File.values(fromFile), Rank.values(fromRank))
val to = Square(File.values(toFile), Rank.values(toRank))
if isKingMove(context, from) && isRookSquare(to, context) then Some(decodeCastling(from, to))
else
val moveTypeOpt: Option[MoveType] =
if promotionBits > 0 then
promotionBits match
case 1 => Some(MoveType.Promotion(PromotionPiece.Knight))
case 2 => Some(MoveType.Promotion(PromotionPiece.Bishop))
case 3 => Some(MoveType.Promotion(PromotionPiece.Rook))
case 4 => Some(MoveType.Promotion(PromotionPiece.Queen))
case _ => None
else Some(MoveType.Normal(context.board.pieces.contains(to)))
moveTypeOpt.map(moveType => 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.head
else
val totalWeight = entries.map(_.weight).sum
val pick = Random.nextInt(totalWeight.max(1)) // NOSONAR
@scala.annotation.tailrec
def select(remaining: Int, idx: Int): BookEntry =
if idx >= entries.length then entries.last
else if remaining < entries(idx).weight then entries(idx)
else select(remaining - entries(idx).weight, idx + 1)
select(pick, 0)
private case class BookEntry(key: Long, move: Short, weight: Int)
@@ -0,0 +1,204 @@
package de.nowchess.bot.util
import de.nowchess.api.board.{Color, Piece, PieceType, 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 =
val piecesHash = context.board.pieces.foldLeft(0L) { case (h, (sq, piece)) =>
h ^ Random(pieceIndex(piece) * 64 + squareIndex(sq))
}
val h1 = if context.castlingRights.whiteKingSide then piecesHash ^ Random(768) else piecesHash
val h2 = if context.castlingRights.whiteQueenSide then h1 ^ Random(769) else h1
val h3 = if context.castlingRights.blackKingSide then h2 ^ Random(770) else h2
val h4 = if context.castlingRights.blackQueenSide then h3 ^ Random(771) else h3
val h5 = context.enPassantSquare.fold(h4) { sq =>
if canCaptureEnPassant(context, sq) then h4 ^ Random(772 + sq.file.ordinal) else h4
}
if context.turn == Color.White then h5 ^ Random(780) else h5
private def pieceIndex(piece: Piece): Int =
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
val colorOffset = if piece.color == Color.White then 1 else 0
typeIdx * 2 + colorOffset
private def squareIndex(sq: Square): Int =
sq.file.ordinal + 8 * sq.rank.ordinal
private def canCaptureEnPassant(context: GameContext, epSquare: Square): Boolean =
val pawn = Piece(context.turn, PieceType.Pawn)
val rankDelta = if context.turn == Color.White then -1 else 1
List(-1, 1).exists { fileDelta =>
epSquare
.offset(fileDelta, rankDelta)
.flatMap(context.board.pieces.get)
.contains(pawn)
}
@@ -0,0 +1,125 @@
package de.nowchess.bot.util
import de.nowchess.api.board.{Color, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import scala.util.Random
object ZobristHash:
// 768 entries: 64 squares * 12 piece variants (2 colors * 6 piece types)
private val pieceRands: Array[Long] = Array.ofDim(768)
// Side-to-move: XOR when Black to move
private val sideToMoveRand: Long = Random(0x1badb002L).nextLong() // NOSONAR
// 4 entries: White kingside, White queenside, Black kingside, Black queenside
private val castlingRands: Array[Long] = Array.ofDim(4)
// 8 entries: one per file (a-h)
private val enPassantRands: Array[Long] = Array.ofDim(8)
// Initialize all random values using a seeded RNG for reproducibility
locally:
val rng = Random(0x1badb002L) // NOSONAR
for i <- 0 until 768 do pieceRands(i) = rng.nextLong()
for i <- 0 until 4 do castlingRands(i) = rng.nextLong()
for i <- 0 until 8 do enPassantRands(i) = rng.nextLong()
/** Compute a 64-bit Zobrist hash for a GameContext. */
def hash(context: GameContext): Long =
val piecesHash = context.board.pieces.foldLeft(0L) { case (h, (square, piece)) =>
val squareIndex = square.rank.ordinal * 8 + square.file.ordinal
val colorIndex = if piece.color == Color.White then 0 else 1
val pieceIndex = colorIndex * 6 + piece.pieceType.ordinal
h ^ pieceRands(squareIndex * 12 + pieceIndex)
}
val h1 = if context.turn == Color.Black then piecesHash ^ sideToMoveRand else piecesHash
val h2 = if context.castlingRights.whiteKingSide then h1 ^ castlingRands(0) else h1
val h3 = if context.castlingRights.whiteQueenSide then h2 ^ castlingRands(1) else h2
val h4 = if context.castlingRights.blackKingSide then h3 ^ castlingRands(2) else h3
val h5 = if context.castlingRights.blackQueenSide then h4 ^ castlingRands(3) else h4
context.enPassantSquare.fold(h5)(sq => h5 ^ enPassantRands(sq.file.ordinal))
def nextHash(context: GameContext, currentHash: Long, move: Move, nextContext: GameContext): Long =
val h0 = currentHash ^ sideToMoveRand
val h1 = toggleCastling(h0, context, nextContext)
val h2 = toggleEnPassant(h1, context, nextContext)
move.moveType match
case MoveType.CastleKingside | MoveType.CastleQueenside =>
applyCastleDelta(h2, context.turn, move.moveType == MoveType.CastleKingside)
case MoveType.EnPassant =>
applyEnPassantDelta(h2, context, move)
case MoveType.Promotion(piece) =>
applyPromotionDelta(h2, context, move, piece)
case MoveType.Normal(_) =>
applyNormalDelta(h2, context, move)
private def applyNormalDelta(h0: Long, context: GameContext, move: Move): Long =
context.board.pieceAt(move.from).fold(h0) { mover =>
val h1 = h0 ^ pieceKey(move.from, mover)
val h2 = context.board.pieceAt(move.to).fold(h1)(captured => h1 ^ pieceKey(move.to, captured))
h2 ^ pieceKey(move.to, mover)
}
private def applyPromotionDelta(h0: Long, context: GameContext, move: Move, promoted: PromotionPiece): Long =
context.board.pieceAt(move.from).fold(h0) { pawn =>
val h1 = h0 ^ pieceKey(move.from, pawn)
val h2 = context.board.pieceAt(move.to).fold(h1)(captured => h1 ^ pieceKey(move.to, captured))
h2 ^ pieceKey(move.to, Piece(context.turn, promotedPieceType(promoted)))
}
private def applyEnPassantDelta(h0: Long, context: GameContext, move: Move): Long =
context.board.pieceAt(move.from).fold(h0) { pawn =>
val capturedSquare = Square(move.to.file, move.from.rank)
val h1 = h0 ^ pieceKey(move.from, pawn)
val h2 = context.board.pieceAt(capturedSquare).fold(h1)(captured => h1 ^ pieceKey(capturedSquare, captured))
h2 ^ pieceKey(move.to, pawn)
}
private def applyCastleDelta(h0: Long, color: Color, kingside: Boolean): Long =
val rank = if color == Color.White then Rank.R1 else Rank.R8
val (kingFrom, kingTo, rookFrom, rookTo) =
if kingside then
(
Square(de.nowchess.api.board.File.E, rank),
Square(de.nowchess.api.board.File.G, rank),
Square(de.nowchess.api.board.File.H, rank),
Square(de.nowchess.api.board.File.F, rank),
)
else
(
Square(de.nowchess.api.board.File.E, rank),
Square(de.nowchess.api.board.File.C, rank),
Square(de.nowchess.api.board.File.A, rank),
Square(de.nowchess.api.board.File.D, rank),
)
val king = Piece(color, PieceType.King)
val rook = Piece(color, PieceType.Rook)
h0 ^ pieceKey(kingFrom, king) ^ pieceKey(kingTo, king) ^ pieceKey(rookFrom, rook) ^ pieceKey(rookTo, rook)
private def promotedPieceType(promotion: PromotionPiece): PieceType = promotion match
case PromotionPiece.Knight => PieceType.Knight
case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Rook => PieceType.Rook
case PromotionPiece.Queen => PieceType.Queen
private def toggleCastling(h0: Long, before: GameContext, after: GameContext): Long =
val h1 =
if before.castlingRights.whiteKingSide != after.castlingRights.whiteKingSide then h0 ^ castlingRands(0) else h0
val h2 =
if before.castlingRights.whiteQueenSide != after.castlingRights.whiteQueenSide then h1 ^ castlingRands(1) else h1
val h3 =
if before.castlingRights.blackKingSide != after.castlingRights.blackKingSide then h2 ^ castlingRands(2) else h2
if before.castlingRights.blackQueenSide != after.castlingRights.blackQueenSide then h3 ^ castlingRands(3) else h3
private def toggleEnPassant(h0: Long, before: GameContext, after: GameContext): Long =
val h1 = before.enPassantSquare.fold(h0)(sq => h0 ^ enPassantRands(sq.file.ordinal))
after.enPassantSquare.fold(h1)(sq => h1 ^ enPassantRands(sq.file.ordinal))
private def pieceKey(square: Square, piece: Piece): Long =
val squareIndex = square.rank.ordinal * 8 + square.file.ordinal
val colorIndex = if piece.color == Color.White then 0 else 1
val pieceIndex = colorIndex * 6 + piece.pieceType.ordinal
pieceRands(squareIndex * 12 + pieceIndex)
@@ -0,0 +1,336 @@
package de.nowchess.bot
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.rules.RuleSet
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import de.nowchess.rules.sets.DefaultRules
import java.util.concurrent.atomic.AtomicBoolean
class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
private object ZeroEval extends Evaluation:
val CHECKMATE_SCORE: Int = 1_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 0
test("bestMove on initial position returns a move"):
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 2)
move should not be None
test("bestMove on a position with one legal move returns that move"):
// Create a simple position: White king on h1, Black rook on a2
// (set up so there's only one legal move available)
// For simplicity, just test that a position with forced mate returns a move
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val context = GameContext.initial
val move = search.bestMove(context, maxDepth = 1)
move should not be None
test("bestMoveWithTime skips excluded root moves"):
val blockedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(blockedMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(stubRules, weights = EvaluationClassic)
val move = search.bestMoveWithTime(GameContext.initial, 1000L, Set(blockedMove))
move should be(None)
test("bestMove returns None for initial position has no legal moves"):
// Use a stub RuleSet that returns empty legal moves
val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = true
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(stubRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 2)
move should be(None)
test("transposition table is cleared at start of bestMove"):
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val context = GameContext.initial
// Call bestMove twice and verify both work independently
val move1 = search.bestMove(context, maxDepth = 1)
val move2 = search.bestMove(context, maxDepth = 1)
move1 should be(move2)
test("quiescence captures are ordered"):
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
// A position with multiple captures to verify quiescence orders them
val context = GameContext.initial
val move = search.bestMove(context, maxDepth = 2)
// Just verify it completes without error
move.isDefined should be(true)
test("search respects alpha-beta bounds"):
// This is implicit in the structure, but we test via behavior
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val context = GameContext.initial
val move = search.bestMove(context, maxDepth = 3)
move should not be None
test("iterative deepening finds a move at each depth"):
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val context = GameContext.initial
// Searching to depth 3 should use iterative deepening (depths 1, 2, 3)
val move = search.bestMove(context, maxDepth = 3)
move should not be None
test("stalemate position returns score 0"):
// Create a stalemate stub: white to move, no legal moves, not checkmate
val stalematRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = true
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(stalematRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1)
move should be(None)
test("insufficient material returns score 0"):
val insufficientRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = true
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(insufficientRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1)
move should be(None)
test("fifty move rule returns score 0"):
val fiftyMoveRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = true
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(fiftyMoveRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1)
move should be(None)
test("capture moves are recognized in quiescence search"):
// Create a position with a capture available
val board = Board(
Map(
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
Square(File.E, Rank.R5) -> Piece.BlackPawn,
),
)
val context = GameContext.initial.withBoard(board)
val captureMove = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
val rulesWithCapture = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(captureMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(rulesWithCapture, weights = EvaluationClassic)
val move = search.bestMove(context, maxDepth = 1)
move should be(Some(captureMove))
test("non-capture moves are not included in quiescence"):
val quietMove = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal())
val rulesQuiet = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(quietMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(rulesQuiet, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1)
move should be(Some(quietMove)) // bestMove returns the quiet move since it's the only legal move
test("default constructor uses DefaultRules"):
val search = AlphaBetaSearch(weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1)
move should not be None
test("bestMoveWithTime without excluded moves overload"):
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val move = search.bestMoveWithTime(GameContext.initial, 500L)
move should not be None
test("en passant move is treated as capture in quiescence"):
val epMove = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
val board = Board(
Map(
Square(File.E, Rank.R5) -> Piece.WhitePawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
),
)
val ctx = GameContext.initial.withBoard(board).withTurn(Color.White)
val epRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(epMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(epRules, weights = EvaluationClassic)
search.bestMove(ctx, maxDepth = 1) should be(Some(epMove))
test("promotion capture move is treated as capture in quiescence"):
val promoCapture = Move(Square(File.E, Rank.R7), Square(File.D, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
val board = Board(
Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn,
Square(File.D, Rank.R8) -> Piece.BlackRook,
),
)
val ctx = GameContext.initial.withBoard(board).withTurn(Color.White)
val promoCaptureRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(promoCapture)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(promoCaptureRules, weights = EvaluationClassic)
search.bestMove(ctx, maxDepth = 1) should be(Some(promoCapture))
test("draw when isInsufficientMaterial with legal moves present"):
val legalMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val drawRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(legalMove)
def legalMoves(context: GameContext)(square: Square): List[Move] = List(legalMove)
def allLegalMoves(context: GameContext): List[Move] = List(legalMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = true
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(drawRules, weights = EvaluationClassic)
search.bestMove(GameContext.initial, maxDepth = 2) should be(None)
test("repetition cutoff is reached on forced self-loop positions"):
// Use a no-op move from an empty square so nextHash alternates between a tiny set of hashes.
// This forces repetition counts >= 3 and exercises immediateSearchResult's repetition cutoff.
val loopMove = Move(Square(File.A, Rank.R3), Square(File.A, Rank.R4), MoveType.Normal())
val loopRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(loopMove)
def legalMoves(context: GameContext)(square: Square): List[Move] = List(loopMove)
def allLegalMoves(context: GameContext): List[Move] = List(loopMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(loopRules, weights = ZeroEval)
search.bestMove(GameContext.initial, maxDepth = 8) should be(Some(loopMove))
test("quiescence returns checkmate score when side is in check and has no tactical moves"):
val rootMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val capMove = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true))
val qRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
def legalMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
def allLegalMoves(context: GameContext): List[Move] =
context.moves.length match
case 0 => List(rootMove)
case 1 => List(capMove)
case _ => Nil
def isCheck(context: GameContext): Boolean =
context.moves.length >= 2
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext =
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
val search = AlphaBetaSearch(qRules, weights = ZeroEval)
search.bestMove(GameContext.initial, maxDepth = 1) should be(Some(rootMove))
test("quiescence depth-limit in-check branch is exercised"):
val rootMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val capMove = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true))
val firstChildCheckCall = AtomicBoolean(true)
val deepQRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
def legalMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
def allLegalMoves(context: GameContext): List[Move] =
if context.moves.isEmpty then List(rootMove) else List(capMove)
def isCheck(context: GameContext): Boolean =
if context.moves.length == 1 && firstChildCheckCall.compareAndSet(true, false) then false
else context.moves.nonEmpty
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext =
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
val search = AlphaBetaSearch(deepQRules, weights = ZeroEval)
search.bestMove(GameContext.initial, maxDepth = 1) should be(Some(rootMove))
@@ -0,0 +1,18 @@
package de.nowchess.bot
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class BotControllerTest extends AnyFunSuite with Matchers:
test("BotController can be instantiated"):
BotController.listBots should not be empty
test("getBot returns known bots by name"):
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
test("getBot returns None for unknown bot"):
BotController.getBot("unknown") should be(None)
@@ -0,0 +1,14 @@
package de.nowchess.bot
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class BotDifficultyTest extends AnyFunSuite with Matchers:
test("all difficulty values are defined"):
val difficulties = BotDifficulty.values
difficulties should contain(BotDifficulty.Easy)
difficulties should contain(BotDifficulty.Medium)
difficulties should contain(BotDifficulty.Hard)
difficulties should contain(BotDifficulty.Expert)
difficulties should have length 4
@@ -0,0 +1,30 @@
package de.nowchess.bot
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class BotMoveRepetitionTest extends AnyFunSuite with Matchers:
private val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
private val move2 = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4), MoveType.Normal())
test("filterAllowed passes through moves when none are blocked"):
val ctx = GameContext.initial
val allowed = BotMoveRepetition.filterAllowed(ctx, List(move1, move2))
allowed should contain(move1)
allowed should contain(move2)
test("filterAllowed removes the move repeated three times"):
val ctx = GameContext.initial.copy(moves = List(move1, move1, move1))
val allowed = BotMoveRepetition.filterAllowed(ctx, List(move1, move2))
allowed should not contain move1
allowed should contain(move2)
test("filterAllowed keeps all moves when repetition is below threshold"):
val ctx = GameContext.initial.copy(moves = List(move1, move1))
val allowed = BotMoveRepetition.filterAllowed(ctx, List(move1, move2))
allowed should contain(move1)
allowed should contain(move2)
@@ -0,0 +1,98 @@
package de.nowchess.bot
import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.move.MoveType
import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.rules.RuleSet
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import de.nowchess.rules.sets.DefaultRules
class ClassicalBotTest extends AnyFunSuite with Matchers:
test("name returns expected format"):
val botEasy = ClassicalBot(BotDifficulty.Easy)
botEasy.name should include("ClassicalBot")
botEasy.name should include("Easy")
val botMedium = ClassicalBot(BotDifficulty.Medium)
botMedium.name should include("Medium")
test("nextMove on initial position returns a move"):
val bot = ClassicalBot(BotDifficulty.Easy)
val move = bot.nextMove(GameContext.initial)
move should not be None
test("nextMove returns None for position with no legal moves"):
val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = true
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial)
move should be(None)
test("all BotDifficulty values work"):
BotDifficulty.values.foreach { difficulty =>
val bot = ClassicalBot(difficulty)
val move = bot.nextMove(GameContext.initial)
// All difficulties should return a move on the initial position
move should not be None
}
test("custom RuleSet injection works"):
val moveToReturn = Move(
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R2),
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R4),
de.nowchess.api.move.MoveType.Normal(),
)
val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(moveToReturn)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial)
move should be(Some(moveToReturn))
test("nextMove skips a move repeated three times in a row"):
val repeatedMove = Move(
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R2),
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R4),
MoveType.Normal(),
)
val stubRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(repeatedMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val context = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
bot.nextMove(context) should be(None)
@@ -0,0 +1,142 @@
package de.nowchess.bot
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.Board
import de.nowchess.bot.bots.classic.EvaluationClassic
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class EvaluationTest extends AnyFunSuite with Matchers:
test("initial position evaluates to tempo bonus"):
val eval = EvaluationClassic.evaluate(GameContext.initial)
eval should equal(10) // TEMPO_BONUS only
test("remove white queen gives negative evaluation"):
val initial = GameContext.initial
val board = initial.board
val emptySquare = Square(File.D, Rank.R1)
val boardWithoutQueen = board.pieces.filter((sq, _) => sq != emptySquare)
val newContext = initial.withBoard(Board(boardWithoutQueen))
val eval = EvaluationClassic.evaluate(newContext)
eval should be < 0
test("remove black queen gives positive evaluation"):
val initial = GameContext.initial
val board = initial.board
val emptySquare = Square(File.D, Rank.R8)
val boardWithoutQueen = board.pieces.filter((sq, _) => sq != emptySquare)
val newContext = initial.withBoard(Board(boardWithoutQueen))
val eval = EvaluationClassic.evaluate(newContext)
eval should be > 0
test("different piece-square bonuses are applied"):
// Knight on d4 (center) vs knight on a1 (corner) - center should be better
val knightD4Board = Board(Map(Square(File.D, Rank.R4) -> Piece.WhiteKnight))
val knightA1Board = Board(Map(Square(File.A, Rank.R1) -> Piece.WhiteKnight))
val knightD4 = GameContext.initial.withBoard(knightD4Board)
val knightA1 = GameContext.initial.withBoard(knightA1Board)
val eval1 = EvaluationClassic.evaluate(knightD4)
val eval2 = EvaluationClassic.evaluate(knightA1)
eval1 should be > eval2 // d4 (center) is better than a1 (corner) for knight
test("all piece types are in material map"):
PieceType.values.length should be > 0
// Just verify evaluate works with all piece types
val eval = EvaluationClassic.evaluate(GameContext.initial)
eval should not be (EvaluationClassic.CHECKMATE_SCORE)
test("CHECKMATE_SCORE and DRAW_SCORE are accessible"):
EvaluationClassic.CHECKMATE_SCORE should equal(10_000_000)
EvaluationClassic.DRAW_SCORE should equal(0)
test("active knight (center) scores higher than passive knight (corner)"):
val knightD4Board = Board(Map(Square(File.D, Rank.R4) -> Piece.WhiteKnight))
val knightA1Board = Board(Map(Square(File.A, Rank.R1) -> Piece.WhiteKnight))
val knightD4Context = GameContext.initial.withBoard(knightD4Board)
val knightA1Context = GameContext.initial.withBoard(knightA1Board)
val evalD4 = EvaluationClassic.evaluate(knightD4Context)
val evalA1 = EvaluationClassic.evaluate(knightA1Context)
evalD4 should be > evalA1 // Knight on d4 (center, more mobility) should score higher
test("bishop pair scores higher than bishop + knight"):
val bishopPairBoard = Board(
Map(
Square(File.C, Rank.R1) -> Piece.WhiteBishop,
Square(File.F, Rank.R1) -> Piece.WhiteBishop,
),
)
val bishopKnightBoard = Board(
Map(
Square(File.C, Rank.R1) -> Piece.WhiteBishop,
Square(File.B, Rank.R1) -> Piece.WhiteKnight,
),
)
val pairContext = GameContext.initial.withBoard(bishopPairBoard)
val knightContext = GameContext.initial.withBoard(bishopKnightBoard)
val evalPair = EvaluationClassic.evaluate(pairContext)
val evalKnight = EvaluationClassic.evaluate(knightContext)
evalPair should be > evalKnight // Bishop pair should score higher
test("rook on 7th rank scores higher than rook on 4th rank"):
val rook7thBoard = Board(Map(Square(File.A, Rank.R7) -> Piece.WhiteRook))
val rook4thBoard = Board(Map(Square(File.A, Rank.R4) -> Piece.WhiteRook))
val rook7thContext = GameContext.initial.withBoard(rook7thBoard)
val rook4thContext = GameContext.initial.withBoard(rook4thBoard)
val eval7th = EvaluationClassic.evaluate(rook7thContext)
val eval4th = EvaluationClassic.evaluate(rook4thContext)
eval7th should be > eval4th // Rook on 7th rank should score higher
test("enemy rook on 7th rank is penalised"):
// Black rook on rank 2 (7th for black) with white to move — hits the enemy branch
val board = Board(Map(Square(File.A, Rank.R2) -> Piece.BlackRook))
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val eval = EvaluationClassic.evaluate(context)
eval should be < 0 // disadvantageous for white
test("king at edge rank yields zero king-shield bonus"):
// White king on rank 8 — shieldRank would be 9, out of bounds → guard fires
val board = Board(Map(Square(File.H, Rank.R8) -> Piece.WhiteKing, Square(File.H, Rank.R1) -> Piece.BlackKing))
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
// Evaluating does not throw and uses the guard path
noException should be thrownBy EvaluationClassic.evaluate(context)
test("endgame bonus is applied when material is low"):
// Kings + one rook: phase = 2 < 8, triggers endgameBonus with friendly material advantage
val board = Board(
Map(
Square(File.D, Rank.R4) -> Piece.WhiteKing,
Square(File.D, Rank.R6) -> Piece.BlackKing,
Square(File.A, Rank.R1) -> Piece.WhiteRook,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
noException should be thrownBy EvaluationClassic.evaluate(context)
test("endgame bonus else branch when material is equal"):
// Both sides have a rook: friendlyMaterial == enemyMaterial → edgeBonus = 0
val board = Board(
Map(
Square(File.D, Rank.R4) -> Piece.WhiteKing,
Square(File.D, Rank.R6) -> Piece.BlackKing,
Square(File.A, Rank.R1) -> Piece.WhiteRook,
Square(File.H, Rank.R8) -> Piece.BlackRook,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
noException should be thrownBy EvaluationClassic.evaluate(context)
test("passed pawn bonus is applied in endgame"):
// No enemy pawns anywhere → white pawn on e5 is passed; phase = 0 → endgame → egPassedPawnBonus
val board = Board(
Map(
Square(File.E, Rank.R5) -> Piece.WhitePawn,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.E, Rank.R8) -> Piece.BlackKing,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val eval = EvaluationClassic.evaluate(context)
eval should be > 0
@@ -0,0 +1,160 @@
package de.nowchess.bot
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.HybridBot
import de.nowchess.bot.util.{PolyglotBook, PolyglotHash}
import de.nowchess.rules.RuleSet
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{DataOutputStream, FileOutputStream}
import java.nio.file.Files
import java.util.concurrent.atomic.AtomicBoolean
import scala.util.Using
class HybridBotTest extends AnyFunSuite with Matchers:
test("HybridBot name includes difficulty"):
val bot = HybridBot(BotDifficulty.Easy)
bot.name should include("HybridBot")
bot.name should include("Easy")
test("HybridBot nextMove returns a move on the initial position"):
val bot = HybridBot(BotDifficulty.Easy)
val move = bot.nextMove(GameContext.initial)
move should not be None
test("HybridBot nextMove returns None when no legal moves"):
val noMovesRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = true
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = HybridBot(BotDifficulty.Easy, noMovesRules)
val move = bot.nextMove(GameContext.initial)
move should be(None)
test("HybridBot with empty book falls through to search"):
val emptyBook = PolyglotBook("/nonexistent/book.bin")
val bot = HybridBot(BotDifficulty.Easy, book = Some(emptyBook))
val move = bot.nextMove(GameContext.initial)
move should not be None
test("HybridBot skips move repeated three times"):
val repeatedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val onlyMoveRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(repeatedMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
val bot = HybridBot(BotDifficulty.Easy, onlyMoveRules)
bot.nextMove(ctx) should be(None)
test("HybridBot uses book move when available"):
val tempFile = Files.createTempFile("hybrid_book", ".bin")
try
val ctx = GameContext.initial
val hash = PolyglotHash.hash(ctx)
val e2e4: Short = (4 | (3 << 3) | (4 << 6) | (1 << 9)).toShort
Using(DataOutputStream(FileOutputStream(tempFile.toFile))) { dos =>
dos.writeLong(hash)
dos.writeShort(e2e4)
dos.writeShort(100)
dos.writeInt(0)
}.get
val book = PolyglotBook(tempFile.toString)
val bot = HybridBot(BotDifficulty.Easy, book = Some(book))
bot.nextMove(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
finally Files.deleteIfExists(tempFile)
test("HybridBot reports veto when classical and NNUE differ above threshold"):
val forcedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val oneMoveRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
def legalMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
def allLegalMoves(context: GameContext): List[Move] = List(forcedMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext =
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
object LowNnue extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 0
object HighClassic extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 10_000
val reported = AtomicBoolean(false)
val bot = HybridBot(
BotDifficulty.Easy,
rules = oneMoveRules,
nnueEvaluation = LowNnue,
classicalEvaluation = HighClassic,
vetoReporter = _ => reported.set(true),
)
bot.nextMove(GameContext.initial) should be(Some(forcedMove))
reported.get should be(true)
test("HybridBot default veto reporter prints when threshold is exceeded"):
val forcedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val oneMoveRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
def legalMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
def allLegalMoves(context: GameContext): List[Move] = List(forcedMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext =
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
object LowNnue extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 0
object HighClassic extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 10_000
val bot = HybridBot(
BotDifficulty.Easy,
rules = oneMoveRules,
nnueEvaluation = LowNnue,
classicalEvaluation = HighClassic,
)
val printed = Console.withOut(new java.io.ByteArrayOutputStream()) {
bot.nextMove(GameContext.initial)
}
printed should be(Some(forcedMove))
@@ -0,0 +1,219 @@
package de.nowchess.bot
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.logic.MoveOrdering
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveOrderingTest extends AnyFunSuite with Matchers:
test("queen capture ranks higher than rook capture"):
val board = Board(
Map(
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
Square(File.E, Rank.R5) -> Piece.BlackQueen,
Square(File.E, Rank.R6) -> Piece.BlackRook,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val queenCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
val rookCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6), MoveType.Normal(true))
val queenScore = MoveOrdering.score(context, queenCapture, None)
val rookScore = MoveOrdering.score(context, rookCapture, None)
queenScore should be > rookScore
test("quiet move ranks lower than capture"):
val board = Board(
Map(
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
Square(File.E, Rank.R5) -> Piece.BlackPawn,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val capture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
val quiet = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5))
val captureScore = MoveOrdering.score(context, capture, None)
val quietScore = MoveOrdering.score(context, quiet, None)
captureScore should be > quietScore
test("TT best move ranks first"):
val board = Board(
Map(
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
Square(File.E, Rank.R5) -> Piece.BlackPawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val bestMove = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
val otherCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
val bestScore = MoveOrdering.score(context, bestMove, Some(bestMove))
val otherScore = MoveOrdering.score(context, otherCapture, Some(bestMove))
bestScore should equal(Int.MaxValue)
otherScore should be < bestScore
test("promotion to queen ranks high"):
val board = Board(
Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val promotionQueen =
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
val promotionKnight =
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight))
val queenScore = MoveOrdering.score(context, promotionQueen, None)
val knightScore = MoveOrdering.score(context, promotionKnight, None)
queenScore should be > knightScore
queenScore should be > 100_000 // Queen promotion score is > 100_000
test("en passant is treated as capture"):
val board = Board(
Map(
Square(File.E, Rank.R5) -> Piece.WhitePawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val epCapture = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
val quiet = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6))
val epScore = MoveOrdering.score(context, epCapture, None)
val quietScore = MoveOrdering.score(context, quiet, None)
epScore should be > quietScore
test("sort returns moves ordered by score"):
val board = Board(
Map(
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
Square(File.E, Rank.R5) -> Piece.BlackPawn,
Square(File.D, Rank.R5) -> Piece.BlackRook,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val moves = List(
Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true)), // Rook capture
Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true)), // Pawn capture
Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6)), // Quiet
)
val sorted = MoveOrdering.sort(context, moves, None)
// Rook capture should be first (higher victim value)
sorted.head.to should equal(Square(File.D, Rank.R5))
// Pawn capture should be second
sorted(1).to should equal(Square(File.E, Rank.R5))
// Quiet should be last
sorted.last.to should equal(Square(File.E, Rank.R6))
test("castling move is quiet (not capture)"):
val board = Board(
Map(
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.H, Rank.R1) -> Piece.WhiteRook,
),
)
val context = GameContext.initial.withBoard(board)
val castleMove = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
val score = MoveOrdering.score(context, castleMove, None)
score should equal(0) // Quiet move
test("all MoveType variants are handled in victimValue"):
val board = Board(
Map(
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.H, Rank.R1) -> Piece.WhiteRook,
Square(File.E, Rank.R2) -> Piece.WhitePawn,
),
)
val context = GameContext.initial.withBoard(board)
// Test castling queenside - should have victim value 0
val castleQs = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
val scoreQs = MoveOrdering.score(context, castleQs, None)
scoreQs should equal(0)
test("attackerValue covers all piece types"):
val board = Board(
Map(
Square(File.A, Rank.R1) -> Piece.WhiteRook,
Square(File.B, Rank.R1) -> Piece.WhiteKnight,
Square(File.C, Rank.R1) -> Piece.WhiteBishop,
Square(File.D, Rank.R1) -> Piece.WhiteQueen,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.F, Rank.R2) -> Piece.WhitePawn,
),
)
val context = GameContext.initial.withBoard(board)
// Create captures with each piece type
val rookCapture = Move(Square(File.A, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val knightCapture = Move(Square(File.B, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val bishopCapture = Move(Square(File.C, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val queenCapture = Move(Square(File.D, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val kingCapture = Move(Square(File.E, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true))
val pawnCapture = Move(Square(File.F, Rank.R2), Square(File.A, Rank.R8), MoveType.Normal(true))
// Just verify all are scored without error
MoveOrdering.score(context, rookCapture, None) should be >= 0
MoveOrdering.score(context, knightCapture, None) should be >= 0
MoveOrdering.score(context, bishopCapture, None) should be >= 0
MoveOrdering.score(context, queenCapture, None) should be >= 0
MoveOrdering.score(context, kingCapture, None) should be >= 0
MoveOrdering.score(context, pawnCapture, None) should be >= 0
test("promotion capture is distinct from quiet promotion"):
val board = Board(
Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn,
Square(File.D, Rank.R8) -> Piece.BlackPawn,
),
)
val context = GameContext.initial.withBoard(board)
// Promotion with capture
val promotionWithCapture =
Move(Square(File.E, Rank.R7), Square(File.D, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
// Regular queen promotion (no capture)
val quietPromotion =
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
val score1 = MoveOrdering.score(context, promotionWithCapture, None)
val score2 = MoveOrdering.score(context, quietPromotion, None)
score1 should be > score2
test("non-Queen promotion captures trigger promotionPieceType for Knight, Bishop, Rook"):
val board = Board(
Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn,
Square(File.D, Rank.R8) -> Piece.BlackRook,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val knightPromo = Move(Square(File.E, Rank.R7), Square(File.D, Rank.R8), MoveType.Promotion(PromotionPiece.Knight))
val bishopPromo = Move(Square(File.E, Rank.R7), Square(File.D, Rank.R8), MoveType.Promotion(PromotionPiece.Bishop))
val rookPromo = Move(Square(File.E, Rank.R7), Square(File.D, Rank.R8), MoveType.Promotion(PromotionPiece.Rook))
MoveOrdering.score(context, knightPromo, None) should be > 0
MoveOrdering.score(context, bishopPromo, None) should be > 0
MoveOrdering.score(context, rookPromo, None) should be > 0
test("negative SEE capture path is scored below neutral capture baseline"):
val board = Board(
Map(
Square(File.D, Rank.R4) -> Piece.WhiteQueen,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
Square(File.D, Rank.R8) -> Piece.BlackRook,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val move = Move(Square(File.D, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
MoveOrdering.score(context, move, None) should be < 100_000
test("non-capture move keeps fallback scoring at zero"):
val board = Board(Map(Square(File.E, Rank.R1) -> Piece.WhiteKing, Square(File.A, Rank.R8) -> Piece.BlackKing))
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val castle = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
MoveOrdering.score(context, castle, None) should be(0)
@@ -0,0 +1,153 @@
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.bot.bots.ClassicalBot
import de.nowchess.bot.util.{PolyglotBook, PolyglotHash}
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,58 @@
package de.nowchess.bot
import de.nowchess.api.board.{Color, File, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.bot.util.PolyglotHash
import de.nowchess.io.fen.FenParser
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class PolyglotHashTest extends AnyFunSuite with Matchers:
test("Initial position matches Polyglot reference key"):
val ctx = GameContext.initial
PolyglotHash.hash(ctx) shouldEqual java.lang.Long.parseUnsignedLong("463b96181691fc9c", 16)
test("Known Polyglot FEN vector matches reference key"):
val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
val ctx = FenParser.parseFen(fen).toOption.getOrElse(fail("FEN parse failed"))
PolyglotHash.hash(ctx) shouldEqual java.lang.Long.parseUnsignedLong("823c9b50fd114196", 16)
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("En passant file is ignored when no side-to-move pawn can capture"):
val fenWithEp = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq e3 0 1"
val fenWithoutEp = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1"
val withEp = FenParser.parseFen(fenWithEp).toOption.getOrElse(fail("FEN parse failed"))
val withoutEp = FenParser.parseFen(fenWithoutEp).toOption.getOrElse(fail("FEN parse failed"))
PolyglotHash.hash(withEp) shouldEqual PolyglotHash.hash(withoutEp)
test("Different en passant files produce different hashes when capture is possible"):
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
@@ -0,0 +1,73 @@
package de.nowchess.bot
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.bot.logic.{TTEntry, TTFlag, TranspositionTable}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class TranspositionTableTest extends AnyFunSuite with Matchers:
test("probe on empty table returns None"):
val tt = TranspositionTable(sizePow2 = 4)
tt.probe(12345L) should be(None)
test("store then probe returns the stored entry"):
val tt = TranspositionTable(sizePow2 = 4)
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val entry = TTEntry(hash = 12345L, depth = 3, score = 50, flag = TTFlag.Exact, bestMove = Some(move))
tt.store(entry)
val retrieved = tt.probe(12345L)
retrieved should be(Some(entry))
test("probe returns None when hash differs (collision guard)"):
val tt = TranspositionTable(sizePow2 = 4)
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val entry = TTEntry(hash = 12345L, depth = 3, score = 50, flag = TTFlag.Exact, bestMove = Some(move))
tt.store(entry)
tt.probe(54321L) should be(None)
test("clear removes all entries"):
val tt = TranspositionTable(sizePow2 = 4)
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val entry = TTEntry(hash = 12345L, depth = 3, score = 50, flag = TTFlag.Exact, bestMove = Some(move))
tt.store(entry)
tt.probe(12345L) should be(Some(entry))
tt.clear()
tt.probe(12345L) should be(None)
test("all TTFlag values store and retrieve correctly"):
val tt = TranspositionTable(sizePow2 = 4)
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
TTFlag.values.foreach { flag =>
val entry = TTEntry(hash = 12345L + flag.ordinal, depth = 2, score = 100, flag = flag, bestMove = Some(move))
tt.store(entry)
val retrieved = tt.probe(12345L + flag.ordinal)
retrieved.map(_.flag) should be(Some(flag))
}
test("bestMove = None roundtrips"):
val tt = TranspositionTable(sizePow2 = 4)
val entry = TTEntry(hash = 99999L, depth = 1, score = 0, flag = TTFlag.Upper, bestMove = None)
tt.store(entry)
val retrieved = tt.probe(99999L)
retrieved.map(_.bestMove) should be(Some(None))
test("always-replace overwrites at same slot"):
val tt = TranspositionTable(sizePow2 = 4)
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val move2 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val entry1 = TTEntry(hash = 12345L, depth = 2, score = 50, flag = TTFlag.Exact, bestMove = Some(move1))
val entry2 = TTEntry(hash = 12345L, depth = 3, score = 100, flag = TTFlag.Lower, bestMove = Some(move2))
tt.store(entry1)
tt.probe(12345L).map(_.score) should be(Some(50))
tt.store(entry2)
tt.probe(12345L).map(_.score) should be(Some(100))
test("size is 1 << sizePow2"):
val tt = TranspositionTable(sizePow2 = 4)
(1 << 4) should equal(16)
val tt2 = TranspositionTable(sizePow2 = 10)
(1 << 10) should equal(1024)
@@ -0,0 +1,162 @@
package de.nowchess.bot
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.util.ZobristHash
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class ZobristHashTest extends AnyFunSuite with Matchers:
test("hash is deterministic"):
val hash1 = ZobristHash.hash(GameContext.initial)
val hash2 = ZobristHash.hash(GameContext.initial)
hash1 should equal(hash2)
test("hash differs after a pawn move"):
val initial = GameContext.initial
// Move pawn from e2 to e4
val board = initial.board.pieces
val newBoard = board.removed(Square(File.E, Rank.R2)).updated(Square(File.E, Rank.R4), Piece.WhitePawn)
val afterMove = initial.withBoard(Board(newBoard)).withTurn(Color.Black)
val hash1 = ZobristHash.hash(initial)
val hash2 = ZobristHash.hash(afterMove)
hash1 should not equal hash2
test("hash includes castling rights"):
val ctx1 = GameContext.initial
val ctx2 = ctx1.withCastlingRights(CastlingRights.None)
val hash1 = ZobristHash.hash(ctx1)
val hash2 = ZobristHash.hash(ctx2)
hash1 should not equal hash2
test("hash includes en-passant square"):
val ctx1 = GameContext.initial
val ctx2 = ctx1.withEnPassantSquare(Some(Square(File.E, Rank.R3)))
val hash1 = ZobristHash.hash(ctx1)
val hash2 = ZobristHash.hash(ctx2)
hash1 should not equal hash2
test("hash includes side to move"):
val ctx1 = GameContext.initial
val ctx2 = ctx1.withTurn(Color.Black)
val hash1 = ZobristHash.hash(ctx1)
val hash2 = ZobristHash.hash(ctx2)
hash1 should not equal hash2
test("nextHash matches recomputed hash for a normal move"):
val context = GameContext.initial
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val next = DefaultRules.applyMove(context)(move)
val incremental = ZobristHash.nextHash(context, ZobristHash.hash(context), move, next)
incremental should equal(ZobristHash.hash(next))
test("nextHash matches recomputed hash for promotion and castling"):
val promotionBoard = Board(
Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.H, Rank.R1) -> Piece.WhiteRook,
Square(File.E, Rank.R8) -> Piece.BlackKing,
),
)
val promotionContext = GameContext.initial
.withBoard(promotionBoard)
.withTurn(Color.White)
.withCastlingRights(CastlingRights.All)
val promotionMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
val promotionNext = DefaultRules.applyMove(promotionContext)(promotionMove)
val promotionHash =
ZobristHash.nextHash(promotionContext, ZobristHash.hash(promotionContext), promotionMove, promotionNext)
promotionHash should equal(ZobristHash.hash(promotionNext))
val castleBoard = Board(
Map(
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.H, Rank.R1) -> Piece.WhiteRook,
Square(File.E, Rank.R8) -> Piece.BlackKing,
),
)
val castleContext = GameContext.initial
.withBoard(castleBoard)
.withTurn(Color.White)
.withCastlingRights(
CastlingRights(whiteKingSide = true, whiteQueenSide = false, blackKingSide = false, blackQueenSide = false),
)
val castleMove = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
val castleNext = DefaultRules.applyMove(castleContext)(castleMove)
val castleHash = ZobristHash.nextHash(castleContext, ZobristHash.hash(castleContext), castleMove, castleNext)
castleHash should equal(ZobristHash.hash(castleNext))
test("nextHash matches recomputed hash for queenside castling"):
val board = Board(
Map(
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.A, Rank.R1) -> Piece.WhiteRook,
Square(File.E, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withCastlingRights(
CastlingRights(whiteKingSide = false, whiteQueenSide = true, blackKingSide = false, blackQueenSide = false),
)
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
val next = DefaultRules.applyMove(ctx)(move)
ZobristHash.nextHash(ctx, ZobristHash.hash(ctx), move, next) should equal(ZobristHash.hash(next))
test("nextHash matches recomputed hash for en passant"):
val board = Board(
Map(
Square(File.E, Rank.R5) -> Piece.WhitePawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.E, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withEnPassantSquare(Some(Square(File.D, Rank.R6)))
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
val next = DefaultRules.applyMove(ctx)(move)
ZobristHash.nextHash(ctx, ZobristHash.hash(ctx), move, next) should equal(ZobristHash.hash(next))
test("nextHash matches recomputed hash for black kingside castling"):
val board = Board(
Map(
Square(File.E, Rank.R8) -> Piece.BlackKing,
Square(File.H, Rank.R8) -> Piece.BlackRook,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
),
)
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.Black)
.withCastlingRights(
CastlingRights(whiteKingSide = false, whiteQueenSide = false, blackKingSide = true, blackQueenSide = false),
)
val move = Move(Square(File.E, Rank.R8), Square(File.G, Rank.R8), MoveType.CastleKingside)
val next = DefaultRules.applyMove(ctx)(move)
ZobristHash.nextHash(ctx, ZobristHash.hash(ctx), move, next) should equal(ZobristHash.hash(next))
test("nextHash matches recomputed hash for knight and rook promotions"):
val board = Board(
Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.E, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withCastlingRights(CastlingRights(false, false, false, false))
for pp <- List(PromotionPiece.Knight, PromotionPiece.Bishop, PromotionPiece.Rook) do
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(pp))
val next = DefaultRules.applyMove(ctx)(move)
ZobristHash.nextHash(ctx, ZobristHash.hash(ctx), move, next) should equal(ZobristHash.hash(next))