From a247eb3d0df92f2e2ef56bcf6e0724d43fd7d6b0 Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 7 Apr 2026 12:33:07 +0200 Subject: [PATCH] feat: add AlphaBetaSearch and bot implementation with difficulty levels --- .../de/nowchess/bot/AlphaBetaSearch.scala | 139 +++++++++++++++ .../scala/de/nowchess/bot/BotDifficulty.scala | 7 + .../scala/de/nowchess/bot/ClassicalBot.scala | 25 +++ .../scala/de/nowchess/bot/Evaluation.scala | 122 +++++++++++++ .../scala/de/nowchess/bot/MoveOrdering.scala | 66 +++++++ .../de/nowchess/bot/TranspositionTable.scala | 32 ++++ .../scala/de/nowchess/bot/ZobristHash.scala | 60 +++++++ .../de/nowchess/bot/BotControllerTest.scala | 10 ++ .../de/nowchess/bot/BotDifficultyTest.scala | 14 ++ .../de/nowchess/bot/ClassicalBotTest.scala | 70 ++++++++ .../de/nowchess/bot/EvaluationTest.scala | 52 ++++++ .../de/nowchess/bot/MoveOrderingTest.scala | 161 ++++++++++++++++++ .../nowchess/bot/TranspositionTableTest.scala | 72 ++++++++ .../de/nowchess/bot/ZobristHashTest.scala | 45 +++++ modules/core/build.gradle.kts | 1 + 15 files changed, 876 insertions(+) create mode 100644 modules/bot/src/main/scala/de/nowchess/bot/AlphaBetaSearch.scala create mode 100644 modules/bot/src/main/scala/de/nowchess/bot/BotDifficulty.scala create mode 100644 modules/bot/src/main/scala/de/nowchess/bot/ClassicalBot.scala create mode 100644 modules/bot/src/main/scala/de/nowchess/bot/Evaluation.scala create mode 100644 modules/bot/src/main/scala/de/nowchess/bot/MoveOrdering.scala create mode 100644 modules/bot/src/main/scala/de/nowchess/bot/TranspositionTable.scala create mode 100644 modules/bot/src/main/scala/de/nowchess/bot/ZobristHash.scala create mode 100644 modules/bot/src/test/scala/de/nowchess/bot/BotControllerTest.scala create mode 100644 modules/bot/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala create mode 100644 modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala create mode 100644 modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala create mode 100644 modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala create mode 100644 modules/bot/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala create mode 100644 modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala diff --git a/modules/bot/src/main/scala/de/nowchess/bot/AlphaBetaSearch.scala b/modules/bot/src/main/scala/de/nowchess/bot/AlphaBetaSearch.scala new file mode 100644 index 0000000..bfa86b7 --- /dev/null +++ b/modules/bot/src/main/scala/de/nowchess/bot/AlphaBetaSearch.scala @@ -0,0 +1,139 @@ +package de.nowchess.bot + +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType} +import de.nowchess.rules.RuleSet +import de.nowchess.rules.sets.DefaultRules + +final class AlphaBetaSearch( + rules: RuleSet = DefaultRules, + tt: TranspositionTable = TranspositionTable() +): + + private val INF = Int.MaxValue / 2 + private val MAX_QUIESCENCE_PLY = 64 + + /** Return the best move for the side to move, searching to maxDepth plies. + * Uses iterative deepening: searches depth 1, 2, ..., maxDepth. */ + def bestMove(context: GameContext, maxDepth: Int): Option[Move] = + tt.clear() + var bestSoFar: Option[Move] = None + for depth <- 1 to maxDepth do + val (_, move) = search(context, depth, ply = 0, alpha = -INF, beta = INF) + move.foreach(m => bestSoFar = Some(m)) + bestSoFar + + /** Negamax alpha-beta search returning (score, best move). */ + private def search( + context: GameContext, + depth: Int, + ply: Int, + alpha: Int, + beta: Int + ): (Int, Option[Move]) = + val hash = ZobristHash.hash(context) + + // TT probe + tt.probe(hash).foreach { entry => + if entry.depth >= depth then + entry.flag match + case TTFlag.Exact => + return (entry.score, entry.bestMove) + case TTFlag.Lower => + val newAlpha = math.max(alpha, entry.score) + if newAlpha >= beta then + return (entry.score, entry.bestMove) + case TTFlag.Upper => + val newBeta = math.min(beta, entry.score) + if alpha >= newBeta then + return (entry.score, entry.bestMove) + } + + // Terminal node check + val legalMoves = rules.allLegalMoves(context) + if legalMoves.isEmpty then + val score = if rules.isCheckmate(context) then + -(Evaluation.CHECKMATE_SCORE - ply) + else + Evaluation.DRAW_SCORE + return (score, None) + + if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then + return (Evaluation.DRAW_SCORE, None) + + // Leaf node: call quiescence + if depth == 0 then + return (quiescence(context, ply, alpha, beta), None) + + // Get TT best move for ordering + val ttBest = tt.probe(hash).flatMap(_.bestMove) + + // Order moves + val ordered = MoveOrdering.sort(context, legalMoves, ttBest) + + + var bestMove: Option[Move] = None + var bestScore = -INF + var a = alpha + + for move <- ordered do + val child = rules.applyMove(context)(move) + val (rawScore, _) = search(child, depth - 1, ply + 1, -beta, -a) + val score = -rawScore + + if score > bestScore then + bestScore = score + bestMove = Some(move) + + a = math.max(a, score) + + if a >= beta then + // Beta cutoff: store as lower bound + tt.store(TTEntry(hash, depth, bestScore, TTFlag.Lower, bestMove)) + return (bestScore, bestMove) + + // No cutoff: determine flag + val flag = + if bestScore <= alpha then TTFlag.Upper + else TTFlag.Exact + tt.store(TTEntry(hash, depth, bestScore, flag, bestMove)) + (bestScore, bestMove) + + /** Quiescence search: only captures until position is quiet. */ + private def quiescence( + context: GameContext, + ply: Int, + alpha: Int, + beta: Int + ): Int = + // Stand-pat: evaluate current position + val standPat = Evaluation.evaluate(context) + if standPat >= beta then + return beta + + var a = math.max(alpha, standPat) + + // Guard against infinite quiescence + if ply >= MAX_QUIESCENCE_PLY then + return standPat + + // Generate only captures + val allMoves = rules.allLegalMoves(context) + val captures = allMoves.filter(isCapture) + val ordered = MoveOrdering.sort(context, captures, None) + + for move <- ordered do + val child = rules.applyMove(context)(move) + val score = -quiescence(child, ply + 1, -beta, -a) + if score >= beta then + return beta + a = math.max(a, score) + + a + + /** Predicate: is a move a capture (including promotions)? */ + private def isCapture(move: Move): Boolean = move.moveType match + case MoveType.Normal(true) => true + case MoveType.EnPassant => true + case MoveType.Promotion(_) => true + case _ => false diff --git a/modules/bot/src/main/scala/de/nowchess/bot/BotDifficulty.scala b/modules/bot/src/main/scala/de/nowchess/bot/BotDifficulty.scala new file mode 100644 index 0000000..fa80fb0 --- /dev/null +++ b/modules/bot/src/main/scala/de/nowchess/bot/BotDifficulty.scala @@ -0,0 +1,7 @@ +package de.nowchess.bot + +enum BotDifficulty: + case Easy + case Medium + case Hard + case Expert diff --git a/modules/bot/src/main/scala/de/nowchess/bot/ClassicalBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/ClassicalBot.scala new file mode 100644 index 0000000..66391ee --- /dev/null +++ b/modules/bot/src/main/scala/de/nowchess/bot/ClassicalBot.scala @@ -0,0 +1,25 @@ +package de.nowchess.bot + +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move +import de.nowchess.rules.RuleSet +import de.nowchess.rules.sets.DefaultRules + +final class ClassicalBot( + difficulty: BotDifficulty, + rules: RuleSet = DefaultRules +) extends Bot: + + private val search: AlphaBetaSearch = AlphaBetaSearch(rules) + + override val name: String = s"ClassicalBot(${difficulty.toString})" + + override def nextMove(context: GameContext): Option[Move] = + val depth = depthForDifficulty(difficulty) + search.bestMove(context, depth) + + private def depthForDifficulty(d: BotDifficulty): Int = d match + case BotDifficulty.Easy => 2 + case BotDifficulty.Medium => 3 + case BotDifficulty.Hard => 4 + case BotDifficulty.Expert => 5 diff --git a/modules/bot/src/main/scala/de/nowchess/bot/Evaluation.scala b/modules/bot/src/main/scala/de/nowchess/bot/Evaluation.scala new file mode 100644 index 0000000..31ce3ae --- /dev/null +++ b/modules/bot/src/main/scala/de/nowchess/bot/Evaluation.scala @@ -0,0 +1,122 @@ +package de.nowchess.bot + +import de.nowchess.api.board.{Color, PieceType, Square} +import de.nowchess.api.game.GameContext + +object Evaluation: + + val CHECKMATE_SCORE: Int = 10_000_000 + val DRAW_SCORE: Int = 0 + + // Material values in centipawns + private val materialValue: Map[PieceType, Int] = Map( + PieceType.Pawn -> 100, + PieceType.Knight -> 320, + PieceType.Bishop -> 330, + PieceType.Rook -> 500, + PieceType.Queen -> 900, + PieceType.King -> 20_000 + ) + + private val TEMPO_BONUS: Int = 10 + + // Piece-square tables (Simplified Evaluation Function, Michniewski) + // Indexed by squareIndex = rank.ordinal * 8 + file.ordinal + // White's perspective: rank 0 = home (r1), rank 7 = back rank (r8) + // Black is vertically mirrored + + private val pawnTable: Array[Int] = Array( + 0, 0, 0, 0, 0, 0, 0, 0, + 50, 50, 50, 50, 50, 50, 50, 50, + 10, 10, 20, 30, 30, 20, 10, 10, + 5, 5, 10, 25, 25, 10, 5, 5, + 0, 0, 0, 20, 20, 0, 0, 0, + 5, -5, -10, 0, 0, -10, -5, 5, + 5, 10, 10, -20, -20, 10, 10, 5, + 0, 0, 0, 0, 0, 0, 0, 0 + ) + + private val knightTable: Array[Int] = Array( + -50, -40, -30, -30, -30, -30, -40, -50, + -40, -20, 0, 0, 0, 0, -20, -40, + -30, 0, 10, 15, 15, 10, 0, -30, + -30, 5, 15, 20, 20, 15, 5, -30, + -30, 0, 15, 20, 20, 15, 0, -30, + -30, 5, 10, 15, 15, 10, 5, -30, + -40, -20, 0, 5, 5, 0, -20, -40, + -50, -40, -30, -30, -30, -30, -40, -50 + ) + + private val bishopTable: Array[Int] = Array( + -20, -10, -10, -10, -10, -10, -10, -20, + -10, 0, 0, 0, 0, 0, 0, -10, + -10, 0, 5, 10, 10, 5, 0, -10, + -10, 5, 5, 10, 10, 5, 5, -10, + -10, 0, 10, 10, 10, 10, 0, -10, + -10, 10, 10, 10, 10, 10, 10, -10, + -10, 5, 0, 0, 0, 0, 5, -10, + -20, -10, -10, -10, -10, -10, -10, -20 + ) + + private val rookTable: Array[Int] = Array( + 0, 0, 0, 0, 0, 0, 0, 0, + 5, 10, 10, 10, 10, 10, 10, 5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + 0, 0, 0, 5, 5, 0, 0, 0 + ) + + private val queenTable: Array[Int] = Array( + -20, -10, -10, -5, -5, -10, -10, -20, + -10, 0, 0, 0, 0, 0, 0, -10, + -10, 0, 5, 5, 5, 5, 0, -10, + -5, 0, 5, 5, 5, 5, 0, -5, + 0, 0, 5, 5, 5, 5, 0, -5, + -10, 5, 5, 5, 5, 5, 0, -10, + -10, 0, 5, 0, 0, 0, 0, -10, + -20, -10, -10, -5, -5, -10, -10, -20 + ) + + private val kingMidgameTable: Array[Int] = Array( + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -20, -30, -30, -40, -40, -30, -30, -20, + -10, -20, -20, -20, -20, -20, -20, -10, + 20, 20, 0, 0, 0, 0, 20, 20, + 20, 30, 10, 0, 0, 10, 30, 20 + ) + + /** Evaluate the position from the perspective of context.turn. + * Positive = good for context.turn. */ + def evaluate(context: GameContext): Int = + val material = materialAndPositional(context) + material + TEMPO_BONUS + + private def materialAndPositional(context: GameContext): Int = + var score = 0 + for (square, piece) <- context.board.pieces do + val value = materialValue(piece.pieceType) + squareBonus(piece.pieceType, piece.color, square) + if piece.color == context.turn then + score += value + else + score -= value + score + + private def squareBonus(pieceType: PieceType, color: Color, sq: Square): Int = + val table = pieceType match + case PieceType.Pawn => pawnTable + case PieceType.Knight => knightTable + case PieceType.Bishop => bishopTable + case PieceType.Rook => rookTable + case PieceType.Queen => queenTable + case PieceType.King => kingMidgameTable + + val rankIdx = if color == Color.White then sq.rank.ordinal else 7 - sq.rank.ordinal + val fileIdx = sq.file.ordinal + val squareIdx = rankIdx * 8 + fileIdx + table(squareIdx) diff --git a/modules/bot/src/main/scala/de/nowchess/bot/MoveOrdering.scala b/modules/bot/src/main/scala/de/nowchess/bot/MoveOrdering.scala new file mode 100644 index 0000000..8a28ab0 --- /dev/null +++ b/modules/bot/src/main/scala/de/nowchess/bot/MoveOrdering.scala @@ -0,0 +1,66 @@ +package de.nowchess.bot + +import de.nowchess.api.board.PieceType +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} + +object MoveOrdering: + + /** Score a single move for ordering (higher = search first). */ + def score(context: GameContext, move: Move, ttBestMove: Option[Move]): Int = + if ttBestMove.exists(m => m.from == move.from && m.to == move.to) then + Int.MaxValue // TT best move always first + else + move.moveType match + case MoveType.Promotion(PromotionPiece.Queen) => + 1_000_000 // Queen promotion is always good + case MoveType.Normal(true) => + 100_000 + mvvLva(context, move) // Capture + case MoveType.EnPassant => + 100_000 + mvvLva(context, move) // En passant is a pawn capture + case MoveType.Promotion(_) => + 50_000 + mvvLva(context, move) // Minor/rook/bishop promotion + case _ => + 0 // Quiet move + + /** Sort moves: TT best move first, then by score descending. */ + def sort(context: GameContext, moves: List[Move], ttBestMove: Option[Move]): List[Move] = + moves.sortBy(m => -score(context, m, ttBestMove)) + + /** MVV-LVA score: (victim value * 10) - attacker value. + * Higher score = better trade (most valuable victim captured by least valuable attacker). */ + private def mvvLva(context: GameContext, move: Move): Int = + val victim = victimValue(context, move) + val attacker = attackerValue(context, move) + (victim * 10) - attacker + + /** Material value of the attacking piece. */ + private def attackerValue(context: GameContext, move: Move): Int = + context.board.pieceAt(move.from) match + case Some(piece) => + piece.pieceType match + case PieceType.Pawn => 1 + case PieceType.Knight => 3 + case PieceType.Bishop => 3 + case PieceType.Rook => 5 + case PieceType.Queen => 9 + case PieceType.King => 200 // King never captures, but include for completeness + case None => 0 + + /** Material value of the captured piece. */ + private def victimValue(context: GameContext, move: Move): Int = + move.moveType match + case MoveType.Normal(true) => + context.board.pieceAt(move.to) match + case Some(piece) => + piece.pieceType match + case PieceType.Pawn => 1 + case PieceType.Knight => 3 + case PieceType.Bishop => 3 + case PieceType.Rook => 5 + case PieceType.Queen => 9 + case PieceType.King => 200 + case None => 0 + case MoveType.EnPassant => 1 // En passant captures a pawn + case MoveType.Promotion(_) => 0 // Promotion is not a capture (destination is empty) + case _ => 0 diff --git a/modules/bot/src/main/scala/de/nowchess/bot/TranspositionTable.scala b/modules/bot/src/main/scala/de/nowchess/bot/TranspositionTable.scala new file mode 100644 index 0000000..a7d1c8d --- /dev/null +++ b/modules/bot/src/main/scala/de/nowchess/bot/TranspositionTable.scala @@ -0,0 +1,32 @@ +package de.nowchess.bot + +import de.nowchess.api.move.Move + +enum TTFlag: + case Exact // Score is exact + case Lower // Score is a lower bound + case Upper // Score is an upper bound + +final case class TTEntry( + hash: Long, + depth: Int, + score: Int, + flag: TTFlag, + bestMove: Option[Move] +) + +final class TranspositionTable(val sizePow2: Int = 20): + private val size = 1 << sizePow2 + private val mask = size - 1L + private var table: Array[Option[TTEntry]] = Array.fill(size)(None) + + def probe(hash: Long): Option[TTEntry] = + val index = (hash & mask).toInt + table(index).filter(_.hash == hash) + + def store(entry: TTEntry): Unit = + val index = (entry.hash & mask).toInt + table(index) = Some(entry) + + def clear(): Unit = + table = Array.fill(size)(None) diff --git a/modules/bot/src/main/scala/de/nowchess/bot/ZobristHash.scala b/modules/bot/src/main/scala/de/nowchess/bot/ZobristHash.scala new file mode 100644 index 0000000..bcb58d2 --- /dev/null +++ b/modules/bot/src/main/scala/de/nowchess/bot/ZobristHash.scala @@ -0,0 +1,60 @@ +package de.nowchess.bot + +import de.nowchess.api.board.{Color, File, PieceType, Square} +import de.nowchess.api.game.GameContext +import scala.util.Random + +object ZobristHash: + + // 768 entries: 64 squares * 12 piece variants (2 colors * 6 piece types) + private val pieceRands: Array[Long] = Array.ofDim(768) + + // Side-to-move: XOR when Black to move + private val sideToMoveRand: Long = Random(0x1BADB002L).nextLong() + + // 4 entries: White kingside, White queenside, Black kingside, Black queenside + private val castlingRands: Array[Long] = Array.ofDim(4) + + // 8 entries: one per file (a-h) + private val enPassantRands: Array[Long] = Array.ofDim(8) + + // Initialize all random values using a seeded RNG for reproducibility + locally: + val rng = Random(0x1BADB002L) + for i <- 0 until 768 do + pieceRands(i) = rng.nextLong() + for i <- 0 until 4 do + castlingRands(i) = rng.nextLong() + for i <- 0 until 8 do + enPassantRands(i) = rng.nextLong() + + /** Compute a 64-bit Zobrist hash for a GameContext. */ + def hash(context: GameContext): Long = + var h = 0L + + // Hash board pieces + for (square, piece) <- context.board.pieces do + val squareIndex = square.rank.ordinal * 8 + square.file.ordinal + val colorIndex = if piece.color == Color.White then 0 else 1 + val pieceIndex = colorIndex * 6 + piece.pieceType.ordinal + val randIndex = squareIndex * 12 + pieceIndex + h ^= pieceRands(randIndex) + + // Hash side to move + if context.turn == Color.Black then + h ^= sideToMoveRand + + // Hash castling rights + if context.castlingRights.whiteKingSide then + h ^= castlingRands(0) + if context.castlingRights.whiteQueenSide then + h ^= castlingRands(1) + if context.castlingRights.blackKingSide then + h ^= castlingRands(2) + if context.castlingRights.blackQueenSide then + h ^= castlingRands(3) + + // Hash en-passant square + context.enPassantSquare.foreach(sq => h ^= enPassantRands(sq.file.ordinal)) + + h diff --git a/modules/bot/src/test/scala/de/nowchess/bot/BotControllerTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/BotControllerTest.scala new file mode 100644 index 0000000..58cf813 --- /dev/null +++ b/modules/bot/src/test/scala/de/nowchess/bot/BotControllerTest.scala @@ -0,0 +1,10 @@ +package de.nowchess.bot + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class BotControllerTest extends AnyFunSuite with Matchers: + + test("BotController can be instantiated"): + // BotController is an object with a private map, just test instantiation + BotController should not be null diff --git a/modules/bot/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala new file mode 100644 index 0000000..56dd2be --- /dev/null +++ b/modules/bot/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala @@ -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 diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala new file mode 100644 index 0000000..68df90a --- /dev/null +++ b/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala @@ -0,0 +1,70 @@ +package de.nowchess.bot + +import de.nowchess.api.board.Square +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move +import de.nowchess.rules.RuleSet +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import de.nowchess.rules.sets.DefaultRules + +class ClassicalBotTest extends AnyFunSuite with Matchers: + + test("name returns expected format"): + val botEasy = ClassicalBot(BotDifficulty.Easy) + botEasy.name should include("ClassicalBot") + botEasy.name should include("Easy") + + val botMedium = ClassicalBot(BotDifficulty.Medium) + botMedium.name should include("Medium") + + test("nextMove on initial position returns a move"): + val bot = ClassicalBot(BotDifficulty.Easy) + val move = bot.nextMove(GameContext.initial) + move should not be None + + test("nextMove returns None for position with no legal moves"): + val stubRules = new RuleSet: + def candidateMoves(context: GameContext)(square: Square) = List() + def legalMoves(context: GameContext)(square: Square) = List() + def allLegalMoves(context: GameContext) = List() + def isCheck(context: GameContext) = false + def isCheckmate(context: GameContext) = true + def isStalemate(context: GameContext) = false + def isInsufficientMaterial(context: GameContext) = false + def isFiftyMoveRule(context: GameContext) = false + def applyMove(context: GameContext)(move: Move) = context + + val bot = ClassicalBot(BotDifficulty.Easy, stubRules) + val move = bot.nextMove(GameContext.initial) + move should be(None) + + test("all BotDifficulty values work"): + BotDifficulty.values.foreach { difficulty => + val bot = ClassicalBot(difficulty) + val move = bot.nextMove(GameContext.initial) + // All difficulties should return a move on the initial position + move should not be None + } + + test("custom RuleSet injection works"): + val moveToReturn = Move( + de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R2), + de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R4), + de.nowchess.api.move.MoveType.Normal() + ) + + val stubRules = new RuleSet: + def candidateMoves(context: GameContext)(square: Square) = List() + def legalMoves(context: GameContext)(square: Square) = List() + def allLegalMoves(context: GameContext) = List(moveToReturn) + def isCheck(context: GameContext) = false + def isCheckmate(context: GameContext) = false + def isStalemate(context: GameContext) = false + def isInsufficientMaterial(context: GameContext) = false + def isFiftyMoveRule(context: GameContext) = false + def applyMove(context: GameContext)(move: Move) = context + + val bot = ClassicalBot(BotDifficulty.Easy, stubRules) + val move = bot.nextMove(GameContext.initial) + move should be(Some(moveToReturn)) diff --git a/modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala new file mode 100644 index 0000000..277bfc5 --- /dev/null +++ b/modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala @@ -0,0 +1,52 @@ +package de.nowchess.bot + +import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.api.game.GameContext +import de.nowchess.api.board.Board +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class EvaluationTest extends AnyFunSuite with Matchers: + + test("initial position evaluates to tempo bonus"): + val eval = Evaluation.evaluate(GameContext.initial) + eval should equal(10) // TEMPO_BONUS only + + test("remove white queen gives negative evaluation"): + val initial = GameContext.initial + val board = initial.board + val emptySquare = Square(File.D, Rank.R1) + val boardWithoutQueen = board.pieces.filter((sq, _) => sq != emptySquare) + val newContext = initial.withBoard(Board(boardWithoutQueen)) + val eval = Evaluation.evaluate(newContext) + eval should be < 0 + + test("remove black queen gives positive evaluation"): + val initial = GameContext.initial + val board = initial.board + val emptySquare = Square(File.D, Rank.R8) + val boardWithoutQueen = board.pieces.filter((sq, _) => sq != emptySquare) + val newContext = initial.withBoard(Board(boardWithoutQueen)) + val eval = Evaluation.evaluate(newContext) + eval should be > 0 + + test("different piece-square bonuses are applied"): + // Knight on d4 (center) vs knight on a1 (corner) - center should be better + val knightD4Board = Board(Map(Square(File.D, Rank.R4) -> Piece.WhiteKnight)) + val knightA1Board = Board(Map(Square(File.A, Rank.R1) -> Piece.WhiteKnight)) + val knightD4 = GameContext.initial.withBoard(knightD4Board) + val knightA1 = GameContext.initial.withBoard(knightA1Board) + + val eval1 = Evaluation.evaluate(knightD4) + val eval2 = Evaluation.evaluate(knightA1) + eval1 should be > eval2 // d4 (center) is better than a1 (corner) for knight + + test("all piece types are in material map"): + PieceType.values.length should be > 0 + // Just verify evaluate works with all piece types + val eval = Evaluation.evaluate(GameContext.initial) + eval should not be (Evaluation.CHECKMATE_SCORE) + + test("CHECKMATE_SCORE and DRAW_SCORE are accessible"): + Evaluation.CHECKMATE_SCORE should equal(10_000_000) + Evaluation.DRAW_SCORE should equal(0) diff --git a/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala new file mode 100644 index 0000000..b90aabb --- /dev/null +++ b/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala @@ -0,0 +1,161 @@ +package de.nowchess.bot + +import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class MoveOrderingTest extends AnyFunSuite with Matchers: + + test("queen capture ranks higher than rook capture"): + val board = Board(Map( + Square(File.E, Rank.R4) -> Piece.WhiteQueen, + Square(File.E, Rank.R5) -> Piece.BlackQueen, + Square(File.E, Rank.R6) -> Piece.BlackRook + )) + val context = GameContext.initial.withBoard(board).withTurn(Color.White) + val queenCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true)) + val rookCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6), MoveType.Normal(true)) + + val queenScore = MoveOrdering.score(context, queenCapture, None) + val rookScore = MoveOrdering.score(context, rookCapture, None) + queenScore should be > rookScore + + test("quiet move ranks lower than capture"): + val board = Board(Map( + Square(File.E, Rank.R4) -> Piece.WhiteQueen, + Square(File.E, Rank.R5) -> Piece.BlackPawn + )) + val context = GameContext.initial.withBoard(board).withTurn(Color.White) + val capture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true)) + val quiet = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(false)) + + val captureScore = MoveOrdering.score(context, capture, None) + val quietScore = MoveOrdering.score(context, quiet, None) + captureScore should be > quietScore + + test("TT best move ranks first"): + val board = Board(Map( + Square(File.E, Rank.R4) -> Piece.WhiteQueen, + Square(File.E, Rank.R5) -> Piece.BlackPawn, + Square(File.D, Rank.R5) -> Piece.BlackPawn + )) + val context = GameContext.initial.withBoard(board).withTurn(Color.White) + val bestMove = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true)) + val otherCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true)) + + val bestScore = MoveOrdering.score(context, bestMove, Some(bestMove)) + val otherScore = MoveOrdering.score(context, otherCapture, Some(bestMove)) + bestScore should equal(Int.MaxValue) + otherScore should be < bestScore + + test("promotion to queen ranks high"): + val board = Board(Map( + Square(File.E, Rank.R7) -> Piece.WhitePawn + )) + val context = GameContext.initial.withBoard(board).withTurn(Color.White) + val promotionQueen = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)) + val promotionKnight = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight)) + + val queenScore = MoveOrdering.score(context, promotionQueen, None) + val knightScore = MoveOrdering.score(context, promotionKnight, None) + queenScore should be > knightScore + queenScore should be > 100_000 // Queen promotion score is > 100_000 + + test("en passant is treated as capture"): + val board = Board(Map( + Square(File.E, Rank.R5) -> Piece.WhitePawn, + Square(File.D, Rank.R5) -> Piece.BlackPawn + )) + val context = GameContext.initial.withBoard(board).withTurn(Color.White) + val epCapture = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant) + val quiet = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal(false)) + + val epScore = MoveOrdering.score(context, epCapture, None) + val quietScore = MoveOrdering.score(context, quiet, None) + epScore should be > quietScore + + test("sort returns moves ordered by score"): + val board = Board(Map( + Square(File.E, Rank.R4) -> Piece.WhiteQueen, + Square(File.E, Rank.R5) -> Piece.BlackPawn, + Square(File.D, Rank.R5) -> Piece.BlackRook + )) + val context = GameContext.initial.withBoard(board).withTurn(Color.White) + val moves = List( + Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true)), // Rook capture + Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true)), // Pawn capture + Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6), MoveType.Normal(false)) // Quiet + ) + val sorted = MoveOrdering.sort(context, moves, None) + // Rook capture should be first (higher victim value) + sorted.head.to should equal(Square(File.D, Rank.R5)) + // Pawn capture should be second + sorted(1).to should equal(Square(File.E, Rank.R5)) + // Quiet should be last + sorted.last.to should equal(Square(File.E, Rank.R6)) + + test("castling move is quiet (not capture)"): + val board = Board(Map( + Square(File.E, Rank.R1) -> Piece.WhiteKing, + Square(File.H, Rank.R1) -> Piece.WhiteRook + )) + val context = GameContext.initial.withBoard(board) + val castleMove = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside) + val score = MoveOrdering.score(context, castleMove, None) + score should equal(0) // Quiet move + + test("all MoveType variants are handled in victimValue"): + val board = Board(Map( + Square(File.E, Rank.R1) -> Piece.WhiteKing, + Square(File.H, Rank.R1) -> Piece.WhiteRook, + Square(File.E, Rank.R2) -> Piece.WhitePawn + )) + val context = GameContext.initial.withBoard(board) + // Test castling queenside - should have victim value 0 + val castleQs = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside) + val scoreQs = MoveOrdering.score(context, castleQs, None) + scoreQs should equal(0) + + test("attackerValue covers all piece types"): + val board = Board(Map( + Square(File.A, Rank.R1) -> Piece.WhiteRook, + Square(File.B, Rank.R1) -> Piece.WhiteKnight, + Square(File.C, Rank.R1) -> Piece.WhiteBishop, + Square(File.D, Rank.R1) -> Piece.WhiteQueen, + Square(File.E, Rank.R1) -> Piece.WhiteKing, + Square(File.F, Rank.R2) -> Piece.WhitePawn + )) + val context = GameContext.initial.withBoard(board) + // Create captures with each piece type + val rookCapture = Move(Square(File.A, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true)) + val knightCapture = Move(Square(File.B, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true)) + val bishopCapture = Move(Square(File.C, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true)) + val queenCapture = Move(Square(File.D, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true)) + val kingCapture = Move(Square(File.E, Rank.R1), Square(File.A, Rank.R8), MoveType.Normal(true)) + val pawnCapture = Move(Square(File.F, Rank.R2), Square(File.A, Rank.R8), MoveType.Normal(true)) + + // Just verify all are scored without error + MoveOrdering.score(context, rookCapture, None) should be >= 0 + MoveOrdering.score(context, knightCapture, None) should be >= 0 + MoveOrdering.score(context, bishopCapture, None) should be >= 0 + MoveOrdering.score(context, queenCapture, None) should be >= 0 + MoveOrdering.score(context, kingCapture, None) should be >= 0 + MoveOrdering.score(context, pawnCapture, None) should be >= 0 + + test("promotion capture is distinct from quiet promotion"): + val board = Board(Map( + Square(File.E, Rank.R7) -> Piece.WhitePawn, + Square(File.D, Rank.R8) -> Piece.BlackPawn + )) + val context = GameContext.initial.withBoard(board) + // Promotion with capture + val promotionWithCapture = Move(Square(File.E, Rank.R7), Square(File.D, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)) + // Regular queen promotion (no capture) + val quietPromotion = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)) + val score1 = MoveOrdering.score(context, promotionWithCapture, None) + val score2 = MoveOrdering.score(context, quietPromotion, None) + // Both should score high, but let's just verify they're scored + score1 should be > 0 + score2 should be > 0 diff --git a/modules/bot/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala new file mode 100644 index 0000000..293dbb6 --- /dev/null +++ b/modules/bot/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala @@ -0,0 +1,72 @@ +package de.nowchess.bot + +import de.nowchess.api.board.{File, Rank, Square} +import de.nowchess.api.move.{Move, MoveType} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class TranspositionTableTest extends AnyFunSuite with Matchers: + + test("probe on empty table returns None"): + val tt = TranspositionTable(sizePow2 = 4) + tt.probe(12345L) should be(None) + + test("store then probe returns the stored entry"): + val tt = TranspositionTable(sizePow2 = 4) + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()) + val entry = TTEntry(hash = 12345L, depth = 3, score = 50, flag = TTFlag.Exact, bestMove = Some(move)) + tt.store(entry) + val retrieved = tt.probe(12345L) + retrieved should be(Some(entry)) + + test("probe returns None when hash differs (collision guard)"): + val tt = TranspositionTable(sizePow2 = 4) + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()) + val entry = TTEntry(hash = 12345L, depth = 3, score = 50, flag = TTFlag.Exact, bestMove = Some(move)) + tt.store(entry) + tt.probe(54321L) should be(None) + + test("clear removes all entries"): + val tt = TranspositionTable(sizePow2 = 4) + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()) + val entry = TTEntry(hash = 12345L, depth = 3, score = 50, flag = TTFlag.Exact, bestMove = Some(move)) + tt.store(entry) + tt.probe(12345L) should be(Some(entry)) + tt.clear() + tt.probe(12345L) should be(None) + + test("all TTFlag values store and retrieve correctly"): + val tt = TranspositionTable(sizePow2 = 4) + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()) + + TTFlag.values.foreach { flag => + val entry = TTEntry(hash = 12345L + flag.ordinal, depth = 2, score = 100, flag = flag, bestMove = Some(move)) + tt.store(entry) + val retrieved = tt.probe(12345L + flag.ordinal) + retrieved.map(_.flag) should be(Some(flag)) + } + + test("bestMove = None roundtrips"): + val tt = TranspositionTable(sizePow2 = 4) + val entry = TTEntry(hash = 99999L, depth = 1, score = 0, flag = TTFlag.Upper, bestMove = None) + tt.store(entry) + val retrieved = tt.probe(99999L) + retrieved.map(_.bestMove) should be(Some(None)) + + test("always-replace overwrites at same slot"): + val tt = TranspositionTable(sizePow2 = 4) + val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()) + val move2 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal()) + val entry1 = TTEntry(hash = 12345L, depth = 2, score = 50, flag = TTFlag.Exact, bestMove = Some(move1)) + val entry2 = TTEntry(hash = 12345L, depth = 3, score = 100, flag = TTFlag.Lower, bestMove = Some(move2)) + + tt.store(entry1) + tt.probe(12345L).map(_.score) should be(Some(50)) + tt.store(entry2) + tt.probe(12345L).map(_.score) should be(Some(100)) + + test("size is 1 << sizePow2"): + val tt = TranspositionTable(sizePow2 = 4) + (1 << 4) should equal(16) + val tt2 = TranspositionTable(sizePow2 = 10) + (1 << 10) should equal(1024) diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala new file mode 100644 index 0000000..e764066 --- /dev/null +++ b/modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala @@ -0,0 +1,45 @@ +package de.nowchess.bot + +import de.nowchess.api.board.{Board, Color, CastlingRights, File, Piece, Rank, Square} +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class ZobristHashTest extends AnyFunSuite with Matchers: + + test("hash is deterministic"): + val hash1 = ZobristHash.hash(GameContext.initial) + val hash2 = ZobristHash.hash(GameContext.initial) + hash1 should equal(hash2) + + test("hash differs after a pawn move"): + val initial = GameContext.initial + // Move pawn from e2 to e4 + val board = initial.board.pieces + val newBoard = board.removed(Square(File.E, Rank.R2)).updated(Square(File.E, Rank.R4), Piece.WhitePawn) + val afterMove = initial.withBoard(Board(newBoard)).withTurn(Color.Black) + val hash1 = ZobristHash.hash(initial) + val hash2 = ZobristHash.hash(afterMove) + hash1 should not equal hash2 + + test("hash includes castling rights"): + val ctx1 = GameContext.initial + val ctx2 = ctx1.withCastlingRights(CastlingRights.None) + val hash1 = ZobristHash.hash(ctx1) + val hash2 = ZobristHash.hash(ctx2) + hash1 should not equal hash2 + + test("hash includes en-passant square"): + val ctx1 = GameContext.initial + val ctx2 = ctx1.withEnPassantSquare(Some(Square(File.E, Rank.R3))) + val hash1 = ZobristHash.hash(ctx1) + val hash2 = ZobristHash.hash(ctx2) + hash1 should not equal hash2 + + test("hash includes side to move"): + val ctx1 = GameContext.initial + val ctx2 = ctx1.withTurn(Color.Black) + val hash1 = ZobristHash.hash(ctx1) + val hash2 = ZobristHash.hash(ctx2) + hash1 should not equal hash2 diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index 637d9c3..2946b0f 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation(project(":modules:api")) implementation(project(":modules:io")) implementation(project(":modules:rule")) + implementation(project(":modules:bot")) testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter")