From be884e0703a6ccecf5d87b7da91832960cac7de6 Mon Sep 17 00:00:00 2001 From: Robert Lech Date: Tue, 11 Nov 2025 16:49:20 -0500 Subject: [PATCH 1/3] test: creates tests for capture logic --- CMakeLists.txt | 7 +++- src/game/Rules.cpp | 5 +++ tests/rules_tests.cpp | 90 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/rules_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4bdd47f..435d015 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,7 +39,12 @@ FetchContent_Declare( FetchContent_MakeAvailable(googletest) # Test executable -add_executable(go_tests tests/test_placeholder.cpp) +add_executable(go_tests + tests/test_placeholder.cpp + tests/rules_tests.cpp + src/game/Board.cpp + src/game/Rules.cpp +) target_link_libraries(go_tests PRIVATE GTest::gtest_main) include(GoogleTest) diff --git a/src/game/Rules.cpp b/src/game/Rules.cpp index 405c7da..282dda9 100644 --- a/src/game/Rules.cpp +++ b/src/game/Rules.cpp @@ -123,7 +123,12 @@ auto make_move(GameSession &session, Point move_point, Stone stone_color) -> int if (!has_liberties && !check_liberties(session, move_point, move_point, stone_color)) { + auto &captures = board.captured_groups(); + const auto previous_capture_count = captures.size(); remove_block(session, move_point, stone_color); + while (captures.size() > previous_capture_count) { + captures.pop_back(); + } return 0; } diff --git a/tests/rules_tests.cpp b/tests/rules_tests.cpp new file mode 100644 index 0000000..242f291 --- /dev/null +++ b/tests/rules_tests.cpp @@ -0,0 +1,90 @@ +#include + +#include "game/Board.h" +#include "game/GameSession.h" +#include "game/Rules.h" + +namespace { + +auto make_session() -> GameSession { + GameSession session; + rules::init_board(session); + session.board.clear_captured_groups(); + return session; +} + +} // namespace + +TEST(RulesTest, PlacesStoneOnEmptyPoint) { + auto session = make_session(); + const Point move{0, 0}; + + const auto result = rules::make_move(session, move, Stone::Black); + + EXPECT_EQ(result, 1); + EXPECT_EQ(session.board.stone_at(move), Stone::Black); +} + +TEST(RulesTest, RejectsMoveOutOfBounds) { + auto session = make_session(); + const Point move{Board::CENTER + 1, 0}; + + const auto result = rules::make_move(session, move, Stone::White); + + EXPECT_EQ(result, 0); + EXPECT_TRUE(session.board.is_empty(Point{0, 0})); +} + +TEST(RulesTest, RejectsMoveOnOccupiedPoint) { + auto session = make_session(); + const Point target{1, 1}; + + ASSERT_EQ(rules::make_move(session, target, Stone::Black), 1); + + const auto second_try = rules::make_move(session, target, Stone::White); + + EXPECT_EQ(second_try, 0); + EXPECT_EQ(session.board.stone_at(target), Stone::Black); +} + +TEST(RulesTest, CapturesSurroundedStone) { + auto session = make_session(); + const Point white_stone{0, 0}; + + ASSERT_EQ(rules::make_move(session, white_stone, Stone::White), 1); + ASSERT_EQ(rules::make_move(session, Point{1, 0}, Stone::Black), 1); + ASSERT_EQ(rules::make_move(session, Point{-1, 0}, Stone::Black), 1); + ASSERT_EQ(rules::make_move(session, Point{0, 1}, Stone::Black), 1); + + session.board.clear_captured_groups(); + + const auto capture_result = rules::make_move(session, Point{0, -1}, Stone::Black); + + EXPECT_EQ(capture_result, 1); + EXPECT_EQ(session.board.stone_at(white_stone), Stone::Empty); + + const auto &captured = session.board.captured_groups(); + ASSERT_EQ(captured.size(), 1U); + const auto &group = captured.front(); + ASSERT_EQ(group.size(), 1U); + EXPECT_EQ(group.front().location.x, white_stone.x); + EXPECT_EQ(group.front().location.y, white_stone.y); + EXPECT_EQ(group.front().color, Stone::White); +} + +TEST(RulesTest, PreventsSelfCaptureWithoutCapture) { + auto session = make_session(); + + ASSERT_EQ(rules::make_move(session, Point{1, 0}, Stone::Black), 1); + ASSERT_EQ(rules::make_move(session, Point{-1, 0}, Stone::Black), 1); + ASSERT_EQ(rules::make_move(session, Point{0, 1}, Stone::Black), 1); + ASSERT_EQ(rules::make_move(session, Point{0, -1}, Stone::Black), 1); + + session.board.clear_captured_groups(); + + const auto result = rules::make_move(session, Point{0, 0}, Stone::White); + + EXPECT_EQ(result, 0); + EXPECT_TRUE(session.board.is_empty(Point{0, 0})); + EXPECT_TRUE(session.board.captured_groups().empty()); +} From 4e2be7a5bfbdcbac2efa4b93a9713fb4c27f7020 Mon Sep 17 00:00:00 2001 From: Robert Lech Date: Tue, 11 Nov 2025 17:04:43 -0500 Subject: [PATCH 2/3] refactor: extracts group checking logic --- CMakeLists.txt | 2 + src/game/Board.cpp | 21 ------- src/game/Board.h | 7 --- src/game/GroupAnalyzer.cpp | 94 ++++++++++++++++++++++++++++ src/game/GroupAnalyzer.h | 37 ++++++++++++ src/game/Rules.cpp | 121 ++++++++++++------------------------- 6 files changed, 170 insertions(+), 112 deletions(-) create mode 100644 src/game/GroupAnalyzer.cpp create mode 100644 src/game/GroupAnalyzer.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 435d015..052afb0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,7 @@ set(SOURCE_FILES src/game/GameSession.cpp src/game/GameState.cpp src/game/Board.cpp + src/game/GroupAnalyzer.cpp src/game/Rules.cpp src/graphics/Lights.cpp src/graphics/Meshes.cpp @@ -43,6 +44,7 @@ add_executable(go_tests tests/test_placeholder.cpp tests/rules_tests.cpp src/game/Board.cpp + src/game/GroupAnalyzer.cpp src/game/Rules.cpp ) target_link_libraries(go_tests PRIVATE GTest::gtest_main) diff --git a/src/game/Board.cpp b/src/game/Board.cpp index 1e4b06d..5e67abf 100644 --- a/src/game/Board.cpp +++ b/src/game/Board.cpp @@ -12,16 +12,9 @@ void Board::clear() { for (auto &column : stones_) { column.fill(Stone::Empty); } - clear_liberties(); clear_captured_groups(); } -void Board::clear_liberties() { - for (auto &column : liberties_) { - column.fill(-1); - } -} - auto Board::stone_at(Point point) const -> Stone { assert(is_on_board(point)); const auto [ix, iy] = to_index(point); @@ -40,20 +33,6 @@ auto Board::is_empty(Point point) const -> bool { return stone_at(point) == Stone::Empty; } -auto Board::liberty(Point point) const -> int { - assert(is_on_board(point)); - const auto [ix, iy] = to_index(point); - return liberties_[ix][iy]; -} - -auto Board::mutable_liberty(Point point) -> int & { - assert(is_on_board(point)); - const auto [ix, iy] = to_index(point); - return liberties_[ix][iy]; -} - -void Board::set_liberty(Point point, int value) { mutable_liberty(point) = value; } - auto Board::captured_groups() -> CaptureGroups & { return captured_groups_; } auto Board::captured_groups() const -> const CaptureGroups & { diff --git a/src/game/Board.h b/src/game/Board.h index 91d8985..e80977f 100644 --- a/src/game/Board.h +++ b/src/game/Board.h @@ -39,24 +39,18 @@ class Board { static constexpr int CENTER = SIZE / 2; using Grid = std::array, SIZE>; - using LibertyGrid = std::array, SIZE>; using CaptureGroup = std::vector; using CaptureGroups = std::list; static auto is_on_board(Point point) -> bool; void clear(); - void clear_liberties(); auto stone_at(Point point) const -> Stone; void place_stone(Point point, Stone stone); void remove_stone(Point point); auto is_empty(Point point) const -> bool; - auto liberty(Point point) const -> int; - auto mutable_liberty(Point point) -> int &; - void set_liberty(Point point, int value); - auto captured_groups() -> CaptureGroups &; auto captured_groups() const -> const CaptureGroups &; @@ -76,7 +70,6 @@ class Board { static auto index_to_point(int x, int y) -> Point; Grid stones_{}; - LibertyGrid liberties_{}; CaptureGroups captured_groups_; }; diff --git a/src/game/GroupAnalyzer.cpp b/src/game/GroupAnalyzer.cpp new file mode 100644 index 0000000..eb5438b --- /dev/null +++ b/src/game/GroupAnalyzer.cpp @@ -0,0 +1,94 @@ +#include "game/GroupAnalyzer.h" + +#include "game/Board.h" + +#include +#include +#include +#include + +namespace { + +constexpr auto GRID_SIZE = Board::SIZE; + +using VisitedGrid = std::array, GRID_SIZE>; + +void clear_grid(VisitedGrid &grid) { + for (auto &column : grid) { + column.fill(false); + } +} + +} // namespace + +GroupAnalyzer::GroupAnalyzer(const Board &board) : board_(board) {} + +auto GroupAnalyzer::analyze(Point start) const -> std::optional { + if (!Board::is_on_board(start)) { + return std::nullopt; + } + + const Stone color = board_.stone_at(start); + if (color == Stone::Empty) { + return std::nullopt; + } + + VisitedGrid visited{}; + clear_grid(visited); + + VisitedGrid liberty_seen{}; + clear_grid(liberty_seen); + + GroupAnalysis analysis; + analysis.color = color; + + std::stack stack; + stack.push(start); + + while (!stack.empty()) { + const Point current = stack.top(); + stack.pop(); + + const auto [ix, iy] = to_index(current); + if (visited[ix][iy]) { + continue; + } + visited[ix][iy] = true; + + analysis.stones.push_back(current); + + for (const auto &neighbor : neighbors(current)) { + if (!Board::is_on_board(neighbor)) { + continue; + } + + const auto neighbor_color = board_.stone_at(neighbor); + if (neighbor_color == color) { + const auto [nx, ny] = to_index(neighbor); + if (!visited[nx][ny]) { + stack.push(neighbor); + } + continue; + } + + if (neighbor_color == Stone::Empty) { + const auto [nx, ny] = to_index(neighbor); + if (!liberty_seen[nx][ny]) { + liberty_seen[nx][ny] = true; + analysis.liberties.push_back(neighbor); + } + } + } + } + + return analysis; +} + +auto GroupAnalyzer::to_index(Point point) -> std::pair { + return {point.x + Board::CENTER, point.y + Board::CENTER}; +} + +auto GroupAnalyzer::neighbors(Point point) -> std::array { + return {Point{point.x - 1, point.y}, Point{point.x + 1, point.y}, + Point{point.x, point.y - 1}, Point{point.x, point.y + 1}}; +} diff --git a/src/game/GroupAnalyzer.h b/src/game/GroupAnalyzer.h new file mode 100644 index 0000000..4c3e9d3 --- /dev/null +++ b/src/game/GroupAnalyzer.h @@ -0,0 +1,37 @@ +#ifndef GAME_GROUP_ANALYZER_H +#define GAME_GROUP_ANALYZER_H + +#include +#include +#include +#include + +#include "game/Board.h" + +// Liberties are the empty intersections immediately adjacent to any stone in a +// connected group. A group with no liberties is captured. +struct GroupAnalysis { + Stone color = Stone::Empty; + std::vector stones; + std::vector liberties; + + [[nodiscard]] auto has_liberties() const -> bool { return !liberties.empty(); } +}; + +class GroupAnalyzer { + public: + explicit GroupAnalyzer(const Board &board); + + // Returns the stones and liberties for the connected group rooted at + // `start`. `std::nullopt` signals either an empty intersection or an + // out-of-bounds request. + [[nodiscard]] auto analyze(Point start) const -> std::optional; + + private: + const Board &board_; + + [[nodiscard]] static auto to_index(Point point) -> std::pair; + [[nodiscard]] static auto neighbors(Point point) -> std::array; +}; + +#endif // GAME_GROUP_ANALYZER_H diff --git a/src/game/Rules.cpp b/src/game/Rules.cpp index 282dda9..f613982 100644 --- a/src/game/Rules.cpp +++ b/src/game/Rules.cpp @@ -1,77 +1,26 @@ #include "game/Rules.h" #include -#include #include +#include #include "game/Board.h" #include "game/GameSession.h" +#include "game/GroupAnalyzer.h" namespace { -void collect_block(GameSession &session, Point point, Stone target_color, - Board::CaptureGroup &removed_group) { - if (!Board::is_on_board(point)) { - return; - } - - if (session.board.stone_at(point) != target_color) { - return; - } - - session.board.remove_stone(point); - std::cout << "Jump from " << point.x << point.y << '\n'; - - removed_group.emplace_back(point, target_color); - - collect_block(session, Point{point.x - 1, point.y}, target_color, removed_group); - collect_block(session, Point{point.x + 1, point.y}, target_color, removed_group); - collect_block(session, Point{point.x, point.y - 1}, target_color, removed_group); - collect_block(session, Point{point.x, point.y + 1}, target_color, removed_group); -} - -void remove_block(GameSession &session, Point point, Stone target_color) { - Board::CaptureGroup removed{}; - collect_block(session, point, target_color, removed); - if (!removed.empty()) { - session.board.add_captured_group(std::move(removed)); - } +auto neighbors(Point point) -> std::array { + return {Point{point.x - 1, point.y}, Point{point.x + 1, point.y}, + Point{point.x, point.y - 1}, Point{point.x, point.y + 1}}; } -auto check_liberties(GameSession &session, Point point, Point origin, - Stone target_color) -> int { - if (!Board::is_on_board(point)) { - return 0; - } - - const auto stone = session.board.stone_at(point); - if (is_empty(stone)) { - return 1; - } - if (stone != target_color) { - return 0; - } - - auto &liberty_value = session.board.mutable_liberty(point); - if (liberty_value != -1) { - return liberty_value; - } - - const int direcx = point.x - origin.x; - const int direcy = point.y - origin.y; - if ((direcx >= 0 && - check_liberties(session, Point{point.x + 1, point.y}, origin, target_color)) || - (direcx <= 0 && - check_liberties(session, Point{point.x - 1, point.y}, origin, target_color)) || - (direcy >= 0 && - check_liberties(session, Point{point.x, point.y + 1}, origin, target_color)) || - (direcy <= 0 && - check_liberties(session, Point{point.x, point.y - 1}, origin, target_color))) { - liberty_value = 1; - } else { - liberty_value = 0; +void restore_captures(Board &board, const std::vector &captures) { + for (const auto &group : captures) { + for (const auto &captured : group) { + board.place_stone(captured.location, captured.color); + } } - return liberty_value; } } // namespace @@ -92,46 +41,50 @@ auto make_move(GameSession &session, Point move_point, Stone stone_color) -> int } board.place_stone(move_point, stone_color); - board.clear_liberties(); const Stone opponent = stone_color == Stone::Black ? Stone::White : Stone::Black; - int has_liberties = 0; - const std::array neighbors{{ - Point{move_point.x - 1, move_point.y}, - Point{move_point.x + 1, move_point.y}, - Point{move_point.x, move_point.y - 1}, - Point{move_point.x, move_point.y + 1}, - }}; + std::vector captured_groups; + GroupAnalyzer analyzer(board); - for (const auto &neighbor : neighbors) { + for (const auto &neighbor : neighbors(move_point)) { if (!Board::is_on_board(neighbor)) { continue; } - if (board.stone_at(neighbor) == stone_color) { + if (board.stone_at(neighbor) != opponent) { continue; } - if (stone_color == Stone::Empty) { - has_liberties = 1; - } else if (!check_liberties(session, neighbor, neighbor, opponent)) { - has_liberties = 1; - remove_block(session, neighbor, opponent); + const auto analysis = analyzer.analyze(neighbor); + if (!analysis.has_value() || analysis->has_liberties()) { + continue; } - } - if (!has_liberties && - !check_liberties(session, move_point, move_point, stone_color)) { - auto &captures = board.captured_groups(); - const auto previous_capture_count = captures.size(); - remove_block(session, move_point, stone_color); - while (captures.size() > previous_capture_count) { - captures.pop_back(); + Board::CaptureGroup removed; + removed.reserve(analysis->stones.size()); + for (const auto &stone_point : analysis->stones) { + board.remove_stone(stone_point); + removed.emplace_back(stone_point, opponent); } + captured_groups.push_back(std::move(removed)); + } + + GroupAnalyzer self_analyzer(board); + const auto own_group = self_analyzer.analyze(move_point); + + const bool move_has_liberty = own_group.has_value() && own_group->has_liberties(); + + if (!move_has_liberty && captured_groups.empty()) { + board.remove_stone(move_point); + restore_captures(board, captured_groups); return 0; } + for (auto &group : captured_groups) { + board.add_captured_group(std::move(group)); + } + return 1; } From 0de3b37bae8a1b8d5b0c51485fe8309fc8b6210c Mon Sep 17 00:00:00 2001 From: Robert Lech Date: Tue, 11 Nov 2025 18:31:59 -0500 Subject: [PATCH 3/3] refactor: extracts move validation logic --- .clang-format | 2 +- CMakeLists.txt | 3 ++ main.cpp | 1 + src/game/Board.h | 2 +- src/game/GameSession.h | 11 ++--- src/game/GroupAnalyzer.h | 5 +- src/game/Move.h | 13 +++++ src/game/MoveValidator.cpp | 89 ++++++++++++++++++++++++++++++++++ src/game/MoveValidator.h | 17 +++++++ src/game/Rules.cpp | 9 ++-- src/graphics/Lights.cpp | 6 +-- src/graphics/Meshes.cpp | 9 ++-- src/graphics/Renderer.cpp | 16 +++--- src/input/InputRouter.cpp | 40 +++++++++------ tests/move_validator_tests.cpp | 85 ++++++++++++++++++++++++++++++++ tests/rules_tests.cpp | 4 +- 16 files changed, 263 insertions(+), 49 deletions(-) create mode 100644 src/game/Move.h create mode 100644 src/game/MoveValidator.cpp create mode 100644 src/game/MoveValidator.h create mode 100644 tests/move_validator_tests.cpp diff --git a/.clang-format b/.clang-format index 3db6288..7749e2d 100644 --- a/.clang-format +++ b/.clang-format @@ -111,7 +111,7 @@ ForEachMacros: - BOOST_FOREACH IfMacros: - KJ_IF_MAYBE -IncludeBlocks: Preserve +IncludeBlocks: Regroup IncludeCategories: - Regex: '^"(llvm|llvm-c|clang|clang-c)/' Priority: 2 diff --git a/CMakeLists.txt b/CMakeLists.txt index 052afb0..8dfd1d1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ set(SOURCE_FILES src/game/GameState.cpp src/game/Board.cpp src/game/GroupAnalyzer.cpp + src/game/MoveValidator.cpp src/game/Rules.cpp src/graphics/Lights.cpp src/graphics/Meshes.cpp @@ -43,8 +44,10 @@ FetchContent_MakeAvailable(googletest) add_executable(go_tests tests/test_placeholder.cpp tests/rules_tests.cpp + tests/move_validator_tests.cpp src/game/Board.cpp src/game/GroupAnalyzer.cpp + src/game/MoveValidator.cpp src/game/Rules.cpp ) target_link_libraries(go_tests PRIVATE GTest::gtest_main) diff --git a/main.cpp b/main.cpp index f4452d8..0220043 100644 --- a/main.cpp +++ b/main.cpp @@ -1,6 +1,7 @@ // includes, graphics #include #include +#include // DevIL includes #define ILUT_USE_OPENGL #include diff --git a/src/game/Board.h b/src/game/Board.h index e80977f..cdf1e40 100644 --- a/src/game/Board.h +++ b/src/game/Board.h @@ -2,8 +2,8 @@ #define GAME_BOARD_H #include -#include #include +#include #include #include #include diff --git a/src/game/GameSession.h b/src/game/GameSession.h index 446d770..aa89879 100644 --- a/src/game/GameSession.h +++ b/src/game/GameSession.h @@ -1,16 +1,13 @@ #ifndef GAME_SESSION_H #define GAME_SESSION_H -#include - -#include -#include - #include "game/Board.h" #include "game/GameState.h" -struct Point; -enum class Stone : std::uint8_t; +#include +#include +#include +#include constexpr int BOARD_SIZE = Board::SIZE; constexpr int BOARD_CENTER = Board::CENTER; diff --git a/src/game/GroupAnalyzer.h b/src/game/GroupAnalyzer.h index 4c3e9d3..757a64f 100644 --- a/src/game/GroupAnalyzer.h +++ b/src/game/GroupAnalyzer.h @@ -1,13 +1,14 @@ #ifndef GAME_GROUP_ANALYZER_H #define GAME_GROUP_ANALYZER_H +#include "game/Board.h" + #include +#include #include #include #include -#include "game/Board.h" - // Liberties are the empty intersections immediately adjacent to any stone in a // connected group. A group with no liberties is captured. struct GroupAnalysis { diff --git a/src/game/Move.h b/src/game/Move.h new file mode 100644 index 0000000..b9339b0 --- /dev/null +++ b/src/game/Move.h @@ -0,0 +1,13 @@ +#ifndef GAME_MOVE_H +#define GAME_MOVE_H + +#include "game/Board.h" + +// Simple value object representing a move request: a point and the stone +// color to place there. +struct Move { + Point point{}; + Stone stone = Stone::Empty; +}; + +#endif // GAME_MOVE_H diff --git a/src/game/MoveValidator.cpp b/src/game/MoveValidator.cpp new file mode 100644 index 0000000..33084c2 --- /dev/null +++ b/src/game/MoveValidator.cpp @@ -0,0 +1,89 @@ +#include "game/MoveValidator.h" + +#include "game/Board.h" +#include "game/GameSession.h" +#include "game/GroupAnalyzer.h" +#include "game/Move.h" + +#include +#include + +// Implementation notes: +// - This validator copies the board and simulates the placement to determine +// whether the move would be self-capture. It does not mutate the real +// session. Ko/history detection is left as a placeholder hook. + +auto MoveValidator::is_legal(const GameSession &session, const Move &move) -> bool { + const auto &board = session.board; + + // Bounds check + if (!Board::is_on_board(move.point)) { + return false; + } + + // Occupancy check + if (!board.is_empty(move.point)) { + return false; + } + + // Copy the board to simulate the move without mutating session. + Board temp = board; // default copy is fine for arrays and lists + + // Place the stone on the temp board + temp.place_stone(move.point, move.stone); + + const Stone opponent = move.stone == Stone::Black ? Stone::White : Stone::Black; + + // Check captures of adjacent opponent groups. If any adjacent opponent group + // loses all liberties after the move, those stones would be captured and + // therefore the move cannot be a self-capture. + GroupAnalyzer analyzer(temp); + + bool any_capture = false; + const auto neighbors = std::array{ + Point{move.point.x - 1, move.point.y}, Point{move.point.x + 1, move.point.y}, + Point{move.point.x, move.point.y - 1}, Point{move.point.x, move.point.y + 1}}; + + for (const auto &neighbor : neighbors) { + if (!Board::is_on_board(neighbor)) { + continue; + } + + if (temp.stone_at(neighbor) != opponent) { + continue; + } + + const auto analysis = analyzer.analyze(neighbor); + if (!analysis.has_value()) { + continue; + } + + if (!analysis->has_liberties()) { + any_capture = true; + break; + } + } + + if (any_capture) { + // The move captures opponent stones; therefore it is legal even if the + // placed stone itself might appear to have no liberties. + return true; + } + + // No captures; check whether the newly-placed stone's group has liberties. + GroupAnalyzer self_analyzer(temp); + const auto own_group = self_analyzer.analyze(move.point); + + const bool move_has_liberty = own_group.has_value() && own_group->has_liberties(); + + if (!move_has_liberty) { + // Self-capture with no captures is illegal. + return false; + } + + // Placeholder for ko/history rules: we could compute the board hash after + // the move and consult session history to disallow repeating positions. + // For now, return true as the basic rules pass. + + return true; +} diff --git a/src/game/MoveValidator.h b/src/game/MoveValidator.h new file mode 100644 index 0000000..a9cf649 --- /dev/null +++ b/src/game/MoveValidator.h @@ -0,0 +1,17 @@ +#ifndef GAME_MOVE_VALIDATOR_H +#define GAME_MOVE_VALIDATOR_H + +class GameSession; +struct Move; + +// MoveValidator performs legality checks before a move mutates the real +// GameSession state. It intentionally avoids mutating the provided session. +// Current checks: bounds, occupancy, and self-capture. Hooks are prepared for +// future ko/history rules. +class MoveValidator { + public: + // Returns true when the move is legal for the session's current board. + static auto is_legal(const GameSession &session, const Move &move) -> bool; +}; + +#endif // GAME_MOVE_VALIDATOR_H diff --git a/src/game/Rules.cpp b/src/game/Rules.cpp index f613982..d7b29ea 100644 --- a/src/game/Rules.cpp +++ b/src/game/Rules.cpp @@ -1,13 +1,14 @@ #include "game/Rules.h" -#include -#include -#include - #include "game/Board.h" #include "game/GameSession.h" #include "game/GroupAnalyzer.h" +#include +#include +#include +#include + namespace { auto neighbors(Point point) -> std::array { diff --git a/src/graphics/Lights.cpp b/src/graphics/Lights.cpp index 4e0f5d6..b6c02d7 100644 --- a/src/graphics/Lights.cpp +++ b/src/graphics/Lights.cpp @@ -1,10 +1,10 @@ #include "graphics/Lights.h" -#include +#include "game/GameSession.h" +#include "game/GameState.h" #include - -#include "game/GameSession.h" +#include namespace graphics::lights { diff --git a/src/graphics/Meshes.cpp b/src/graphics/Meshes.cpp index 5d42871..87951dd 100644 --- a/src/graphics/Meshes.cpp +++ b/src/graphics/Meshes.cpp @@ -1,13 +1,12 @@ #include "graphics/Meshes.h" -#include -#include -#include +#include "game/GameSession.h" #include #include - -#include "game/GameSession.h" +#include +#include +#include namespace graphics::mesh { diff --git a/src/graphics/Renderer.cpp b/src/graphics/Renderer.cpp index 2a38599..f9acdf1 100644 --- a/src/graphics/Renderer.cpp +++ b/src/graphics/Renderer.cpp @@ -1,18 +1,18 @@ #include "graphics/Renderer.h" -#include -#include -#include +#include "game/Board.h" +#include "game/GameSession.h" +#include "game/GameState.h" +#include "graphics/Lights.h" +#include "graphics/Meshes.h" #include #include #include - #include - -#include "game/GameSession.h" -#include "graphics/Lights.h" -#include "graphics/Meshes.h" +#include +#include +#include namespace { diff --git a/src/input/InputRouter.cpp b/src/input/InputRouter.cpp index e23709e..11ffcbb 100644 --- a/src/input/InputRouter.cpp +++ b/src/input/InputRouter.cpp @@ -1,14 +1,16 @@ #include "input/InputRouter.h" -#include -#include - -#include - #include "game/Board.h" #include "game/GameSession.h" +#include "game/GameState.h" +#include "game/Move.h" +#include "game/MoveValidator.h" #include "game/Rules.h" +#include +#include +#include + namespace { constexpr float ZOOM_SCALE = 0.01F; @@ -146,19 +148,25 @@ void keyboard(GameSession &session, unsigned char key, [[maybe_unused]] int x, const int cursor_y = state.cursor_y(); const Point cursor_point{cursor_x, cursor_y}; - if (!Board::is_on_board(cursor_point)) { - std::cout << "Cursor is out of bounds; cannot place a stone.\n"; - break; - } - - if (!session.board.is_empty(cursor_point)) { - std::cout << "YOU CAN'T PLACE A PIECE HERE BECAUSE THERE ALREADY IS A " - << "PIECE HERE!!!\n"; + const auto current_stone = static_cast(state.current_player()); + const Move attempted_move{.point = cursor_point, .stone = current_stone}; + + if (!MoveValidator::is_legal(session, attempted_move)) { + // Reuse existing messages for clarity + if (!Board::is_on_board(cursor_point)) { + std::cout << "Cursor is out of bounds; cannot place a stone.\n"; + } else if (!session.board.is_empty(cursor_point)) { + std::cout << "YOU CAN'T PLACE A PIECE HERE BECAUSE THERE ALREADY IS A " + << "PIECE HERE!!!\n"; + } else { + std::cout << "Move is illegal (self-capture or other rule)\n"; + } } else { std::cout << "ENTER KEY PRESSED!!!\n"; - const auto current_stone = static_cast(state.current_player()); - rules::make_move(session, cursor_point, current_stone); - state.advance_turn(); + const auto result = rules::make_move(session, cursor_point, current_stone); + if (result == 1) { + state.advance_turn(); + } } break; } diff --git a/tests/move_validator_tests.cpp b/tests/move_validator_tests.cpp new file mode 100644 index 0000000..0e0b4cc --- /dev/null +++ b/tests/move_validator_tests.cpp @@ -0,0 +1,85 @@ +#include "game/Board.h" +#include "game/GameSession.h" +#include "game/Move.h" +#include "game/MoveValidator.h" +#include "game/Rules.h" + +#include + +namespace { + +auto make_session() -> GameSession { + GameSession session; + rules::init_board(session); + session.board.clear_captured_groups(); + return session; +} + +} // namespace + +TEST(MoveValidatorTest, RejectsOutOfBoundsMove) { + auto session = make_session(); + const Move move{.point = Point{Board::CENTER + 1, 0}, .stone = Stone::Black}; + + EXPECT_FALSE(MoveValidator::is_legal(session, move)); +} + +TEST(MoveValidatorTest, RejectsMoveOnOccupiedPoint) { + auto session = make_session(); + const Point point{0, 0}; + + ASSERT_EQ(rules::make_move(session, point, Stone::Black), 1); + + const Move attempted_move{.point = point, .stone = Stone::White}; + + EXPECT_FALSE(MoveValidator::is_legal(session, attempted_move)); +} + +TEST(MoveValidatorTest, AllowsMoveThatCapturesOpponent) { + auto session = make_session(); + auto &board = session.board; + + const Point white{0, 0}; + board.place_stone(white, Stone::White); + board.place_stone(Point{1, 0}, Stone::Black); + board.place_stone(Point{-1, 0}, Stone::Black); + board.place_stone(Point{0, 1}, Stone::Black); + + const Move capturing_move{.point = Point{0, -1}, .stone = Stone::Black}; + + EXPECT_TRUE(MoveValidator::is_legal(session, capturing_move)); +} + +TEST(MoveValidatorTest, RejectsSelfCaptureWithoutCapturing) { + auto session = make_session(); + auto &board = session.board; + + board.place_stone(Point{1, 0}, Stone::Black); + board.place_stone(Point{-1, 0}, Stone::Black); + board.place_stone(Point{0, 1}, Stone::Black); + board.place_stone(Point{0, -1}, Stone::Black); + + const Move suicide_move{.point = Point{0, 0}, .stone = Stone::White}; + + EXPECT_FALSE(MoveValidator::is_legal(session, suicide_move)); +} + +TEST(MoveValidatorTest, DoesNotMutateBoardWhenEvaluating) { + auto session = make_session(); + auto &board = session.board; + + board.place_stone(Point{1, 0}, Stone::Black); + board.place_stone(Point{-1, 0}, Stone::Black); + board.place_stone(Point{0, 1}, Stone::Black); + board.place_stone(Point{0, -1}, Stone::Black); + + const Move illegal_move{.point = Point{0, 0}, .stone = Stone::White}; + + EXPECT_FALSE(MoveValidator::is_legal(session, illegal_move)); + + EXPECT_TRUE(board.is_empty(Point{0, 0})); + EXPECT_EQ(board.stone_at(Point{1, 0}), Stone::Black); + EXPECT_EQ(board.stone_at(Point{-1, 0}), Stone::Black); + EXPECT_EQ(board.stone_at(Point{0, 1}), Stone::Black); + EXPECT_EQ(board.stone_at(Point{0, -1}), Stone::Black); +} diff --git a/tests/rules_tests.cpp b/tests/rules_tests.cpp index 242f291..4ef5091 100644 --- a/tests/rules_tests.cpp +++ b/tests/rules_tests.cpp @@ -1,9 +1,9 @@ -#include - #include "game/Board.h" #include "game/GameSession.h" #include "game/Rules.h" +#include + namespace { auto make_session() -> GameSession {