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:
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))
|
||||
Reference in New Issue
Block a user