diff --git a/docs/commands.md b/docs/commands.md index bae999f2..d895b2ba 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -125,8 +125,16 @@ Implemented behavior (current state): - `depth `: fixed-depth search. - `infinite`: iterative deepening until `stop`. -- If `depth` is not provided, search defaults to infinite mode. -- If `depth` and `infinite` are both present, `infinite` takes precedence. +- `movetime `: iterative deepening search limited by a per-move budget in milliseconds. +- Non-positive `movetime` values are clamped to `1` ms. +- `movetime` takes precedence over `wtime`/`btime`/`winc`/`binc`. +- `infinite` takes precedence over every other limit, including `movetime` and `depth`. +- If both `depth` and a time-based limit are provided, the current implementation runs the timed search path. +- When `movetime` is not provided, time is estimated from the side-to-move clock: + roughly remaining time divided across future moves, with increment added and a safety reserve kept aside. + +- If no argument is provided behind `go`, search defaults to infinite mode. +- If `depth` is not provided and no time control is provided either, search defaults to infinite mode. Response when search ends: @@ -205,6 +213,7 @@ Implemented behavior (current state): - Uses the same limit parser as `go`. - `depth ` runs a fixed-depth benchmark. +- `movetime ` runs a time-limited benchmark using the same semantics as `go`. - 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. @@ -223,18 +232,3 @@ Not implemented yet. - The engine currently does not expose configurable UCI options. - No `option name ...` lines are emitted in `uci` response. - -### `go` time-control fields - -The following tokens are parsed but not yet enforced by search scheduling: - -- `movetime ` -- `wtime ` -- `btime ` -- `winc ` -- `binc ` - -Today, practical stopping control is: - -- `go depth ` for bounded search -- `go` or `go infinite`, then `stop` for unbounded search diff --git a/include/bitbishop/interface/search_worker.hpp b/include/bitbishop/interface/search_worker.hpp index 23c7eb28..ab2d5048 100644 --- a/include/bitbishop/interface/search_worker.hpp +++ b/include/bitbishop/interface/search_worker.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include #include @@ -30,6 +30,22 @@ struct SearchLimits { * @return The built SearchLimits object. */ static SearchLimits from_uci_cmd(const std::vector& line); + + /** + * @brief Returns whether any time-based search limit was provided. + */ + [[nodiscard]] bool has_time_limit() const; + + /** + * @brief Estimates how much time the current side should spend on this move. + * + * `movetime` takes precedence when present. Otherwise the estimate is derived + * from the side-to-move remaining clock and increment. + * + * @param side_to_move Side that is currently searching. + * @return Think time budget in milliseconds, or std::nullopt if no clock is available. + */ + [[nodiscard]] std::optional think_time_ms(Color side_to_move) const; }; /** @@ -86,7 +102,7 @@ class SearchWorker { * @brief Starts the search process with given parameters. * * Initializes the search controller with a new worker thread and parameters. - * If no depth is specified, switches to infinite search mode. + * If no search limit is specified, switches to infinite search mode. */ void start(); diff --git a/include/bitbishop/movegen/legal_moves.hpp b/include/bitbishop/movegen/legal_moves.hpp index e89e46b0..62c10aaa 100644 --- a/include/bitbishop/movegen/legal_moves.hpp +++ b/include/bitbishop/movegen/legal_moves.hpp @@ -19,7 +19,7 @@ #include namespace MoveGen { -enum class Scope { +enum class Scope : std::uint8_t { AllMoves, CapturesOnly, }; diff --git a/include/bitbishop/tools/time_guard.hpp b/include/bitbishop/tools/time_guard.hpp new file mode 100644 index 00000000..13f13dc9 --- /dev/null +++ b/include/bitbishop/tools/time_guard.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Tools { + +/** + * @class TimeGuard + * @brief An RAII-based asynchronous countdown timer used to signal a timeout via an atomic flag. + * + * This class spawns a background thread that waits for a specified duration. If the duration + * elapses without the object being destroyed or cancelled, it sets the provided @p stop_flag to true. + * + * @note This is designed for search algorithms where a search must + * be terminated after a certain amount of time. + */ +class TimeGuard { + public: + /** + * @brief Constructs the timer and immediately starts the countdown in a background thread. + * + * @param stop_flag Reference to an atomic boolean. This flag is monitored for external + * cancellation and is set to @c true by this class upon timeout. + * @param timeout The maximum duration to wait before setting the flag. + */ + TimeGuard(std::atomic& stop_flag, std::chrono::steady_clock::duration timeout); + + /** + * @brief Destructor. Safely cancels the timer and joins the background thread. + * + * This ensures that no "zombie" threads are left running. If the timer has not yet + * expired, it is signaled to wake up and exit immediately. + */ + ~TimeGuard(); + + // Deleted copy and move operations to prevent accidental thread ownership issues. + TimeGuard(const TimeGuard&) = delete; + TimeGuard& operator=(const TimeGuard&) = delete; + TimeGuard(TimeGuard&&) = delete; + TimeGuard& operator=(TimeGuard&&) = delete; + + private: + /** + * @brief The main loop for the worker thread. + * + * Uses a @c std::condition_variable::wait_for to sleep efficiently. It wakes up if: + * 1. The @p timeout expires (sets @p stop_flag to @c true). + * 2. The @p cancelled flag is set via the destructor/cancel(). + * 3. The @p stop_flag is set to @c true by external logic. + */ + void start(); + + /** + * @brief Internal helper to signal the worker thread to stop and clean up resources. + * + * Thread-safe. It notifies the condition variable and joins the worker thread. + */ + void cancel(); + + std::atomic& stop_flag; + std::chrono::steady_clock::duration timeout; + std::mutex mutex; + std::condition_variable cv; + bool cancelled = false; + std::thread worker; +}; + +} // namespace Tools diff --git a/src/bitbishop/interface/search_worker.cpp b/src/bitbishop/interface/search_worker.cpp index 414be7dc..fbf6a0a8 100644 --- a/src/bitbishop/interface/search_worker.cpp +++ b/src/bitbishop/interface/search_worker.cpp @@ -1,4 +1,32 @@ +#include #include +#include +#include + +namespace { + +constexpr int DEFAULT_MOVES_TO_GO = 20; +constexpr int MIN_THINK_TIME_MS = 1; +constexpr int SAFETY_BUFFER_MS = 50; +constexpr int MAX_DEPTH = 50; + +[[nodiscard]] int estimate_clock_think_time_ms(int remaining_ms, int increment_ms) { + remaining_ms = std::max(remaining_ms, 0); + increment_ms = std::max(increment_ms, 0); + + if (remaining_ms <= MIN_THINK_TIME_MS) { + return MIN_THINK_TIME_MS; + } + + const int reserve_ms = std::min(remaining_ms - MIN_THINK_TIME_MS, std::max(SAFETY_BUFFER_MS, remaining_ms / 20)); + const int spendable_ms = std::max(MIN_THINK_TIME_MS, remaining_ms - reserve_ms); + const int base_ms = spendable_ms / DEFAULT_MOVES_TO_GO; + const int increment_bonus_ms = (increment_ms * 3) / 4; + + return std::clamp(base_ms + increment_bonus_ms, MIN_THINK_TIME_MS, spendable_ms); +} + +} // namespace Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(const std::vector& line) { SearchLimits limits; @@ -29,14 +57,31 @@ Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(const std::vector } } - const bool has_time_control = limits.movetime || limits.wtime || limits.btime || limits.winc || limits.binc; - if (!limits.depth && !has_time_control && !limits.infinite) { + if (!limits.depth && !limits.has_time_limit() && !limits.infinite) { limits.infinite = true; } return limits; } +bool Uci::SearchLimits::has_time_limit() const { + return movetime.has_value() || wtime.has_value() || btime.has_value() || winc.has_value() || binc.has_value(); +} + +std::optional Uci::SearchLimits::think_time_ms(Color side_to_move) const { + if (movetime.has_value()) { + return std::max(*movetime, MIN_THINK_TIME_MS); + } + + const std::optional& remaining_opt = (side_to_move == Color::WHITE) ? wtime : btime; + if (!remaining_opt.has_value()) { + return std::nullopt; + } + + const std::optional& increment_opt = (side_to_move == Color::WHITE) ? winc : binc; + return estimate_clock_think_time_ms(*remaining_opt, increment_opt.value_or(0)); +} + Uci::SearchWorker::SearchWorker(Board board, SearchLimits limits) : board(board), position(Position(this->board)), limits(limits) {} @@ -49,47 +94,52 @@ void Uci::SearchWorker::push_report(SearchReport report) { void Uci::SearchWorker::run() { using namespace Search; + + // scope_exit function is not yet shipped in all stdlibs (linux is fine, not windows nor macos - 2026-04-29) + // const auto guard = std::experimental::scope_exit([this] { finished.store(true); }); + // Replacing it by a struct with custom destructor. 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); + SearchReport current_best_report{.kind = SearchReportKind::Iteration}; + + const auto side = board.get_side_to_move(); + const auto think_time = limits.infinite ? std::nullopt : limits.think_time_ms(side); + std::optional timeguard; + if (think_time) { + timeguard.emplace(stop_flag, std::chrono::milliseconds(*think_time)); + } + + auto perform_search_at_depth = [&](int depth) { + auto result = negamax(position, 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, - }); + current_best_report.best = result; + current_best_report.depth = depth; + current_best_report.stats = stats; + push_report(current_best_report); + return true; + } + return false; + }; + + if (limits.depth && !limits.infinite && !think_time) { + // Case: Fixed depth search (e.g., "go depth 10") + perform_search_at_depth(*limits.depth); + } else { + // Case: Iterative deepening (Infinite or Time-limited) + for (int depth = 1; depth <= MAX_DEPTH && !stop_flag.load(); ++depth) { + if (!perform_search_at_depth(depth)) { + break; + } } } - 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, - }); + current_best_report.kind = SearchReportKind::Finish; + push_report(current_best_report); } void Uci::SearchWorker::start() { diff --git a/src/bitbishop/tools/time_guard.cpp b/src/bitbishop/tools/time_guard.cpp new file mode 100644 index 00000000..83b48c51 --- /dev/null +++ b/src/bitbishop/tools/time_guard.cpp @@ -0,0 +1,34 @@ +#include + +namespace Tools { + +TimeGuard::TimeGuard(std::atomic& stop_flag, std::chrono::steady_clock::duration timeout) + : stop_flag(stop_flag), timeout(timeout) { + worker = std::thread(&TimeGuard::start, this); +} + +TimeGuard::~TimeGuard() { cancel(); } + +void TimeGuard::start() { + std::unique_lock lock(mutex); + + const bool was_interrupted = cv.wait_for(lock, timeout, [this] { return cancelled || this->stop_flag.load(); }); + + if (!was_interrupted) { + this->stop_flag.store(true); + } +} + +void TimeGuard::cancel() { + { + std::lock_guard lock(mutex); + cancelled = true; + } + cv.notify_one(); + + if (worker.joinable()) { + worker.join(); + } +} + +} // namespace Tools diff --git a/tests/bitbishop/helpers/async.hpp b/tests/bitbishop/helpers/async.hpp index 185a2235..2d1b073b 100644 --- a/tests/bitbishop/helpers/async.hpp +++ b/tests/bitbishop/helpers/async.hpp @@ -40,7 +40,7 @@ void assert_output_contains(const std::stringstream& output, const std::string& 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; })) + ASSERT_TRUE(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_limits.cpp b/tests/bitbishop/interface/test_search_limits.cpp index b2f94ab6..01b3d1f8 100644 --- a/tests/bitbishop/interface/test_search_limits.cpp +++ b/tests/bitbishop/interface/test_search_limits.cpp @@ -14,6 +14,49 @@ TEST(SearchLimitsTest, DefaultsAreEmptyAndNotInfinite) { EXPECT_FALSE(limits.infinite); } +TEST(SearchLimitsTest, HasTimeLimitDetectsAnyClockField) { + Uci::SearchLimits limits; + EXPECT_FALSE(limits.has_time_limit()); + + limits.wtime = 10'000; + EXPECT_TRUE(limits.has_time_limit()); +} + +TEST(SearchLimitsTest, ThinkTimeUsesMovetimeWhenProvided) { + Uci::SearchLimits limits{ + .movetime = 750, + .wtime = 10'000, + .btime = 8'000, + .winc = 100, + .binc = 200, + }; + + EXPECT_EQ(limits.think_time_ms(Color::WHITE), 750); + EXPECT_EQ(limits.think_time_ms(Color::BLACK), 750); +} + +TEST(SearchLimitsTest, ThinkTimeUsesSideSpecificClockAndIncrement) { + Uci::SearchLimits limits{ + .wtime = 10'000, + .btime = 8'000, + .winc = 100, + .binc = 200, + }; + + EXPECT_EQ(limits.think_time_ms(Color::WHITE), 550); + EXPECT_EQ(limits.think_time_ms(Color::BLACK), 530); +} + +TEST(SearchLimitsTest, ThinkTimeUsesDefaultRemainingTimeIfInvalidLowerEqZero) { + Uci::SearchLimits limits{ + .wtime = -10'000, + .btime = 0, + }; + + EXPECT_GT(limits.think_time_ms(Color::WHITE), 0); + EXPECT_GT(limits.think_time_ms(Color::BLACK), 0); +} + struct SearchLimitsFromUciTestCase { std::string test_name; std::vector command_line; diff --git a/tests/bitbishop/interface/test_search_worker.cpp b/tests/bitbishop/interface/test_search_worker.cpp index 96aa55c3..1303b6a5 100644 --- a/tests/bitbishop/interface/test_search_worker.cpp +++ b/tests/bitbishop/interface/test_search_worker.cpp @@ -41,3 +41,25 @@ TEST(SearchControllerTest, StartPublishesFinishReportWithInfiniteSearch) { return report.kind == Uci::SearchReportKind::Iteration; })); } + +TEST(SearchControllerTest, MovetimeStopsSearchAutomatically) { + Board board = Board::StartingPosition(); + Uci::SearchLimits limits; + limits.movetime = 50; + + Uci::SearchWorker controller(board, limits); + const auto start = std::chrono::steady_clock::now(); + controller.start(); + controller.wait(); + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - start); + + 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_GE(elapsed.count(), *limits.movetime); + EXPECT_LT(elapsed.count(), *limits.movetime * 4); // very permissive for slow ci builds and tests + 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 index 0e0b234c..49ce10bc 100644 --- a/tests/bitbishop/interface/test_uci_command_channel.cpp +++ b/tests/bitbishop/interface/test_uci_command_channel.cpp @@ -76,3 +76,21 @@ TEST(UciCommandChannelTest, ReceivesCommandProducedAfterStart) { producer.join(); channel.stop(); } + +TEST(UciCommandChannelTest, StopDoesNotHangWhenReaderIsBlockedByInputStream) { + BlockingIStream input; // needs input.close() to signal eof + Uci::UciCommandChannel channel(input); + + channel.start(); + // The thread is now stuck in std::getline + // Calling stop() should return immediately, not wait for a newline + + auto start_time = std::chrono::steady_clock::now(); + channel.stop(); + auto end_time = std::chrono::steady_clock::now(); + + auto duration = std::chrono::duration_cast(end_time - start_time); + + // If it took 0ms, it means we successfully detached instead of joining a hung thread. + EXPECT_EQ(duration.count(), 0); +} diff --git a/tests/bitbishop/interface/test_uci_engine.cpp b/tests/bitbishop/interface/test_uci_engine.cpp index 6d3a4347..efac7c73 100644 --- a/tests/bitbishop/interface/test_uci_engine.cpp +++ b/tests/bitbishop/interface/test_uci_engine.cpp @@ -6,6 +6,7 @@ #include using namespace Squares; +using namespace std::chrono; /** * @brief GoogleTest fixture for exercising UciEngine in a realistic threaded setup. @@ -159,7 +160,7 @@ TEST_F(UciEngineTest, IsReadyBeforeUciStillWorks) { TEST_F(UciEngineTest, PositionCommandWithOneArgDoesNothing) { input.write("position\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -168,7 +169,7 @@ TEST_F(UciEngineTest, PositionCommandWithMissingFenArgDoesNothing) { // Invalid fen: missing color to play (FEN has 5 components instead of 6) input.write("position fen rnkqnbbr/pppppppp/8/8/8/8/PPPPPPPP/RNKQNBBR - - 0 1\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -177,7 +178,7 @@ TEST_F(UciEngineTest, PositionCommandWithInvalidSecondaryKeyworkDoesNothing) { // Invalid command: fren instead of fen input.write("position fren ...\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -221,7 +222,7 @@ TEST_F(UciEngineTest, PositionStartposMovesWithSpacesAppliesMoves) { TEST_F(UciEngineTest, PositionStartposWithEmptyMovesDoesNothing) { input.write("position startpos moves\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -229,7 +230,7 @@ TEST_F(UciEngineTest, PositionStartposWithEmptyMovesDoesNothing) { TEST_F(UciEngineTest, CommandWithoutNewlineDoesNotApply) { input.write("position startpos moves e2e4"); // no '\n' - std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::this_thread::sleep_for(milliseconds(50)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -291,7 +292,7 @@ TEST_F(UciEngineTest, UnknownCommandProducesNoOutput) { input.write("this_is_not_a_uci_command\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); EXPECT_TRUE(output.str().empty()); } @@ -347,12 +348,12 @@ TEST_F(UciEngineTest, GoInfiniteThenStopProducesBestmove) { assert_output_contains(output, "bestmove "); } -TEST_F(UciEngineTest, GoWithoutDepthIsInfinite) { +TEST_F(UciEngineTest, GoWithoutArgsIsInfinite) { input.write("go\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::this_thread::sleep_for(milliseconds(50)); - ASSERT_TRUE(output.str().find("bestmove ") == std::string::npos); + assert_output_not_contains(output, "bestmove "); } TEST_F(UciEngineTest, GoDepthKeepsEngineResponsive) { @@ -382,7 +383,7 @@ TEST_F(UciEngineTest, StopWithoutGoDoesNothing) { input.write("stop\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); EXPECT_TRUE(output.str().empty()); } @@ -479,3 +480,146 @@ TEST_F(UciEngineTest, BenchCanBeStopped) { assert_output_contains(output, "bench nodes "); } + +TEST_F(UciEngineTest, ZeroMoveTimeUsesDefaultMinThinkTimeInstead) { + input.write("go movetime 0\n"); + + assert_output_contains(output, "bestmove ", milliseconds(10)); +} + +TEST_F(UciEngineTest, InvalidNegativeMoveTimeUsesDefaultMinThinkTimeInstead) { + input.write("go movetime -642\n"); + + assert_output_contains(output, "bestmove ", milliseconds(10)); +} + +TEST_F(UciEngineTest, GoMoveTimeRespectsLimit) { + auto start = steady_clock::now(); + input.write("go movetime 200\n"); + + assert_output_contains(output, "bestmove "); + auto end = steady_clock::now(); + + auto duration = duration_cast(end - start); + + EXPECT_GE(duration.count(), 190); + EXPECT_LE(duration.count(), 400); // very permissive for slow ci builds and tests +} + +TEST_F(UciEngineTest, GoMoveTimeCanBeStoppedWhenTooLong) { + input.write( + "go movetime 1000000000\n" + "stop\n"); + + assert_output_contains(output, "bestmove ", milliseconds(10)); +} + +TEST_F(UciEngineTest, GoMoveTimeTooLongDoesNotDisplayResults) { + input.write("go movetime 1000000000\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} + +TEST_F(UciEngineTest, GoMoveTimeTakesPrecedenceOverOtherWhiteTimeControls) { + input.write( + "position startpos\n" + "go movetime 1000000000 wtime 10 winc\n" + "stop\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} + +TEST_F(UciEngineTest, GoMoveTimeTakesPrecedenceOverOtherBlackTimeControls) { + input.write( + "position startpos moves e2e4\n" + "go movetime 1000000000 btime 10 binc 1 \n" + "stop\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} + +TEST_F(UciEngineTest, GoInfiniteTakesPrecedenceOverMoveTimeControls) { + input.write("go movetime 10 infinite wtime 10 winc 1 btime 10 binc 1 \n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} + +TEST_F(UciEngineTest, GoInfiniteTakesPrecedenceOverDepth) { + input.write("go depth 1 infinite\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} + +TEST_F(UciEngineTest, GoWtimeWincUsesCorrectBudgetForWhites) { + Uci::SearchLimits limits; + limits.wtime = 10'000; // 10 seconds + limits.winc = 100; // 100 millisecond increment + + auto budget = limits.think_time_ms(Color::WHITE); + EXPECT_TRUE(budget.has_value()); + + auto start = steady_clock::now(); + input.write( + "position startpos\n" + "go wtime 10000 winc 100\n"); + + assert_output_contains(output, "bestmove ", milliseconds(*budget * 2)); + auto end = steady_clock::now(); + + auto duration = duration_cast(end - start); + + EXPECT_GE(duration.count(), *budget * 0.5); + EXPECT_LE(duration.count(), *budget * 1.5); +} + +TEST_F(UciEngineTest, GoBtimeBincUsesCorrectBudgetForBlacks) { + Uci::SearchLimits limits; + limits.btime = 10'000; // 10 seconds + limits.binc = 100; // 100 millisecond increment + + auto budget = limits.think_time_ms(Color::BLACK); + EXPECT_TRUE(budget.has_value()); + + auto start = steady_clock::now(); + input.write( + "position startpos moves e2e4\n" + "go btime 10000 binc 100\n"); + + assert_output_contains(output, "bestmove ", milliseconds(*budget * 2)); + auto end = steady_clock::now(); + + auto duration = duration_cast(end - start); + + EXPECT_GE(duration.count(), *budget * 0.5); + EXPECT_LE(duration.count(), *budget * 1.5); +} + +TEST_F(UciEngineTest, GoWtimeWincGoesInfiniteWhenItsBlackTurn) { + Uci::SearchLimits limits; + limits.wtime = 10'000; // 10 seconds + limits.winc = 100; // 100 millisecond increment + + auto budget = limits.think_time_ms(Color::WHITE); + EXPECT_TRUE(budget.has_value()); + + input.write( + "position startpos moves e2e4\n" + "go wtime 10000 winc 100\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(*budget * 2)); +} + +TEST_F(UciEngineTest, GoBtimeBincGoesInfiniteWhenItsWhiteTurn) { + Uci::SearchLimits limits; + limits.btime = 10'000; // 10 seconds + limits.binc = 100; // 100 millisecond increment + + auto budget = limits.think_time_ms(Color::BLACK); + EXPECT_TRUE(budget.has_value()); + + input.write( + "position startpos\n" + "go btime 10000 binc 100\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(*budget * 2)); +} diff --git a/tests/bitbishop/tools/test_time_guard.cpp b/tests/bitbishop/tools/test_time_guard.cpp new file mode 100644 index 00000000..3236aa21 --- /dev/null +++ b/tests/bitbishop/tools/test_time_guard.cpp @@ -0,0 +1,69 @@ +#include + +#include + +using namespace Tools; + +/** + * Verifies the flag is set after the timer thread finishes its work. + */ +TEST(TimeGuardTest, SetsFlagOnTimeout) { + std::atomic_bool should_stop{false}; + { + TimeGuard guard(should_stop, std::chrono::milliseconds(10)); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + } + + EXPECT_TRUE(should_stop); +} + +/** + * Verifies that the guard stops early if destroyed (RAII works). + * This ensures the condition_variable is actually interrupting the sleep. + */ +TEST(TimeGuardTest, DestructorJoinsQuicklyBeforeTimeout) { + std::atomic_bool should_stop{false}; + auto timeout = std::chrono::milliseconds(1'000); + auto sleep_time = std::chrono::milliseconds(10); + + auto start = std::chrono::steady_clock::now(); + { + TimeGuard guard(should_stop, timeout); + std::this_thread::sleep_for(sleep_time); + } + auto end = std::chrono::steady_clock::now(); + + auto duration = std::chrono::duration_cast(end - start); + + // Destruction should be (relatively) quick (depends on the platform tests runs on) + EXPECT_LE(duration.count(), 100); + EXPECT_FALSE(should_stop); +} + +/** + * External code sets stop flag to true, resulting in time guard interruption. + * Interruption should not alter external stop flag, already set to true. + */ +TEST(TimeGuardTest, GuardDoesNotModifyExternalFlagOnExternalBoolFlagInterruption) { + std::atomic_bool should_stop{false}; + std::chrono::milliseconds timeout(20); + { + TimeGuard guard(should_stop, timeout); + should_stop.store(true); + } + + EXPECT_TRUE(should_stop); +} + +/** + * Time guard should not alter the stop flag when still alive. + */ +TEST(TimeGuardTest, GuardDoesNotSetExternalFlagWhenStillAlive) { + std::atomic_bool should_stop{false}; + { + std::chrono::milliseconds timeout(20); + TimeGuard guard(should_stop, timeout); + + EXPECT_FALSE(should_stop); + } +}