Commit b2118460 authored by Mahmoud Aglan's avatar Mahmoud Aglan

initi

parents
.git
*.md
.env
.env.*
# CapRover sets PORT=80 automatically. These are for local dev only.
PORT=8082
STOCKFISH_PATH=/usr/local/bin/stockfish
POOL_SIZE=12
IDLE_TIMEOUT_SEC=300
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
stockfish-api
vendor/
.env
# Stage 1: Build Stockfish from source
FROM ubuntu:22.04 AS stockfish-builder
RUN apt-get update && apt-get install -y \
build-essential \
git \
wget \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /stockfish
RUN git clone --depth 1 --branch sf_18 https://github.com/official-stockfish/Stockfish.git .
WORKDIR /stockfish/src
# Download neural networks and build.
# Use x86-64-sse41-popcnt for broad AWS/Hetzner compatibility.
# Switch to x86-64-avx2 if your server CPU supports it (check: grep avx2 /proc/cpuinfo)
RUN make net && make -j$(nproc) build ARCH=x86-64-sse41-popcnt
# Stage 2: Build Go API
FROM golang:1.22-alpine AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o stockfish-api ./cmd/server
# Stage 3: Final minimal image
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=stockfish-builder /stockfish/src/stockfish /usr/local/bin/stockfish
COPY --from=stockfish-builder /stockfish/src/*.nnue /app/
COPY --from=go-builder /app/stockfish-api /app/stockfish-api
ENV PORT=80
ENV STOCKFISH_PATH=/usr/local/bin/stockfish
ENV POOL_SIZE=12
ENV IDLE_TIMEOUT_SEC=300
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost:80/health || exit 1
CMD ["/app/stockfish-api"]
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
package main
import (
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"stockfish-api/internal/api"
"stockfish-api/internal/engine"
)
func main() {
port := envOrDefault("PORT", "8082")
sfPath := envOrDefault("STOCKFISH_PATH", "/usr/local/bin/stockfish")
poolSize := envIntOrDefault("POOL_SIZE", 12)
idleTimeout := envIntOrDefault("IDLE_TIMEOUT_SEC", 300)
engine.InitPool(sfPath, poolSize, time.Duration(idleTimeout)*time.Second)
r := chi.NewRouter()
r.Use(api.CORSMiddleware)
r.Use(api.LoggingMiddleware)
r.Use(api.RateLimitMiddleware)
r.Post("/api/chess/move", api.HandleGetMove)
r.Post("/api/chess/analyze", api.HandleAnalyze)
r.Get("/api/chess/bots", api.HandleListBots)
r.Get("/api/chess/stats", api.HandleStats)
r.Get("/health", api.HandleHealth)
log.Printf("Stockfish API starting on :%s (pool_size=%d, idle_timeout=%ds)", port, poolSize, idleTimeout)
if err := http.ListenAndServe(":"+port, r); err != nil {
log.Fatal(err)
}
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envIntOrDefault(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
module stockfish-api
go 1.22
require github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r1HyIntPvPfITZECERMunWMTRw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
package api
import (
"context"
"encoding/json"
"log"
"net/http"
"time"
"stockfish-api/internal/bots"
"stockfish-api/internal/engine"
)
type MoveRequestBody struct {
FEN string `json:"fen"`
BotID string `json:"bot_id"`
TimeLimitMs int `json:"time_limit_ms,omitempty"`
}
type AnalyzeRequestBody struct {
FEN string `json:"fen"`
Depth int `json:"depth"`
Lines int `json:"lines"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
func HandleGetMove(w http.ResponseWriter, r *http.Request) {
var body MoveRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"})
return
}
if body.FEN == "" {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "fen is required"})
return
}
if body.BotID == "" {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "bot_id is required"})
return
}
bot := bots.GetBot(body.BotID)
if bot == nil {
writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "bot not found"})
return
}
depth := bot.Depth
skillLevel := bot.SkillLevel
contempt := bot.Contempt
// For blunder-prone bots, reduce depth dramatically on blunder rolls
if bot.ShouldBlunder() {
depth = 1
skillLevel = 0
}
timeLimitMs := body.TimeLimitMs
if timeLimitMs <= 0 {
timeLimitMs = 0 // use depth-based search
}
req := engine.MoveRequest{
FEN: body.FEN,
Depth: depth,
SkillLevel: skillLevel,
Contempt: contempt,
TimeLimitMs: timeLimitMs,
MultiPV: 1,
}
start := time.Now()
resp, err := engine.GetMove(r.Context(), req)
if err != nil {
log.Printf("ERROR get_move bot=%s fen=%s err=%v", body.BotID, body.FEN, err)
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "engine error: " + err.Error()})
return
}
// Simulate human-like think time
elapsed := time.Since(start).Milliseconds()
targetThink := int64(bot.RandomThinkTime())
if elapsed < targetThink {
sleepDuration := time.Duration(targetThink-elapsed) * time.Millisecond
select {
case <-time.After(sleepDuration):
case <-r.Context().Done():
return
}
}
resp.ThinkTimeMs = time.Since(start).Milliseconds()
log.Printf("MOVE bot=%s depth=%d skill=%d fen=%.40s move=%s eval=%.2f time=%dms",
body.BotID, depth, skillLevel, body.FEN, resp.BestMove, resp.Evaluation, resp.ThinkTimeMs)
writeJSON(w, http.StatusOK, resp)
}
func HandleAnalyze(w http.ResponseWriter, r *http.Request) {
var body AnalyzeRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"})
return
}
if body.FEN == "" {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "fen is required"})
return
}
if body.Depth <= 0 || body.Depth > 30 {
body.Depth = 18
}
if body.Lines <= 0 || body.Lines > 5 {
body.Lines = 3
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
lines, err := engine.Analyze(ctx, body.FEN, body.Depth, body.Lines)
if err != nil {
log.Printf("ERROR analyze fen=%s err=%v", body.FEN, err)
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "engine error: " + err.Error()})
return
}
log.Printf("ANALYZE depth=%d lines=%d fen=%.40s", body.Depth, body.Lines, body.FEN)
writeJSON(w, http.StatusOK, map[string]interface{}{
"fen": body.FEN,
"depth": body.Depth,
"lines": lines,
})
}
func HandleListBots(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"bots": bots.ListBots(),
})
}
func HandleStats(w http.ResponseWriter, r *http.Request) {
alive, idle := engine.PoolStats()
writeJSON(w, http.StatusOK, map[string]interface{}{
"pool_alive": alive,
"pool_idle": idle,
})
}
func HandleHealth(w http.ResponseWriter, r *http.Request) {
req := engine.MoveRequest{
FEN: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
Depth: 1,
SkillLevel: 20,
Contempt: 0,
TimeLimitMs: 0,
MultiPV: 1,
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err := engine.GetMove(ctx, req)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "unhealthy",
"error": err.Error(),
})
return
}
alive, idle := engine.PoolStats()
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "healthy",
"engine": "stockfish-18",
"pool_alive": alive,
"pool_idle": idle,
})
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
package api
import (
"log"
"net/http"
"sync"
"time"
)
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, apikey")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
type rateLimiter struct {
mu sync.Mutex
requests map[string][]time.Time
limit int
window time.Duration
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
rl := &rateLimiter{
requests: make(map[string][]time.Time),
limit: limit,
window: window,
}
go rl.cleanup()
return rl
}
func (rl *rateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
// Remove expired entries
reqs := rl.requests[key]
valid := reqs[:0]
for _, t := range reqs {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) >= rl.limit {
rl.requests[key] = valid
return false
}
rl.requests[key] = append(valid, now)
return true
}
func (rl *rateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
rl.mu.Lock()
now := time.Now()
cutoff := now.Add(-rl.window)
for key, reqs := range rl.requests {
valid := reqs[:0]
for _, t := range reqs {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(rl.requests, key)
} else {
rl.requests[key] = valid
}
}
rl.mu.Unlock()
}
}
// 60 requests per minute per IP
var limiter = newRateLimiter(60, time.Minute)
func RateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
}
if !limiter.allow(ip) {
writeJSON(w, http.StatusTooManyRequests, ErrorResponse{Error: "rate limit exceeded"})
return
}
next.ServeHTTP(w, r)
})
}
package bots
import "math/rand"
type BotPersonality struct {
ID string `json:"id"`
Name string `json:"name"`
NameAr string `json:"name_ar"`
Style string `json:"style"`
Bio string `json:"bio"`
BioAr string `json:"bio_ar"`
ELOMin int `json:"elo_min"`
ELOMax int `json:"elo_max"`
SkillLevel int `json:"skill_level"`
Depth int `json:"depth"`
Contempt int `json:"contempt"`
BlunderChance float64 `json:"blunder_chance"`
ThinkTimeMin int `json:"think_time_min_ms"`
ThinkTimeMax int `json:"think_time_max_ms"`
OpeningBook []string `json:"opening_book"`
AvatarID string `json:"avatar_id"`
}
var Personalities = map[string]*BotPersonality{
"amina": {
ID: "amina",
Name: "Amina",
NameAr: "أمينة المبتدئة",
Style: "beginner",
Bio: "Just learning chess! Makes mistakes but tries her best.",
BioAr: "لسه بتتعلم شطرنج! بتغلط كتير بس بتحاول.",
ELOMin: 400,
ELOMax: 600,
SkillLevel: 1,
Depth: 3,
Contempt: 0,
BlunderChance: 0.30,
ThinkTimeMin: 500,
ThinkTimeMax: 2000,
OpeningBook: []string{},
AvatarID: "bot-amina",
},
"tarek": {
ID: "tarek",
Name: "Tarek",
NameAr: "طارق المتحفظ",
Style: "defensive",
Bio: "Plays it safe. Castles early, protects everything, rarely attacks.",
BioAr: "بيلعب أمان. بيتبيّت بدري و بيحمي كل حاجة.",
ELOMin: 800,
ELOMax: 1000,
SkillLevel: 5,
Depth: 6,
Contempt: -30,
BlunderChance: 0.15,
ThinkTimeMin: 1000,
ThinkTimeMax: 3000,
OpeningBook: []string{"italian", "queens_gambit_declined", "caro_kann"},
AvatarID: "bot-tarek",
},
"nour": {
ID: "nour",
Name: "Nour",
NameAr: "نور المهاجمة",
Style: "aggressive",
Bio: "Loves to attack! Sacrifices pieces for a shot at your king.",
BioAr: "بتحب الهجوم! بتضحي بقطع عشان توصل للملك.",
ELOMin: 1000,
ELOMax: 1200,
SkillLevel: 8,
Depth: 10,
Contempt: 50,
BlunderChance: 0.08,
ThinkTimeMin: 800,
ThinkTimeMax: 3000,
OpeningBook: []string{"kings_gambit", "sicilian_dragon", "evans_gambit"},
AvatarID: "bot-nour",
},
"omar": {
ID: "omar",
Name: "Omar",
NameAr: "عمر الاستراتيجي",
Style: "positional",
Bio: "Controls the center, improves pieces slowly, dominates the endgame.",
BioAr: "بيسيطر على المركز، بيحسّن القطع ببطء، و بيكسب النهايات.",
ELOMin: 1200,
ELOMax: 1400,
SkillLevel: 11,
Depth: 12,
Contempt: 10,
BlunderChance: 0.04,
ThinkTimeMin: 1500,
ThinkTimeMax: 4000,
OpeningBook: []string{"queens_gambit", "english", "reti"},
AvatarID: "bot-omar",
},
"layla": {
ID: "layla",
Name: "Layla",
NameAr: "ليلى المبدعة",
Style: "creative",
Bio: "Looks for tactics, pins, forks, and sacrifices. Unpredictable.",
BioAr: "بتدور على التكتيكات والتضحيات. مش هتتوقع خطوتها.",
ELOMin: 1400,
ELOMax: 1600,
SkillLevel: 14,
Depth: 14,
Contempt: 30,
BlunderChance: 0.02,
ThinkTimeMin: 1000,
ThinkTimeMax: 5000,
OpeningBook: []string{"sicilian_najdorf", "kings_indian", "grunfeld"},
AvatarID: "bot-layla",
},
"ziad": {
ID: "ziad",
Name: "Ziad",
NameAr: "زياد الصلب",
Style: "solid",
Bio: "No weaknesses. Plays principled chess. Hard to beat, rarely blunders.",
BioAr: "مفيش نقط ضعف. بيلعب شطرنج محترم. صعب تكسبه.",
ELOMin: 1600,
ELOMax: 1800,
SkillLevel: 17,
Depth: 16,
Contempt: 0,
BlunderChance: 0.01,
ThinkTimeMin: 2000,
ThinkTimeMax: 6000,
OpeningBook: []string{"ruy_lopez", "queens_gambit", "nimzo_indian"},
AvatarID: "bot-ziad",
},
"grandmaster": {
ID: "grandmaster",
Name: "Grandmaster Bot",
NameAr: "الجراند ماستر",
Style: "near_perfect",
Bio: "Full Stockfish strength. Punishes every mistake. Good luck.",
BioAr: "قوة ستوكفيش الكاملة. بيعاقب كل غلطة. حظ سعيد.",
ELOMin: 2000,
ELOMax: 2200,
SkillLevel: 20,
Depth: 20,
Contempt: 0,
BlunderChance: 0.0,
ThinkTimeMin: 3000,
ThinkTimeMax: 8000,
OpeningBook: []string{},
AvatarID: "bot-grandmaster",
},
}
func GetBot(id string) *BotPersonality {
return Personalities[id]
}
func ListBots() []*BotPersonality {
order := []string{"amina", "tarek", "nour", "omar", "layla", "ziad", "grandmaster"}
out := make([]*BotPersonality, 0, len(order))
for _, id := range order {
if bot, ok := Personalities[id]; ok {
out = append(out, bot)
}
}
return out
}
func (b *BotPersonality) ShouldBlunder() bool {
if b.BlunderChance <= 0 {
return false
}
return rand.Float64() < b.BlunderChance
}
func (b *BotPersonality) RandomThinkTime() int {
if b.ThinkTimeMin >= b.ThinkTimeMax {
return b.ThinkTimeMin
}
return b.ThinkTimeMin + rand.Intn(b.ThinkTimeMax-b.ThinkTimeMin)
}
package engine
import (
"bufio"
"context"
"fmt"
"io"
"log"
"os/exec"
"strings"
"sync"
"time"
)
type MoveRequest struct {
FEN string
Depth int
SkillLevel int
Contempt int
TimeLimitMs int
MultiPV int
}
type MoveResponse struct {
BestMove string `json:"best_move"`
Evaluation float64 `json:"evaluation"`
Depth int `json:"depth"`
Nodes int64 `json:"nodes"`
ThinkTimeMs int64 `json:"think_time_ms"`
PV string `json:"pv,omitempty"`
}
type AnalysisLine struct {
Rank int `json:"rank"`
Move string `json:"move"`
Evaluation float64 `json:"evaluation"`
Depth int `json:"depth"`
PV string `json:"pv"`
}
// --- Process Pool ---
type sfProcess struct {
cmd *exec.Cmd
stdin io.WriteCloser
scanner *bufio.Scanner
lastUsed time.Time
mu sync.Mutex
}
type Pool struct {
path string
size int
idle chan *sfProcess
idleTime time.Duration
mu sync.Mutex
alive int
}
var pool *Pool
func InitPool(stockfishPath string, size int, idleTimeout time.Duration) {
pool = &Pool{
path: stockfishPath,
size: size,
idle: make(chan *sfProcess, size),
idleTime: idleTimeout,
}
// Pre-warm half the pool
warmCount := size / 2
if warmCount < 2 {
warmCount = 2
}
for i := 0; i < warmCount; i++ {
proc, err := pool.spawn()
if err != nil {
log.Printf("WARN: failed to pre-warm process %d: %v", i, err)
continue
}
pool.idle <- proc
}
log.Printf("Pool initialized: size=%d pre-warmed=%d idle_timeout=%s", size, warmCount, idleTimeout)
go pool.reaper()
}
func (p *Pool) spawn() (*sfProcess, error) {
cmd := exec.Command(p.path)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, 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)
}
proc := &sfProcess{
cmd: cmd,
stdin: stdin,
scanner: bufio.NewScanner(stdout),
lastUsed: time.Now(),
}
// Initialize UCI and wait for readiness (NNUE loads here)
proc.send("uci")
proc.waitFor("uciok")
proc.send("isready")
proc.waitFor("readyok")
p.mu.Lock()
p.alive++
p.mu.Unlock()
return proc, nil
}
func (p *Pool) acquire(ctx context.Context) (*sfProcess, error) {
// Try to grab an idle process
select {
case proc := <-p.idle:
if proc.isAlive() {
return proc, nil
}
// Dead process, fall through to spawn
p.mu.Lock()
p.alive--
p.mu.Unlock()
default:
}
// No idle process available — can we spawn a new one?
p.mu.Lock()
if p.alive < p.size {
p.mu.Unlock()
return p.spawn()
}
p.mu.Unlock()
// Pool full, wait for one to become available
select {
case proc := <-p.idle:
if proc.isAlive() {
return proc, nil
}
p.mu.Lock()
p.alive--
p.mu.Unlock()
return p.spawn()
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (p *Pool) release(proc *sfProcess) {
if !proc.isAlive() {
p.mu.Lock()
p.alive--
p.mu.Unlock()
return
}
// Reset engine state for next use
proc.send("ucinewgame")
proc.send("isready")
proc.waitFor("readyok")
proc.lastUsed = time.Now()
select {
case p.idle <- proc:
default:
// Pool channel full, kill this process
proc.kill()
p.mu.Lock()
p.alive--
p.mu.Unlock()
}
}
func (p *Pool) reaper() {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
// Drain and re-check idle processes
n := len(p.idle)
for i := 0; i < n; i++ {
select {
case proc := <-p.idle:
if time.Since(proc.lastUsed) > p.idleTime {
proc.kill()
p.mu.Lock()
p.alive--
p.mu.Unlock()
log.Printf("POOL reaped idle process (alive=%d)", p.alive)
} else if proc.isAlive() {
p.idle <- proc
} else {
p.mu.Lock()
p.alive--
p.mu.Unlock()
}
default:
break
}
}
}
}
func (p *Pool) Stats() (alive int, idle int) {
p.mu.Lock()
alive = p.alive
p.mu.Unlock()
idle = len(p.idle)
return
}
func PoolStats() (alive int, idle int) {
return pool.Stats()
}
// --- sfProcess methods ---
func (proc *sfProcess) send(command string) {
io.WriteString(proc.stdin, command+"\n")
}
func (proc *sfProcess) waitFor(token string) {
for proc.scanner.Scan() {
if strings.Contains(proc.scanner.Text(), token) {
return
}
}
}
func (proc *sfProcess) isAlive() bool {
if proc.cmd == nil || proc.cmd.Process == nil {
return false
}
// Send isready and check if we get readyok back
proc.send("isready")
for proc.scanner.Scan() {
if strings.Contains(proc.scanner.Text(), "readyok") {
return true
}
}
return false
}
func (proc *sfProcess) kill() {
proc.send("quit")
done := make(chan struct{})
go func() {
proc.cmd.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
proc.cmd.Process.Kill()
}
}
// --- Public API ---
func GetMove(ctx context.Context, req MoveRequest) (*MoveResponse, error) {
proc, err := pool.acquire(ctx)
if err != nil {
return nil, fmt.Errorf("acquire process: %w", err)
}
defer pool.release(proc)
proc.mu.Lock()
defer proc.mu.Unlock()
// Set options for this request
proc.send(fmt.Sprintf("setoption name Skill Level value %d", req.SkillLevel))
proc.send(fmt.Sprintf("setoption name Contempt value %d", req.Contempt))
if req.MultiPV > 1 {
proc.send(fmt.Sprintf("setoption name MultiPV value %d", req.MultiPV))
} else {
proc.send("setoption name MultiPV value 1")
}
proc.send("isready")
proc.waitFor("readyok")
// Set position and search
proc.send(fmt.Sprintf("position fen %s", req.FEN))
start := time.Now()
if req.TimeLimitMs > 0 {
proc.send(fmt.Sprintf("go movetime %d", req.TimeLimitMs))
} else {
proc.send(fmt.Sprintf("go depth %d", req.Depth))
}
resp := &MoveResponse{}
for proc.scanner.Scan() {
line := proc.scanner.Text()
if strings.HasPrefix(line, "info depth") && !strings.Contains(line, "upperbound") && !strings.Contains(line, "lowerbound") {
parseInfo(line, resp)
}
if strings.HasPrefix(line, "bestmove") {
parts := strings.Fields(line)
if len(parts) >= 2 {
resp.BestMove = parts[1]
}
break
}
}
resp.ThinkTimeMs = time.Since(start).Milliseconds()
if resp.BestMove == "" {
return nil, fmt.Errorf("no bestmove returned")
}
return resp, nil
}
func Analyze(ctx context.Context, fen string, depth int, lines int) ([]AnalysisLine, error) {
proc, err := pool.acquire(ctx)
if err != nil {
return nil, fmt.Errorf("acquire process: %w", err)
}
defer pool.release(proc)
proc.mu.Lock()
defer proc.mu.Unlock()
proc.send("setoption name Skill Level value 20")
proc.send("setoption name Contempt value 0")
proc.send(fmt.Sprintf("setoption name MultiPV value %d", lines))
proc.send("isready")
proc.waitFor("readyok")
proc.send(fmt.Sprintf("position fen %s", fen))
proc.send(fmt.Sprintf("go depth %d", depth))
results := make(map[int]*AnalysisLine)
for proc.scanner.Scan() {
line := proc.scanner.Text()
if strings.HasPrefix(line, "info depth") && strings.Contains(line, "multipv") {
al := parseAnalysisLine(line)
if al != nil && al.Depth == depth {
results[al.Rank] = al
}
}
if strings.HasPrefix(line, "bestmove") {
break
}
}
out := make([]AnalysisLine, 0, len(results))
for i := 1; i <= lines; i++ {
if al, ok := results[i]; ok {
out = append(out, *al)
}
}
return out, nil
}
// --- Parsing helpers ---
func parseInfo(line string, resp *MoveResponse) {
fields := strings.Fields(line)
for i, f := range fields {
switch f {
case "depth":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &resp.Depth)
}
case "nodes":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &resp.Nodes)
}
case "cp":
if i+1 < len(fields) {
var cp int
fmt.Sscanf(fields[i+1], "%d", &cp)
resp.Evaluation = float64(cp) / 100.0
}
case "mate":
if i+1 < len(fields) {
var m int
fmt.Sscanf(fields[i+1], "%d", &m)
if m > 0 {
resp.Evaluation = 999.0
} else {
resp.Evaluation = -999.0
}
}
case "pv":
if i+1 < len(fields) {
resp.PV = strings.Join(fields[i+1:], " ")
}
}
}
}
func parseAnalysisLine(line string) *AnalysisLine {
al := &AnalysisLine{}
fields := strings.Fields(line)
for i, f := range fields {
switch f {
case "multipv":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &al.Rank)
}
case "depth":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &al.Depth)
}
case "cp":
if i+1 < len(fields) {
var cp int
fmt.Sscanf(fields[i+1], "%d", &cp)
al.Evaluation = float64(cp) / 100.0
}
case "mate":
if i+1 < len(fields) {
var m int
fmt.Sscanf(fields[i+1], "%d", &m)
if m > 0 {
al.Evaluation = 999.0
} else {
al.Evaluation = -999.0
}
}
case "pv":
if i+1 < len(fields) {
al.PV = strings.Join(fields[i+1:], " ")
if parts := strings.Fields(al.PV); len(parts) > 0 {
al.Move = parts[0]
}
}
}
}
if al.Rank == 0 {
return nil
}
return al
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment