253 lines
4.8 KiB
Go
253 lines
4.8 KiB
Go
// FILE: lixenwraith/chess/internal/server/engine/engine.go
|
|
package engine
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const enginePath = "stockfish"
|
|
|
|
type UCI struct {
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
stdout *bufio.Scanner
|
|
mu sync.Mutex
|
|
}
|
|
|
|
type SearchResult struct {
|
|
BestMove string
|
|
Score int
|
|
Depth int
|
|
IsMate bool
|
|
MateIn int
|
|
}
|
|
|
|
func New() (*UCI, error) {
|
|
cmd := exec.Command(enginePath)
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err = cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("failed to start engine: %v", err)
|
|
}
|
|
|
|
uci := &UCI{
|
|
cmd: cmd,
|
|
stdin: stdin,
|
|
stdout: bufio.NewScanner(stdout),
|
|
}
|
|
|
|
if err := uci.initialize(); err != nil {
|
|
uci.Close()
|
|
return nil, err
|
|
}
|
|
|
|
return uci, nil
|
|
}
|
|
|
|
// SetSkillLevel sets the Stockfish skill level (0-20)
|
|
func (u *UCI) SetSkillLevel(level int) {
|
|
if level < 0 {
|
|
level = 0
|
|
} else if level > 20 {
|
|
level = 20
|
|
}
|
|
u.sendCommand(fmt.Sprintf("setoption name Skill Level value %d", level))
|
|
}
|
|
|
|
// Get FEN from Stockfish's debug ('d') command
|
|
func (u *UCI) GetFEN() (string, error) {
|
|
u.sendCommand("d")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
done := make(chan string, 1)
|
|
go func() {
|
|
for u.stdout.Scan() {
|
|
line := u.stdout.Text()
|
|
if strings.HasPrefix(line, "Fen: ") {
|
|
done <- strings.TrimPrefix(line, "Fen: ")
|
|
return
|
|
}
|
|
}
|
|
done <- ""
|
|
}()
|
|
|
|
select {
|
|
case fen := <-done:
|
|
if fen == "" {
|
|
return "", fmt.Errorf("failed to get FEN from engine")
|
|
}
|
|
return fen, nil
|
|
case <-ctx.Done():
|
|
return "", fmt.Errorf("timeout getting FEN")
|
|
}
|
|
}
|
|
|
|
func (u *UCI) initialize() error {
|
|
u.sendCommand("uci")
|
|
|
|
// Wait for uciok with timeout
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
done := make(chan bool)
|
|
go func() {
|
|
for u.stdout.Scan() {
|
|
if u.stdout.Text() == "uciok" {
|
|
done <- true
|
|
return
|
|
}
|
|
}
|
|
done <- false
|
|
}()
|
|
|
|
select {
|
|
case success := <-done:
|
|
if !success {
|
|
return fmt.Errorf("engine closed unexpectedly")
|
|
}
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("timeout waiting for uciok")
|
|
}
|
|
|
|
u.sendCommand("isready")
|
|
return u.waitReady()
|
|
}
|
|
|
|
func (u *UCI) waitReady() error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
done := make(chan error)
|
|
go func() {
|
|
for u.stdout.Scan() {
|
|
if u.stdout.Text() == "readyok" {
|
|
done <- nil
|
|
return
|
|
}
|
|
}
|
|
done <- fmt.Errorf("engine closed unexpectedly")
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("timeout waiting for readyok")
|
|
}
|
|
}
|
|
|
|
func (u *UCI) sendCommand(cmd string) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
fmt.Fprintln(u.stdin, cmd)
|
|
}
|
|
|
|
func (u *UCI) NewGame() {
|
|
u.sendCommand("ucinewgame")
|
|
u.sendCommand("isready")
|
|
u.waitReady()
|
|
}
|
|
|
|
func (u *UCI) SetPosition(fen string, moves []string) {
|
|
cmd := fmt.Sprintf("position fen %s", fen)
|
|
if len(moves) > 0 {
|
|
cmd += " moves " + strings.Join(moves, " ")
|
|
}
|
|
u.sendCommand(cmd)
|
|
}
|
|
|
|
func (u *UCI) Search(timeMs int) (*SearchResult, error) {
|
|
u.sendCommand(fmt.Sprintf("go movetime %d", timeMs))
|
|
|
|
result := &SearchResult{}
|
|
|
|
// Add timeout protection (2x the search time + buffer)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeMs*2+1000)*time.Millisecond)
|
|
defer cancel()
|
|
|
|
done := make(chan error)
|
|
go func() {
|
|
for u.stdout.Scan() {
|
|
line := u.stdout.Text()
|
|
|
|
if strings.HasPrefix(line, "info ") {
|
|
fields := strings.Fields(line)
|
|
for i := 0; i < len(fields)-1; i++ {
|
|
switch fields[i] {
|
|
case "depth":
|
|
fmt.Sscanf(fields[i+1], "%d", &result.Depth)
|
|
case "cp":
|
|
fmt.Sscanf(fields[i+1], "%d", &result.Score)
|
|
result.IsMate = false
|
|
case "mate":
|
|
fmt.Sscanf(fields[i+1], "%d", &result.MateIn)
|
|
result.IsMate = true
|
|
// Convert mate score to centipawn equivalent for backwards compatibility
|
|
if result.MateIn > 0 {
|
|
result.Score = 100000 - result.MateIn
|
|
} else {
|
|
result.Score = -100000 - result.MateIn
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(line, "bestmove ") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 {
|
|
result.BestMove = parts[1]
|
|
}
|
|
done <- nil
|
|
return
|
|
}
|
|
}
|
|
done <- fmt.Errorf("engine closed unexpectedly")
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("timeout waiting for bestmove")
|
|
}
|
|
}
|
|
|
|
func (u *UCI) Close() error {
|
|
u.sendCommand("quit")
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Try graceful shutdown first
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- u.cmd.Wait()
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
return nil
|
|
case <-time.After(1 * time.Second):
|
|
// Force kill if doesn't exit gracefully
|
|
return u.cmd.Process.Kill()
|
|
}
|
|
} |