Salta al contenuto principale

Comunicare con Stockfish da Go: il protocollo UCI

·7 minuti
Indice dei contenuti
Stockfish e il motore scacchistico piu forte al mondo. Comunicare con esso da Go tramite il protocollo UCI richiede circa 30 righe. Questo articolo mostra l’implementazione reale: avvio del processo, handshake UCI, input FEN, parsing della valutazione e una struct Engine completa e riusabile.

La versione originale di questo articolo chiamava metodi inesistenti come chess.NewEngine("stockfish"), SetDifficulty e Evaluate sulla libreria notnil/chess. Quella libreria e un motore per le regole degli scacchi – non fa da wrapper a Stockfish in alcun modo. Comunicare con Stockfish richiede il protocollo UCI su stdin/stdout, che e esattamente cio che questo articolo illustra.

Il Protocollo UCI
#

UCI (Universal Chess Interface) e un protocollo testuale su stdin/stdout. Il tuo programma scrive comandi sullo stdin di Stockfish e legge le risposte dal suo stdout. Non c’e memoria condivisa, nessuna API di libreria, nessun RPC. Solo testo.

L’handshake minimo:

tu    → uci
fish  → id name Stockfish 16
fish  → id author T. Romstad, M. Costalba, J. Kiiski, G. Linscott
fish  → uciok
tu    → isready
fish  → readyok
tu    → position fen rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
tu    → go depth 20
fish  → info depth 1 seldepth 1 multipv 1 score cp 22 nodes 20 nps 20000 time 1 pv e2e4
fish  → info depth 20 seldepth 27 multipv 1 score cp 30 nodes 1234567 ...
fish  → bestmove e2e4 ponder c7c5

Le righe info trasmettono l’analisi: profondita, punteggio in centipedoni (100 cp = 1 pedone), e la variazione principale (migliore linea). La riga finale bestmove e la risposta.

Setup: Installare Stockfish
#

# macOS
brew install stockfish

# Ubuntu/Debian
sudo apt-get install stockfish

# verifica
stockfish --version

La Struct Engine Completa
#

engine.go
package chess

import (
    "bufio"
    "fmt"
    "io"
    "os/exec"
    "strconv"
    "strings"
)

type Engine struct {
    cmd    *exec.Cmd
    stdin  io.WriteCloser
    stdout *bufio.Scanner
}

type AnalysisResult struct {
    BestMove string
    Score    int    // centipedoni; positivo = vantaggio bianco
    Depth    int
    PV       string // variazione principale (migliore linea)
    Mate     *int   // non-nil se matto forzato; valore = mosse al matto
}

// NewEngine avvia un processo Stockfish e completa l'handshake UCI.
func NewEngine(path string) (*Engine, error) {
    if path == "" {
        path = "stockfish"
    }

    cmd := exec.Command(path)

    stdin, err := cmd.StdinPipe()
    if err != nil {
        return nil, fmt.Errorf("stdin pipe: %w", err)
    }

    stdoutPipe, err := cmd.StdoutPipe()
    if err != nil {
        return nil, fmt.Errorf("stdout pipe: %w", err)
    }

    if err := cmd.Start(); err != nil {
        return nil, fmt.Errorf("start stockfish: %w", err)
    }

    e := &Engine{
        cmd:    cmd,
        stdin:  stdin,
        stdout: bufio.NewScanner(stdoutPipe),
    }

    if err := e.handshake(); err != nil {
        e.Close()
        return nil, err
    }

    return e, nil
}

func (e *Engine) handshake() error {
    if err := e.send("uci"); err != nil {
        return err
    }
    // Leggi fino a "uciok"
    if err := e.readUntil("uciok"); err != nil {
        return fmt.Errorf("uci handshake: %w", err)
    }

    if err := e.send("isready"); err != nil {
        return err
    }
    // Leggi fino a "readyok"
    if err := e.readUntil("readyok"); err != nil {
        return fmt.Errorf("isready: %w", err)
    }

    return nil
}

func (e *Engine) send(cmd string) error {
    _, err := fmt.Fprintln(e.stdin, cmd)
    return err
}

func (e *Engine) readUntil(token string) error {
    for e.stdout.Scan() {
        if strings.HasPrefix(e.stdout.Text(), token) {
            return nil
        }
    }
    if err := e.stdout.Err(); err != nil {
        return err
    }
    return fmt.Errorf("EOF prima di %q", token)
}

Analizzare una Posizione
#

analysis.go
package chess

import (
    "fmt"
    "strconv"
    "strings"
)

// AnalyzeFEN analizza la posizione data in notazione FEN.
// depth controlla il numero di semismosse che Stockfish ricerca.
func (e *Engine) AnalyzeFEN(fen string, depth int) (*AnalysisResult, error) {
    if err := e.send(fmt.Sprintf("position fen %s", fen)); err != nil {
        return nil, err
    }
    if err := e.send(fmt.Sprintf("go depth %d", depth)); err != nil {
        return nil, err
    }

    var result AnalysisResult

    for e.stdout.Scan() {
        line := e.stdout.Text()

        if strings.HasPrefix(line, "info") {
            parseInfo(line, &result)
            continue
        }

        if strings.HasPrefix(line, "bestmove") {
            parts := strings.Fields(line)
            if len(parts) >= 2 {
                result.BestMove = parts[1]
            }
            return &result, nil
        }
    }

    return nil, fmt.Errorf("il motore si e chiuso prima di bestmove")
}

func parseInfo(line string, r *AnalysisResult) {
    fields := strings.Fields(line)
    for i := 0; i < len(fields)-1; i++ {
        switch fields[i] {
        case "depth":
            if v, err := strconv.Atoi(fields[i+1]); err == nil {
                r.Depth = v
            }
        case "score":
            if i+2 < len(fields) {
                switch fields[i+1] {
                case "cp":
                    if v, err := strconv.Atoi(fields[i+2]); err == nil {
                        r.Score = v
                        r.Mate = nil
                    }
                case "mate":
                    if v, err := strconv.Atoi(fields[i+2]); err == nil {
                        r.Mate = &v
                    }
                }
            }
        case "pv":
            // tutto dopo "pv" e la variazione principale
            if i+1 < len(fields) {
                r.PV = strings.Join(fields[i+1:], " ")
            }
        }
    }
}

Analisi per Profondita vs per Tempo
#

// Ricerca esattamente 20 semismosse di profondita.
// Deterministica: la stessa posizione produce sempre lo stesso risultato.
result, err := engine.AnalyzeFEN(fen, 20)

L’analisi per profondita e riproducibile. Usala per pipeline di valutazione delle posizioni, test automatici e qualsiasi caso in cui si voglia coerenza nei risultati.

// Ricerca esattamente per 1000 millisecondi.
// Si adatta all'hardware: macchine piu veloci raggiungono profondita maggiori.
func (e *Engine) AnalyzeFENTime(fen string, ms int) (*AnalysisResult, error) {
    if err := e.send(fmt.Sprintf("position fen %s", fen)); err != nil {
        return nil, err
    }
    if err := e.send(fmt.Sprintf("go movetime %d", ms)); err != nil {
        return nil, err
    }
    // stesso loop di parsing di AnalyzeFEN
    return e.readBestMove()
}

L’analisi per tempo e migliore per le applicazioni interattive in cui si vuole un’esperienza utente consistente indipendentemente dall’hardware. Usa go movetime 1000 per 1 secondo per mossa.

Cleanup
#

cleanup.go
package chess

// Close invia "quit" a Stockfish e attende la chiusura del processo.
// Chiama sempre Close() al termine, altrimenti il processo Stockfish diventa un orfano.
func (e *Engine) Close() error {
    _ = e.send("quit")
    return e.cmd.Wait()
}

Usa defer engine.Close() immediatamente dopo che NewEngine restituisce con successo.

Notazione FEN con notnil/chess
#

FEN (Forsyth-Edwards Notation) e il modo standard per rappresentare una posizione scacchistica come stringa. La libreria notnil/chess genera FEN corretto dallo stato del gioco – questo e cio per cui la libreria e effettivamente utile:

fen_example.go
package main

import (
    "fmt"
    "log"

    "github.com/notnil/chess"
)

func main() {
    game := chess.NewGame()

    // Esegui mosse dalla notazione algebrica
    moves := []string{"e4", "c5", "Nf3", "d6", "d4", "cxd4"}
    for _, san := range moves {
        if err := game.MoveStr(san); err != nil {
            log.Fatalf("mossa non valida %s: %v", san, err)
        }
    }

    // Ottieni il FEN per la posizione corrente
    fen := game.Position().String()
    fmt.Println(fen)
    // Output: r1bqkbnr/pp2pppp/3p4/8/3pP3/5N2/PPP2PPP/RNBQKB1R w KQkq - 0 4

    // Invia a Stockfish
    engine, err := NewEngine("")
    if err != nil {
        log.Fatal(err)
    }
    defer engine.Close()

    result, err := engine.AnalyzeFEN(fen, 20)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("best move: %s\n", result.BestMove)
    fmt.Printf("score:     %+d cp\n", result.Score)
    fmt.Printf("depth:     %d\n", result.Depth)
    fmt.Printf("pv:        %s\n", result.PV)
}

notnil/chess gestisce le regole e la generazione del FEN. Stockfish gestisce l’analisi. Le due librerie non interagiscono direttamente – il tuo codice e la colla che le unisce.

Errori comuni e come evitarli

Non leggere stdout continuamente (deadlock) Stockfish scrive il suo output in un buffer di pipe. Se non leggi dalla pipe, il buffer si riempie e Stockfish si blocca cercando di scrivere. Questo causa un deadlock: il tuo codice aspetta che Stockfish finisca, e Stockfish aspetta che il tuo codice legga. Assicurati sempre di avere una goroutine o un loop che svuoti attivamente stdout. Il loop bufio.Scanner in AnalyzeFEN gestisce questo correttamente.

Non gestire le righe “info” prima di “bestmove” Stockfish emette molte righe info prima del bestmove finale. Se leggi solo fino alla prima riga simile a bestmove e ti fermi, perdi il punteggio finale alla profondita massima. Il loop di parsing deve continuare a leggere le righe info e aggiornare il risultato finche non vede bestmove.

Non inviare “quit” allo spegnimento Se termini il processo Stockfish senza inviare prima “quit”, il processo potrebbe lasciare file temporanei o non riuscire a svuotare l’output. Inoltre, chiamare cmd.Wait() senza quit potrebbe bloccarsi indefinitamente. Invia sempre “quit” prima di aspettare il processo.

Usare metodi di libreria inesistenti La libreria notnil/chess non espone Stockfish. E un motore per le regole degli scacchi per rappresentare posizioni, validare mosse e generare FEN. Non cercare NewEngine, SetDifficulty o Evaluate in quel pacchetto. Usa exec.Command e UCI come mostrato in questo articolo.

Hardcodare il path di Stockfish Passa il path come parametro o leggilo da una variabile d’ambiente. Su sistemi diversi Stockfish puo trovarsi in /usr/bin/stockfish, /opt/homebrew/bin/stockfish o un path personalizzato. Usare "stockfish" come default (che dipende dal PATH) e accettabile per lo sviluppo; usa un path esplicito nei container di produzione.


Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati