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