feat: refactor AlphaBetaSearch and ClassicalBot for improved evaluation and organization

This commit is contained in:
2026-04-07 21:11:34 +02:00
parent 539f53b000
commit 558f43d0f6
20 changed files with 77 additions and 37 deletions
@@ -1,5 +1,7 @@
package de.nowchess.bot
import de.nowchess.bot.bots.ClassicalBot
object BotController {
private var bots: Map[String, Bot] = Map.empty
@@ -0,0 +1,12 @@
package de.nowchess.bot.ai
import de.nowchess.api.game.GameContext
trait Weights {
def CHECKMATE_SCORE: Int
def DRAW_SCORE: Int
def evaluate(context: GameContext): Int
}
@@ -1,7 +1,11 @@
package de.nowchess.bot
package de.nowchess.bot.bots
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.{Bot, BotDifficulty}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
@@ -11,7 +15,7 @@ final class ClassicalBot(
book: Option[PolyglotBook] = None
) extends Bot:
private val search: AlphaBetaSearch = AlphaBetaSearch(rules)
private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationClassic)
private val TIME_BUDGET_MS = 1000L
override val name: String = s"ClassicalBot(${difficulty.toString})"
@@ -1,9 +1,10 @@
package de.nowchess.bot
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.Weights
object Evaluation:
object EvaluationClassic extends Weights:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
@@ -1,18 +1,23 @@
package de.nowchess.bot
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.Weights
import de.nowchess.bot.logic.{MoveOrdering, TTEntry, TTFlag, TranspositionTable}
import de.nowchess.bot.util.ZobristHash
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.atomic.AtomicReference
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.{ExecutionContext, Future}
final class AlphaBetaSearch(
rules: RuleSet = DefaultRules,
tt: TranspositionTable = TranspositionTable(),
weights: Weights,
numThreads: Int = Runtime.getRuntime().availableProcessors()
):
@@ -135,7 +140,7 @@ final class AlphaBetaSearch(
// Periodic time check
nodeCount += 1
if nodeCount % TIME_CHECK_FREQUENCY == 0 && isOutOfTime() then
return (Evaluation.evaluate(context), None)
return (weights.evaluate(context), None)
val hash = ZobristHash.hash(context)
@@ -159,13 +164,13 @@ final class AlphaBetaSearch(
val legalMoves = rules.allLegalMoves(context)
if legalMoves.isEmpty then
val score = if rules.isCheckmate(context) then
-(Evaluation.CHECKMATE_SCORE - ply)
-(weights.CHECKMATE_SCORE - ply)
else
Evaluation.DRAW_SCORE
weights.DRAW_SCORE
return (score, None)
if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then
return (Evaluation.DRAW_SCORE, None)
return (weights.DRAW_SCORE, None)
// Leaf node: call quiescence
if depth == 0 then
@@ -211,7 +216,7 @@ final class AlphaBetaSearch(
// Futility pruning at frontier nodes: if static eval + margin is still below alpha, skip quiet moves
if depth == 1 && isQuiet && moveNumber > 2 then
val staticEval = Evaluation.evaluate(context)
val staticEval = weights.evaluate(context)
if staticEval + FUTILITY_MARGIN < alpha then
moveNumber += 1
@@ -328,7 +333,7 @@ final class AlphaBetaSearch(
beta: Int
): Int =
// Stand-pat: evaluate current position
val standPat = Evaluation.evaluate(context)
val standPat = weights.evaluate(context)
if standPat >= beta then
return beta
@@ -1,8 +1,9 @@
package de.nowchess.bot
package de.nowchess.bot.logic
import de.nowchess.api.board.PieceType
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import scala.collection.mutable
object MoveOrdering:
@@ -1,4 +1,4 @@
package de.nowchess.bot
package de.nowchess.bot.logic
import de.nowchess.api.move.Move
@@ -1,8 +1,9 @@
package de.nowchess.bot
package de.nowchess.bot.util
import de.nowchess.api.board.{Color, File, PieceType, Rank, Square}
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
@@ -1,4 +1,4 @@
package de.nowchess.bot
package de.nowchess.bot.util
import de.nowchess.api.board.{Color, Piece, PieceType, Square}
import de.nowchess.api.game.GameContext
@@ -1,7 +1,8 @@
package de.nowchess.bot
package de.nowchess.bot.util
import de.nowchess.api.board.{Color, File, PieceType, Square}
import de.nowchess.api.game.GameContext
import scala.util.Random
object ZobristHash:
@@ -3,6 +3,7 @@ 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}
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.rules.RuleSet
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -3,6 +3,7 @@ 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.bot.bots.ClassicalBot
import de.nowchess.rules.RuleSet
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -3,13 +3,14 @@ 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 = Evaluation.evaluate(GameContext.initial)
val eval = EvaluationClassic.evaluate(GameContext.initial)
eval should equal(10) // TEMPO_BONUS only
test("remove white queen gives negative evaluation"):
@@ -18,7 +19,7 @@ class EvaluationTest extends AnyFunSuite with Matchers:
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)
val eval = EvaluationClassic.evaluate(newContext)
eval should be < 0
test("remove black queen gives positive evaluation"):
@@ -27,7 +28,7 @@ class EvaluationTest extends AnyFunSuite with Matchers:
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)
val eval = EvaluationClassic.evaluate(newContext)
eval should be > 0
test("different piece-square bonuses are applied"):
@@ -37,27 +38,27 @@ class EvaluationTest extends AnyFunSuite with Matchers:
val knightD4 = GameContext.initial.withBoard(knightD4Board)
val knightA1 = GameContext.initial.withBoard(knightA1Board)
val eval1 = Evaluation.evaluate(knightD4)
val eval2 = Evaluation.evaluate(knightA1)
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 = Evaluation.evaluate(GameContext.initial)
eval should not be (Evaluation.CHECKMATE_SCORE)
val eval = EvaluationClassic.evaluate(GameContext.initial)
eval should not be (EvaluationClassic.CHECKMATE_SCORE)
test("CHECKMATE_SCORE and DRAW_SCORE are accessible"):
Evaluation.CHECKMATE_SCORE should equal(10_000_000)
Evaluation.DRAW_SCORE should equal(0)
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 = Evaluation.evaluate(knightD4Context)
val evalA1 = Evaluation.evaluate(knightA1Context)
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"):
@@ -71,8 +72,8 @@ class EvaluationTest extends AnyFunSuite with Matchers:
))
val pairContext = GameContext.initial.withBoard(bishopPairBoard)
val knightContext = GameContext.initial.withBoard(bishopKnightBoard)
val evalPair = Evaluation.evaluate(pairContext)
val evalKnight = Evaluation.evaluate(knightContext)
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"):
@@ -80,6 +81,6 @@ class EvaluationTest extends AnyFunSuite with Matchers:
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 = Evaluation.evaluate(rook7thContext)
val eval4th = Evaluation.evaluate(rook4thContext)
val eval7th = EvaluationClassic.evaluate(rook7thContext)
val eval4th = EvaluationClassic.evaluate(rook4thContext)
eval7th should be > eval4th // Rook on 7th rank should score higher
@@ -3,6 +3,7 @@ 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 de.nowchess.bot.logic.MoveOrdering
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -3,9 +3,12 @@ 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
@@ -2,6 +2,7 @@ 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
@@ -2,6 +2,7 @@ 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
@@ -1,8 +1,9 @@
package de.nowchess.bot
import de.nowchess.api.board.{Board, Color, CastlingRights, File, Piece, Rank, Square}
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}
import de.nowchess.bot.util.ZobristHash
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -2,7 +2,8 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.Color
import de.nowchess.api.game.GameContext
import de.nowchess.bot.{BotDifficulty, ClassicalBot, BotController}
import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.bot.{BotDifficulty, BotController}
import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
@@ -1,7 +1,9 @@
package de.nowchess.ui
import de.nowchess.api.board.Color.Black
import de.nowchess.bot.{BotDifficulty, ClassicalBot, PolyglotBook}
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.chess.engine.GameEngine
import de.nowchess.ui.terminal.TerminalUI
import de.nowchess.ui.gui.ChessGUILauncher