feat: enhance evaluation logic with mobility scoring and positional bonuses

This commit is contained in:
2026-04-07 19:55:42 +02:00
committed by Janis
parent 8e208b8a25
commit 0bc7282dfa
6 changed files with 172 additions and 30 deletions
+1
View File
@@ -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)))