From 222d597a5d4c6dc5de022009852b37120d3f5edc Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Tue, 2 Jun 2026 22:12:15 +0200 Subject: [PATCH] feat(uci): add adapter seam and convert globals to struct types --- uci/adapter.go | 77 +++++++++++ uci/adapter_test.go | 186 ++++++++++++++++++++++++++ uci/cmd.go | 319 +++++++++++++++++--------------------------- uci/engine.go | 126 ++++------------- uci/engine_test.go | 8 +- uci/fake.go | 30 +++++ 6 files changed, 447 insertions(+), 299 deletions(-) create mode 100644 uci/adapter.go create mode 100644 uci/adapter_test.go create mode 100644 uci/fake.go diff --git a/uci/adapter.go b/uci/adapter.go new file mode 100644 index 0000000..9dbf72d --- /dev/null +++ b/uci/adapter.go @@ -0,0 +1,77 @@ +package uci + +import ( + "bufio" + "fmt" + "io" + "os/exec" +) + +type Adapter interface { + Exchange(cmd Cmd) ([]string, error) + Close() error +} + +type SubprocessAdapter struct { + cmd *exec.Cmd + writer *io.PipeWriter + reader *io.PipeReader + scanner *bufio.Scanner +} + +func NewSubprocessAdapter(path string) (*SubprocessAdapter, error) { + path, err := exec.LookPath(path) + if err != nil { + return nil, fmt.Errorf("uci: executable not found at path %s %w", path, err) + } + rIn, wIn := io.Pipe() + rOut, wOut := io.Pipe() + cmd := exec.Command(path) + cmd.Stdin = rIn + cmd.Stdout = wOut + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("uci: failed to start executable %s: %w", path, err) + } + go cmd.Wait() + return &SubprocessAdapter{ + cmd: cmd, + writer: wIn, + reader: rOut, + scanner: bufio.NewScanner(rOut), + }, nil +} + +func (s *SubprocessAdapter) Exchange(cmd Cmd) ([]string, error) { + if _, err := fmt.Fprintln(s.writer, cmd.String()); err != nil { + return nil, err + } + if cmd.IsDone("") { + return nil, nil + } + var lines []string + for s.scanner.Scan() { + line := s.scanner.Text() + lines = append(lines, line) + if cmd.IsDone(line) { + break + } + } + if err := s.scanner.Err(); err != nil { + return lines, err + } + return lines, nil +} + +func (s *SubprocessAdapter) Close() error { + if err := s.writer.Close(); err != nil { + return err + } + if err := s.reader.Close(); err != nil { + return err + } + return s.cmd.Process.Kill() +} + +func (s *SubprocessAdapter) Pid() int { + return s.cmd.Process.Pid +} diff --git a/uci/adapter_test.go b/uci/adapter_test.go new file mode 100644 index 0000000..27b5234 --- /dev/null +++ b/uci/adapter_test.go @@ -0,0 +1,186 @@ +package uci_test + +import ( + "testing" + + "github.com/corentings/chess/v2" + "github.com/corentings/chess/v2/uci" +) + +func Test_FakeAdapter_CmdUCI(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "uci": {"id name TestEngine", "id author test", "option name Hash type spin default 16 min 1 max 33554432", "uciok"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + err := eng.Run(uci.CmdUCI{}) + if err != nil { + t.Fatal(err) + } + + id := eng.ID() + if id["name"] != "TestEngine" { + t.Errorf("expected name TestEngine, got %s", id["name"]) + } + if id["author"] != "test" { + t.Errorf("expected author test, got %s", id["author"]) + } + + opts := eng.Options() + if _, ok := opts["Hash"]; !ok { + t.Error("expected Hash option") + } +} + +func Test_FakeAdapter_CmdGo(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "go": {"info depth 10 score cp 50 nodes 1000 nps 500000 tbhits 0 time 2 pv e2e4", "bestmove e2e4"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + pos := chess.StartingPosition() + cmdPos := uci.CmdPosition{Position: pos} + cmdGo := uci.CmdGo{MoveTime: 100} + if err := eng.Run(cmdPos, cmdGo); err != nil { + t.Fatal(err) + } + + results := eng.SearchResults() + if results.BestMove == nil { + t.Fatal("expected best move") + } + if results.Info.Depth != 10 { + t.Errorf("expected depth 10, got %d", results.Info.Depth) + } + if results.Info.Score.CP != 50 { + t.Errorf("expected score cp 50, got %d", results.Info.Score.CP) + } +} + +func Test_FakeAdapter_CmdEval(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "eval": {"Final evaluation 12.5"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + pos := chess.StartingPosition() + cmdPos := uci.CmdPosition{Position: pos} + if err := eng.Run(cmdPos, uci.CmdEval{}); err != nil { + t.Fatal(err) + } + + eval := eng.Eval() + if eval != 1250 { + t.Errorf("expected eval 1250, got %d", eval) + } +} + +func Test_FakeAdapter_MultiPV(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "go": { + "info depth 10 multipv 1 score cp 50 nodes 1000 pv e2e4", + "info depth 10 multipv 2 score cp 30 nodes 1000 pv d2d4", + "bestmove e2e4", + }, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + pos := chess.StartingPosition() + cmdPos := uci.CmdPosition{Position: pos} + cmdGo := uci.CmdGo{MoveTime: 100} + if err := eng.Run(cmdPos, cmdGo); err != nil { + t.Fatal(err) + } + + results := eng.SearchResults() + if len(results.MultiPVInfo) != 2 { + t.Fatalf("expected 2 MultiPV lines, got %d", len(results.MultiPVInfo)) + } + if results.MultiPVInfo[0].Score.CP != 50 { + t.Errorf("expected cp 50 for pv 1, got %d", results.MultiPVInfo[0].Score.CP) + } + if results.MultiPVInfo[1].Score.CP != 30 { + t.Errorf("expected cp 30 for pv 2, got %d", results.MultiPVInfo[1].Score.CP) + } +} + +func Test_FakeAdapter_CmdIsReady(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "isready": {"readyok"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + if err := eng.Run(uci.CmdIsReady{}); err != nil { + t.Fatal(err) + } +} + +func Test_FakeAdapter_FireAndForgetPassthrough(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{}, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + if err := eng.Run(uci.CmdUCINewGame{}); err != nil { + t.Fatal(err) + } + if err := eng.Run(uci.CmdStop{}); err != nil { + t.Fatal(err) + } + if err := eng.Run(uci.CmdPonderHit{}); err != nil { + t.Fatal(err) + } +} + +func Test_FakeAdapter_FullGame(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "uci": {"id name FakeFish", "uciok"}, + "isready": {"readyok"}, + "go": {"info depth 1 score cp 10 nodes 1 pv e2e4", "bestmove e2e4"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + if err := eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}); err != nil { + t.Fatal(err) + } + + id := eng.ID() + if id["name"] != "FakeFish" { + t.Errorf("expected name FakeFish, got %s", id["name"]) + } + + pos := chess.StartingPosition() + cmdPos := uci.CmdPosition{Position: pos} + cmdGo := uci.CmdGo{MoveTime: 100} + if err := eng.Run(cmdPos, cmdGo); err != nil { + t.Fatal(err) + } + + bestMove := eng.SearchResults().BestMove + if bestMove == nil { + t.Fatal("expected best move") + } + san := chess.AlgebraicNotation{}.Encode(pos, bestMove) + if san != "e4" { + t.Errorf("expected e4, got %s", san) + } +} diff --git a/uci/cmd.go b/uci/cmd.go index 5d28bd1..4a4c17c 100644 --- a/uci/cmd.go +++ b/uci/cmd.go @@ -1,7 +1,6 @@ package uci import ( - "bufio" "errors" "fmt" "math" @@ -12,152 +11,122 @@ import ( "github.com/corentings/chess/v2" ) -// Cmd is a UCI compliant command. type Cmd interface { fmt.Stringer - ProcessResponse(e *Engine) error + IsDone(line string) bool + Handle(lines []string, e *Engine) error + LockRequired() bool } -type cmdNoOptions struct { - F func(e *Engine) error - Name string -} +type CmdUCI struct{} + +func (CmdUCI) String() string { return "uci" } + +func (CmdUCI) IsDone(line string) bool { return line == "uciok" } + +func (CmdUCI) LockRequired() bool { return true } -func (cmd cmdNoOptions) String() string { - return cmd.Name +func (CmdUCI) Handle(lines []string, e *Engine) error { + e.id = map[string]string{} + e.options = map[string]Option{} + for _, text := range lines { + k, v, err := parseIDLine(text) + if err == nil { + e.id[k] = v + continue + } + o := &Option{} + if err = o.UnmarshalText([]byte(text)); err == nil { + e.options[o.Name] = *o + } + } + return nil } -func (cmd cmdNoOptions) ProcessResponse(e *Engine) error { - return cmd.F(e) +type CmdIsReady struct{} + +func (CmdIsReady) String() string { return "isready" } + +func (CmdIsReady) IsDone(line string) bool { return line == "readyok" } + +func (CmdIsReady) LockRequired() bool { return true } + +func (CmdIsReady) Handle(_ []string, _ *Engine) error { return nil } + +type CmdUCINewGame struct{} + +func (CmdUCINewGame) String() string { return "ucinewgame" } + +func (CmdUCINewGame) IsDone(_ string) bool { return true } + +func (CmdUCINewGame) LockRequired() bool { return true } + +func (CmdUCINewGame) Handle(_ []string, _ *Engine) error { return nil } + +type CmdPonderHit struct{} + +func (CmdPonderHit) String() string { return "ponderhit" } + +func (CmdPonderHit) IsDone(_ string) bool { return true } + +func (CmdPonderHit) LockRequired() bool { return false } + +func (CmdPonderHit) Handle(_ []string, _ *Engine) error { return nil } + +type CmdStop struct{} + +func (CmdStop) String() string { return "stop" } + +func (CmdStop) IsDone(_ string) bool { return true } + +func (CmdStop) LockRequired() bool { return false } + +func (CmdStop) Handle(_ []string, _ *Engine) error { return nil } + +type CmdQuit struct{} + +func (CmdQuit) String() string { return "quit" } + +func (CmdQuit) IsDone(_ string) bool { return true } + +func (CmdQuit) LockRequired() bool { return true } + +func (CmdQuit) Handle(_ []string, _ *Engine) error { return nil } + +type CmdEval struct{} + +func (CmdEval) String() string { return "eval" } + +func (CmdEval) IsDone(line string) bool { + lower := strings.ToLower(line) + return strings.HasPrefix(line, "Final evaluation") || + strings.Contains(lower, "error") || + strings.Contains(lower, "unknown command") } -var ( - // CmdUCI corresponds to the "uci" command: - // tell engine to use the uci (universal chess interface), - // this will be send once as a first command after program boot - // to tell the engine to switch to uci mode. - // After receiving the uci command the engine must identify itself with the "id" command - // and sent the "option" commands to tell the GUI which engine settings the engine supports if any. - // After that the engine should sent "uciok" to acknowledge the uci mode. - // If no uciok is sent within a certain time period, the engine task will be killed by the GUI. - //nolint:gochecknoglobals // Will need to improve this - // TODO: Remove global variable - CmdUCI = cmdNoOptions{Name: "uci", F: func(e *Engine) error { - e.id = map[string]string{} - e.options = map[string]Option{} - scanner := bufio.NewScanner(e.out) - for scanner.Scan() { - text := e.readLine(scanner) - k, v, err := parseIDLine(text) - if err == nil { - e.id[k] = v - continue - } - o := &Option{} - err = o.UnmarshalText([]byte(text)) - if err == nil { - e.options[o.Name] = *o - continue - } - if text == "uciok" { - break - } - } - return nil - }} - - // CmdIsReady corresponds to the "isready" command: - // this is used to synchronize the engine with the GUI. When the GUI has sent a command or - // multiple commands that can take some time to complete, - // this command can be used to wait for the engine to be ready again or - // to ping the engine to find out if it is still alive. - // E.g. this should be sent after setting the path to the tablebases as this can take some time. - // This command is also required once before the engine is asked to do any search - // to wait for the engine to finish initializing. - // This command must always be answered with "readyok" and can be sent also when the engine is calculating - // in which case the engine should also immediately answer with "readyok" without stopping the search. - CmdIsReady = cmdNoOptions{Name: "isready", F: func(e *Engine) error { - scanner := bufio.NewScanner(e.out) - for scanner.Scan() { - text := e.readLine(scanner) - if text == "readyok" { - break - } +func (CmdEval) LockRequired() bool { return true } + +func (CmdEval) Handle(lines []string, e *Engine) error { + for _, text := range lines { + lower := strings.ToLower(text) + if strings.Contains(lower, "error") || strings.Contains(lower, "unknown command") { + return errors.New("eval command not supported") } - return nil - }} - - // CmdUCINewGame corresponds to the "ucinewgame" command: - // this is sent to the engine when the next search (started with "position" and "go") will be from - // a different game. This can be a new game the engine should play or a new game it should analyse but - // also the next position from a testsuite with positions only. - // If the GUI hasn't sent a "ucinewgame" before the first "position" command, the engine shouldn't - // expect any further ucinewgame commands as the GUI is probably not supporting the ucinewgame command. - // So the engine should not rely on this command even though all new GUIs should support it. - // As the engine's reaction to "ucinewgame" can take some time the GUI should always send "isready" - // after "ucinewgame" to wait for the engine to finish its operation. - CmdUCINewGame = cmdNoOptions{Name: "ucinewgame", F: func(_ *Engine) error { - return nil - }} - - // CmdPonderHit corresponds to the "ponderhit" command: - // the user has played the expected move. This will be sent if the engine was told to ponder on the same move - // the user has played. The engine should continue searching but switch from pondering to normal search. - CmdPonderHit = cmdNoOptions{Name: "ponderhit", F: func(_ *Engine) error { - return nil - }} - - // CmdStop corresponds to the "stop" command: - // stop calculating as soon as possible, - // don't forget the "bestmove" and possibly the "ponder" token when finishing the search. - CmdStop = cmdNoOptions{Name: "stop", F: func(_ *Engine) error { - return nil - }} - - // CmdQuit (shouldn't be used directly as its handled by Engine.Close()) corresponds to the "quit" command: - // quit the program as soon as possible. - CmdQuit = cmdNoOptions{Name: "quit", F: func(_ *Engine) error { - return nil - }} - - // CmdEval is a non-standard command that requests the engine's static evaluation of the current position. - CmdEval = cmdNoOptions{Name: "eval", F: func(e *Engine) error { - scanner := bufio.NewScanner(e.out) - for scanner.Scan() { - text := e.readLine(scanner) - if strings.Contains(text, "error") { - return errors.New("eval command not supported") - } - if strings.HasPrefix(text, "Final evaluation") { - parts := strings.Fields(text) - if len(parts) >= 3 { - evalStr := parts[2] - eval, err := strconv.ParseFloat(evalStr, 64) - if err == nil { - e.eval = int(math.Round(eval * 100)) - } - break + if strings.HasPrefix(text, "Final evaluation") { + parts := strings.Fields(text) + if len(parts) >= 3 { + evalStr := parts[2] + eval, err := strconv.ParseFloat(evalStr, 64) + if err == nil { + e.eval = int(math.Round(eval * 100)) } + break } } - return nil - }} -) + } + return nil +} -// CmdSetOption corresponds to the "setoption" command: -// this is sent to the engine when the user wants to change the internal parameters -// of the engine. For the "button" type no value is needed. -// One string will be sent for each parameter and this will only be sent when the engine is waiting. -// The name of the option in should not be case sensitive and can inludes spaces like also the value. -// The substrings "value" and "name" should be avoided in and to allow unambiguous parsing, -// for example do not use = "draw value". -// Here are some strings for the example below: -// -// "setoption name Nullmove value true\n" -// "setoption name Selectivity value 3\n" -// "setoption name Style value Risky\n" -// "setoption name Clear Hash\n" -// "setoption name NalimovPath value c:\chess\tb\4;c:\chess\tb\5\n" type CmdSetOption struct { Name string Value string @@ -167,17 +136,12 @@ func (cmd CmdSetOption) String() string { return fmt.Sprintf("setoption name %s value %s", cmd.Name, cmd.Value) } -// ProcessResponse implements the Cmd interface. -func (cmd CmdSetOption) ProcessResponse(_ *Engine) error { - return nil -} +func (CmdSetOption) IsDone(_ string) bool { return true } + +func (CmdSetOption) LockRequired() bool { return true } + +func (CmdSetOption) Handle(_ []string, _ *Engine) error { return nil } -// CmdPosition corresponds to the "position" command: -// set up the position described in fenstring on the internal board and -// play the moves on the internal chess board. -// if the game was played from the start position the string "startpos" will be sent -// Note: no "new" command is needed. However, if this position is from a different game than -// the last position sent to the engine, the GUI should have sent a "ucinewgame" inbetween. type CmdPosition struct { Position *chess.Position Moves []*chess.Move @@ -198,51 +162,12 @@ func (cmd CmdPosition) String() string { return fmt.Sprintf("position fen %s moves %s", cmd.Position, strings.Join(moveStrs, " ")) } -// ProcessResponse implements the Cmd interface. -func (CmdPosition) ProcessResponse(_ *Engine) error { - return nil -} +func (CmdPosition) IsDone(_ string) bool { return true } + +func (CmdPosition) LockRequired() bool { return true } + +func (CmdPosition) Handle(_ []string, _ *Engine) error { return nil } -// CmdGo corresponds to the "go" command: -// start calculating on the current position set up with the "position" command. -// There are a number of commands that can follow this command, all will be sent in the same string. -// If one command is not send its value should be interpreted as it would not influence the search. -// - searchmoves .... -// restrict search to this moves only -// Example: After "position startpos" and "go infinite searchmoves e2e4 d2d4" -// the engine should only search the two moves e2e4 and d2d4 in the initial position. -// - ponder -// start searching in pondering mode. -// Do not exit the search in ponder mode, even if it's mate! -// This means that the last move sent in in the position string is the ponder move. -// The engine can do what it wants to do, but after a "ponderhit" command -// it should execute the suggested move to ponder on. This means that the ponder move sent by -// the GUI can be interpreted as a recommendation about which move to ponder. However, if the -// engine decides to ponder on a different move, it should not display any mainlines as they are -// likely to be misinterpreted by the GUI because the GUI expects the engine to ponder -// on the suggested move. -// - wtime -// white has x msec left on the clock -// - btime -// black has x msec left on the clock -// - winc -// white increment per move in mseconds if x > 0 -// - binc -// black increment per move in mseconds if x > 0 -// - movestogo -// there are x moves to the next time control, -// this will only be sent if x > 0, -// if you don't get this and get the wtime and btime it's sudden death -// - depth -// search x plies only. -// - nodes -// search x nodes only, -// - mate -// search for a mate in x moves -// - movetime -// search exactly x mseconds -// - infinite -// search until the "stop" command. Do not exit the search without being told so in this mode! type CmdGo struct { SearchMoves []*chess.Move WhiteTime time.Duration @@ -303,17 +228,17 @@ func (cmd CmdGo) String() string { return strings.Join(a, " ") } -// ProcessResponse implements the Cmd interface. -// TODO: Refactor this function to be shorter and more readable. -// -//nolint:nestif // work to be done -func (CmdGo) ProcessResponse(e *Engine) error { +func (CmdGo) IsDone(line string) bool { + return strings.HasPrefix(line, "bestmove") +} + +func (CmdGo) LockRequired() bool { return true } + +func (CmdGo) Handle(lines []string, e *Engine) error { const maxParts = 4 - scanner := bufio.NewScanner(e.out) results := SearchResults{MultiPVInfo: make([]Info, 1)} - for scanner.Scan() { - text := e.readLine(scanner) + for _, text := range lines { if strings.HasPrefix(text, "bestmove") { parts := strings.Split(text, " ") if len(parts) <= 1 { @@ -322,8 +247,6 @@ func (CmdGo) ProcessResponse(e *Engine) error { var position *chess.Position if e.position != nil { position = e.position.Position - } else { - position = nil } bestMove, err := chess.UCINotation{}.Decode(position, parts[1]) if err != nil { @@ -337,7 +260,7 @@ func (CmdGo) ProcessResponse(e *Engine) error { } results.Ponder = ponderMove } - break + continue } info := &Info{} diff --git a/uci/engine.go b/uci/engine.go index f093f9f..0d05bc3 100644 --- a/uci/engine.go +++ b/uci/engine.go @@ -1,21 +1,13 @@ package uci import ( - "bufio" - "fmt" - "io" "log" "os" - "os/exec" "sync" ) -// Engine represents a UCI compliant chess engine (e.g. Stockfish, Shredder, etc.). -// Engine is safe for concurrent use. type Engine struct { - cmd *exec.Cmd - in *io.PipeWriter - out *io.PipeReader + adapter Adapter logger *log.Logger id map[string]string options map[string]Option @@ -26,54 +18,38 @@ type Engine struct { debug bool } -// Debug is an option for the New function to add logging for debugging. This will -// log all output to and from the chess engine. func Debug(e *Engine) { e.debug = true } -// Logger is an option for the New function to customize the logger. The logger is -// only used if the Debug option is also used. func Logger(logger *log.Logger) func(e *Engine) { return func(e *Engine) { e.logger = logger } } -// New constructs an engine from the executable path (found using exec.LookPath). -// New also starts running the executable process in the background. Once created -// the Engine can be controlled via the Run method. func New(path string, opts ...func(e *Engine)) (*Engine, error) { - path, err := exec.LookPath(path) + adapter, err := NewSubprocessAdapter(path) if err != nil { - return nil, fmt.Errorf("uci: executable not found at path %s %w", path, err) + return nil, err + } + return NewWithAdapter(adapter, opts...), nil +} + +func NewWithAdapter(adapter Adapter, opts ...func(e *Engine)) *Engine { + e := &Engine{ + adapter: adapter, + logger: log.New(os.Stdout, "uci", log.LstdFlags), + mu: &sync.RWMutex{}, + position: &CmdPosition{}, + results: SearchResults{MultiPVInfo: []Info{}}, } - rIn, wIn := io.Pipe() - rOut, wOut := io.Pipe() - cmd := exec.Command(path) - cmd.Stdin = rIn - cmd.Stdout = wOut - e := &Engine{cmd: cmd, in: wIn, out: rOut, mu: &sync.RWMutex{}, logger: log.New(os.Stdout, "uci", log.LstdFlags), position: &CmdPosition{}, results: SearchResults{MultiPVInfo: []Info{}}} for _, opt := range opts { opt(e) } - err = e.cmd.Start() - if err != nil { - return nil, fmt.Errorf("uci: failed to start executable %s: %w", path, err) - } - go e.cmd.Wait() - - return e, nil + return e } -func (e *Engine) Getpid() int { - return e.cmd.Process.Pid -} - -// ID returns the id values returned from the most recent CmdUCI invocation. It includes -// key value data such as the following: -// id name Stockfish 12 -// id author the Stockfish developers (see AUTHORS file). func (e *Engine) ID() map[string]string { e.mu.RLock() defer e.mu.RUnlock() @@ -85,32 +61,6 @@ func (e *Engine) ID() map[string]string { return cp } -// Options returns exposed options from the most recent CmdUCI invocation. It includes -// data such as the following: -// option name Debug Log File type string default -// option name Contempt type spin default 24 min -100 max 100 -// option name Analysis Contempt type combo default Both var Off var White var Black var Both -// option name Threads type spin default 1 min 1 max 512 -// option name Hash type spin default 16 min 1 max 33554432 -// option name Clear Hash type button -// option name Ponder type check default false -// option name MultiPV type spin default 1 min 1 max 500 -// option name Skill Level type spin default 20 min 0 max 20 -// option name Move Overhead type spin default 10 min 0 max 5000 -// option name Slow Mover type spin default 100 min 10 max 1000 -// option name nodestime type spin default 0 min 0 max 10000 -// option name UCI_Chess960 type check default false -// option name UCI_AnalyseMode type check default false -// option name UCI_LimitStrength type check default false -// option name UCI_Elo type spin default 1350 min 1350 max 2850 -// option name UCI_ShowWDL type check default false -// option name SyzygyPath type string default -// option name SyzygyProbeDepth type spin default 1 min 1 max 100 -// option name Syzygy50MoveRule type check default true -// option name SyzygyProbeLimit type spin default 7 min 0 max 7 -// option name Use NNUE type check default true -// option name EvalFile type string default nn-82215d0fd0df.nnue -// The key is the option name and the value is the Option struct. func (e *Engine) Options() map[string]Option { e.mu.RLock() defer e.mu.RUnlock() @@ -122,10 +72,6 @@ func (e *Engine) Options() map[string]Option { return cp } -// SearchResults returns results from the most recent CmdGo invocation. It includes -// data such as the following: -// info depth 21 seldepth 31 multipv 1 score cp 39 nodes 862438 nps 860716 hashfull 409 tbhits 0 time 1002 pv e2e4 -// bestmove e2e4 ponder c7c5. func (e *Engine) SearchResults() SearchResults { e.mu.RLock() defer e.mu.RUnlock() @@ -138,12 +84,9 @@ func (e *Engine) Eval() int { return e.eval } -// Run runs the set of Cmds in the order given and returns an error if -// any of the commands fails. Except for CmdStop (usually paired with -// CmdGo's infinite option) all commands block via mutux until completed. func (e *Engine) Run(cmds ...Cmd) error { for _, cmd := range cmds { - if cmd.String() == CmdStop.Name { + if !cmd.LockRequired() { if err := e.processCommand(cmd); err != nil { return err } @@ -156,19 +99,13 @@ func (e *Engine) Run(cmds ...Cmd) error { return nil } -// Close releases readers, writers, and processes associated with the -// Engine. It also invokes the CmdQuit to signal the engine to terminate. func (e *Engine) Close() error { - if err := e.Run(CmdQuit); err != nil { - return err - } - if err := e.in.Close(); err != nil { - return err - } - if err := e.out.Close(); err != nil { - return err + quitErr := e.Run(CmdQuit{}) + closeErr := e.adapter.Close() + if quitErr != nil { + return quitErr } - return e.cmd.Process.Kill() + return closeErr } func (e *Engine) processCommandLocked(cmd Cmd) error { @@ -181,25 +118,20 @@ func (e *Engine) processCommand(cmd Cmd) error { if e.debug { e.logger.Println(cmd.String()) } - if _, err := fmt.Fprintln(e.in, cmd.String()); err != nil { + lines, err := e.adapter.Exchange(cmd) + if err != nil { return err } + if e.debug { + for _, line := range lines { + e.logger.Println(line) + } + } if posCmd, ok := cmd.(*CmdPosition); ok { e.position = posCmd } if posCmd, ok := cmd.(CmdPosition); ok { e.position = &posCmd } - if err := cmd.ProcessResponse(e); err != nil { - return err - } - return nil -} - -func (e *Engine) readLine(scanner *bufio.Scanner) string { - s := scanner.Text() - if e.debug { - e.logger.Println(s) - } - return s + return cmd.Handle(lines, e) } diff --git a/uci/engine_test.go b/uci/engine_test.go index 3af7d72..d62d80a 100644 --- a/uci/engine_test.go +++ b/uci/engine_test.go @@ -40,7 +40,7 @@ func Test_EngineEval(t *testing.T) { defer eng.Close() cmdPos := uci.CmdPosition{Position: pos} - err = eng.Run(uci.CmdUCI, uci.CmdIsReady, uci.CmdUCINewGame, cmdPos, uci.CmdEval) + err = eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}, cmdPos, uci.CmdEval{}) if name == "stockfish" { if err != nil { @@ -83,7 +83,7 @@ func Test_EngineInfo(t *testing.T) { cmdWDL := uci.CmdSetOption{Name: "UCI_ShowWDL", Value: "true"} cmdPos := uci.CmdPosition{Position: pos} cmdGo := uci.CmdGo{MoveTime: time.Second / 10} - if err := eng.Run(uci.CmdUCI, uci.CmdIsReady, uci.CmdUCINewGame, cmdMultiPV, cmdWDL, cmdPos, cmdGo); err != nil { + if err := eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}, cmdMultiPV, cmdWDL, cmdPos, cmdGo); err != nil { t.Fatal("failed to run command", err) } @@ -124,7 +124,7 @@ func Test_EngineMultiPVInfo(t *testing.T) { cmdMultiPV := uci.CmdSetOption{Name: "multipv", Value: "2"} cmdPos := uci.CmdPosition{Position: pos} cmdGo := uci.CmdGo{MoveTime: time.Second / 10} - if err := eng.Run(uci.CmdUCI, uci.CmdIsReady, uci.CmdUCINewGame, cmdMultiPV, cmdPos, cmdGo); err != nil { + if err := eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}, cmdMultiPV, cmdPos, cmdGo); err != nil { t.Fatal("failed to run command", err) } @@ -164,7 +164,7 @@ func Test_UCIMovesTags(t *testing.T) { setOpt := uci.CmdSetOption{Name: "UCI_Elo", Value: "1500"} setPos := uci.CmdPosition{Position: chess.StartingPosition()} setGo := uci.CmdGo{MoveTime: time.Second / 10} - if err := eng.Run(uci.CmdUCI, uci.CmdIsReady, setOpt, uci.CmdUCINewGame, setPos, setGo); err != nil { + if err := eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, setOpt, uci.CmdUCINewGame{}, setPos, setGo); err != nil { t.Fatal("failed to run command", err) } diff --git a/uci/fake.go b/uci/fake.go new file mode 100644 index 0000000..e60f526 --- /dev/null +++ b/uci/fake.go @@ -0,0 +1,30 @@ +package uci + +import "strings" + +type FakeAdapter struct { + Responses map[string][]string +} + +func (f *FakeAdapter) Exchange(cmd Cmd) ([]string, error) { + key := strings.SplitN(cmd.String(), " ", 2)[0] + responses, ok := f.Responses[key] + if !ok { + return nil, nil + } + if cmd.IsDone("") { + return nil, nil + } + var lines []string + for _, line := range responses { + lines = append(lines, line) + if cmd.IsDone(line) { + break + } + } + return lines, nil +} + +func (f *FakeAdapter) Close() error { + return nil +}