diff --git a/.github/workflows/code-health.yml b/.github/workflows/code-health.yml index 835da97bed..cb577266da 100644 --- a/.github/workflows/code-health.yml +++ b/.github/workflows/code-health.yml @@ -1,8 +1,15 @@ name: C++ Code Health +# Stub: the linter (devtools/code_health) and its self-tests live on the fork's agents branch. +# This checks that branch out next to the code and runs it against the code checkout. on: push: branches: [main] pull_request: + workflow_dispatch: + inputs: + base-ref: + description: Revision to compare with (e.g. origin/main); empty = whole tree + required: false permissions: contents: read @@ -14,22 +21,33 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 + - uses: actions/checkout@v7 + with: + repository: dayfine/Arduino-Source + ref: agents + path: .agents-tools - uses: actions/setup-python@v6 with: python-version: '3.11' cache: pip - cache-dependency-path: devtools/code_health/requirements.txt - - run: python -m pip install -r devtools/code_health/requirements.txt + cache-dependency-path: .agents-tools/devtools/code_health/requirements.txt + - run: python -m pip install -r .agents-tools/devtools/code_health/requirements.txt - name: Linter self-tests + working-directory: .agents-tools + env: + CODE_HEALTH_REPO: ${{ github.workspace }} run: python -m unittest discover -s devtools/code_health/tests -v - name: Check changed C++ files + working-directory: .agents-tools env: PR_BASE: ${{ github.event.pull_request.base.sha }} + INPUT_BASE: ${{ inputs.base-ref }} PUSH_BASE: ${{ github.event.before }} + CODE: ${{ github.workspace }} shell: bash run: | - base="${PR_BASE:-$PUSH_BASE}" + base="${PR_BASE:-${INPUT_BASE:-$PUSH_BASE}}" if [[ -z "$base" || "$base" == 0000000000000000000000000000000000000000 ]]; then - base=$(git hash-object -w -t tree --stdin > hold(context, positions, direction); + * int tiles(const Hold&); + * The policy type selects game rules at compile time. Runtime Context data + * (map, walking mode, etc.) is passed separately; no policy instance or global + * map is needed. hold() receives Context and a const vector of hypotheses, + * rejecting any direction unsafe for any hypothesis, including NPCs, warps + * and unknown edges. Hypotheses are copyable; Hypothesis/Position support ==. + */ + +#ifndef PokemonAutomation_Pokemon_WallRecovery_H +#define PokemonAutomation_Pokemon_WallRecovery_H + +#include +#include +#include +#include +#include "Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + +// Bound the number of directional wall holds in a recovery plan. +struct WallRecoveryLimits{ + size_t max_holds = 3; +}; + +// One safe directional hold and its resulting hypotheses, supplied by the policy. +template +struct WallRecoveryStep{ + std::vector positions; + Hold hold; +}; + +// A sequence of safe holds that brings every hypothesis to the same position. +template +struct WallAnchor{ + std::vector holds; + Position at; +}; + +// Return minimum-hold anchors, ordered by held tiles, then ALL_DIRECTIONS. +// Empty input has no anchor; already-converged input needs no holds even at +// max_holds=0. The borrowed span is not retained: search branches own their +// hypothesis vectors, including any distinct object-world alternatives. +template +std::vector> find_wall_anchors( + std::span positions, const Context& context, + const WallRecoveryLimits& limits = WallRecoveryLimits() +); + +namespace WallRecoveryInternal{ + +// Whether a nonempty belief set represents one physical position. +template +bool converged(std::span positions){ + return !positions.empty() && std::all_of(positions.begin(), positions.end(), [&](const auto& position){ + return MovementPolicy::position(position) == MovementPolicy::position(positions.front()); + }); +} + +// Explore exactly depth more holds, retaining only common safe anchors. +template +void search( + const std::vector& positions, const Context& context, + size_t depth, std::vector& holds, + std::vector>& found +){ + for (Direction direction : ALL_DIRECTIONS){ + auto next = MovementPolicy::hold(context, positions, direction); + if (!next || next->positions.empty() || next->positions == positions || MovementPolicy::tiles(next->hold) == 0){ + continue; + } + holds.push_back(next->hold); + if (converged(next->positions)){ + if (depth == 1){ + found.push_back({holds, MovementPolicy::position(next->positions.front())}); + } + }else if (depth > 1){ + search(next->positions, context, depth - 1, holds, found); + } + holds.pop_back(); + } +} + +// Sum held-tile distances for stable ordering among equal-length plans. +template +int total_tiles(std::span holds){ + int total = 0; + for (const auto& hold : holds){ + total += MovementPolicy::tiles(hold); + } + return total; +} + +} // namespace WallRecoveryInternal + +template +std::vector> find_wall_anchors( + std::span positions, const Context& context, + const WallRecoveryLimits& limits +){ + using Anchor = WallAnchor; + if (WallRecoveryInternal::converged(positions)){ + return {Anchor{{}, MovementPolicy::position(positions.front())}}; + } + std::vector found; + if (positions.empty()){ + return found; + } + const std::vector starts(positions.begin(), positions.end()); + for (size_t depth = 1; found.empty() && depth <= limits.max_holds; depth++){ + std::vector holds; + WallRecoveryInternal::search(starts, context, depth, holds, found); + std::stable_sort(found.begin(), found.end(), [&](const Anchor& a, const Anchor& b){ + return WallRecoveryInternal::total_tiles(a.holds) < WallRecoveryInternal::total_tiles(b.holds); + }); + } + return found; +} + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp new file mode 100644 index 0000000000..21d1d10e6c --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp @@ -0,0 +1,256 @@ +/* Wall Recovery Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Synthetic diagnostics of the shared search. Game adapters retain the + * real forest/mine scenarios and collision/transition semantics. + */ + +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "Pokemon_WallRecovery.h" +#include "Pokemon_Navigation_Tests.h" + +namespace PokemonAutomation{ +namespace Pokemon{ +namespace{ + +struct TestHold{ + Direction direction; + int tiles; + bool operator==(const TestHold&) const = default; +}; + +// A one-dimensional synthetic corridor, not an open map edge: +// wall | walkable x=0 ... 10 | wall +// -1 | . . A B . . . . . . . | 11 +// A/B are example hypotheses x=2/3. WEST stops at x=0; EAST at x=10. +// NORTH/SOUTH are unavailable. A person anywhere on a ray makes it unsafe. +struct CorridorContext{ + std::optional person; +}; + +class CorridorPolicy{ +public: + using Hypothesis = int; + using Position = int; + using Hold = TestHold; + + static Position position(Hypothesis at){ return at; } + static int tiles(const Hold& hold){ return hold.tiles; } + static std::optional> hold( + const CorridorContext& context, const std::vector& from, Direction direction + ){ + if (direction != Direction::WEST && direction != Direction::EAST){ + return std::nullopt; + } + const int end = direction == Direction::WEST ? 0 : 10; + int longest = 0; + for (int start : from){ + if (context.person && std::min(start, end) <= *context.person && *context.person <= std::max(start, end)){ + return std::nullopt; + } + longest = std::max(longest, start > end ? start - end : end - start); + } + return WallRecoveryStep{{end}, {direction, longest}}; + } + +}; + +// Show both directions and distances when a selected recovery plan differs. +std::string describe_holds(std::span holds){ + std::string result; + for (const auto& hold : holds){ + result += std::format("{} {} tiles; ", direction_name(hold.direction), hold.tiles); + } + return result.empty() ? "none" : result; +} + +// Inputs and expectations are separate aggregates; each registered test owns one row. +struct CorridorInput{ + std::string name; + std::vector positions; + std::optional person; + size_t budget; +}; + +struct CorridorExpectation{ + std::optional first_anchor; + std::vector holds; +}; + +struct CorridorTestCase{ + CorridorInput input; + CorridorExpectation expectation; +}; + +class Test_WallRecovery : public UnitTest{ +public: + explicit Test_WallRecovery(CorridorTestCase scenario) + : UnitTest("Pokemon::WallRecovery - " + scenario.input.name), m_scenario(std::move(scenario)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const auto& input = m_scenario.input; + const auto& expected = m_scenario.expectation; + const auto found = find_wall_anchors(input.positions, CorridorContext{.person = input.person}, {.max_holds = input.budget}); + const std::optional actual = found.empty() ? std::nullopt : std::optional(found.front().at); + if (actual != expected.first_anchor){ + return std::format("Expected first anchor {}, got {} ({} anchors).", + expected.first_anchor ? std::to_string(*expected.first_anchor) : "none", + actual ? std::to_string(*actual) : "none", found.size()); + } + if (!found.empty() && found.front().holds != expected.holds){ + return std::format("Expected holds {}, got {}.", describe_holds(expected.holds), describe_holds(found.front().holds)); + } + return true; + } +private: + CorridorTestCase m_scenario; +}; + +const CorridorTestCase CORRIDOR_CASES[] = { + { + .input = {.name = "empty", .positions = {}, .person = std::nullopt, .budget = 3}, + .expectation = {.first_anchor = std::nullopt, .holds = {}}, + }, + { + .input = {.name = "already known", .positions = {4}, .person = std::nullopt, .budget = 0}, + .expectation = {.first_anchor = 4, .holds = {}}, + }, + { + .input = {.name = "both tiles", .positions = {2, 3}, .person = std::nullopt, .budget = 3}, + .expectation = {.first_anchor = 0, .holds = {{.direction = Direction::WEST, .tiles = 3}}}, + }, + { + .input = {.name = "three shortfall endpoints", .positions = {3, 4, 5}, .person = std::nullopt, .budget = 3}, + .expectation = {.first_anchor = 0, .holds = {{.direction = Direction::WEST, .tiles = 5}}}, + }, + { + .input = {.name = "shortest hold first", .positions = {8, 9}, .person = std::nullopt, .budget = 3}, + .expectation = {.first_anchor = 10, .holds = {{.direction = Direction::EAST, .tiles = 2}}}, + }, + { + .input = {.name = "person is not an anchor", .positions = {2, 3}, .person = 1, .budget = 3}, + .expectation = {.first_anchor = 10, .holds = {{.direction = Direction::EAST, .tiles = 8}}}, + }, + { + .input = {.name = "person splits belief", .positions = {2, 8}, .person = 5, .budget = 3}, + .expectation = {.first_anchor = std::nullopt, .holds = {}}, + }, + { + .input = {.name = "budget exhausted", .positions = {2, 3}, .person = std::nullopt, .budget = 0}, + .expectation = {.first_anchor = std::nullopt, .holds = {}}, + }, +}; + +// A synthetic closed room: all (x,y) in [0,10] x [0,10] are walkable. +// Walls are outside that rectangle at x/y=-1 and x/y=11. +// ############# +// #C..........# y=0; C=(0,0), the expected northwest stop +// #...........# +// #...........# +// #..A........# A=(2,3) +// #...B.......# B=(3,4) +// #...........# ... through y=10 +// ############# +// NORTH 4 maps A/B to (2,0)/(3,0); WEST 3 then merges them at C. +// Coordinates always stay inside the room; no unknown edge is crossed. +struct CornerContext{}; + +class CornerPolicy{ +public: + using Hypothesis = TilePos; + using Position = TilePos; + using Hold = TestHold; + + static Position position(const Hypothesis& at){ return at; } + static int tiles(const Hold& hold){ return hold.tiles; } + static std::optional> hold( + const CornerContext&, const std::vector& from, Direction direction + ){ + std::vector ends; + int longest = 0; + for (const TilePos& start : from){ + TilePos end = start; + const bool vertical = direction == Direction::NORTH || direction == Direction::SOUTH; + int& coordinate = vertical ? end.y : end.x; + const int distance = direction == Direction::NORTH || direction == Direction::WEST ? coordinate : 10 - coordinate; + coordinate = direction == Direction::NORTH || direction == Direction::WEST ? 0 : 10; + longest = std::max(longest, distance); + if (std::find(ends.begin(), ends.end(), end) == ends.end()){ + ends.push_back(end); + } + } + return WallRecoveryStep{ends, {direction, longest}}; + } +}; + +struct CornerInput{ + std::string name; + std::array positions; + size_t budget; +}; + +struct CornerExpectation{ + std::optional first_anchor; + std::vector holds; +}; + +struct CornerTestCase{ + CornerInput input; + CornerExpectation expectation; +}; + +class Test_WallRecoveryCorner : public UnitTest{ +public: + explicit Test_WallRecoveryCorner(CornerTestCase scenario) + : UnitTest("Pokemon::WallRecovery - " + scenario.input.name), m_scenario(std::move(scenario)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const auto& input = m_scenario.input; + const auto& expected = m_scenario.expectation; + // std::array and the corridor's std::vector both bind to the borrowed span API. + const auto found = find_wall_anchors(input.positions, CornerContext{}, {.max_holds = input.budget}); + const std::optional actual = found.empty() ? std::nullopt : std::optional(found.front().at); + if (actual != expected.first_anchor){ + return std::format("Expected first corner {}, got {} ({} anchors).", + expected.first_anchor ? to_string(*expected.first_anchor) : "none", + actual ? to_string(*actual) : "none", found.size()); + } + if (!found.empty() && found.front().holds != expected.holds){ + return std::format("Expected holds {}, got {}.", + describe_holds(expected.holds), describe_holds(found.front().holds)); + } + return true; + } +private: + CornerTestCase m_scenario; +}; + +const CornerTestCase CORNER_CASES[] = { + { + .input = {.name = "one hold cannot localize both axes", .positions = {{{.x = 2, .y = 3}, {.x = 3, .y = 4}}}, .budget = 1}, + .expectation = {.first_anchor = std::nullopt, .holds = {}}, + }, + { + .input = {.name = "two holds converge at northwest corner", .positions = {{{.x = 2, .y = 3}, {.x = 3, .y = 4}}}, .budget = 2}, + .expectation = {.first_anchor = TilePos{.x = 0, .y = 0}, + .holds = {{.direction = Direction::NORTH, .tiles = 4}, {.direction = Direction::WEST, .tiles = 3}}}, + }, +}; + +} // namespace + +void add_tests_WallRecovery(UnitTestDatabase& database){ + for (const CorridorTestCase& scenario : CORRIDOR_CASES){ + database.add(scenario); + } + for (const CornerTestCase& scenario : CORNER_CASES){ + database.add(scenario); + } +} + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index edc76c907b..794c951f8b 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1460,6 +1460,8 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/Inference/Pokemon_TypeReader.h Source/Pokemon/Navigation/Pokemon_Grid.cpp Source/Pokemon/Navigation/Pokemon_Grid.h + Source/Pokemon/Navigation/Pokemon_WallRecovery.h + Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp Source/Pokemon/Navigation/Pokemon_LegPlanner.h Source/Pokemon/Navigation/Pokemon_LegPlanner_Tests.cpp Source/Pokemon/Navigation/Pokemon_LegWalker.cpp