diff --git a/.golangci.yml b/.golangci.yml index d3e7a15..4c44c5f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,3 @@ linters: disable: - - errcheck \ No newline at end of file + - errcheck diff --git a/main.go b/main.go index 755cebd..1710c9c 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strings" + "sync" . "github.com/eugenioenko/libra-chess/pkg" ) @@ -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) @@ -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 } } diff --git a/pkg/search.go b/pkg/search.go index c057003..42bc4d6 100644 --- a/pkg/search.go +++ b/pkg/search.go @@ -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 { @@ -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)) @@ -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{}) @@ -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 diff --git a/pkg/uci.go b/pkg/uci.go index 6be311f..18d4f9f 100644 --- a/pkg/uci.go +++ b/pkg/uci.go @@ -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 } diff --git a/tests/search_test.go b/tests/search_test.go index 339bf58..67a4b57 100644 --- a/tests/search_test.go +++ b/tests/search_test.go @@ -13,13 +13,13 @@ var tt = NewTranspositionTable() func TestSearch5(t *testing.T) { board := NewBoard() board.FromFEN("rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8") - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearch4(t *testing.T) { board := NewBoard() board.FromFEN("r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1") - result := board.Search(5, tt, 0, nil) + result := board.Search(5, tt, 0, nil, nil) if result.BestScore > -200 { t.Errorf("Expected score > -200, got %d", result.BestScore) @@ -38,14 +38,14 @@ func TestSearch4(t *testing.T) { func TestCaptureWithLessFirst(t *testing.T) { board := NewBoard() board.FromFEN("k7/8/4p3/3r4/2B1Q3/8/8/7K w - - 0 1") - result := board.Search(1, tt, 0, nil) + result := board.Search(1, tt, 0, nil, nil) if result.BestMove == nil || result.BestMove.ToUCI() != "c4d5" { t.Errorf("Expected move c4d5, got %s", result.BestMove.ToUCI()) } board.FromFEN("k7/8/4p3/3r4/2Q1B3/8/8/7K w - - 0 1") - result = board.Search(2, tt, 0, nil) + result = board.Search(2, tt, 0, nil, nil) if result.BestMove == nil || result.BestMove.ToUCI() != "e4d5" { t.Errorf("Expected move e4d5, got %s", result.BestMove.ToUCI()) @@ -55,7 +55,7 @@ func TestCaptureWithLessFirst(t *testing.T) { func TestPreferMateInsteadOfCapture(t *testing.T) { board := NewBoard() board.FromFEN("k7/8/4p3/3r4/2Q1B3/8/8/7K w - - 0 1") - result := board.Search(5, tt, 0, nil) + result := board.Search(5, tt, 0, nil, nil) if result.BestMove == nil || result.BestMove.ToUCI() != "c4c7" { t.Errorf("Expected move c4c7, got %s", result.BestMove.ToUCI()) @@ -66,70 +66,70 @@ func TestSearchPerft1(t *testing.T) { board := NewBoard() board.LoadInitial() tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearchPerft2(t *testing.T) { board := NewBoard() board.FromFEN("r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1") // Corrected FEN tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearchPerft3(t *testing.T) { board := NewBoard() board.FromFEN("8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1") tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearchPerft4(t *testing.T) { board := NewBoard() board.FromFEN("r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1") tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearchPerft5(t *testing.T) { board := NewBoard() board.FromFEN("rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8") // Corrected FEN tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearchPerft6(t *testing.T) { board := NewBoard() board.FromFEN("r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10 ") // Corrected FEN tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearchPerft7(t *testing.T) { board := NewBoard() board.FromFEN("r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10 ") tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearchPerft8(t *testing.T) { board := NewBoard() board.FromFEN("4k2r/2b2ppp/5n2/7P/1Q1N4/4P3/5PP1/KR6 w k - 0 1") tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearchPerft9(t *testing.T) { board := NewBoard() board.FromFEN("1r5k/2b2ppp/5n2/NPP4P/PKR2B2/8/8/8 w - - 0 1") tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearchPerft10(t *testing.T) { board := NewBoard() board.FromFEN("8/8/ppk5/2p5/1P6/PKP5/8/8 w - - 0 1") tt := NewTranspositionTable() - board.Search(5, tt, 0, nil) + board.Search(5, tt, 0, nil, nil) } func TestSearch123(t *testing.T) {