diff --git a/Makefile b/Makefile index 5f4f546..4574682 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ all: build # Build the Go binary build: - go build -o $(APP_NAME) main.go + go build -ldflags="-s -w" -trimpath -buildmode=exe -gcflags="all=-l -B" -asmflags="all=-trimpath=$$(pwd)" -o $(APP_NAME) main.go # Build release binaries for Linux, macOS, and Windows .PHONY: build-release @@ -70,6 +70,19 @@ test-self: -debug \ -rounds 1 +test-self-multi: + make build + ./dist/cutechess-cli/cutechess-cli \ + -engine name=Libra1 cmd=./libra-chess \ + -engine name=Libra2 cmd=./libra-chess \ + -openings file=./books/chess.epd format=epd order=random plies=8 \ + -each proto=uci tc=30+1 \ + -games 1000 \ + -concurrency 20 \ + -ratinginterval 10 \ + -draw movenumber=40 movecount=6 score=10 \ + -rounds 1 + test-position: make build ./dist/cutechess-cli/cutechess-cli \ @@ -91,13 +104,39 @@ test-stockfish: -engine name=Stockfish cmd=./stockfish/stockfish-cli option.UCI_LimitStrength=true option.UCI_Elo=1500 \ -each proto=uci tc=30+0 \ -games 10 \ - -concurrency 10 \ + -concurrency 1 \ -openings file=./books/chess.epd format=epd order=random plies=8 \ -ratinginterval 10 \ -draw movenumber=40 movecount=6 score=10 \ -debug \ -rounds 1 +test-weasel: + make build + ./dist/cutechess-cli/cutechess-cli \ + -engine name=PullLibra cmd=./libra-chess \ + -engine name=Weasel cmd=./dist/Chess/Chess \ + -each proto=uci tc=30+0 \ + -games 10 \ + -concurrency 10 \ + -openings file=./books/chess.epd format=epd order=random plies=8 \ + -ratinginterval 10 \ + -draw movenumber=40 movecount=6 score=10 \ + -rounds 1 + +test-weasel2: + make build + ./dist/cutechess-cli/cutechess-cli \ + -engine name=Stockfish cmd=./stockfish/stockfish-cli option.UCI_LimitStrength=true option.UCI_Elo=2500 \ + -engine name=Weasel cmd=./dist/Chess/Chess \ + -each proto=uci tc=30+0 \ + -games 10 \ + -concurrency 10 \ + -openings file=./books/chess.epd format=epd order=random plies=8 \ + -ratinginterval 10 \ + -draw movenumber=40 movecount=6 score=10 \ + -rounds 1 + test-debug: make build ./dist/cutechess-cli/cutechess-cli \ diff --git a/main.go b/main.go index 755cebd..2d62f68 100644 --- a/main.go +++ b/main.go @@ -38,9 +38,13 @@ func main() { board.ParseAndApplyPosition(fields[1:]) case "go": remainingTimeInMs := GetUCIRemainingTime(board.WhiteToMove, fields) + maxDepth := 16 + if remainingTimeInMs <= 2500 { + maxDepth = 4 // Limit depth search when running out of time + } bestMove := board.IterativeDeepeningSearch(SearchOptions{ - RemainingTimeInMs: remainingTimeInMs, - TimeLimitInMs: 1000, + MaxDepth: maxDepth, + TimeLimitInMs: 7000, }) if bestMove != nil { fmt.Printf("bestmove %s\n", bestMove.ToUCI()) diff --git a/pkg/evaluate.go b/pkg/evaluate.go index 8dfbf09..7608d01 100644 --- a/pkg/evaluate.go +++ b/pkg/evaluate.go @@ -126,7 +126,88 @@ func (board *Board) EvaluateMaterialAndPST() (int, int) { return whiteScore, blackScore } -// Mobility: count the number of legal moves for each side +// EvaluatePawnStructure evaluates pawn structure for both sides: doubled, isolated, and passed pawns. +func (board *Board) EvaluatePawnStructure() (int, int) { + whitePenalty := 0 + blackPenalty := 0 + whiteBonus := 0 + blackBonus := 0 + + // Helper: get file mask for a file + fileMask := func(file int) uint64 { + return 0x0101010101010101 << file + } + + // Doubled pawns + for file := 0; file < 8; file++ { + wPawnsOnFile := bits.OnesCount64(board.WhitePawns & fileMask(file)) + bPawnsOnFile := bits.OnesCount64(board.BlackPawns & fileMask(file)) + if wPawnsOnFile > 1 { + whitePenalty += (wPawnsOnFile - 1) * 10 // Penalty per extra pawn + } + if bPawnsOnFile > 1 { + blackPenalty += (bPawnsOnFile - 1) * 10 + } + } + + // Isolated pawns + for file := 0; file < 8; file++ { + wFilePawns := board.WhitePawns & fileMask(file) + bFilePawns := board.BlackPawns & fileMask(file) + adjWhite := uint64(0) + adjBlack := uint64(0) + if file > 0 { + adjWhite |= board.WhitePawns & fileMask(file-1) + adjBlack |= board.BlackPawns & fileMask(file-1) + } + if file < 7 { + adjWhite |= board.WhitePawns & fileMask(file+1) + adjBlack |= board.BlackPawns & fileMask(file+1) + } + if wFilePawns != 0 && adjWhite == 0 { + whitePenalty += bits.OnesCount64(wFilePawns) * 15 // Penalty per isolated pawn + } + if bFilePawns != 0 && adjBlack == 0 { + blackPenalty += bits.OnesCount64(bFilePawns) * 15 + } + } + + // Passed pawns + for file := 0; file < 8; file++ { + wPawns := board.WhitePawns & fileMask(file) + bPawns := board.BlackPawns & fileMask(file) + for wPawns != 0 { + sq := bits.TrailingZeros64(wPawns) + rank := sq / 8 + // Passed if no black pawns on same or adjacent files ahead + mask := uint64(0) + for f := MathMinByte(byte(file+1), 7); f >= MathMinByte(byte(file-1), 0) && f <= 7; f-- { + mask |= board.BlackPawns & fileMask(int(f)) + } + mask &= ^((1 << (sq + 1)) - 1) // Only pawns ahead + if mask == 0 { + whiteBonus += (7 - rank) * 12 // More bonus as pawn advances + } + wPawns &= wPawns - 1 + } + for bPawns != 0 { + sq := bits.TrailingZeros64(bPawns) + rank := sq / 8 + mask := uint64(0) + for f := MathMinByte(byte(file+1), 7); f >= MathMinByte(byte(file-1), 0) && f <= 7; f-- { + mask |= board.WhitePawns & fileMask(int(f)) + } + mask &= (1 << sq) - 1 // Only pawns ahead for black + if mask == 0 { + blackBonus += rank * 12 + } + bPawns &= bPawns - 1 + } + } + + return whiteBonus - whitePenalty, blackBonus - blackPenalty +} + func (board *Board) MateOrStalemateScore(maximizing bool) int { kingSq := board.ActiveKingSquare() if board.IsSquareAttacked(kingSq, board.WhiteToMove) { @@ -140,8 +221,104 @@ func (board *Board) MateOrStalemateScore(maximizing bool) int { } } +// EvaluateKingSafety evaluates king safety for both sides in the middlegame. +func (board *Board) EvaluateKingSafety() (int, int) { + whitePenalty := 0 + blackPenalty := 0 + + // Only apply in middlegame (if both sides have queens or enough material) + material := 0 + material += bits.OnesCount64(board.WhitePawns) + bits.OnesCount64(board.BlackPawns) + material += bits.OnesCount64(board.WhiteKnights)*3 + bits.OnesCount64(board.BlackKnights)*3 + material += bits.OnesCount64(board.WhiteBishops)*3 + bits.OnesCount64(board.BlackBishops)*3 + material += bits.OnesCount64(board.WhiteRooks)*5 + bits.OnesCount64(board.BlackRooks)*5 + material += bits.OnesCount64(board.WhiteQueens)*9 + bits.OnesCount64(board.BlackQueens)*9 + if material <= 20 { // skip in endgame + return 0, 0 + } + + // Helper: count friendly pawns in 3x3 area around king + countKingPawnShield := func(kingSq int, pawns uint64, isWhite bool) int { + file := kingSq % 8 + rank := kingSq / 8 + count := 0 + for df := -1; df <= 1; df++ { + for dr := 0; dr <= 1; dr++ { // only in front and same rank + f := file + df + r := rank + dr*func() int { + if isWhite { + return -1 + } else { + return 1 + } + }() + if f < 0 || f > 7 || r < 0 || r > 7 { + continue + } + sq := r*8 + f + if (pawns & (1 << sq)) != 0 { + count++ + } + } + } + return count + } + + if board.WhiteKing != 0 { + wKingSq := bits.TrailingZeros64(board.WhiteKing) + shield := countKingPawnShield(wKingSq, board.WhitePawns, true) + whitePenalty -= shield * 12 // reward for pawn cover + whitePenalty += (3 - shield) * 18 // penalty for missing pawns + } + if board.BlackKing != 0 { + bKingSq := bits.TrailingZeros64(board.BlackKing) + shield := countKingPawnShield(bKingSq, board.BlackPawns, false) + blackPenalty -= shield * 12 + blackPenalty += (3 - shield) * 18 + } + + // TODO: Add open file and enemy piece proximity checks for more accuracy + return whitePenalty, blackPenalty +} + +// EvaluateBishopPair returns a bonus for having both bishops. +func (board *Board) EvaluateBishopPair() (int, int) { + whiteBonus := 0 + blackBonus := 0 + if bits.OnesCount64(board.WhiteBishops) >= 2 { + whiteBonus = 35 // typical value, can be tuned + } + if bits.OnesCount64(board.BlackBishops) >= 2 { + blackBonus = 35 + } + return whiteBonus, blackBonus +} + +// EvaluateCenterControl rewards control of the central squares (d4, d5, e4, e5). +func (board *Board) EvaluateCenterControl() (int, int) { + centerSquares := [4]byte{27, 28, 35, 36} // d4, e4, d5, e5 (0-based) + whiteControl := 0 + blackControl := 0 + + for _, sq := range centerSquares { + if board.IsSquareAttacked(sq, true) { + whiteControl++ + } + if board.IsSquareAttacked(sq, false) { + blackControl++ + } + } + // Each control gets a bonus (tune as needed) + return whiteControl * 10, blackControl * 10 +} + func (board *Board) Evaluate() int { whiteScore, blackScore := board.EvaluateMaterialAndPST() - + pawnStructWhite, pawnStructBlack := board.EvaluatePawnStructure() + kingSafeWhite, kingSafeBlack := board.EvaluateKingSafety() + bishopPairWhite, bishopPairBlack := board.EvaluateBishopPair() + centerWhite, centerBlack := board.EvaluateCenterControl() + whiteScore += pawnStructWhite + kingSafeWhite + bishopPairWhite + centerWhite + blackScore += pawnStructBlack + kingSafeBlack + bishopPairBlack + centerBlack return whiteScore - blackScore } diff --git a/pkg/generate.go b/pkg/generate.go index c199ee7..f5f4163 100644 --- a/pkg/generate.go +++ b/pkg/generate.go @@ -333,7 +333,8 @@ func (board *Board) GeneratePseudoLegalMoves() []Move { } func (board *Board) GenerateLegalMoves() []Move { - legalMoves := []Move{} + // Preallocate with a reasonable capacity to reduce allocations + legalMoves := make([]Move, 0, 32) moves := board.GeneratePseudoLegalMoves() for _, move := range moves { if board.IsMoveLegal(move) { @@ -436,6 +437,7 @@ func (board *Board) IsSquareAttackedBySlidingPieces(square byte, whiteToMove boo bishopsAndQueens = board.WhiteBishops | board.WhiteQueens } occupied := board.OccupiedSquares() + // Rook/Queen directions for dir := 0; dir < 4; dir++ { ray := RookRays[square][dir] @@ -443,26 +445,22 @@ func (board *Board) IsSquareAttackedBySlidingPieces(square byte, whiteToMove boo if attackers == 0 { continue } - // Find the closest attacker in this direction - var sqStep int - switch dir { - case 0: - sqStep = -8 // North - case 1: - sqStep = 1 // East - case 2: - sqStep = 8 // South - case 3: - sqStep = -1 // West - } - for s := int(square) + sqStep; s >= 0 && s < 64 && (ray&(1<= 0 && s < 64 && (ray&(1< 0 { + standPat := board.Evaluate() + margin := 100 // Tune this margin as needed + if maximizing && standPat+margin <= alpha { + return standPat + } + if !maximizing && standPat-margin >= beta { + return standPat + } + } + + // --- Null Move Pruning --- + if depth >= NullMoveMinDepth && ply > 0 && !board.IsInCheck(maximizing) && len(board.GenerateLegalMoves()) > 0 { + nullBoard := board.Clone() + nullBoard.WhiteToMove = !nullBoard.WhiteToMove // Switch side to move (null move) + nullEval := 0 + newDepth := depth - NullMoveReduction - 1 + if newDepth < 1 { + newDepth = 1 + } + if maximizing { + nullEval = nullBoard.AlphaBetaSearch(newDepth, false, alpha, beta, tt, stats, ctx, ply+1) + if nullEval >= beta { + return beta // Fail-hard beta cutoff + } + } else { + nullEval = nullBoard.AlphaBetaSearch(newDepth, true, alpha, beta, tt, stats, ctx, ply+1) + if nullEval <= alpha { + return alpha // Fail-hard alpha cutoff + } + } + } + + // --- Late Move Reductions (LMR) --- moves := board.GenerateLegalMoves() stats.IncMoveGeneration() moves = board.SortMovesAlphaBeta(moves, depth, tt, hash, ctx, ply) @@ -218,6 +253,7 @@ func (board *Board) AlphaBetaSearch(depth int, maximizing bool, alpha int, beta return board.MateOrStalemateScore(maximizing) } + // --- Alpha-Beta Search --- var result int var bestMove Move if maximizing { @@ -227,7 +263,15 @@ func (board *Board) AlphaBetaSearch(depth int, maximizing bool, alpha int, beta runtime.Gosched() } prev := board.Move(move) - eval := board.AlphaBetaSearch(depth-1, false, alpha, beta, tt, stats, ctx, ply+1) + newDepth := depth - 1 + // Apply LMR: reduce depth for late quiet moves + if depth >= LMRMinDepth && i >= LMRMinMoves && move.IsQuiet() { + newDepth -= LMRReduction + if newDepth < 1 { + newDepth = 1 + } + } + eval := board.AlphaBetaSearch(newDepth, false, alpha, beta, tt, stats, ctx, ply+1) board.UndoMove(prev) if eval > maxEval { maxEval = eval @@ -240,7 +284,6 @@ func (board *Board) AlphaBetaSearch(depth int, maximizing bool, alpha int, beta stats.IncBetaCutoff() if move.MoveType != MoveCapture && move.MoveType != MovePromotionCapture { ctx.AddKillerMove(move, ply) - // Update history heuristic for quiet moves ctx.HistoryHeuristic[PieceToHistoryIndex[move.Piece]][move.To] += depth * depth } nodesPruned := len(moves) - (i + 1) @@ -258,7 +301,15 @@ func (board *Board) AlphaBetaSearch(depth int, maximizing bool, alpha int, beta runtime.Gosched() } prev := board.Move(move) - eval := board.AlphaBetaSearch(depth-1, true, alpha, beta, tt, stats, ctx, ply+1) + newDepth := depth - 1 + // Apply LMR: reduce depth for late quiet moves + if depth >= LMRMinDepth && i >= LMRMinMoves && move.IsQuiet() { + newDepth -= LMRReduction + if newDepth < 1 { + newDepth = 1 + } + } + eval := board.AlphaBetaSearch(newDepth, true, alpha, beta, tt, stats, ctx, ply+1) board.UndoMove(prev) if eval < minEval { minEval = eval @@ -271,7 +322,6 @@ func (board *Board) AlphaBetaSearch(depth int, maximizing bool, alpha int, beta stats.IncBetaCutoff() if move.MoveType != MoveCapture && move.MoveType != MovePromotionCapture { ctx.AddKillerMove(move, ply) - // Update history heuristic for quiet moves ctx.HistoryHeuristic[PieceToHistoryIndex[move.Piece]][move.To] += depth * depth } nodesPruned := len(moves) - (i + 1) @@ -288,3 +338,14 @@ func (board *Board) AlphaBetaSearch(depth int, maximizing bool, alpha int, beta tt.Set(hash, depth, result, bestMove) return result } + +// IsInCheck returns true if the side to move is in check. +func (board *Board) IsInCheck(whiteToMove bool) bool { + var kingSq byte + if whiteToMove { + kingSq = byte(bits.TrailingZeros64(board.WhiteKing)) + } else { + kingSq = byte(bits.TrailingZeros64(board.BlackKing)) + } + return board.IsSquareAttacked(kingSq, !whiteToMove) +} diff --git a/pkg/sort.go b/pkg/sort.go index c207079..5428389 100644 --- a/pkg/sort.go +++ b/pkg/sort.go @@ -40,17 +40,19 @@ func (board *Board) SortMovesAlphaBeta( score += 90_0000 // High value for TT move } - // 2. MVV-LVA for captures + // 2. MVV-LVA for captures, now with SEE switch m.MoveType { - case MoveCapture: + case MoveCapture, MovePromotionCapture: + see := board.SEE(m) victim := m.Captured attacker := m.Piece - score += 70_000 + 10*PieceCodeToValue[victim] - 10*PieceCodeToValue[attacker] - case MovePromotionCapture: - victim := m.Captured - attacker := m.Piece - promoPiece := m.Promoted - score += 50_000 + 10*PieceCodeToValue[victim] + 10*PieceCodeToValue[promoPiece] - 10*PieceCodeToValue[attacker] + if see >= 0 { + // Good capture: high score + score += 70_000 + 10*PieceCodeToValue[victim] - 10*PieceCodeToValue[attacker] + 1000*see + } else { + // Bad capture: demote, but still above quiets + score += 10_000 + see + } case MovePromotion: promoPiece := m.Promoted score += 30_000 + 10*PieceCodeToValue[promoPiece] @@ -121,17 +123,24 @@ func (board *Board) SortMovesRoot( score += 70_000 } - // 3. MVV-LVA for captures + // 3. MVV-LVA for captures, now with SEE switch m.MoveType { - case MovePromotionCapture: + case MovePromotionCapture, MoveCapture: + see := board.SEE(m) victim := m.Captured attacker := m.Piece - promoPiece := m.Promoted - score += 50_000 + 10*PieceCodeToValue[victim] + 10*PieceCodeToValue[promoPiece] - 10*PieceCodeToValue[attacker] - case MoveCapture: - victim := m.Captured - attacker := m.Piece - score += 30_000 + 10*PieceCodeToValue[victim] - 10*PieceCodeToValue[attacker] + if see >= 0 { + // Good capture: high score + if m.MoveType == MovePromotionCapture { + promoPiece := m.Promoted + score += 50_000 + 10*PieceCodeToValue[victim] + 10*PieceCodeToValue[promoPiece] - 10*PieceCodeToValue[attacker] + 1000*see + } else { + score += 30_000 + 10*PieceCodeToValue[victim] - 10*PieceCodeToValue[attacker] + 1000*see + } + } else { + // Bad capture: demote, but still above quiets + score += 10_000 + see + } case MovePromotion: promoPiece := m.Promoted score += 10_000 + 10*PieceCodeToValue[promoPiece] @@ -149,3 +158,17 @@ func (board *Board) SortMovesRoot( } return moves } + +// SEE (Static Exchange Evaluation) estimates the net material gain/loss for a capture move. +// Returns the net gain (positive for winning, negative for losing, 0 for even). +func (board *Board) SEE(move Move) int { + // Only evaluate captures + if move.MoveType != MoveCapture && move.MoveType != MovePromotionCapture { + return 0 + } + victim := move.Captured + attacker := move.Piece + // Simple SEE: value of captured piece minus value of attacker + // (does not recurse, but is fast and good enough for move ordering) + return PieceCodeToValue[victim] - PieceCodeToValue[attacker] +} diff --git a/pkg/stats.go b/pkg/stats.go index f25a0c1..3ac97a3 100644 --- a/pkg/stats.go +++ b/pkg/stats.go @@ -140,7 +140,7 @@ func (s *SearchResult) PrintUCI() { if s.BestMove != nil { bestMove = s.BestMove.ToUCI() } - fmt.Printf("info depth %d score cp %d nodes %d nps %d prun %.0f%% pv %s time %dms\n", + fmt.Printf("info depth %d score cp %d nodes %d nps %d prun %.0f%% pv %s time %d\n", s.MaxSearchDepth, s.BestScore, s.NodesSearched, diff --git a/tests.test b/tests.test new file mode 100755 index 0000000..d8f7c08 Binary files /dev/null and b/tests.test differ diff --git a/tests/search_test.go b/tests/search_test.go index 339bf58..5540931 100644 --- a/tests/search_test.go +++ b/tests/search_test.go @@ -71,7 +71,7 @@ func TestSearchPerft1(t *testing.T) { func TestSearchPerft2(t *testing.T) { board := NewBoard() - board.FromFEN("r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1") // Corrected FEN + board.FromFEN("r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1") tt := NewTranspositionTable() board.Search(5, tt, 0, nil) } @@ -92,14 +92,14 @@ func TestSearchPerft4(t *testing.T) { func TestSearchPerft5(t *testing.T) { board := NewBoard() - board.FromFEN("rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8") // Corrected FEN + board.FromFEN("rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8") tt := NewTranspositionTable() board.Search(5, tt, 0, nil) } func TestSearchPerft6(t *testing.T) { board := NewBoard() - board.FromFEN("r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10 ") // Corrected FEN + board.FromFEN("r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10 ") tt := NewTranspositionTable() board.Search(5, tt, 0, nil) } @@ -108,7 +108,7 @@ func TestSearchPerft7(t *testing.T) { board := NewBoard() board.FromFEN("r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10 ") tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 30000, nil) } func TestSearchPerft8(t *testing.T) {