feat: enhance evaluation logic with mobility scoring and positional bonuses
This commit is contained in:
@@ -47,6 +47,7 @@ Try to stick to these commands for consistency.
|
||||
- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
|
||||
- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens to events, not polling. GameEngine never imports UI code.
|
||||
- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as a black box; rules don't know about the rest of core.
|
||||
- **Polyglot hash must follow spec index layout:** piece keys use interleaved mapping `(pieceType * 2 + colorBit)` with black=0/white=1, castling keys are `768..771`, en-passant file keys are `772..779` and are XORed only if side-to-move has a pawn that can capture en passant, side-to-move key is `780` for white.
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -167,14 +167,31 @@ object Evaluation:
|
||||
private val isolatedMg = -15
|
||||
private val isolatedEg = -20
|
||||
|
||||
// Mobility weights: centipawns per reachable square (indexed by PieceType.ordinal)
|
||||
private val mobilityMg = Array(0, 4, 3, 2, 1, 0, 0)
|
||||
private val mobilityEg = Array(0, 4, 3, 4, 2, 0, 0)
|
||||
|
||||
// Direction offsets for sliding pieces
|
||||
private val diagonals = List((-1, -1), (-1, 1), (1, -1), (1, 1))
|
||||
private val orthogonals = List((-1, 0), (1, 0), (0, -1), (0, 1))
|
||||
private val knightOffsets = List((-2, -1), (-2, 1), (-1, -2), (-1, 2), (1, -2), (1, 2), (2, -1), (2, 1))
|
||||
|
||||
// Rook and bishop bonuses
|
||||
private val bishopPairMg = 50
|
||||
private val bishopPairEg = 70
|
||||
private val rookOn7thMg = 20
|
||||
private val rookOn7thEg = 10
|
||||
|
||||
/** Evaluate the position from the perspective of context.turn.
|
||||
* Positive = good for context.turn. */
|
||||
def evaluate(context: GameContext): Int =
|
||||
val phase = gamePhase(context.board)
|
||||
val material = materialAndPositional(context, phase)
|
||||
val structure = pawnStructure(context, phase)
|
||||
val mobility = mobilityScore(context, phase)
|
||||
val rookBishop = rookAndBishopBonuses(context, phase)
|
||||
val bonuses = positionalBonuses(context, phase)
|
||||
material + structure + bonuses + TEMPO_BONUS
|
||||
material + structure + mobility + rookBishop + bonuses + TEMPO_BONUS
|
||||
|
||||
private def gamePhase(board: de.nowchess.api.board.Board): Int =
|
||||
val phase = board.pieces.values.foldLeft(0) { (acc, piece) =>
|
||||
@@ -310,3 +327,74 @@ object Evaluation:
|
||||
sq.rank.ordinal == shieldRank
|
||||
} * 10
|
||||
(rawBonus * phase) / maxPhase
|
||||
|
||||
private def slidingCount(sq: Square, board: de.nowchess.api.board.Board, color: Color, directions: List[(Int, Int)]): Int =
|
||||
directions.foldLeft(0) { case (total, (fileDelta, rankDelta)) =>
|
||||
var count = 0
|
||||
var current = sq.offset(fileDelta, rankDelta)
|
||||
while current.isDefined do
|
||||
val target = current.get
|
||||
board.pieceAt(target) match
|
||||
case Some(piece) if piece.color == color => current = None
|
||||
case Some(_) => count += 1; current = None
|
||||
case None => count += 1; current = target.offset(fileDelta, rankDelta)
|
||||
total + count
|
||||
}
|
||||
|
||||
private def knightCount(sq: Square, board: de.nowchess.api.board.Board, color: Color): Int =
|
||||
knightOffsets.count { case (fileDelta, rankDelta) =>
|
||||
sq.offset(fileDelta, rankDelta).forall { target =>
|
||||
board.pieceAt(target).forall(_.color != color)
|
||||
}
|
||||
}
|
||||
|
||||
private def mobilityScore(context: GameContext, phase: Int): Int =
|
||||
var mg = 0
|
||||
var eg = 0
|
||||
for (sq, piece) <- context.board.pieces do
|
||||
val count = piece.pieceType match
|
||||
case PieceType.Knight => knightCount(sq, context.board, piece.color)
|
||||
case PieceType.Bishop => slidingCount(sq, context.board, piece.color, diagonals)
|
||||
case PieceType.Rook => slidingCount(sq, context.board, piece.color, orthogonals)
|
||||
case PieceType.Queen => slidingCount(sq, context.board, piece.color, diagonals ++ orthogonals)
|
||||
case _ => 0
|
||||
val pieceMg = count * mobilityMg(piece.pieceType.ordinal)
|
||||
val pieceEg = count * mobilityEg(piece.pieceType.ordinal)
|
||||
val sign = if piece.color == context.turn then 1 else -1
|
||||
mg += sign * pieceMg
|
||||
eg += sign * pieceEg
|
||||
taper(mg, eg, phase)
|
||||
|
||||
private def rookAndBishopBonuses(context: GameContext, phase: Int): Int =
|
||||
var mg = 0
|
||||
var eg = 0
|
||||
|
||||
// Bishop pair bonus
|
||||
val friendlyBishops = context.board.pieces.filter((_, p) => p.color == context.turn && p.pieceType == PieceType.Bishop)
|
||||
val enemyBishops = context.board.pieces.filter((_, p) => p.color != context.turn && p.pieceType == PieceType.Bishop)
|
||||
|
||||
val friendlyLightSquare = friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0)
|
||||
val friendlyDarkSquare = friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1)
|
||||
if friendlyLightSquare && friendlyDarkSquare then
|
||||
mg += bishopPairMg
|
||||
eg += bishopPairEg
|
||||
|
||||
val enemyLightSquare = enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0)
|
||||
val enemyDarkSquare = enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1)
|
||||
if enemyLightSquare && enemyDarkSquare then
|
||||
mg -= bishopPairMg
|
||||
eg -= bishopPairEg
|
||||
|
||||
// Rook on 7th rank
|
||||
for (sq, piece) <- context.board.pieces do
|
||||
if piece.pieceType == PieceType.Rook then
|
||||
val is7th = if piece.color == Color.White then sq.rank.ordinal == 6 else sq.rank.ordinal == 1
|
||||
if is7th then
|
||||
if piece.color == context.turn then
|
||||
mg += rookOn7thMg
|
||||
eg += rookOn7thEg
|
||||
else
|
||||
mg -= rookOn7thMg
|
||||
eg -= rookOn7thEg
|
||||
|
||||
taper(mg, eg, phase)
|
||||
|
||||
@@ -18,10 +18,13 @@ import scala.util.Random
|
||||
final class PolyglotBook(path: String):
|
||||
|
||||
private val entries: Map[Long, Vector[BookEntry]] =
|
||||
try
|
||||
loadBookFile(path)
|
||||
catch
|
||||
try {
|
||||
val r = loadBookFile(path)
|
||||
println(s"Book loaded successfully. ${r.size} entries found.")
|
||||
r
|
||||
} catch
|
||||
case e: Exception =>
|
||||
println(s"Error loading book: $e")
|
||||
// Gracefully fail: return empty map if book cannot be loaded
|
||||
// This allows the bot to work even if the book file is missing
|
||||
scala.collection.immutable.Map.empty
|
||||
@@ -29,10 +32,11 @@ final class PolyglotBook(path: String):
|
||||
/** Probe the book for a move in the given position. Returns a weighted random move, or None if not in book. */
|
||||
def probe(context: GameContext): Option[Move] =
|
||||
val hash = PolyglotHash.hash(context)
|
||||
println(f"0x${hash}%016X")
|
||||
entries.get(hash).flatMap { bookEntries =>
|
||||
if bookEntries.isEmpty then None
|
||||
if bookEntries.isEmpty then
|
||||
None
|
||||
else
|
||||
print(s"Book hit: ${bookEntries.length} entries for hash ${hash.toHexString}. ")
|
||||
val entry = weightedRandom(bookEntries)
|
||||
decodeMove(entry.move, context)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.bot
|
||||
|
||||
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.board.{Color, Piece, PieceType, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
object PolyglotHash:
|
||||
@@ -213,29 +213,29 @@ object PolyglotHash:
|
||||
val idx = pieceIndex(piece) * 64 + squareIndex(sq)
|
||||
h ^= Random(idx)
|
||||
|
||||
// Side to move (XOR if White is to move)
|
||||
if context.turn == Color.White then
|
||||
h ^= Random(768)
|
||||
|
||||
// Castling rights
|
||||
if context.castlingRights.whiteKingSide then
|
||||
h ^= Random(769)
|
||||
h ^= Random(768)
|
||||
if context.castlingRights.whiteQueenSide then
|
||||
h ^= Random(770)
|
||||
h ^= Random(769)
|
||||
if context.castlingRights.blackKingSide then
|
||||
h ^= Random(771)
|
||||
h ^= Random(770)
|
||||
if context.castlingRights.blackQueenSide then
|
||||
h ^= Random(772)
|
||||
h ^= Random(771)
|
||||
|
||||
// En passant (by file only)
|
||||
// En passant (by file only, but only if side-to-move can capture en passant)
|
||||
context.enPassantSquare.foreach { sq =>
|
||||
h ^= Random(773 + sq.file.ordinal)
|
||||
if canCaptureEnPassant(context, sq) then
|
||||
h ^= Random(772 + sq.file.ordinal)
|
||||
}
|
||||
|
||||
// Side to move (XOR if White is to move)
|
||||
if context.turn == Color.White then
|
||||
h ^= Random(780)
|
||||
|
||||
h
|
||||
|
||||
private def pieceIndex(piece: Piece): Int =
|
||||
val colorIdx = if piece.color == Color.Black then 0 else 1
|
||||
val typeIdx = piece.pieceType match
|
||||
case PieceType.Pawn => 0
|
||||
case PieceType.Knight => 1
|
||||
@@ -243,7 +243,19 @@ object PolyglotHash:
|
||||
case PieceType.Rook => 3
|
||||
case PieceType.Queen => 4
|
||||
case PieceType.King => 5
|
||||
colorIdx * 6 + typeIdx
|
||||
val colorOffset = if piece.color == Color.White then 1 else 0
|
||||
typeIdx * 2 + colorOffset
|
||||
|
||||
private def squareIndex(sq: Square): Int =
|
||||
sq.file.ordinal + 8 * sq.rank.ordinal
|
||||
|
||||
private def canCaptureEnPassant(context: GameContext, epSquare: Square): Boolean =
|
||||
val pawn = Piece(context.turn, PieceType.Pawn)
|
||||
val rankDelta = if context.turn == Color.White then -1 else 1
|
||||
List(-1, 1).exists { fileDelta =>
|
||||
epSquare
|
||||
.offset(fileDelta, rankDelta)
|
||||
.flatMap(context.board.pieces.get)
|
||||
.contains(pawn)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,3 +50,36 @@ class EvaluationTest extends AnyFunSuite with Matchers:
|
||||
test("CHECKMATE_SCORE and DRAW_SCORE are accessible"):
|
||||
Evaluation.CHECKMATE_SCORE should equal(10_000_000)
|
||||
Evaluation.DRAW_SCORE should equal(0)
|
||||
|
||||
test("active knight (center) scores higher than passive knight (corner)"):
|
||||
val knightD4Board = Board(Map(Square(File.D, Rank.R4) -> Piece.WhiteKnight))
|
||||
val knightA1Board = Board(Map(Square(File.A, Rank.R1) -> Piece.WhiteKnight))
|
||||
val knightD4Context = GameContext.initial.withBoard(knightD4Board)
|
||||
val knightA1Context = GameContext.initial.withBoard(knightA1Board)
|
||||
val evalD4 = Evaluation.evaluate(knightD4Context)
|
||||
val evalA1 = Evaluation.evaluate(knightA1Context)
|
||||
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 = Evaluation.evaluate(pairContext)
|
||||
val evalKnight = Evaluation.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 = Evaluation.evaluate(rook7thContext)
|
||||
val eval4th = Evaluation.evaluate(rook4thContext)
|
||||
eval7th should be > eval4th // Rook on 7th rank should score higher
|
||||
|
||||
@@ -2,16 +2,20 @@ package de.nowchess.bot
|
||||
|
||||
import de.nowchess.api.board.{Color, File, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
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 produces consistent hash"):
|
||||
test("Initial position matches Polyglot reference key"):
|
||||
val ctx = GameContext.initial
|
||||
val hash1 = PolyglotHash.hash(ctx)
|
||||
val hash2 = PolyglotHash.hash(ctx)
|
||||
hash1 shouldEqual hash2
|
||||
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
|
||||
@@ -29,14 +33,14 @@ class PolyglotHashTest extends AnyFunSuite with Matchers:
|
||||
val hash2 = PolyglotHash.hash(noCastling)
|
||||
hash1 should not equal hash2
|
||||
|
||||
test("Hash changes when en passant square changes"):
|
||||
val ctx = GameContext.initial
|
||||
val hash1 = PolyglotHash.hash(ctx)
|
||||
val withEP = ctx.withEnPassantSquare(Some(Square(File.E, Rank.R3)))
|
||||
val hash2 = PolyglotHash.hash(withEP)
|
||||
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"):
|
||||
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)))
|
||||
|
||||
Reference in New Issue
Block a user