Salta al contenuto principale

Costruire un motore scacchistico - dalla valutazione della posizione alle tecniche di ricerca

·14 minuti
Indice dei contenuti
I motori scacchistici sono software affascinanti che combinano diversi concetti di informatica: valutazione della posizione, ricerca ad albero, generazione delle mosse e tecniche di ottimizzazione. Questa guida illustra come implementare un motore scacchistico, con particolare attenzione alla…

I motori scacchistici sono software affascinanti che combinano diversi concetti di informatica: valutazione della posizione, ricerca ad albero, generazione delle mosse e tecniche di ottimizzazione. Questa guida illustra come implementare un motore scacchistico, con particolare attenzione alla valutazione della posizione e alle strategie di ricerca.

Parte 1: Rappresentazione di base della posizione
#

Per prima cosa implementiamo una rappresentazione di base della scacchiera. Sebbene FEN (Forsyth-Edwards Notation) sia lo standard per le posizioni scacchistiche, internamente useremo un formato più adatto ai calcoli.

package chess

// Piece constants
const (
    Empty = iota
    Pawn
    Knight
    Bishop
    Rook
    Queen
    King
)

// Color constants
const (
    White = 1
    Black = -1
)

// Position represents a chess position
type Position struct {
    Board    [64]int    // Piece type (0-6)
    Colors   [64]int    // Piece color (1 for white, -1 for black)
    ToMove   int        // Side to move
    CastlingRights int  // Castling availability
    EnPassant    int    // En passant target square
    HalfMoveClock int   // Halfmove clock for fifty-move rule
    FullMoveNumber int  // Fullmove number
}

// Square converts file (a-h) and rank (1-8) to board index
func Square(file byte, rank int) int {
    return (rank-1)*8 + int(file-'a')
}

// NewPosition creates a starting chess position
func NewPosition() *Position {
    pos := &Position{
        ToMove: White,
        CastlingRights: 0xF, // All castling rights available
    }

    // Initialize starting position
    // Back rank pieces
    backRank := []int{Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook}
    for i, piece := range backRank {
        // White pieces
        pos.Board[i] = piece
        pos.Colors[i] = White
        // Black pieces
        pos.Board[i+56] = piece
        pos.Colors[i+56] = Black
    }

    // Pawns
    for i := 0; i < 8; i++ {
        // White pawns
        pos.Board[i+8] = Pawn
        pos.Colors[i+8] = White
        // Black pawns
        pos.Board[i+48] = Pawn
        pos.Colors[i+48] = Black
    }

    return pos
}

Parte 2: Valutazione della posizione
#

La valutazione della posizione è fondamentale per un motore scacchistico. Implementeremo diversi componenti di valutazione:

  1. Conteggio del materiale
  2. Tabelle di posizione dei pezzi
  3. Valutazione della struttura pedonale
  4. Sicurezza del re
  5. Valutazione della mobilità
// Piece values in centipawns
var pieceValues = map[int]int{
    Pawn:   100,
    Knight: 320,
    Bishop: 330,
    Rook:   500,
    Queen:  900,
    King:   20000,
}

// Piece-Square tables for position evaluation
var pawnTable = [64]int{
     0,  0,  0,  0,  0,  0,  0,  0,
    50, 50, 50, 50, 50, 50, 50, 50,
    10, 10, 20, 30, 30, 20, 10, 10,
     5,  5, 10, 25, 25, 10,  5,  5,
     0,  0,  0, 20, 20,  0,  0,  0,
     5, -5,-10,  0,  0,-10, -5,  5,
     5, 10, 10,-20,-20, 10, 10,  5,
     0,  0,  0,  0,  0,  0,  0,  0,
}

// Similar tables for other pieces...

type Evaluator struct {
    pos *Position
    stage GameStage
}

// Evaluate returns a position score in centipawns
// Positive scores favor White, negative scores favor Black
func (e *Evaluator) Evaluate() int {
    score := 0

    // Material evaluation
    score += e.evaluateMaterial()

    // Piece-square table evaluation
    score += e.evaluatePiecePositions()

    // Pawn structure evaluation
    score += e.evaluatePawnStructure()

    // King safety
    score += e.evaluateKingSafety()

    // Mobility
    score += e.evaluateMobility()

    return score * e.pos.ToMove
}

func (e *Evaluator) evaluateMaterial() int {
    score := 0
    for sq := 0; sq < 64; sq++ {
        if piece := e.pos.Board[sq]; piece != Empty {
            score += pieceValues[piece] * e.pos.Colors[sq]
        }
    }
    return score
}

func (e *Evaluator) evaluatePawnStructure() int {
    score := 0

    // Evaluate doubled pawns
    for file := 0; file < 8; file++ {
        whitePawns := 0
        blackPawns := 0
        for rank := 0; rank < 8; rank++ {
            sq := rank*8 + file
            if e.pos.Board[sq] == Pawn {
                if e.pos.Colors[sq] == White {
                    whitePawns++
                } else {
                    blackPawns++
                }
            }
        }
        // Penalty for doubled pawns
        if whitePawns > 1 {
            score -= 20 * (whitePawns - 1)
        }
        if blackPawns > 1 {
            score += 20 * (blackPawns - 1)
        }
    }

    // Evaluate isolated pawns
    for file := 0; file < 8; file++ {
        hasWhitePawn := false
        hasBlackPawn := false
        for rank := 0; rank < 8; rank++ {
            sq := rank*8 + file
            if e.pos.Board[sq] == Pawn {
                if e.pos.Colors[sq] == White {
                    hasWhitePawn = true
                } else {
                    hasBlackPawn = true
                }
            }
        }

        if hasWhitePawn {
            isIsolated := true
            if file > 0 {
                // Check left file for pawns
                for rank := 0; rank < 8; rank++ {
                    sq := rank*8 + (file-1)
                    if e.pos.Board[sq] == Pawn && e.pos.Colors[sq] == White {
                        isIsolated = false
                        break
                    }
                }
            }
            if file < 7 && isIsolated {
                // Check right file for pawns
                for rank := 0; rank < 8; rank++ {
                    sq := rank*8 + (file+1)
                    if e.pos.Board[sq] == Pawn && e.pos.Colors[sq] == White {
                        isIsolated = false
                        break
                    }
                }
            }
            if isIsolated {
                score -= 15 // Penalty for isolated pawn
            }
        }

        // Similar check for black pawns...
    }

    return score
}

func (e *Evaluator) evaluateKingSafety() int {
    score := 0

    // Find king positions
    var whiteKingSq, blackKingSq int
    for sq := 0; sq < 64; sq++ {
        if e.pos.Board[sq] == King {
            if e.pos.Colors[sq] == White {
                whiteKingSq = sq
            } else {
                blackKingSq = sq
            }
        }
    }

    // Evaluate pawn shield
    score += e.evaluatePawnShield(whiteKingSq, White)
    score -= e.evaluatePawnShield(blackKingSq, Black)

    // Evaluate king tropism (enemy pieces' distance to king)
    score += e.evaluateKingTropism(whiteKingSq, blackKingSq)

    return score
}

func (e *Evaluator) evaluatePawnShield(kingSq, color int) int {
    score := 0
    rank := kingSq / 8
    file := kingSq % 8

    // Check pawns in front of king
    pawnShieldSquares := []int{
        Square(byte('a'+file-1), rank+1),
        Square(byte('a'+file), rank+1),
        Square(byte('a'+file+1), rank+1),
    }

    for _, sq := range pawnShieldSquares {
        if sq >= 0 && sq < 64 {
            if e.pos.Board[sq] == Pawn && e.pos.Colors[sq] == color {
                score += 10 // Bonus for each pawn shield
            }
        }
    }

    return score
}

Parte 3: Implementazione della ricerca
#

Ora implementiamo l’algoritmo di ricerca. Useremo la potatura alpha-beta con diversi miglioramenti:

  1. Principal Variation Search
  2. Quiescence Search
  3. Ordinamento delle mosse
  4. Tabella delle trasposizioni
type SearchInfo struct {
    Depth      int
    NodesSearched int64
    StartTime  time.Time
    StopTime   time.Time
    Stopped    bool
}

type TranspositionEntry struct {
    Hash    uint64
    Depth   int
    Score   int
    Type    int // EXACT, ALPHA, or BETA
    Move    Move
}

const (
    EXACT = iota
    ALPHA
    BETA
)

func (e *Engine) Search(pos *Position, depth int) (bestMove Move, score int) {
    info := &SearchInfo{
        Depth:     depth,
        StartTime: time.Now(),
        StopTime:  time.Now().Add(5 * time.Second), // 5 second time control
    }

    alpha := -infinity
    beta := infinity

    // Iterative deepening
    for currentDepth := 1; currentDepth <= depth; currentDepth++ {
        score = e.alphaBeta(pos, currentDepth, alpha, beta, info)

        // Check if search should be stopped
        if info.Stopped {
            break
        }

        // Get best move from transposition table
        bestMove = e.tt.GetMove(pos.Hash())
    }

    return bestMove, score
}

func (e *Engine) alphaBeta(pos *Position, depth, alpha, beta int, info *SearchInfo) int {
    info.NodesSearched++

    // Check for time
    if time.Now().After(info.StopTime) {
        info.Stopped = true
        return 0
    }

    // Check transposition table
    if entry, found := e.tt.Get(pos.Hash()); found {
        if entry.Depth >= depth {
            if entry.Type == EXACT {
                return entry.Score
            }
            if entry.Type == ALPHA && entry.Score <= alpha {
                return alpha
            }
            if entry.Type == BETA && entry.Score >= beta {
                return beta
            }
        }
    }

    // Base case: evaluate position
    if depth == 0 {
        return e.quiescence(pos, alpha, beta, info)
    }

    moves := e.generateMoves(pos)
    moves = e.orderMoves(pos, moves) // Order moves for better pruning

    bestScore := -infinity
    for _, move := range moves {
        newPos := pos.MakeMove(move)
        score := -e.alphaBeta(newPos, depth-1, -beta, -alpha, info)

        if score > bestScore {
            bestScore = score

            // Update transposition table
            e.tt.Store(pos.Hash(), TranspositionEntry{
                Hash:  pos.Hash(),
                Depth: depth,
                Score: score,
                Type:  EXACT,
                Move:  move,
            })
        }

        alpha = max(alpha, score)
        if alpha >= beta {
            break // Beta cutoff
        }
    }

    return bestScore
}

func (e *Engine) quiescence(pos *Position, alpha, beta int, info *SearchInfo) int {
    info.NodesSearched++

    standPat := e.evaluate(pos)
    if standPat >= beta {
        return beta
    }
    if alpha < standPat {
        alpha = standPat
    }

    // Generate capturing moves only
    captures := e.generateCaptures(pos)
    captures = e.orderMoves(pos, captures)

    for _, move := range captures {
        newPos := pos.MakeMove(move)
        score := -e.quiescence(newPos, -beta, -alpha, info)

        if score >= beta {
            return beta
        }
        if score > alpha {
            alpha = score
        }
    }

    return alpha
}

func (e *Engine) orderMoves(pos *Position, moves []Move) []Move {
    // Score moves for ordering
    type ScoredMove struct {
        move  Move
        score int
    }

    scoredMoves := make([]ScoredMove, len(moves))
    for i, move := range moves {
        score := 0

        // TTMove bonus
        if ttMove, found := e.tt.GetMove(pos.Hash()); found && move == ttMove {
            score += 10000
        }

        // MVV/LVA (Most Valuable Victim / Least Valuable Attacker)
        if move.IsCapture() {
            victim := pos.Board[move.To()]
            attacker := pos.Board[move.From()]
            score += pieceValues[victim] - pieceValues[attacker]/10
        }

        // Killer move bonus
        if e.isKillerMove(move, pos.Ply()) {
            score += 900
        }

        // History heuristic
        score += e.history[move.From()][move.To()]

        scoredMoves[i] = ScoredMove{move, score}
    }

    // Sort moves by score
    sort.Slice(scoredMoves, func(i, j int) bool {
        return scoredMoves[i].score > scoredMoves[j].score
    })

    // Extract sorted moves
    sortedMoves := make([]Move, len(moves))
    for i, sm := range scoredMoves {
        sortedMoves[i] = sm.move
    }

    return sortedMoves
}

Parte 4: Funzionalità di valutazione avanzate
#

Aggiungiamo funzionalità di valutazione più sofisticate:

func (e *Evaluator) evaluateMobility() int {
    score := 0

    // Piece mobility bonuses/penalties
func (e *Evaluator) evaluateMobility() int {
    score := 0

    // Mobility weights for each piece type
    mobilityWeights := map[int]int{
        Knight: 4,
        Bishop: 5,
        Rook:   2,
        Queen:  1,
    }

    // Calculate mobility for both sides
    for sq := 0; sq < 64; sq++ {
        piece := e.pos.Board[sq]
        color := e.pos.Colors[sq]

        if piece == Empty || piece == Pawn || piece == King {
            continue
        }

        moves := e.generatePieceMoves(sq)
        mobilityScore := len(moves) * mobilityWeights[piece]
        score += mobilityScore * color
    }

    return score
}

// Helper function to check piece attacks
func (e *Evaluator) isSquareAttacked(sq, byColor int) bool {
    // Check pawn attacks
    pawnDir := -byColor // Pawns move in opposite direction of their color
    for _, offset := range []int{7, 9} {
        attackSq := sq + pawnDir*8 + offset
        if attackSq >= 0 && attackSq < 64 {
            if e.pos.Board[attackSq] == Pawn && e.pos.Colors[attackSq] == byColor {
                return true
            }
        }
    }

    // Check knight attacks
    knightOffsets := []int{-17, -15, -10, -6, 6, 10, 15, 17}
    for _, offset := range knightOffsets {
        attackSq := sq + offset
        if attackSq >= 0 && attackSq < 64 {
            if e.pos.Board[attackSq] == Knight && e.pos.Colors[attackSq] == byColor {
                return true
            }
        }
    }

    // Check sliding piece attacks (Bishop, Rook, Queen)
    directions := [][]int{
        {-1, -1}, {-1, 1}, {1, -1}, {1, 1},  // Bishop/Queen directions
        {-1, 0}, {1, 0}, {0, -1}, {0, 1},    // Rook/Queen directions
    }

    for _, dir := range directions {
        dx, dy := dir[0], dir[1]
        x, y := sq%8, sq/8

        for i := 1; i < 8; i++ {
            newX, newY := x+dx*i, y+dy*i
            if newX < 0 || newX >= 8 || newY < 0 || newY >= 8 {
                break
            }

            attackSq := newY*8 + newX
            piece := e.pos.Board[attackSq]
            if piece != Empty {
                if e.pos.Colors[attackSq] == byColor {
                    // Check if piece can move in this direction
                    if piece == Queen ||
                        (piece == Rook && (dx == 0 || dy == 0)) ||
                        (piece == Bishop && dx != 0 && dy != 0) {
                        return true
                    }
                }
                break
            }
        }
    }

    return false
}

Parte 5: Tecniche di ricerca avanzate
#

Implementiamo alcune tecniche avanzate per migliorare la forza del motore:

// Null Move Pruning
func (e *Engine) alphaBetaWithNullMove(pos *Position, depth, alpha, beta int, info *SearchInfo) int {
    if depth >= 3 && !pos.InCheck() && pos.HasNonPawnMaterial() {
        // Make null move
        newPos := pos.MakeNullMove()

        // Reduced depth for null move search
        R := 2 + depth/4

        // Recursive search with reduced depth
        score := -e.alphaBeta(newPos, depth-R-1, -beta, -beta+1, info)

        if score >= beta {
            return beta // Null move cutoff
        }
    }

    // Continue with regular alpha-beta search...
    return e.alphaBeta(pos, depth, alpha, beta, info)
}

// Late Move Reduction
func (e *Engine) alphaBetaWithLMR(pos *Position, depth, alpha, beta int, moveCount int, info *SearchInfo) int {
    if depth >= 3 && moveCount >= 4 && !pos.InCheck() {
        // Reduce depth for late moves
        reduction := 1
        if moveCount >= 6 {
            reduction++
        }

        // Reduced depth search
        score := -e.alphaBeta(pos, depth-reduction-1, -alpha-1, -alpha, info)

        // If reduced search fails high, do a full depth search
        if score > alpha {
            return -e.alphaBeta(pos, depth-1, -beta, -alpha, info)
        }

        return score
    }

    // Continue with regular search...
    return -e.alphaBeta(pos, depth-1, -beta, -alpha, info)
}

// Aspiration Windows
func (e *Engine) searchWithAspirationWindows(pos *Position, depth int, info *SearchInfo) int {
    score := 0
    alpha := -infinity
    beta := infinity
    window := 50 // Initial window size in centipawns

    // First search with full window
    score = e.alphaBeta(pos, depth, alpha, beta, info)

    // Subsequent searches with aspiration windows
    for currentDepth := 2; currentDepth <= depth; currentDepth++ {
        alpha = score - window
        beta = score + window

        score = e.alphaBeta(pos, currentDepth, alpha, beta, info)

        if score <= alpha || score >= beta {
            // Window failed, retry with larger window
            window *= 2
            score = e.alphaBeta(pos, currentDepth, -infinity, infinity, info)
        }

        window = 50 // Reset window size for next iteration
    }

    return score
}

Parte 6: Gestione del tempo
#

Una corretta gestione del tempo è fondamentale in tornei competitivi:

type TimeManager struct {
    InitialTime  time.Duration
    Increment    time.Duration
    MovesToGo    int
    MaxMoveTime  time.Duration
}

func NewTimeManager(initialTime, increment time.Duration, movesToGo int) *TimeManager {
    return &TimeManager{
        InitialTime: initialTime,
        Increment:   increment,
        MovesToGo:   movesToGo,
        MaxMoveTime: initialTime / 20, // Don't use more than 5% of total time
    }
}

func (tm *TimeManager) allocateTime(pos *Position) time.Duration {
    // Basic time allocation
    baseTime := tm.InitialTime / time.Duration(tm.MovesToGo)

    // Adjust based on game phase
    gamePhase := evaluateGamePhase(pos)
    switch {
    case gamePhase < 0.3: // Opening
        baseTime *= 8/10
    case gamePhase > 0.7: // Endgame
        baseTime *= 12/10
    }

    // Add increment
    allocatedTime := baseTime + tm.Increment*8/10

    // Never exceed maximum move time
    if allocatedTime > tm.MaxMoveTime {
        allocatedTime = tm.MaxMoveTime
    }

    return allocatedTime
}

func evaluateGamePhase(pos *Position) float64 {
    // Count material to determine game phase
    totalMaterial := 0
    maxMaterial := 0

    for sq := 0; sq < 64; sq++ {
        piece := pos.Board[sq]
        if piece != Empty && piece != King {
            totalMaterial += pieceValues[piece]
            maxMaterial += pieceValues[piece]
        }
    }

    // Return value between 0 (opening) and 1 (endgame)
    return 1.0 - float64(totalMaterial)/float64(maxMaterial)
}

Parte 7: Libro delle aperture e tablebases di finale
#

Per completezza, aggiungiamo il supporto per il libro delle aperture e le tablebases di finale:

type OpeningBook struct {
    positions map[uint64][]BookMove
}

type BookMove struct {
    Move   Move
    Weight int
}

func (book *OpeningBook) GetBookMove(pos *Position) (Move, bool) {
    moves, exists := book.positions[pos.Hash()]
    if !exists {
        return Move{}, false
    }

    // Select move based on weights
    totalWeight := 0
    for _, m := range moves {
        totalWeight += m.Weight
    }

    // Random selection weighted by frequency
    r := rand.Intn(totalWeight)
    currentWeight := 0
    for _, m := range moves {
        currentWeight += m.Weight
        if r < currentWeight {
            return m.Move, true
        }
    }

    return moves[0].Move, true
}

type Tablebase struct {
    cache map[uint64]TablebaseEntry
}

type TablebaseEntry struct {
    Score    int
    BestMove Move
    DTM     int // Distance to mate
}

func (tb *Tablebase) Probe(pos *Position) (TablebaseEntry, bool) {
    // Probe tablebase cache
    if entry, exists := tb.cache[pos.Hash()]; exists {
        return entry, true
    }

    // Only probe for positions with 6 or fewer pieces
    if pos.PieceCount() > 6 {
        return TablebaseEntry{}, false
    }

    // Here you would implement actual tablebase probing
    // This typically involves accessing Syzygy or Gaviota tablebases

    return TablebaseEntry{}, false
}

Conclusione
#

Costruire un motore scacchistico richiede l’integrazione di più componenti complessi:

  1. Rappresentazione della scacchiera: archiviazione efficiente della posizione e generazione delle mosse
  2. Valutazione: materiale, posizionamento dei pezzi, struttura pedonale, sicurezza del re e mobilità
  3. Ricerca: potatura alpha-beta con miglioramenti (null move, LMR, aspiration windows)
  4. Gestione del tempo: allocazione adattiva del tempo in base alla posizione e alla fase di gioco
  5. Apertura/Finale: mosse da libro e accesso alle tablebases per il gioco perfetto

Possibili miglioramenti futuri:

  • Valutazione con reti neurali
  • Ricerca multi-thread
  • Apprendimento supervisionato da partite di maestri
  • Valutazione NNUE (Efficiently Updatable Neural Network)
  • Tecniche di potatura avanzate (futility pruning, SEE, ecc.)
  • Fattore di disprezzo per il gioco in torneo

Ricordate che la programmazione scacchistica è un processo iterativo. Iniziate con le basi e aggiungete gradualmente funzionalità più sofisticate, testando contro altri motori per misurare i miglioramenti.

Riferimenti
#

Articoli correlati