diff --git a/.clang-tidy b/.clang-tidy index 4636b7e0..dfd97729 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -11,8 +11,8 @@ WarningsAsErrors: "*" CheckOptions: - key: readability-identifier-length.IgnoredVariableNames - value: "^(sq|to|from|bb|us|A[1-8]|B[1-8]|C[1-8]|D[1-8]|E[1-8]|F[1-8]|G[1-8]|H[1-8])$" + value: "^(sq|to|from|bb|us|it|A[1-8]|B[1-8]|C[1-8]|D[1-8]|E[1-8]|F[1-8]|G[1-8]|H[1-8])$" - key: readability-identifier-length.IgnoredParameterNames - value: "^(sq|to|from|bb|us)$" + value: "^(sq|to|from|bb|us|it)$" - key: readability-identifier-length.IgnoredLoopCounterNames value: "^(r|f|i)$" diff --git a/docs/commands.md b/docs/commands.md index 2019ef4d..bae999f2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -191,6 +191,30 @@ help Response: multiline informational text. +### `bench` + +Runs a benchmark search on the current position (non-UCI extension). + +Syntax: + +```text +bench [depth ] [movetime ] [wtime ] [btime ] [winc ] [binc ] [infinite] +``` + +Implemented behavior (current state): + +- Uses the same limit parser as `go`. +- `depth ` runs a fixed-depth benchmark. +- If `infinite` is provided, benchmark mode internally converts to `depth 10`. +- A bare `bench` command (no limits) is parsed as `infinite`, then converted internally to `depth 10`. +- `stop` can still interrupt a running benchmark early. + +Response when benchmark ends: + +```text +bench nodes negamax_nodes quiescence_nodes time(s) s nps +``` + ## Options Support Status ### UCI `setoption` diff --git a/include/bitbishop/engine/search.hpp b/include/bitbishop/engine/search.hpp index 3b27a203..82d23400 100644 --- a/include/bitbishop/engine/search.hpp +++ b/include/bitbishop/engine/search.hpp @@ -9,6 +9,14 @@ class Position; namespace Search { +/** + * @brief Contains statistics about a best move search. + */ +struct SearchStats { + uint64_t negamax_nodes = 0; ///< Number of explored negamax nodes + uint64_t quiescence_nodes = 0; ///< Number of explored quiescence nodes +}; + // We implement negamax with alpha-beta by flipping the window at each ply: // score = -negamax(child, depth-1, -beta, -alpha, ...) // That requires negating `alpha`/`beta`. Negating `INT_MIN` is undefined behaviour in C++, @@ -40,6 +48,7 @@ struct BestMove { * @param position Current position (board + history) * @param alpha Best score the current side can guarantee * @param beta Best score the opponent side can guarantee + * @param stats Statistics about the search process * * @return Score from the perspective of the side to move * @@ -60,7 +69,8 @@ struct BestMove { * positions. * """ */ -[[nodiscard]] int quiesce(Position& position, int alpha, int beta, std::atomic* stop_flag = nullptr); +[[nodiscard]] int quiesce(Position& position, int alpha, int beta, SearchStats& stats, + std::atomic* stop_flag = nullptr); /** * @brief Finds the best achievable move for the side to move assuming an optimal play on both sides. @@ -70,6 +80,7 @@ struct BestMove { * @param alpha Lower bound, aka. minimum score we already guaranteed to get. * @param beta Upper bound, aka. maximum score the opponent is willing to let us have. * @param ply Number of half-moves from root used for mate distance + * @param stats Statistics about the search process * * @return Move and score in a BestMove object * @@ -81,7 +92,7 @@ struct BestMove { * @see https://www.chessprogramming.org/Alpha-Beta * @see https://www.dogeystamp.com/chess2/ */ -[[nodiscard]] BestMove negamax(Position& position, std::size_t depth, int alpha, int beta, int ply, +[[nodiscard]] BestMove negamax(Position& position, std::size_t depth, int alpha, int beta, int ply, SearchStats& stats, std::atomic* stop_flag = nullptr); } // namespace Search diff --git a/include/bitbishop/interface/search_reporter.hpp b/include/bitbishop/interface/search_reporter.hpp new file mode 100644 index 00000000..6f0c41e6 --- /dev/null +++ b/include/bitbishop/interface/search_reporter.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include + +/** + * @brief Interface for reporting progress and results of a search. + * + * SearchReporter provides a callback-based mechanism for observing the + * progress of a search as well as its final result. Implementations can + * forward this information to different outputs (e.g., UCI protocol, + * benchmarking logs, GUI, etc.). + */ +struct SearchReporter { + virtual ~SearchReporter() = default; + + /** + * @brief Called after each completed search iteration. + * + * @param best Current best move found so far. + * @param depth Depth reached in the current iteration. + * @param stats Accumulated search statistics. + */ + virtual void on_iteration(const Search::BestMove& best, int depth, const Search::SearchStats& stats) {} + + /** + * @brief Called once when the search finishes. + * + * Must be implemented by derived classes to handle final reporting. + * + * @param best Final best move found by the search. + * @param stats Final search statistics. + */ + virtual void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) = 0; +}; + +/** + * @brief Reporter that outputs results in UCI (Universal Chess Interface) format. + * + * UciReporter writes the final best move to the provided output stream + * following the UCI protocol specification. Intended for communication + * with chess GUIs or other UCI-compatible tools. + */ +struct UciReporter : SearchReporter { + private: + /** Output stream used for writing UCI messages. */ + std::ostream& out_stream; + + public: + /** + * @brief Constructs a UciReporter. + * + * @param out Output stream where UCI messages will be written. + */ + UciReporter(std::ostream& out); + + /** + * @brief Outputs the final best move in UCI format. + * + * Prints a line of the form: + * "bestmove " + * If no move is available, "0000" is used. + * + * @param best Final best move found by the search. + * @param stats Final search statistics (unused). + */ + void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) override; +}; + +/** + * @brief Reporter that outputs benchmarking information. + * + * BenchReporter measures elapsed time from construction to completion + * and reports total nodes searched along with nodes-per-second (NPS). + */ +struct BenchReporter : SearchReporter { + using Clock = std::chrono::steady_clock; + + public: + /** Injectable time source. Mainly for testing. + * Must be declared *before* start. + */ + std::function now; + + private: + /** Output stream used for writing benchmark results. */ + std::ostream& out_stream; + + /** Start time of the benchmark measurement. */ + Clock::time_point start; + + public: + /** + * @brief Constructs a BenchReporter and records the start time. + * + * @param out Output stream where benchmark results will be written. + */ + BenchReporter(std::ostream& out); + BenchReporter(std::ostream& out, std::function now_fn); + + /** + * @brief Outputs benchmark statistics upon search completion. + * + * Computes total nodes searched (negamax + quiescence), elapsed time, + * and nodes per second (NPS), then prints a summary line (see implementation for details). + * + * @param best Final best move found by the search (unused). + * @param stats Final search statistics. + */ + void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) override; +}; diff --git a/include/bitbishop/interface/search_session.hpp b/include/bitbishop/interface/search_session.hpp new file mode 100644 index 00000000..961d40a9 --- /dev/null +++ b/include/bitbishop/interface/search_session.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include + +namespace Uci { + +/** + * @brief Owns the lifecycle of one active search and its reporter. + * + * Worker threads only publish events. This class consumes those events on the + * control thread and forwards them to the configured reporter. + */ +class SearchSession { + std::ostream& out_stream; + std::unique_ptr worker; + std::unique_ptr reporter; + + /** + * @brief Emits pending reports from worker to reporter. + */ + void emit_reports(); + + /** + * @brief Finalizes and clears resources if search already finished. + */ + void finalize_if_done(); + + public: + explicit SearchSession(std::ostream& out_stream); + ~SearchSession(); + + SearchSession(const SearchSession&) = delete; + SearchSession& operator=(const SearchSession&) = delete; + + /** + * @brief Starts a regular UCI search. + */ + void start_go(Board board, SearchLimits limits); + + /** + * @brief Starts a benchmark search. + */ + void start_bench(Board board, SearchLimits limits); + + /** + * @brief Requests current search to stop (non-blocking). + */ + void request_stop(); + + /** + * @brief Pumps reports and finalization (non-blocking). + */ + void poll(); + + /** + * @brief Stops and joins current search if any (blocking). + */ + void stop_and_join(); + + /** + * @brief Returns true when no search is active. + */ + [[nodiscard]] bool is_idle() const { return worker == nullptr; } +}; + +} // namespace Uci diff --git a/include/bitbishop/interface/search_controller.hpp b/include/bitbishop/interface/search_worker.hpp similarity index 53% rename from include/bitbishop/interface/search_controller.hpp rename to include/bitbishop/interface/search_worker.hpp index d235ece8..23c7eb28 100644 --- a/include/bitbishop/interface/search_controller.hpp +++ b/include/bitbishop/interface/search_worker.hpp @@ -1,11 +1,12 @@ #pragma once #include +#include #include +#include #include -#include -#include #include +#include namespace Uci { @@ -20,29 +21,65 @@ struct SearchLimits { std::optional wtime, btime; ///< White/black time limits (in milliseconds) std::optional winc, binc; ///< White/black increment limits (in milliseconds) bool infinite = false; ///< Flag for infinite search mode + + /** + * @brief Parses arguments from a search uci command into a SearchLimits object. + * + * UCI command is like: `go depth 2` or `go movetime 5000`, etc... + * + * @return The built SearchLimits object. + */ + static SearchLimits from_uci_cmd(const std::vector& line); +}; + +/** + * @brief Represents an event generated by a search worker. + * + * Search workers only publish data. Reporting/printing is done by the UCI control thread. + */ +enum class SearchReportKind : std::uint8_t { + Iteration, + Finish, +}; + +/** + * @brief Structured search report event consumed by the UCI controller. + */ +struct SearchReport { + SearchReportKind kind = SearchReportKind::Finish; + Search::BestMove best; + int depth = 0; + Search::SearchStats stats{}; }; /** * @brief Manages the search process for UCI commands. * - * This class handles the execution of search operations based on UCI parameters. - * It uses a background thread to perform the search and communicates results through a callback. + * This class handles the execution of search operations based on UCI parameters. It uses a background thread to + * perform the search and publishes structured reports for the control thread to consume. */ class SearchWorker { std::thread worker; ///< Worker thread for search operations std::atomic stop_flag{false}; ///< Flag used to forward the stop order to the worker(s) + std::atomic finished{true}; ///< Indicates whether worker thread has completed Board board; ///< Current chess board Position position; ///< Game position associated to the current chess board SearchLimits limits; ///< Current search parameters - std::ostream* out; ///< Output stream for UCI communication + std::mutex reports_mutex; ///< Synchronizes report queue access + std::vector reports; ///< FIFO queue of generated search reports /** * @brief Executes the search algorithm in a background thread. */ void run(); + /** + * @brief Pushes one report event into the queue. + */ + void push_report(SearchReport report); + public: - SearchWorker(Board board, SearchLimits limits, std::ostream& ostream = std::cout); + SearchWorker(Board board, SearchLimits limits); ~SearchWorker(); /** @@ -66,7 +103,26 @@ class SearchWorker { * Thread interruption finishes early the best move search and may not be able to return a best move. * An intermediate state may be returned instead. */ + void request_stop(); + + /** + * @brief Returns whether the current worker run has finished. + */ + [[nodiscard]] bool is_finished() const { return finished.load(); } + + /** + * @brief Requests a stop then waits for completion. + * + * This is a blocking helper. + */ void stop(); + + /** + * @brief Moves all pending search reports out of the worker queue. + * + * This is thread-safe and intended to be called by the control thread. + */ + [[nodiscard]] std::vector drain_reports(); }; } // namespace Uci diff --git a/include/bitbishop/interface/uci_command_channel.hpp b/include/bitbishop/interface/uci_command_channel.hpp new file mode 100644 index 00000000..913e1106 --- /dev/null +++ b/include/bitbishop/interface/uci_command_channel.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Uci { + +/** + * @brief Thread-safe command line channel for UCI input. + * + * This class owns a reader thread that continuously reads raw lines from an input stream. + * Consuming code can wait and pop lines without dealing with synchronization primitives. + */ +class UciCommandChannel { + struct State { + std::mutex lines_mutex; + std::condition_variable lines_cv; + std::deque pending_lines; + std::atomic eof_reached{false}; + std::atomic stop_requested{false}; + }; + + std::istream& input_stream; + std::thread reader_thread; + std::shared_ptr state; + + /** + * @brief Reader loop running in a dedicated thread. + */ + static void reader_loop(std::istream& input_stream, std::shared_ptr state); + + public: + explicit UciCommandChannel(std::istream& input_stream); + ~UciCommandChannel(); + + UciCommandChannel(const UciCommandChannel&) = delete; + UciCommandChannel& operator=(const UciCommandChannel&) = delete; + + /** + * @brief Starts the reader thread. + */ + void start(); + + /** + * @brief Stops reader resources. + * + * If input is already at EOF, this joins the thread. + * Otherwise, thread is detached because std::getline cannot be forcibly interrupted. + */ + void stop(); + + /** + * @brief Waits for one line and pops it if available. + * + * @param line Destination string receiving one raw input line + * @param timeout Max waiting duration + * @return true if a line was popped, false otherwise + */ + bool wait_and_pop_line(std::string& line, std::chrono::milliseconds timeout); + + /** + * @brief Returns whether input EOF was reached by the reader thread. + */ + [[nodiscard]] bool eof() const; +}; + +} // namespace Uci diff --git a/include/bitbishop/interface/uci_command_registry.hpp b/include/bitbishop/interface/uci_command_registry.hpp new file mode 100644 index 00000000..757403e5 --- /dev/null +++ b/include/bitbishop/interface/uci_command_registry.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +namespace Uci { + +/** + * @brief Registry mapping UCI command names to handlers. + */ +class UciCommandRegistry { + public: + using Handler = std::function&)>; + + private: + std::unordered_map handlers; + + public: + /** + * @brief Registers or overrides one command handler. + */ + void register_handler(std::string command, Handler handler); + + /** + * @brief Dispatches the given command line if a handler exists. + * + * @return true when a handler was found and executed, false otherwise. + */ + [[nodiscard]] bool dispatch(const std::vector& line) const; + + /** + * @brief Retrieves the number of handlers currently registered. + * + * @return Number of handlers registered in the UciCommandRegistry. + */ + [[nodiscard]] std::size_t get_handlers_count() const noexcept { return handlers.size(); } +}; + +} // namespace Uci diff --git a/include/bitbishop/interface/uci_engine.hpp b/include/bitbishop/interface/uci_engine.hpp index 7455122d..a7f463df 100644 --- a/include/bitbishop/interface/uci_engine.hpp +++ b/include/bitbishop/interface/uci_engine.hpp @@ -1,6 +1,8 @@ #pragma once -#include +#include +#include +#include #include #include #include @@ -30,11 +32,12 @@ namespace Uci { class UciEngine { Board board; ///< Current chess board Position position; ///< Game position associated to the current chess board - std::unique_ptr search_worker_ptr; ///< Manages the search process + UciCommandChannel command_channel; ///< Command listener (input thread + queue) + SearchSession search_session; ///< Search lifecycle owner (worker + reporter) + UciCommandRegistry command_registry; ///< UCI command -> handler registry bool is_running; - std::istream &in_stream; ///< Input stream for UCI commands - std::ostream &out_stream; ///< Output stream for UCI responses + std::ostream& out_stream; ///< Output stream for UCI responses public: UciEngine() = delete; @@ -82,7 +85,12 @@ class UciEngine { * * @param line The input command tokens to process */ - void dispatch(std::vector &line); + void dispatch(const std::vector &line); + + /** + * @brief Registers all built-in UCI command handlers. + */ + void register_handlers(); /** * @brief Handles the "uci" command. @@ -105,7 +113,7 @@ class UciEngine { * * @param line The input command tokens containing the position information */ - void handle_position(std::vector &line); + void handle_position(const std::vector &line); /** * @brief Parses and handles "go" commands. @@ -114,7 +122,7 @@ class UciEngine { * * @param line The input command tokens containing the search parameters */ - void handle_go(std::vector &line); + void handle_go(const std::vector &line); /** * @brief Handles the "stop" command. @@ -130,11 +138,6 @@ class UciEngine { */ void handle_quit(); - /** - * @brief Stops as soon as possible the search thread and resets it for later computations. - */ - void reset_search_worker(); - /** * @brief Displays the current board state as well as usefull information. * @@ -156,6 +159,13 @@ class UciEngine { * Should be used before the UCI loop starts. */ void send_startup_msg(); + + /** + * @brief Runs a benchmark. + * + * @param line The input command tokens containing the benchmark information + */ + void handle_bench(const std::vector &line); }; } // namespace Uci diff --git a/src/bitbishop/engine/search.cpp b/src/bitbishop/engine/search.cpp index 16ff80f9..630d9d0a 100644 --- a/src/bitbishop/engine/search.cpp +++ b/src/bitbishop/engine/search.cpp @@ -4,7 +4,9 @@ #include // https://www.chessprogramming.org/Quiescence_Search -int Search::quiesce(Position& position, int alpha, int beta, std::atomic* stop_flag) { +int Search::quiesce(Position& position, int alpha, int beta, SearchStats& stats, std::atomic* stop_flag) { + stats.quiescence_nodes++; + if (stop_flag != nullptr && stop_flag->load()) { return alpha; } @@ -47,7 +49,7 @@ int Search::quiesce(Position& position, int alpha, int beta, std::atomic* // Quiescence window flip: child is searched with (-beta, -alpha) and the returned score is negated. // This relies on `ALPHA_INIT` not being `INT_MIN` (see `include/bitbishop/engine/search.hpp`). - int score = -quiesce(position, -beta, -alpha, stop_flag); + int score = -quiesce(position, -beta, -alpha, stats, stop_flag); position.revert_move(); if (stop_flag != nullptr && stop_flag->load()) { @@ -64,7 +66,9 @@ int Search::quiesce(Position& position, int alpha, int beta, std::atomic* } Search::BestMove Search::negamax(Position& position, std::size_t depth, int alpha, int beta, int ply, - std::atomic* stop_flag) { + SearchStats& stats, std::atomic* stop_flag) { + stats.negamax_nodes++; + const Board& board = position.get_board(); BestMove best; @@ -81,7 +85,7 @@ Search::BestMove Search::negamax(Position& position, std::size_t depth, int alph } if (depth == 0) { - best.score = quiesce(position, alpha, beta, stop_flag); + best.score = quiesce(position, alpha, beta, stats, stop_flag); return best; } @@ -115,7 +119,7 @@ Search::BestMove Search::negamax(Position& position, std::size_t depth, int alph position.apply_move(move); // Negamax window flip: child is searched with (-beta, -alpha) and the returned score is negated. // This relies on `ALPHA_INIT` not being `INT_MIN` (see `include/bitbishop/engine/search.hpp`). - int score = -negamax(position, depth - 1, -beta, -alpha, ply + 1, stop_flag).score; + int score = -negamax(position, depth - 1, -beta, -alpha, ply + 1, stats, stop_flag).score; position.revert_move(); if (stop_flag != nullptr && stop_flag->load()) { diff --git a/src/bitbishop/interface/search_controller.cpp b/src/bitbishop/interface/search_controller.cpp deleted file mode 100644 index 45055cec..00000000 --- a/src/bitbishop/interface/search_controller.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include - -Uci::SearchWorker::SearchWorker(Board board, SearchLimits limits, std::ostream& ostream) - : board(board), position(Position(this->board)), limits(limits), out(&ostream) {} - -Uci::SearchWorker::~SearchWorker() { stop(); } - -void Uci::SearchWorker::run() { - using namespace Search; - - BestMove best; - BestMove last_best; - try { - if (limits.infinite) { - for (int current_depth = 1; !stop_flag.load(); ++current_depth) { - best = negamax(position, current_depth, ALPHA_INIT, BETA_INIT, 0, &stop_flag); - if (!stop_flag.load()) { - last_best = best; - } - } - } else if (limits.depth) { - best = negamax(position, *limits.depth, ALPHA_INIT, BETA_INIT, 0, &stop_flag); - } - } catch (const std::exception& e) { - (*out) << "info string exception " << e.what() << "\n"; - } catch (...) { - (*out) << "info string unknown exception\n"; - } - - const BestMove& final = (last_best.move) ? last_best : best; - const std::string best_move_str = (final.move) ? (*final.move).to_uci() : "0000"; - (*out) << "bestmove " << best_move_str << "\n"; - (*out) << std::flush; -} - -void Uci::SearchWorker::start() { - stop(); - stop_flag.store(false); - worker = std::thread(&SearchWorker::run, this); -} - -void Uci::SearchWorker::wait() { - if (worker.joinable()) { - worker.join(); - } -} - -void Uci::SearchWorker::stop() { - stop_flag.store(true); - wait(); -} diff --git a/src/bitbishop/interface/search_reporter.cpp b/src/bitbishop/interface/search_reporter.cpp new file mode 100644 index 00000000..96f2c0cc --- /dev/null +++ b/src/bitbishop/interface/search_reporter.cpp @@ -0,0 +1,29 @@ +#include +#include + +UciReporter::UciReporter(std::ostream& out) : out_stream(out) {} + +void UciReporter::on_finish(const Search::BestMove& best, const Search::SearchStats& stats) { + const std::string best_move_str = (best.move) ? (*best.move).to_uci() : "0000"; + out_stream << "bestmove " << best_move_str << "\n"; + out_stream << std::flush; +} + +BenchReporter::BenchReporter(std::ostream& out) : BenchReporter(out, Clock::now) {} + +BenchReporter::BenchReporter(std::ostream& out, std::function now_fn) + : out_stream(out), now(std::move(now_fn)), start(now()) {} + +void BenchReporter::on_finish(const Search::BestMove& best, const Search::SearchStats& stats) { + using Duration = std::chrono::duration; + + auto end = now(); + double seconds = Duration(end - start).count(); + + uint64_t total = stats.negamax_nodes + stats.quiescence_nodes; + uint64_t nps = (seconds > 0.0) ? static_cast(static_cast(total) / seconds) : 0; + + out_stream << "bench nodes " << total << " negamax_nodes " << stats.negamax_nodes << " quiescence_nodes " + << stats.quiescence_nodes << " time(s) " << seconds << "s" << " nps " << nps << "\n" + << std::flush; +} diff --git a/src/bitbishop/interface/search_session.cpp b/src/bitbishop/interface/search_session.cpp new file mode 100644 index 00000000..d61ee9cb --- /dev/null +++ b/src/bitbishop/interface/search_session.cpp @@ -0,0 +1,77 @@ +#include + +#include + +Uci::SearchSession::SearchSession(std::ostream& out_stream) + : out_stream(out_stream), worker(nullptr), reporter(nullptr) {} + +Uci::SearchSession::~SearchSession() { stop_and_join(); } + +void Uci::SearchSession::start_go(Board board, SearchLimits limits) { + stop_and_join(); + + reporter = std::make_unique(out_stream); + worker = std::make_unique(board, limits); + assert(worker != nullptr); + worker->start(); +} + +void Uci::SearchSession::start_bench(Board board, SearchLimits limits) { + stop_and_join(); + + if (limits.infinite) { + limits.depth = 10; // NOLINT(readability-magic-numbers) + limits.infinite = false; + } + + reporter = std::make_unique(out_stream); + worker = std::make_unique(board, limits); + assert(worker != nullptr); + worker->start(); +} + +void Uci::SearchSession::request_stop() { + if (worker) { + worker->request_stop(); + } +} + +void Uci::SearchSession::emit_reports() { + if (!worker || !reporter) { + return; + } + + const auto reports = worker->drain_reports(); + for (const SearchReport& report : reports) { + if (report.kind == SearchReportKind::Iteration) { + reporter->on_iteration(report.best, report.depth, report.stats); + } else if (report.kind == SearchReportKind::Finish) { + reporter->on_finish(report.best, report.stats); + } + } +} + +void Uci::SearchSession::finalize_if_done() { + if (!worker || !worker->is_finished()) { + return; + } + + worker->wait(); + emit_reports(); + worker.reset(); + reporter.reset(); +} + +void Uci::SearchSession::poll() { + emit_reports(); + finalize_if_done(); +} + +void Uci::SearchSession::stop_and_join() { + if (worker) { + worker->stop(); + emit_reports(); + worker.reset(); + } + reporter.reset(); +} diff --git a/src/bitbishop/interface/search_worker.cpp b/src/bitbishop/interface/search_worker.cpp new file mode 100644 index 00000000..414be7dc --- /dev/null +++ b/src/bitbishop/interface/search_worker.cpp @@ -0,0 +1,124 @@ +#include + +Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(const std::vector& line) { + SearchLimits limits; + + for (std::size_t i = 1; i < line.size(); ++i) { + const auto& tok = line[i]; + + auto read = [&](std::optional& target) { + if (i + 1 < line.size()) { + target = std::stoi(line[++i]); + } + }; + + if (tok == "depth") { + read(limits.depth); + } else if (tok == "movetime") { + read(limits.movetime); + } else if (tok == "wtime") { + read(limits.wtime); + } else if (tok == "btime") { + read(limits.btime); + } else if (tok == "winc") { + read(limits.winc); + } else if (tok == "binc") { + read(limits.binc); + } else if (tok == "infinite") { + limits.infinite = true; + } + } + + const bool has_time_control = limits.movetime || limits.wtime || limits.btime || limits.winc || limits.binc; + if (!limits.depth && !has_time_control && !limits.infinite) { + limits.infinite = true; + } + + return limits; +} + +Uci::SearchWorker::SearchWorker(Board board, SearchLimits limits) + : board(board), position(Position(this->board)), limits(limits) {} + +Uci::SearchWorker::~SearchWorker() { stop(); } + +void Uci::SearchWorker::push_report(SearchReport report) { + std::lock_guard lock(reports_mutex); + reports.push_back(report); +} + +void Uci::SearchWorker::run() { + using namespace Search; + struct FinishGuard { + std::atomic& finished_ref; + ~FinishGuard() { finished_ref.store(true); } + } guard{finished}; + + SearchStats stats{}; + BestMove best; + BestMove last_best; + + if (limits.infinite) { + for (int current_depth = 1; !stop_flag.load(); ++current_depth) { + best = negamax(position, current_depth, ALPHA_INIT, BETA_INIT, 0, stats, &stop_flag); + if (!stop_flag.load()) { + last_best = best; + push_report(SearchReport{ + .kind = SearchReportKind::Iteration, + .best = last_best, + .depth = current_depth, + .stats = stats, + }); + } + } + } else if (limits.depth) { + best = negamax(position, *limits.depth, ALPHA_INIT, BETA_INIT, 0, stats, &stop_flag); + if (!stop_flag.load()) { + push_report(SearchReport{ + .kind = SearchReportKind::Iteration, + .best = best, + .depth = *limits.depth, + .stats = stats, + }); + } + } + + const BestMove& final = (last_best.move) ? last_best : best; + push_report(SearchReport{ + .kind = SearchReportKind::Finish, + .best = final, + .depth = limits.depth.value_or(0), + .stats = stats, + }); +} + +void Uci::SearchWorker::start() { + stop(); + stop_flag.store(false); + finished.store(false); + { + std::lock_guard lock(reports_mutex); + reports.clear(); + } + worker = std::thread(&SearchWorker::run, this); +} + +void Uci::SearchWorker::wait() { + if (worker.joinable()) { + worker.join(); + } +} + +void Uci::SearchWorker::request_stop() { stop_flag.store(true); } + +void Uci::SearchWorker::stop() { + request_stop(); + wait(); +} + +std::vector Uci::SearchWorker::drain_reports() { + std::lock_guard lock(reports_mutex); + std::vector drained; + drained.swap(reports); + return drained; +} diff --git a/src/bitbishop/interface/uci_command_channel.cpp b/src/bitbishop/interface/uci_command_channel.cpp new file mode 100644 index 00000000..051b51eb --- /dev/null +++ b/src/bitbishop/interface/uci_command_channel.cpp @@ -0,0 +1,74 @@ +#include +#include + +Uci::UciCommandChannel::UciCommandChannel(std::istream& input_stream) : input_stream(input_stream), state(nullptr) {} + +Uci::UciCommandChannel::~UciCommandChannel() { stop(); } + +// NOLINTNEXTLINE(performance-unnecessary-value-param) +void Uci::UciCommandChannel::reader_loop(std::istream& input_stream, std::shared_ptr state) { + std::string line; + while (!state->stop_requested.load() && std::getline(input_stream, line)) { + { + std::lock_guard lock(state->lines_mutex); + state->pending_lines.push_back(std::move(line)); + } + state->lines_cv.notify_one(); + } + + state->eof_reached.store(true); + state->lines_cv.notify_all(); +} + +void Uci::UciCommandChannel::start() { + stop(); + + auto new_state = std::make_shared(); + std::thread new_reader(&UciCommandChannel::reader_loop, std::ref(input_stream), new_state); + + // Move values after being sure that thread construction is successfull + // avoiding successfully built state and failed thread. + // Either state and thread are created successfully, or neither of them is. + state = std::move(new_state); + reader_thread = std::move(new_reader); +} + +void Uci::UciCommandChannel::stop() { + if (!state) { + return; + } + + state->stop_requested.store(true); + state->lines_cv.notify_all(); + + if (reader_thread.joinable()) { + if (state->eof_reached.load()) { + reader_thread.join(); + } else { + reader_thread.detach(); + } + } + + state.reset(); +} + +bool Uci::UciCommandChannel::wait_and_pop_line(std::string& line, std::chrono::milliseconds timeout) { + if (!state) { + return false; + } + + std::unique_lock lock(state->lines_mutex); + state->lines_cv.wait_for(lock, timeout, [channel_state = state] { + return !channel_state->pending_lines.empty() || channel_state->eof_reached.load(); + }); + + if (state->pending_lines.empty()) { + return false; + } + + line = std::move(state->pending_lines.front()); + state->pending_lines.pop_front(); + return true; +} + +bool Uci::UciCommandChannel::eof() const { return state && state->eof_reached.load(); } diff --git a/src/bitbishop/interface/uci_command_registry.cpp b/src/bitbishop/interface/uci_command_registry.cpp new file mode 100644 index 00000000..26a0b95d --- /dev/null +++ b/src/bitbishop/interface/uci_command_registry.cpp @@ -0,0 +1,19 @@ +#include + +void Uci::UciCommandRegistry::register_handler(std::string command, Handler handler) { + handlers.insert_or_assign(std::move(command), std::move(handler)); +} + +bool Uci::UciCommandRegistry::dispatch(const std::vector& line) const { + if (line.empty()) { + return false; + } + + const auto it = handlers.find(line.front()); + if (it == handlers.end()) { + return false; + } + + it->second(line); + return true; +} diff --git a/src/bitbishop/interface/uci_engine.cpp b/src/bitbishop/interface/uci_engine.cpp index 7e4e3c5b..499b9a37 100644 --- a/src/bitbishop/interface/uci_engine.cpp +++ b/src/bitbishop/interface/uci_engine.cpp @@ -1,7 +1,6 @@ #include #include -#include [[nodiscard]] std::vector Uci::split(const std::string &str) { std::vector tokens; @@ -14,50 +13,85 @@ } Uci::UciEngine::UciEngine(std::istream &input, std::ostream &output) - : is_running(true), - board(Board::StartingPosition()), + : board(Board::StartingPosition()), position(Position(this->board)), - in_stream(input), - out_stream(output), - search_worker_ptr(nullptr) {} + command_channel(input), + search_session(output), + command_registry(), + is_running(true), + out_stream(output) { + register_handlers(); +} void Uci::UciEngine::loop() { + constexpr const std::chrono::milliseconds LINE_POLL_INTERVAL_MS(5); + send_startup_msg(); + command_channel.start(); - std::string input_str; - std::vector line; - while (is_running && std::getline(in_stream, input_str)) { - line = split(input_str); - dispatch(line); - } -}; + while (is_running) { + search_session.poll(); -void Uci::UciEngine::dispatch(std::vector &line) { - if (line.empty()) { - return; + std::string raw_line; + const bool line_was_popped = command_channel.wait_and_pop_line(raw_line, LINE_POLL_INTERVAL_MS); + if (line_was_popped) { + std::vector line = split(raw_line); + dispatch(line); + continue; + } + + if (command_channel.eof()) { + search_session.request_stop(); + search_session.poll(); + if (search_session.is_idle()) { + is_running = false; + } + } } - if (line.front() == "uci") { + search_session.stop_and_join(); + command_channel.stop(); +} + +void Uci::UciEngine::dispatch(const std::vector& line) { + std::ignore = command_registry.dispatch(line); + // unknown lines are discarded silently following uci rules +}; + +void Uci::UciEngine::register_handlers() { + command_registry.register_handler("uci", [this](const std::vector& line) { + (void)line; handle_uci(); - } else if (line.front() == "isready") { + }); + command_registry.register_handler("isready", [this](const std::vector& line) { + (void)line; out_stream << "readyok\n" << std::flush; - } else if (line.front() == "ucinewgame") { + }); + command_registry.register_handler("ucinewgame", [this](const std::vector& line) { + (void)line; handle_new_game(); - } else if (line.front() == "position") { - handle_position(line); - } else if (line.front() == "go") { - handle_go(line); - } else if (line.front() == "stop") { + }); + command_registry.register_handler("position", + [this](const std::vector& line) { handle_position(line); }); + command_registry.register_handler("go", [this](const std::vector& line) { handle_go(line); }); + command_registry.register_handler("stop", [this](const std::vector& line) { + (void)line; handle_stop(); - } else if (line.front() == "quit") { + }); + command_registry.register_handler("quit", [this](const std::vector& line) { + (void)line; handle_quit(); - } else if (line.front() == "d") { + }); + command_registry.register_handler("d", [this](const std::vector& line) { + (void)line; handle_display(); - } else if (line.front() == "help") { + }); + command_registry.register_handler("help", [this](const std::vector& line) { + (void)line; handle_help(); - } - // unknown lines are discarded silently following uci rules -}; + }); + command_registry.register_handler("bench", [this](const std::vector& line) { handle_bench(line); }); +} void Uci::UciEngine::handle_uci() { out_stream << "id name " << BITBISHOP_PROJECT_NAME << "\n" @@ -71,7 +105,7 @@ void Uci::UciEngine::handle_new_game() { position.reset(); } -void Uci::UciEngine::handle_position(std::vector &line) { +void Uci::UciEngine::handle_position(const std::vector& line) { using namespace Const; if (line.size() < 2) { @@ -115,61 +149,18 @@ void Uci::UciEngine::handle_position(std::vector &line) { } } -void Uci::UciEngine::handle_go(std::vector &line) { - reset_search_worker(); - - SearchLimits limits; - - for (std::size_t i = 1; i < line.size(); ++i) { - const auto &tok = line[i]; - - auto read = [&](std::optional &target) { - if (i + 1 < line.size()) { - target = std::stoi(line[++i]); - } - }; - - if (tok == "depth") { - read(limits.depth); - } else if (tok == "movetime") { - read(limits.movetime); - } else if (tok == "wtime") { - read(limits.wtime); - } else if (tok == "btime") { - read(limits.btime); - } else if (tok == "winc") { - read(limits.winc); - } else if (tok == "binc") { - read(limits.binc); - } else if (tok == "infinite") { - limits.infinite = true; - } - } - - if (!limits.depth) { - limits.infinite = true; // Only depth and infinite limits are supported for now - } - - search_worker_ptr = std::make_unique(board, limits, out_stream); - assert(search_worker_ptr != nullptr); - search_worker_ptr->start(); +void Uci::UciEngine::handle_go(const std::vector& line) { + SearchLimits limits = SearchLimits::from_uci_cmd(line); + search_session.start_go(board, limits); } -void Uci::UciEngine::handle_stop() { reset_search_worker(); } +void Uci::UciEngine::handle_stop() { search_session.request_stop(); } void Uci::UciEngine::handle_quit() { - reset_search_worker(); + search_session.request_stop(); is_running = false; } -void Uci::UciEngine::reset_search_worker() { - if (search_worker_ptr) { - search_worker_ptr->stop(); - search_worker_ptr.reset(); - } - assert(search_worker_ptr == nullptr); -} - void Uci::UciEngine::handle_display() { out_stream << "\n"; out_stream << board << "\n"; @@ -194,3 +185,8 @@ For any further information, visit its GitHub repository: https://github.com/Har void Uci::UciEngine::send_startup_msg() { out_stream << BITBISHOP_PROJECT_NAME << " " << BITBISHOP_VERSION << " " << "by Hardcode3 (Baptiste Penot).\n"; } + +void Uci::UciEngine::handle_bench(const std::vector& line) { + SearchLimits limits = SearchLimits::from_uci_cmd(line); + search_session.start_bench(board, limits); +} diff --git a/tests/bitbishop/engine/search/test_negamax.cpp b/tests/bitbishop/engine/search/test_negamax.cpp index 654b74cd..59c4456f 100644 --- a/tests/bitbishop/engine/search/test_negamax.cpp +++ b/tests/bitbishop/engine/search/test_negamax.cpp @@ -13,8 +13,9 @@ using namespace Squares; TEST(NegaMaxTest, EmptyBoardThrows) { Board board = Board::Empty(); Position pos(board); + SearchStats stats; - EXPECT_THROW(std::ignore = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0), std::bad_optional_access); + EXPECT_THROW(std::ignore = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0, stats), std::bad_optional_access); } TEST(NegaMaxTest, EmptyBoardWithBothKingsDontThrow) { @@ -25,15 +26,17 @@ TEST(NegaMaxTest, EmptyBoardWithBothKingsDontThrow) { board.set_side_to_move(Color::WHITE); Position pos(board); - EXPECT_NO_THROW(std::ignore = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0)); + SearchStats stats; + EXPECT_NO_THROW(std::ignore = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0, stats)); } TEST(NegaMaxTest, FindsScolarsMateInOne) { // White to move, Queen can take on f7 for mate Board board = Board("r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_GT(best.score, Eval::MATE_THRESHOLD); EXPECT_TRUE(best.move.has_value()); @@ -44,8 +47,9 @@ TEST(NegaMaxTest, FindsScolarsMateInOne) { TEST(NegaMaxTest, FindsCornerMateInOne) { Board board("7k/5K2/6Q1/8/8/8/8/8 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_GT(best.score, Eval::MATE_THRESHOLD); EXPECT_TRUE(best.move.has_value()); @@ -56,8 +60,9 @@ TEST(NegaMaxTest, FindsCornerMateInOne) { TEST(NegaMaxTest, FindsStaleMateByWhiteQueenInCorner) { Board board("K7/8/8/8/8/8/5Q2/7k b - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -66,8 +71,9 @@ TEST(NegaMaxTest, FindsStaleMateByWhiteQueenInCorner) { TEST(NegaMaxTest, FindsStaleMateByWhiteAllBlackPiecesBlocked) { Board board("k7/7R/8/7p/b4p1P/5N2/8/RQ5K b - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -76,8 +82,9 @@ TEST(NegaMaxTest, FindsStaleMateByWhiteAllBlackPiecesBlocked) { TEST(NegaMaxTest, KingVsKingIsInsufficientMaterialDraw) { Board board("8/8/8/8/8/8/8/K1k5 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -86,8 +93,9 @@ TEST(NegaMaxTest, KingVsKingIsInsufficientMaterialDraw) { TEST(NegaMaxTest, KingVsKingAndBishopIsInsufficientMaterialDraw) { Board board("8/8/8/8/8/8/8/KBk5 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -96,8 +104,9 @@ TEST(NegaMaxTest, KingVsKingAndBishopIsInsufficientMaterialDraw) { TEST(NegaMaxTest, KingVsKingAndKnightIsInsufficientMaterialDraw) { Board board("8/8/8/8/8/8/8/KNk5 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -106,8 +115,9 @@ TEST(NegaMaxTest, KingVsKingAndKnightIsInsufficientMaterialDraw) { TEST(NegaMaxTest, KingAndBishopVsKingAndSameColorBishopIsInsufficientMaterialDraw) { Board board("8/8/8/3b4/8/3B4/8/K1k5 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -124,16 +134,17 @@ TEST(NegaMaxTest, KingAndBishopVsKingAndSameColorBishopIsInsufficientMaterialDra TEST(NegaMaxTest, ThreefoldRepetitionIsDraw) { Board board = Board::StartingPosition(); Position pos(board); + SearchStats stats; apply_knight_repetition_cycle(pos); apply_knight_repetition_cycle(pos); EXPECT_TRUE(pos.is_threefold_repetition()); - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); - EXPECT_EQ(quiesce(pos, ALPHA_INIT, BETA_INIT), 0); + EXPECT_EQ(quiesce(pos, ALPHA_INIT, BETA_INIT, stats), 0); } /** @@ -143,11 +154,12 @@ TEST(NegaMaxTest, ThreefoldRepetitionIsDraw) { TEST(NegaMaxTest, TwofoldRepetitionDoesNotAutoDraw) { Board board = Board::StartingPosition(); Position pos(board); + SearchStats stats; apply_knight_repetition_cycle(pos); EXPECT_FALSE(pos.is_threefold_repetition()); - BestMove best = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_TRUE(best.move.has_value()); } @@ -158,10 +170,11 @@ TEST(NegaMaxTest, TwofoldRepetitionDoesNotAutoDraw) { TEST(NegaMaxTest, FiftyMoveRuleIsDraw) { Board board("8/8/8/8/8/8/8/RKk5 w - - 100 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); - EXPECT_EQ(quiesce(pos, ALPHA_INIT, BETA_INIT), 0); + EXPECT_EQ(quiesce(pos, ALPHA_INIT, BETA_INIT, stats), 0); } diff --git a/tests/bitbishop/helpers/async.hpp b/tests/bitbishop/helpers/async.hpp new file mode 100644 index 00000000..185a2235 --- /dev/null +++ b/tests/bitbishop/helpers/async.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include +#include +#include + +/** + * @brief Wait until a condition becomes true or timeout expires. + * + * Polls the predicate periodically until it returns true or the timeout + * is reached. Useful for synchronizing with asynchronous engine behavior. + * + * @param pred Condition to evaluate. + * @param timeout Maximum duration to wait. + * @param interval Delay between successive checks. + * @return true if condition became true within timeout, false otherwise. + */ +bool wait_for(std::function pred, std::chrono::milliseconds timeout = std::chrono::milliseconds(500), + std::chrono::milliseconds interval = std::chrono::milliseconds(10)) { + const auto start = std::chrono::steady_clock::now(); + + while (std::chrono::steady_clock::now() - start < timeout) { + if (pred()) { + return true; + } + std::this_thread::sleep_for(interval); + } + return false; +} + +void assert_output_contains(const std::stringstream& output, const std::string& token, + std::chrono::milliseconds timeout = std::chrono::milliseconds(500)) { + wait_for([&] { return output.str().find(token) != std::string::npos; }, timeout); + ASSERT_TRUE(wait_for([&] { return output.str().find(token) != std::string::npos; })) + << "Expected output to contain: " << token << "\nActual output:\n" + << output.str(); +} + +void assert_output_not_contains(const std::stringstream& output, const std::string& token, + std::chrono::milliseconds timeout = std::chrono::milliseconds(500)) { + ASSERT_FALSE(wait_for([&] { return output.str().find(token) == std::string::npos; })) + << "Expected output not to contain: " << token << "\nActual output:\n" + << output.str(); +} diff --git a/tests/bitbishop/interface/test_search_controller.cpp b/tests/bitbishop/interface/test_search_controller.cpp deleted file mode 100644 index de28964e..00000000 --- a/tests/bitbishop/interface/test_search_controller.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include - -#include -#include - -TEST(SearchControllerTest, StartEmitsABestmoveWithDepth1) { - Board board = Board::StartingPosition(); - Uci::SearchLimits limits; - limits.depth = 1; - std::ostringstream out; - - Uci::SearchWorker controller(board, limits, out); - controller.start(); - controller.wait(); - - const std::string res = out.str(); - EXPECT_GT(res.size(), 0); - EXPECT_NE(res.find("bestmove "), std::string::npos); -} - -TEST(SearchControllerTest, StartEmitsABestmoveWithInfiniteSearch) { - Board board = Board::StartingPosition(); - Uci::SearchLimits limits; - limits.infinite = true; - std::ostringstream out; - - Uci::SearchWorker controller(board, limits, out); - controller.start(); - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - controller.stop(); - - const std::string res = out.str(); - EXPECT_GT(res.size(), 0); - EXPECT_NE(res.find("bestmove "), std::string::npos); -} diff --git a/tests/bitbishop/interface/test_search_limits.cpp b/tests/bitbishop/interface/test_search_limits.cpp index 0441a528..b2f94ab6 100644 --- a/tests/bitbishop/interface/test_search_limits.cpp +++ b/tests/bitbishop/interface/test_search_limits.cpp @@ -1,6 +1,6 @@ #include -#include +#include TEST(SearchLimitsTest, DefaultsAreEmptyAndNotInfinite) { Uci::SearchLimits limits; @@ -13,3 +13,175 @@ TEST(SearchLimitsTest, DefaultsAreEmptyAndNotInfinite) { EXPECT_FALSE(limits.binc.has_value()); EXPECT_FALSE(limits.infinite); } + +struct SearchLimitsFromUciTestCase { + std::string test_name; + std::vector command_line; + Uci::SearchLimits expected; +}; + +struct SearchLimitsParamName { + template + std::string operator()(const testing::TestParamInfo& info) const { + return info.param.test_name; + } +}; + +class SearchLimitsFromUciTest : public ::testing::TestWithParam {}; + +TEST_P(SearchLimitsFromUciTest, ParsesCorrectly) { + const auto& param = GetParam(); + auto result = Uci::SearchLimits::from_uci_cmd(param.command_line); + + EXPECT_EQ(result.depth, param.expected.depth); + EXPECT_EQ(result.movetime, param.expected.movetime); + EXPECT_EQ(result.wtime, param.expected.wtime); + EXPECT_EQ(result.btime, param.expected.btime); + EXPECT_EQ(result.winc, param.expected.winc); + EXPECT_EQ(result.binc, param.expected.binc); + EXPECT_EQ(result.infinite, param.expected.infinite); +} + +// clang-format off +INSTANTIATE_TEST_SUITE_P( + SearchLimitsFromUci, + SearchLimitsFromUciTest, + ::testing::Values( + // Empty / malformed input -> implicit infinite + SearchLimitsFromUciTestCase{ + "EmptyCommand", + {"go"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = true + } + }, + + // Explicit infinite + SearchLimitsFromUciTestCase{ + "ExplicitInfinite", + {"go", "infinite"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = true + } + }, + + // Depth only -> NOT infinite + SearchLimitsFromUciTestCase{ + "DepthOnly", + {"go", "depth", "10"}, + Uci::SearchLimits{ + .depth = 10, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = false + } + }, + + // Explicit infinite overrides depth + SearchLimitsFromUciTestCase{ + "DepthAndInfinite", + {"go", "depth", "8", "infinite"}, + Uci::SearchLimits{ + .depth = 8, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = true + } + }, + + // Explicit infinite overrides time controls + SearchLimitsFromUciTestCase{ + "TimeAndInfinite", + {"go", "wtime", "10000", "btime", "8000", "winc", "100", "binc", "200", "infinite"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = std::nullopt, + .wtime = 10'000, + .btime = 8'000, + .winc = 100, + .binc = 200, + .infinite = true + } + }, + + // Movetime only -> NOT infinite + SearchLimitsFromUciTestCase{ + "MovetimeOnly", + {"go", "movetime", "5000"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = 5'000, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = false + } + }, + + // Full time controls -> NOT infinite + SearchLimitsFromUciTestCase{ + "TimeControls", + {"go", "wtime", "10000", "btime", "8000", "winc", "100", "binc", "200"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = std::nullopt, + .wtime = 10000, + .btime = 8000, + .winc = 100, + .binc = 200, + .infinite = false + } + }, + + // Mixed depth + time → depth wins -> NOT infinite + SearchLimitsFromUciTestCase{ + "DepthAndTime", + {"go", "depth", "12", "wtime", "10000"}, + Uci::SearchLimits{ + .depth = 12, + .movetime = std::nullopt, + .wtime = 10000, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = false + } + }, + + // Unknown tokens ignored + SearchLimitsFromUciTestCase{ + "UnknownTokensIgnored", + {"go", "foo", "bar", "depth", "6"}, + Uci::SearchLimits{ + .depth = 6, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = false + } + } + ), + SearchLimitsParamName{} +); +// clang-format on diff --git a/tests/bitbishop/interface/test_search_reporter.cpp b/tests/bitbishop/interface/test_search_reporter.cpp new file mode 100644 index 00000000..92612131 --- /dev/null +++ b/tests/bitbishop/interface/test_search_reporter.cpp @@ -0,0 +1,66 @@ +#include + +#include +#include +#include + +using namespace Search; + +class SearchReporterTest : public ::testing::Test { + protected: + Move fake_move = Move::from_uci("e2e4"); + + BestMove best_move{.move = fake_move}; + BestMove empty_move{.move = std::nullopt}; + + SearchStats stats{.negamax_nodes = 120, .quiescence_nodes = 80}; + + std::ostringstream out; +}; + +TEST_F(SearchReporterTest, UciOutputsBestMoveWhenPresent) { + UciReporter reporter(out); + + reporter.on_finish(best_move, stats); + + EXPECT_EQ(out.str(), "bestmove " + best_move.move->to_uci() + "\n"); +} + +TEST_F(SearchReporterTest, UciOutputsNullMoveWhenMissing) { + UciReporter reporter(out); + + reporter.on_finish(empty_move, stats); + + EXPECT_EQ(out.str(), "bestmove 0000\n"); +} + +TEST_F(SearchReporterTest, BenchOutputsCorrectNodeAggregation) { + BenchReporter reporter(out); + + reporter.on_finish(best_move, stats); + + const std::string result = out.str(); + + const uint64_t total_nodes = stats.negamax_nodes + stats.quiescence_nodes; + + EXPECT_NE(result.find("bench nodes " + std::to_string(total_nodes)), std::string::npos); + + EXPECT_NE(result.find("negamax_nodes " + std::to_string(stats.negamax_nodes)), std::string::npos); + + EXPECT_NE(result.find("quiescence_nodes " + std::to_string(stats.quiescence_nodes)), std::string::npos); + + EXPECT_NE(result.find("time(s)"), std::string::npos); + EXPECT_NE(result.find("nps"), std::string::npos); +} + +TEST_F(SearchReporterTest, BenchHandlesZeroTimeGracefully) { + const auto start_time = std::chrono::steady_clock::now(); + + BenchReporter reporter(out, [start_time]() { return start_time; }); + reporter.on_finish(best_move, stats); + + const std::string result = out.str(); + + EXPECT_NE(result.find("bench nodes "), std::string::npos); + EXPECT_NE(result.find("nps "), std::string::npos); +} diff --git a/tests/bitbishop/interface/test_search_session.cpp b/tests/bitbishop/interface/test_search_session.cpp new file mode 100644 index 00000000..a88aa4b9 --- /dev/null +++ b/tests/bitbishop/interface/test_search_session.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +bool wait_until_idle(Uci::SearchSession& session, std::chrono::milliseconds timeout = 1000ms) { + return wait_for( + [&session] { + session.poll(); + return session.is_idle(); + }, + timeout); +} + +TEST(SearchSessionTest, StartsIdleAndPollIsNoopWhenNoSearchIsRunning) { + std::stringstream output; + Uci::SearchSession session(output); + + EXPECT_TRUE(session.is_idle()); + session.poll(); + EXPECT_TRUE(session.is_idle()); + EXPECT_TRUE(output.str().empty()); +} + +TEST(SearchSessionTest, StartGoDepthOneProducesBestMoveAndBecomesIdle) { + std::stringstream output; + Uci::SearchSession session(output); + + Uci::SearchLimits limits{.depth = 1}; + + session.start_go(Board::StartingPosition(), limits); + + ASSERT_TRUE(wait_until_idle(session)); + + const std::string result = output.str(); + EXPECT_NE(result.find("bestmove "), std::string::npos); +} + +TEST(SearchSessionTest, StartBenchDepthOneProducesBenchSummaryAndBecomesIdle) { + std::stringstream output; + Uci::SearchSession session(output); + + Uci::SearchLimits limits{.depth = 1}; + + session.start_bench(Board::StartingPosition(), limits); + + ASSERT_TRUE(wait_until_idle(session)); + + const std::string result = output.str(); + EXPECT_NE(result.find("bench nodes "), std::string::npos); + EXPECT_NE(result.find("negamax_nodes "), std::string::npos); + EXPECT_NE(result.find("quiescence_nodes "), std::string::npos); + EXPECT_NE(result.find("nps "), std::string::npos); +} + +TEST(SearchSessionTest, RequestStopInterruptsInfiniteSearchAndStillFinalizes) { + std::stringstream output; + Uci::SearchSession session(output); + + Uci::SearchLimits limits{.infinite = true}; + + session.start_go(Board::StartingPosition(), limits); + std::this_thread::sleep_for(20ms); + session.request_stop(); + + ASSERT_TRUE(wait_until_idle(session, 1500ms)); + EXPECT_NE(output.str().find("bestmove "), std::string::npos); +} + +TEST(SearchSessionTest, StartBenchWithInfiniteLimitCompletesWithoutManualStop) { + std::stringstream output; + Uci::SearchSession session(output); + + // Stalemate position keeps the converted fixed-depth bench path very fast. + Board board("K7/8/8/8/8/8/5Q2/7k b - - 0 1"); + + Uci::SearchLimits limits{.infinite = true}; + + session.start_bench(board, limits); + + ASSERT_TRUE(wait_until_idle(session, 1500ms)); + EXPECT_NE(output.str().find("bench nodes "), std::string::npos); +} diff --git a/tests/bitbishop/interface/test_search_worker.cpp b/tests/bitbishop/interface/test_search_worker.cpp new file mode 100644 index 00000000..96aa55c3 --- /dev/null +++ b/tests/bitbishop/interface/test_search_worker.cpp @@ -0,0 +1,43 @@ +#include + +#include +#include +#include +#include + +TEST(SearchControllerTest, StartPublishesFinishReportWithDepth1) { + Board board = Board::StartingPosition(); + Uci::SearchLimits limits; + limits.depth = 1; + + Uci::SearchWorker controller(board, limits); + controller.start(); + controller.wait(); + + const auto reports = controller.drain_reports(); + ASSERT_FALSE(reports.empty()); + EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); + EXPECT_TRUE(reports.back().best.move.has_value()); + EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { + return report.kind == Uci::SearchReportKind::Iteration; + })); +} + +TEST(SearchControllerTest, StartPublishesFinishReportWithInfiniteSearch) { + Board board = Board::StartingPosition(); + Uci::SearchLimits limits; + limits.infinite = true; + + Uci::SearchWorker controller(board, limits); + controller.start(); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + controller.stop(); + + const auto reports = controller.drain_reports(); + ASSERT_FALSE(reports.empty()); + EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); + EXPECT_TRUE(reports.back().best.move.has_value()); + EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { + return report.kind == Uci::SearchReportKind::Iteration; + })); +} diff --git a/tests/bitbishop/interface/test_uci_command_channel.cpp b/tests/bitbishop/interface/test_uci_command_channel.cpp new file mode 100644 index 00000000..0e0b234c --- /dev/null +++ b/tests/bitbishop/interface/test_uci_command_channel.cpp @@ -0,0 +1,78 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +TEST(UciCommandChannelTest, WaitAndPopReturnsFalseWhenNotStarted) { + std::istringstream input("uci\n"); + Uci::UciCommandChannel channel(input); + // UciCommandChannel was not started so nothing is listening for commands + + std::string line; + EXPECT_FALSE(channel.wait_and_pop_line(line, 5ms)); + EXPECT_FALSE(channel.eof()); +} + +TEST(UciCommandChannelTest, ReadsCommandsInOrderAndSignalsEof) { + std::istringstream input("uci\nisready\n"); + Uci::UciCommandChannel channel(input); + + channel.start(); + + std::string line; + ASSERT_TRUE(channel.wait_and_pop_line(line, 100ms)); + EXPECT_EQ(line, "uci"); + + ASSERT_TRUE(channel.wait_and_pop_line(line, 100ms)); + EXPECT_EQ(line, "isready"); + + ASSERT_TRUE(wait_for([&] { return channel.eof(); })); + EXPECT_FALSE(channel.wait_and_pop_line(line, 20ms)); + + channel.stop(); +} + +TEST(UciCommandChannelTest, WaitAndPopTimesOutWhenNoInputYet) { + BlockingIStream input; // needs input.close() to signal eof + Uci::UciCommandChannel channel(input); + + channel.start(); + + std::string line; + EXPECT_FALSE(channel.wait_and_pop_line(line, 20ms)); + EXPECT_FALSE(channel.eof()); + + input.close(); + ASSERT_TRUE(wait_for([&] { return channel.eof(); })); + channel.stop(); +} + +TEST(UciCommandChannelTest, ReceivesCommandProducedAfterStart) { + BlockingIStream input; // needs input.close() to signal eof + Uci::UciCommandChannel channel(input); + + channel.start(); + + std::thread producer([&input] { + std::this_thread::sleep_for(10ms); + input.write("go depth 4\n"); + input.close(); + }); + + std::string line; + ASSERT_TRUE(channel.wait_and_pop_line(line, 200ms)); + EXPECT_EQ(line, "go depth 4"); + + ASSERT_TRUE(wait_for([&] { return channel.eof(); })); + EXPECT_FALSE(channel.wait_and_pop_line(line, 20ms)); + + producer.join(); + channel.stop(); +} diff --git a/tests/bitbishop/interface/test_uci_command_registry.cpp b/tests/bitbishop/interface/test_uci_command_registry.cpp new file mode 100644 index 00000000..9a17563b --- /dev/null +++ b/tests/bitbishop/interface/test_uci_command_registry.cpp @@ -0,0 +1,75 @@ +#include + +#include + +using namespace Uci; + +TEST(UciCommandRegistryTest, RegistersCorreclyHandlerFunction) { + UciCommandRegistry cmd_registry; + std::size_t counter = 0; + const std::string test_command_name = "test_cmd"; + + EXPECT_EQ(cmd_registry.get_handlers_count(), 0); + + cmd_registry.register_handler(test_command_name, [&counter](const std::vector& line) { + (void)line; + ++counter; + }); + + EXPECT_EQ(cmd_registry.get_handlers_count(), 1); +} + +TEST(UciCommandRegistryTest, RegistersMultipleTimesOverridesOldHandler) { + UciCommandRegistry cmd_registry; + std::size_t counter = 0; + const std::string test_command_name = "test_cmd"; + constexpr const std::size_t REGISTER_COUNT = 5; + + EXPECT_EQ(cmd_registry.get_handlers_count(), 0); + + for (std::size_t i = 0; i < REGISTER_COUNT; ++i) { + cmd_registry.register_handler(test_command_name, [&counter](const std::vector& line) { + (void)line; + ++counter; + }); + EXPECT_EQ(cmd_registry.get_handlers_count(), 1); + } +} + +TEST(UciCommandRegistryTest, DispatchesCorreclyHandlerFunction) { + UciCommandRegistry cmd_registry; + std::size_t counter = 0; + const std::string test_command_name = "test_cmd"; + + cmd_registry.register_handler(test_command_name, [&counter](const std::vector& line) { + (void)line; + ++counter; + }); + + EXPECT_EQ(counter, 0); + + const std::vector test_command{test_command_name, "some", "arguments"}; + const bool dispatch_result = cmd_registry.dispatch(test_command); + + EXPECT_TRUE(dispatch_result); + EXPECT_EQ(counter, 1); +} + +TEST(UciCommandRegistryTest, DispatchesDoesNothingWithNonExistingCommand) { + UciCommandRegistry cmd_registry; + std::size_t counter = 0; + const std::string test_command_name = "test_cmd"; + + cmd_registry.register_handler(test_command_name, [&counter](const std::vector& line) { + (void)line; + ++counter; + }); + + EXPECT_EQ(counter, 0); + + const std::vector test_command{"unknown", "some", "arguments"}; + const bool dispatch_result = cmd_registry.dispatch(test_command); + + EXPECT_FALSE(dispatch_result); + EXPECT_EQ(counter, 0); +} diff --git a/tests/bitbishop/interface/test_uci_engine.cpp b/tests/bitbishop/interface/test_uci_engine.cpp index 0372fd41..6d3a4347 100644 --- a/tests/bitbishop/interface/test_uci_engine.cpp +++ b/tests/bitbishop/interface/test_uci_engine.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -102,45 +103,6 @@ class UciEngineTest : public ::testing::Test { } }; -/** - * @brief Wait until a condition becomes true or timeout expires. - * - * Polls the predicate periodically until it returns true or the timeout - * is reached. Useful for synchronizing with asynchronous engine behavior. - * - * @param pred Condition to evaluate. - * @param timeout Maximum duration to wait. - * @param interval Delay between successive checks. - * @return true if condition became true within timeout, false otherwise. - */ -bool wait_for(std::function pred, std::chrono::milliseconds timeout = std::chrono::milliseconds(500), - std::chrono::milliseconds interval = std::chrono::milliseconds(10)) { - const auto start = std::chrono::steady_clock::now(); - - while (std::chrono::steady_clock::now() - start < timeout) { - if (pred()) { - return true; - } - std::this_thread::sleep_for(interval); - } - return false; -} - -void assert_output_contains(const std::stringstream& output, const std::string& token, - std::chrono::milliseconds timeout = std::chrono::milliseconds(500)) { - wait_for([&] { return output.str().find(token) != std::string::npos; }, timeout); - ASSERT_TRUE(wait_for([&] { return output.str().find(token) != std::string::npos; })) - << "Expected output to contain: " << token << "\nActual output:\n" - << output.str(); -} - -void assert_output_not_contains(const std::stringstream& output, const std::string& token, - std::chrono::milliseconds timeout = std::chrono::milliseconds(500)) { - ASSERT_FALSE(wait_for([&] { return output.str().find(token) == std::string::npos; })) - << "Expected output not to contain: " << token << "\nActual output:\n" - << output.str(); -} - TEST_F(UciEngineTest, CommandWithOnlySpacesDoesNothing) { input.write(" \n"); @@ -393,6 +355,16 @@ TEST_F(UciEngineTest, GoWithoutDepthIsInfinite) { ASSERT_TRUE(output.str().find("bestmove ") == std::string::npos); } +TEST_F(UciEngineTest, GoDepthKeepsEngineResponsive) { + input.write( + "go depth 8\n" + "isready\n" + "stop\n"); + + assert_output_contains(output, "readyok"); + assert_output_contains(output, "bestmove "); +} + TEST_F(UciEngineTest, GoStopGoDoesNotCrash) { input.write( "go infinite\n" @@ -493,3 +465,17 @@ TEST_F(UciEngineTest, HelpMessageIsDisplayed) { assert_output_contains(output, " published under "); assert_output_contains(output, "For any further information, "); } + +TEST_F(UciEngineTest, BenchProducesBenchReport) { + input.write("bench depth 2\n"); + + assert_output_contains(output, "bench nodes "); +} + +TEST_F(UciEngineTest, BenchCanBeStopped) { + input.write( + "bench depth 8\n" + "stop\n"); + + assert_output_contains(output, "bench nodes "); +}