Files
NowChessSystems/modules/official-bots/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala
T
2026-04-29 22:06:01 +02:00

220 lines
9.8 KiB
Scala

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)