diff --git a/CLAUDE.md b/CLAUDE.md index 7760345..692b8e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/modules/bot/src/main/scala/de/nowchess/bot/Evaluation.scala b/modules/bot/src/main/scala/de/nowchess/bot/Evaluation.scala index d50899a..72dc138 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/Evaluation.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/Evaluation.scala @@ -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) diff --git a/modules/bot/src/main/scala/de/nowchess/bot/PolyglotBook.scala b/modules/bot/src/main/scala/de/nowchess/bot/PolyglotBook.scala index 2f01c32..97f15c2 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/PolyglotBook.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/PolyglotBook.scala @@ -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) } diff --git a/modules/bot/src/main/scala/de/nowchess/bot/PolyglotHash.scala b/modules/bot/src/main/scala/de/nowchess/bot/PolyglotHash.scala index 5814392..9e2c46b 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/PolyglotHash.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/PolyglotHash.scala @@ -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) + } + diff --git a/modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala index 277bfc5..7b0cda3 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala @@ -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 diff --git a/modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala index 90ab1c8..e7304d3 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala @@ -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)))