feat: Add tests for bot move repetition handling and hybrid bot behavior
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-17 19:21:29 +02:00
parent 5a66a057b3
commit 46659cdc13
8 changed files with 316 additions and 1 deletions
@@ -2,7 +2,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.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.rules.RuleSet
@@ -188,3 +188,75 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
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)
@@ -7,3 +7,12 @@ 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,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)
@@ -88,3 +88,55 @@ class EvaluationTest extends AnyFunSuite with Matchers:
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,62 @@
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.bots.HybridBot
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class HybridBotTest extends AnyFunSuite with Matchers:
test("HybridBot name includes difficulty"):
val bot = HybridBot(BotDifficulty.Easy)
bot.name should include("HybridBot")
bot.name should include("Easy")
test("HybridBot nextMove returns a move on the initial position"):
val bot = HybridBot(BotDifficulty.Easy)
val move = bot.nextMove(GameContext.initial)
move should not be None
test("HybridBot nextMove returns None when no legal moves"):
val noMovesRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = Nil
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = true
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = HybridBot(BotDifficulty.Easy, noMovesRules)
val move = bot.nextMove(GameContext.initial)
move should be(None)
test("HybridBot with empty book falls through to search"):
val emptyBook = PolyglotBook("/nonexistent/book.bin")
val bot = HybridBot(BotDifficulty.Easy, book = Some(emptyBook))
val move = bot.nextMove(GameContext.initial)
move should not be None
test("HybridBot skips move repeated three times"):
val repeatedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val onlyMoveRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(repeatedMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
val bot = HybridBot(BotDifficulty.Easy, onlyMoveRules)
bot.nextMove(ctx) should be(None)
@@ -182,3 +182,18 @@ class MoveOrderingTest extends AnyFunSuite with Matchers:
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
@@ -89,3 +89,62 @@ class ZobristHashTest extends AnyFunSuite with Matchers:
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))