feat: true-microservices (#40)

Reviewed-on: #40
This commit is contained in:
2026-04-29 22:06:01 +02:00
parent 67511fc649
commit 590924254e
328 changed files with 10672 additions and 2939 deletions
@@ -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.api.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,90 @@
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.api.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("nextMove on initial position returns a move"):
val bot = ClassicalBot(BotDifficulty.Easy)
val move = bot.apply(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.apply(GameContext.initial)
move should be(None)
test("all BotDifficulty values work"):
BotDifficulty.values.foreach { difficulty =>
val bot = ClassicalBot(difficulty)
val move = bot.apply(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.apply(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.apply(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,155 @@
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.api.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 apply returns a move on the initial position"):
val bot = HybridBot(BotDifficulty.Easy)
val move = bot.apply(GameContext.initial)
move should not be None
test("HybridBot apply 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.apply(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.apply(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.apply(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.apply(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.apply(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.apply(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.apply(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.apply(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))