Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
linters:
disable:
- errcheck
- errcheck
47 changes: 38 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strings"
"sync"

. "github.com/eugenioenko/libra-chess/pkg"
)
Expand All @@ -18,6 +19,9 @@ func main() {
scanner := bufio.NewScanner(os.Stdin)
board := NewBoard()

var searchMu sync.Mutex
var stopChan chan struct{}

for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
Expand All @@ -37,17 +41,42 @@ func main() {
case "position":
board.ParseAndApplyPosition(fields[1:])
case "go":
remainingTimeInMs := GetUCIRemainingTime(board.WhiteToMove, fields)
bestMove := board.IterativeDeepeningSearch(SearchOptions{
RemainingTimeInMs: remainingTimeInMs,
TimeLimitInMs: 1000,
})
if bestMove != nil {
fmt.Printf("bestmove %s\n", bestMove.ToUCI())
} else {
fmt.Println("bestmove 0000")
goOpts := ParseGoOptions(fields)
optimalTime, maxTime := goOpts.CalcTimeLimit(board.WhiteToMove)

searchMu.Lock()
stopChan = make(chan struct{})
currentStop := stopChan
searchMu.Unlock()

opts := SearchOptions{
TimeLimitInMs: optimalTime,
MaxTimeLimitInMs: maxTime,
MaxDepth: goOpts.Depth,
StopChan: currentStop,
}

go func() {
bestMove := board.IterativeDeepeningSearch(opts)
if bestMove != nil {
fmt.Printf("bestmove %s\n", bestMove.ToUCI())
} else {
fmt.Println("bestmove 0000")
}
}()
case "stop":
searchMu.Lock()
if stopChan != nil {
close(stopChan)
stopChan = nil
}
searchMu.Unlock()
case "quit":
searchMu.Lock()
if stopChan != nil {
close(stopChan)
}
searchMu.Unlock()
return
}
}
Expand Down
83 changes: 59 additions & 24 deletions pkg/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ const (

type SearchOptions struct {
MaxDepth int // Maximum search depth (plies)
RemainingTimeInMs int // Time remaining for the game
TimeLimitInMs int // Maximum time allowed for the search. Defaults MaxEvaluationDepthTimeMs
TimeLimitInMs int // Soft time limit: stop deepening after this (ms)
MaxTimeLimitInMs int // Hard time limit: abort in-flight search after this (ms)
TranspositionTable *TranspositionTable // Optional transposition table to use for search
UseBookMoves bool // Optional flag to use book moves
StopChan chan struct{} // External stop signal (e.g. UCI "stop" command)
}

func (board *Board) IterativeDeepeningSearch(options SearchOptions) *Move {
Expand All @@ -27,41 +28,61 @@ func (board *Board) IterativeDeepeningSearch(options SearchOptions) *Move {
}

maxDepth := SearchMaxDepth

if options.MaxDepth != 0 {
maxDepth = options.MaxDepth
}

// Limit depth search when running out of time
if options.RemainingTimeInMs != 0 && options.RemainingTimeInMs < 2500 {
maxDepth = 3
// Soft limit: stop starting new depths after this
softLimit := options.TimeLimitInMs
// Hard limit: abort in-flight search after this
hardLimit := options.MaxTimeLimitInMs

// Fall back to defaults when no time info is provided
if softLimit == 0 && hardLimit == 0 && options.MaxDepth == 0 {
softLimit = MaxEvaluationTimeMs
hardLimit = MaxEvaluationTimeMs
}

maxSearchTimeMs := MaxEvaluationTimeMs
if options.TimeLimitInMs != 0 {
maxSearchTimeMs = options.TimeLimitInMs
// If only one is set, use it for both
if softLimit == 0 && hardLimit > 0 {
softLimit = hardLimit
}
if hardLimit == 0 && softLimit > 0 {
hardLimit = softLimit
}

var bestMove *Move
totalTimeSpentInMs := 0
// Iterative deepening
for depth := 1; depth <= maxDepth; depth++ {
searchTimeLimit := maxSearchTimeMs - totalTimeSpentInMs
if searchTimeLimit <= 0 {
// Stop deepening if we've exceeded the soft limit
if softLimit > 0 && totalTimeSpentInMs >= softLimit {
break
}
result := board.Search(depth, tt, searchTimeLimit, bestMove)
// Give in-flight search up to the hard limit remaining
searchTimeLimit := 0
if hardLimit > 0 {
searchTimeLimit = hardLimit - totalTimeSpentInMs
if searchTimeLimit <= 0 {
break
}
}
result := board.Search(depth, tt, searchTimeLimit, bestMove, options.StopChan)
result.PrintUCI()
if result.BestMove != nil && (!result.IsInterrupted || bestMove == nil) {
bestMove = result.BestMove
}
totalTimeSpentInMs += int(result.TimeSpentInMs)
// If search was interrupted (timeout or stop), don't start next depth
if result.IsInterrupted {
break
}
}

return bestMove
}

func (board *Board) Search(depth int, tt *TranspositionTable, timeLimitInMs int, pvMove *Move) *SearchResult {
func (board *Board) Search(depth int, tt *TranspositionTable, timeLimitInMs int, pvMove *Move, stopChan chan struct{}) *SearchResult {
result := &SearchResult{}
result.StartTimer()
result.SetMaxSearchDepth(int32(depth))
Expand All @@ -71,12 +92,6 @@ func (board *Board) Search(depth int, tt *TranspositionTable, timeLimitInMs int,
moves = board.SortMovesRoot(moves, pvMove, ttMove)
ctx := &SearchContext{Done: make(chan struct{})}

timeoutTime := MaxEvaluationTimeMs * time.Millisecond
if timeLimitInMs != 0 {
timeoutTime = time.Duration(time.Duration(timeLimitInMs)) * time.Millisecond
}
timeout := time.After(timeoutTime)

var score int
var move *Move
finished := make(chan struct{})
Expand All @@ -85,12 +100,32 @@ func (board *Board) Search(depth int, tt *TranspositionTable, timeLimitInMs int,
close(finished)
}()

select {
case <-timeout:
close(ctx.Done)
if timeLimitInMs > 0 {
// Timed search: respect both timeout and external stop
timeout := time.After(time.Duration(timeLimitInMs) * time.Millisecond)
select {
case <-timeout:
close(ctx.Done)
<-finished
result.IsInterrupted = true
case <-finished:
case <-stopChan:
close(ctx.Done)
<-finished
result.IsInterrupted = true
}
} else if stopChan != nil {
// Infinite/depth-only: wait for finish or external stop
select {
case <-finished:
case <-stopChan:
close(ctx.Done)
<-finished
result.IsInterrupted = true
}
} else {
// No time limit and no stop channel: just wait
<-finished
result.IsInterrupted = true
case <-finished:
}

result.BestScore = score
Expand Down
102 changes: 96 additions & 6 deletions pkg/uci.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,112 @@ package libra

import "fmt"

func GetUCIRemainingTime(whiteToMove bool, fields []string) int {
var wtime, btime int
type GoOptions struct {
WTime int // white time remaining (ms)
BTime int // black time remaining (ms)
WInc int // white increment per move (ms)
BInc int // black increment per move (ms)
MovesToGo int // moves until next time control (0 = sudden death)
MoveTime int // exact time per move (ms)
Depth int // search to exactly this depth
Infinite bool // search until "stop" command
}

const timeManagementSafetyMarginMs = 100

func ParseGoOptions(fields []string) GoOptions {
opts := GoOptions{}
for i := 0; i < len(fields); i++ {
switch fields[i] {
case "wtime":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &wtime)
fmt.Sscanf(fields[i+1], "%d", &opts.WTime)
}
case "btime":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &btime)
fmt.Sscanf(fields[i+1], "%d", &opts.BTime)
}
case "winc":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &opts.WInc)
}
case "binc":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &opts.BInc)
}
case "movestogo":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &opts.MovesToGo)
}
case "movetime":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &opts.MoveTime)
}
case "depth":
if i+1 < len(fields) {
fmt.Sscanf(fields[i+1], "%d", &opts.Depth)
}
case "infinite":
opts.Infinite = true
}
}
return opts
}

// CalcTimeLimit computes optimal (soft) and maximum (hard) time limits in ms.
// optimalTime: target time per move, used to decide when to stop deepening.
// maxTime: absolute ceiling for in-flight searches.
func (opts *GoOptions) CalcTimeLimit(whiteToMove bool) (optimalTime int, maxTime int) {
// Fixed time per move
if opts.MoveTime > 0 {
return opts.MoveTime, opts.MoveTime
}

// Infinite or depth-only: no time constraint
if opts.Infinite || (opts.Depth > 0 && opts.WTime == 0 && opts.BTime == 0) {
return 0, 0
}

remaining := opts.BTime
increment := opts.BInc
if whiteToMove {
return wtime
remaining = opts.WTime
increment = opts.WInc
}

// No time info at all: fall back to a sensible default
if remaining <= 0 {
return MaxEvaluationTimeMs, MaxEvaluationTimeMs
}

// Safety: never use more than remaining - margin
safeRemaining := remaining - timeManagementSafetyMarginMs
if safeRemaining < 50 {
safeRemaining = 50
}
return btime

if opts.MovesToGo > 0 {
// Moves until next time control
optimalTime = safeRemaining/opts.MovesToGo + increment*3/4
maxTime = safeRemaining / 2
} else {
// Sudden death: estimate ~30 moves remaining
optimalTime = safeRemaining/30 + increment*3/4
maxTime = safeRemaining / 5
}

// Clamp optimal to not exceed max
if optimalTime > maxTime {
optimalTime = maxTime
}

// Never exceed safe remaining time
if maxTime > safeRemaining {
maxTime = safeRemaining
}
if optimalTime > safeRemaining {
optimalTime = safeRemaining
}

return optimalTime, maxTime
}
Loading
Loading