From af21f2750289adef1a370ba214eb557b26c9c0ec Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 02:25:33 -0800 Subject: [PATCH 1/5] Extract common wall convergence search behind game movement adapters --- .../ComputerPrograms_UnitTestRunner.cpp | 1 + .../Navigation/Pokemon_Navigation_Tests.h | 1 + .../Pokemon/Navigation/Pokemon_WallRecovery.h | 117 +++++++++++++++ .../Navigation/Pokemon_WallRecovery_Tests.cpp | 140 ++++++++++++++++++ SerialPrograms/cmake/SourceFiles.cmake | 2 + 5 files changed, 261 insertions(+) create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery.h create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index d8c5d1b005..5db1e5bf05 100644 --- a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp +++ b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp @@ -70,6 +70,7 @@ UnitTestDatabase make_UNIT_TESTS_ALL(){ Pokemon::add_tests_AutoStorySelect(ret); Pokemon::add_tests_LegPlanner(ret); Pokemon::add_tests_LegWalker(ret); + Pokemon::add_tests_WallRecovery(ret); Pokemon::add_tests_MapData(ret); Pokemon::add_tests_RouteLog(ret); Pokemon::add_tests_StoryEvents(ret); diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h index 06084295e8..0d68049d3f 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h @@ -18,6 +18,7 @@ namespace Pokemon{ void add_tests_LegPlanner(UnitTestDatabase& database); void add_tests_LegWalker(UnitTestDatabase& database); +void add_tests_WallRecovery(UnitTestDatabase& database); void add_tests_MapData(UnitTestDatabase& database); void add_tests_RouteLog(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery.h new file mode 100644 index 0000000000..0bc8faf054 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery.h @@ -0,0 +1,117 @@ +/* Wall Recovery + * + * From: https://github.com/PokemonAutomation/ + * + * Find common wall holds without choosing one uncertain starting position. + * The game supplies legal holds and maps hypotheses to physical positions. + * Different hypotheses may include different trainer/object worlds. A hold + * is legal only if safe from every hypothesis; doors, NPCs and unknown edges + * must not be reported as walls by the adapter. + * + * Model defines Hypothesis, Hold and Position, and provides: + * Position position(const Hypothesis&) const; + * optional> hold(positions, direction) const; + * int tiles(const Hold&) const; + * Hypothesis and Position must support ==. Holds remain game-owned values. + * Results predict convergence, not detector-confirmed execution. + */ + +#ifndef PokemonAutomation_Pokemon_WallRecovery_H +#define PokemonAutomation_Pokemon_WallRecovery_H + +#include +#include +#include +#include "Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + +struct WallRecoveryLimits{ + size_t max_holds = 3; +}; + +template +struct WallRecoveryStep{ + std::vector positions; + Hold hold; +}; + +template +struct WallAnchor{ + std::vector holds; + Position at; +}; + +namespace WallRecoveryInternal{ + +template +bool converged(const Model& model, const std::vector& positions){ + return !positions.empty() && std::all_of(positions.begin(), positions.end(), [&](const auto& position){ + return model.position(position) == model.position(positions.front()); + }); +} + +template +void search( + const Model& model, const std::vector& positions, + size_t depth, std::vector& holds, + std::vector>& found +){ + for (Direction direction : ALL_DIRECTIONS){ + auto next = model.hold(positions, direction); + if (!next || next->positions.empty() || next->positions == positions || model.tiles(next->hold) == 0){ + continue; + } + holds.push_back(next->hold); + if (converged(model, next->positions)){ + if (depth == 1){ + found.push_back({holds, model.position(next->positions.front())}); + } + }else if (depth > 1){ + search(model, next->positions, depth - 1, holds, found); + } + holds.pop_back(); + } +} + +template +int total_tiles(const Model& model, const std::vector& holds){ + int total = 0; + for (const auto& hold : holds){ + total += model.tiles(hold); + } + return total; +} + +} + +// Fewest holds first, then fewest tiles; ties keep ALL_DIRECTIONS order. +// All results have the minimum hold count. Empty input has no anchor; +// already-converged hypotheses need no holds, including at max_holds=0. +template +std::vector> find_wall_anchors( + const Model& model, const std::vector& positions, + const WallRecoveryLimits& limits = WallRecoveryLimits() +){ + using Anchor = WallAnchor; + std::vector found; + if (WallRecoveryInternal::converged(model, positions)){ + return {Anchor{{}, model.position(positions.front())}}; + } + if (positions.empty()){ + return found; + } + for (size_t depth = 1; found.empty() && depth <= limits.max_holds; depth++){ + std::vector holds; + WallRecoveryInternal::search(model, positions, depth, holds, found); + std::stable_sort(found.begin(), found.end(), [&](const Anchor& a, const Anchor& b){ + return WallRecoveryInternal::total_tiles(model, a.holds) < WallRecoveryInternal::total_tiles(model, b.holds); + }); + } + return found; +} + +} +} +#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..397e3eda94 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp @@ -0,0 +1,140 @@ +/* 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 "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; +}; + +class CorridorModel{ +public: + using Hypothesis = int; + using Position = int; + using Hold = TestHold; + + explicit CorridorModel(std::optional person = std::nullopt) + : m_person(person) + {} + Position position(Hypothesis at) const{ return at; } + int tiles(const Hold& hold) const{ return hold.tiles; } + std::optional> hold( + const std::vector& from, Direction direction + ) const{ + 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 (m_person && std::min(start, end) <= *m_person && *m_person <= std::max(start, end)){ + return std::nullopt; + } + longest = std::max(longest, start > end ? start - end : end - start); + } + return WallRecoveryStep{{end}, {direction, longest}}; + } + +private: + std::optional m_person; +}; + +class CornerModel{ +public: + using Hypothesis = TilePos; + using Position = TilePos; + using Hold = TestHold; + + Position position(const Hypothesis& at) const{ return at; } + int tiles(const Hold& hold) const{ return hold.tiles; } + std::optional> hold( + const std::vector& from, Direction direction + ) const{ + 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}}; + } +}; + +class Test_WallRecoveryCorner : public UnitTest{ +public: + Test_WallRecoveryCorner() : UnitTest("Pokemon::WallRecovery - two holds and depth budget"){} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::vector from{{2, 3}, {3, 4}}; + if (!find_wall_anchors(CornerModel(), from, {.max_holds = 1}).empty()){ + return "One hold cannot localize both axes."; + } + const auto found = find_wall_anchors(CornerModel(), from, {.max_holds = 2}); + if (found.empty() || found[0].holds.size() != 2 || found[0].at != TilePos{0, 0}){ + return "Expected two shortest holds to the northwest corner."; + } + return true; + } +}; + +class Test_WallRecovery : public UnitTest{ +public: + Test_WallRecovery(std::string name, std::vector from, std::optional person, + size_t budget, std::optional expected, size_t holds) + : UnitTest("Pokemon::WallRecovery - " + name) + , m_from(std::move(from)), m_person(person), m_budget(budget), m_expected(expected), m_holds(holds) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const auto found = find_wall_anchors(CorridorModel(m_person), m_from, {.max_holds = m_budget}); + if (!m_expected){ + return found.empty() ? UnitTestResult(true) : UnitTestResult("Expected no safe anchor."); + } + if (found.empty() || found[0].at != *m_expected || found[0].holds.size() != m_holds){ + return "Wrong convergence tile or hold count."; + } + return true; + } + +private: + std::vector m_from; + std::optional m_person; + size_t m_budget; + std::optional m_expected; + size_t m_holds; +}; + +} + +void add_tests_WallRecovery(UnitTestDatabase& database){ + database.add(); + database.add("empty", std::vector{}, std::nullopt, 3, std::nullopt, 0); + database.add("already known", std::vector{4}, std::nullopt, 0, 4, 0); + database.add("both tiles", std::vector{2, 3}, std::nullopt, 3, 0, 1); + database.add("three shortfall endpoints", std::vector{3, 4, 5}, std::nullopt, 3, 0, 1); + database.add("shortest hold first", std::vector{8, 9}, std::nullopt, 3, 10, 1); + database.add("person is not an anchor", std::vector{2, 3}, 1, 3, 10, 1); + database.add("person splits belief", std::vector{2, 8}, 5, 3, std::nullopt, 0); + database.add("budget exhausted", std::vector{2, 3}, std::nullopt, 0, std::nullopt, 0); +} + +} +} 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 From f1f7f3c6e5a84eaabcc5f9a80768a050071042dd Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 11:02:34 -0800 Subject: [PATCH 2/5] Document wall recovery policy and expose span-based API before helpers --- .github/workflows/code-health.yml | 28 ++- .../Navigation/Pokemon_Navigation_Tests.h | 1 + .../Pokemon/Navigation/Pokemon_WallRecovery.h | 109 ++++++++---- .../Navigation/Pokemon_WallRecovery_Tests.cpp | 168 ++++++++++++++---- 4 files changed, 226 insertions(+), 80 deletions(-) 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(positions, direction) const; * int tiles(const Hold&) const; - * Hypothesis and Position must support ==. Holds remain game-owned values. - * Results predict convergence, not detector-confirmed execution. + * hold() receives a const vector of hypotheses and rejects any direction + * unsafe for any hypothesis, including NPCs, warps and unknown map edges. + * Hypothesis and Position support ==. No default movement policy exists: + * choosing the game's collision rules is part of the caller's contract. */ #ifndef PokemonAutomation_Pokemon_WallRecovery_H @@ -21,97 +24,125 @@ #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( + const MovementPolicy& policy, std::span positions, + const WallRecoveryLimits& limits = WallRecoveryLimits() +); + namespace WallRecoveryInternal{ -template -bool converged(const Model& model, const std::vector& positions){ +// Whether a nonempty belief set represents one physical position. +template +bool converged(const MovementPolicy& policy, std::span positions); + +// Explore exactly depth more holds, retaining only common safe anchors. +template +void search( + const MovementPolicy& policy, const std::vector& positions, + size_t depth, std::vector& holds, + std::vector>& found +); + +// Sum held-tile distances for stable ordering among equal-length plans. +template +int total_tiles(const MovementPolicy& policy, std::span holds); + +template +bool converged(const MovementPolicy& policy, std::span positions){ return !positions.empty() && std::all_of(positions.begin(), positions.end(), [&](const auto& position){ - return model.position(position) == model.position(positions.front()); + return policy.position(position) == policy.position(positions.front()); }); } -template +template void search( - const Model& model, const std::vector& positions, - size_t depth, std::vector& holds, - std::vector>& found + const MovementPolicy& policy, const std::vector& positions, + size_t depth, std::vector& holds, + std::vector>& found ){ for (Direction direction : ALL_DIRECTIONS){ - auto next = model.hold(positions, direction); - if (!next || next->positions.empty() || next->positions == positions || model.tiles(next->hold) == 0){ + auto next = policy.hold(positions, direction); + if (!next || next->positions.empty() || next->positions == positions || policy.tiles(next->hold) == 0){ continue; } holds.push_back(next->hold); - if (converged(model, next->positions)){ + if (converged(policy, next->positions)){ if (depth == 1){ - found.push_back({holds, model.position(next->positions.front())}); + found.push_back({holds, policy.position(next->positions.front())}); } }else if (depth > 1){ - search(model, next->positions, depth - 1, holds, found); + search(policy, next->positions, depth - 1, holds, found); } holds.pop_back(); } } -template -int total_tiles(const Model& model, const std::vector& holds){ +template +int total_tiles(const MovementPolicy& policy, std::span holds){ int total = 0; for (const auto& hold : holds){ - total += model.tiles(hold); + total += policy.tiles(hold); } return total; } -} +} // namespace WallRecoveryInternal -// Fewest holds first, then fewest tiles; ties keep ALL_DIRECTIONS order. -// All results have the minimum hold count. Empty input has no anchor; -// already-converged hypotheses need no holds, including at max_holds=0. -template -std::vector> find_wall_anchors( - const Model& model, const std::vector& positions, - const WallRecoveryLimits& limits = WallRecoveryLimits() +template +std::vector> find_wall_anchors( + const MovementPolicy& policy, std::span positions, + const WallRecoveryLimits& limits ){ - using Anchor = WallAnchor; + using Anchor = WallAnchor; std::vector found; - if (WallRecoveryInternal::converged(model, positions)){ - return {Anchor{{}, model.position(positions.front())}}; + if (WallRecoveryInternal::converged(policy, positions)){ + return {Anchor{{}, policy.position(positions.front())}}; } 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(model, positions, depth, holds, found); + std::vector holds; + WallRecoveryInternal::search(policy, starts, depth, holds, found); std::stable_sort(found.begin(), found.end(), [&](const Anchor& a, const Anchor& b){ - return WallRecoveryInternal::total_tiles(model, a.holds) < WallRecoveryInternal::total_tiles(model, b.holds); + return WallRecoveryInternal::total_tiles(policy, a.holds) < WallRecoveryInternal::total_tiles(policy, 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 index 397e3eda94..008e2a1c19 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp @@ -6,6 +6,8 @@ * real forest/mine scenarios and collision/transition semantics. */ +#include +#include #include #include "Common/Cpp/TestRunners/UnitTest.h" #include "Pokemon_WallRecovery.h" @@ -18,6 +20,7 @@ namespace{ struct TestHold{ Direction direction; int tiles; + bool operator==(const TestHold&) const = default; }; class CorridorModel{ @@ -80,61 +83,154 @@ class CornerModel{ } }; -class Test_WallRecoveryCorner : public UnitTest{ +// 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: - Test_WallRecoveryCorner() : UnitTest("Pokemon::WallRecovery - two holds and depth budget"){} + explicit Test_WallRecovery(CorridorTestCase scenario) + : UnitTest("Pokemon::WallRecovery - " + scenario.input.name), m_scenario(std::move(scenario)) + {} virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - const std::vector from{{2, 3}, {3, 4}}; - if (!find_wall_anchors(CornerModel(), from, {.max_holds = 1}).empty()){ - return "One hold cannot localize both axes."; + const auto& input = m_scenario.input; + const auto& expected = m_scenario.expectation; + const auto found = find_wall_anchors(CorridorModel(input.person), input.positions, {.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()); } - const auto found = find_wall_anchors(CornerModel(), from, {.max_holds = 2}); - if (found.empty() || found[0].holds.size() != 2 || found[0].at != TilePos{0, 0}){ - return "Expected two shortest holds to the northwest corner."; + if (!found.empty() && found.front().holds != expected.holds){ + return std::format("Expected holds {}, got {}.", describe(expected.holds), describe(found.front().holds)); } return true; } +private: + static std::string describe(const std::vector& holds){ + std::string result; + for (const auto& hold : holds){ + result += std::format("{} {} tiles; ", direction_name(hold.direction), hold.tiles); + } + return result.empty() ? "none" : result; + } + CorridorTestCase m_scenario; }; -class Test_WallRecovery : public UnitTest{ +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 = {}}, + }, +}; + +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: - Test_WallRecovery(std::string name, std::vector from, std::optional person, - size_t budget, std::optional expected, size_t holds) - : UnitTest("Pokemon::WallRecovery - " + name) - , m_from(std::move(from)), m_person(person), m_budget(budget), m_expected(expected), m_holds(holds) + 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 found = find_wall_anchors(CorridorModel(m_person), m_from, {.max_holds = m_budget}); - if (!m_expected){ - return found.empty() ? UnitTestResult(true) : UnitTestResult("Expected no safe anchor."); + 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(CornerModel(), input.positions, {.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[0].at != *m_expected || found[0].holds.size() != m_holds){ - return "Wrong convergence tile or hold count."; + if (!found.empty() && found.front().holds != expected.holds){ + return std::format("Expected {} holds beginning north 4 then west 3, got {} holds.", + expected.holds.size(), found.front().holds.size()); } return true; } - private: - std::vector m_from; - std::optional m_person; - size_t m_budget; - std::optional m_expected; - size_t m_holds; + 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){ - database.add(); - database.add("empty", std::vector{}, std::nullopt, 3, std::nullopt, 0); - database.add("already known", std::vector{4}, std::nullopt, 0, 4, 0); - database.add("both tiles", std::vector{2, 3}, std::nullopt, 3, 0, 1); - database.add("three shortfall endpoints", std::vector{3, 4, 5}, std::nullopt, 3, 0, 1); - database.add("shortest hold first", std::vector{8, 9}, std::nullopt, 3, 10, 1); - database.add("person is not an anchor", std::vector{2, 3}, 1, 3, 10, 1); - database.add("person splits belief", std::vector{2, 8}, 5, 3, std::nullopt, 0); - database.add("budget exhausted", std::vector{2, 3}, std::nullopt, 0, std::nullopt, 0); + for (const CorridorTestCase& scenario : CORRIDOR_CASES){ + database.add(scenario); + } + for (const CornerTestCase& scenario : CORNER_CASES){ + database.add(scenario); + } } -} -} +} // namespace Pokemon +} // namespace PokemonAutomation From c1a55d784aaf310ee3b47a3e740d6350da8402e0 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 11:03:09 -0800 Subject: [PATCH 3/5] Show expected and actual wall holds in table-driven failures --- .../Navigation/Pokemon_WallRecovery_Tests.cpp | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp index 008e2a1c19..59aad2ec84 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp @@ -83,6 +83,15 @@ class CornerModel{ } }; +// 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; @@ -117,18 +126,11 @@ class Test_WallRecovery : public UnitTest{ actual ? std::to_string(*actual) : "none", found.size()); } if (!found.empty() && found.front().holds != expected.holds){ - return std::format("Expected holds {}, got {}.", describe(expected.holds), describe(found.front().holds)); + return std::format("Expected holds {}, got {}.", describe_holds(expected.holds), describe_holds(found.front().holds)); } return true; } private: - static std::string describe(const std::vector& holds){ - std::string result; - for (const auto& hold : holds){ - result += std::format("{} {} tiles; ", direction_name(hold.direction), hold.tiles); - } - return result.empty() ? "none" : result; - } CorridorTestCase m_scenario; }; @@ -200,8 +202,8 @@ class Test_WallRecoveryCorner : public UnitTest{ actual ? to_string(*actual) : "none", found.size()); } if (!found.empty() && found.front().holds != expected.holds){ - return std::format("Expected {} holds beginning north 4 then west 3, got {} holds.", - expected.holds.size(), found.front().holds.size()); + return std::format("Expected holds {}, got {}.", + describe_holds(expected.holds), describe_holds(found.front().holds)); } return true; } From 554624edf08f35acfd510a5141118e88e795098e Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 11:21:00 -0800 Subject: [PATCH 4/5] Use static movement policies with explicit map context and document test rooms --- .../Pokemon/Navigation/Pokemon_WallRecovery.h | 76 ++++++++----------- .../Navigation/Pokemon_WallRecovery_Tests.cpp | 58 +++++++++----- 2 files changed, 69 insertions(+), 65 deletions(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery.h index 1d5ca709c7..66648cca7c 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery.h @@ -7,16 +7,15 @@ * the player. Its descriptor belongs to the game; it is not a duration or * a count of taps. Results predict convergence, not confirmed execution. * - * MovementPolicy is a compile-time policy, supplied as a stateful object - * so it can refer to the game's map and trainer/object-world hypotheses. - * It defines Hypothesis, Hold and Position, and provides: - * Position position(const Hypothesis&) const; - * optional> hold(positions, direction) const; - * int tiles(const Hold&) const; - * hold() receives a const vector of hypotheses and rejects any direction - * unsafe for any hypothesis, including NPCs, warps and unknown map edges. - * Hypothesis and Position support ==. No default movement policy exists: - * choosing the game's collision rules is part of the caller's contract. + * MovementPolicy defines Hypothesis, Hold and Position, and static operations: + * Position position(const Hypothesis&); + * optional> 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 @@ -54,9 +53,9 @@ struct WallAnchor{ // 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 +template std::vector> find_wall_anchors( - const MovementPolicy& policy, std::span positions, + std::span positions, const Context& context, const WallRecoveryLimits& limits = WallRecoveryLimits() ); @@ -64,80 +63,67 @@ namespace WallRecoveryInternal{ // Whether a nonempty belief set represents one physical position. template -bool converged(const MovementPolicy& policy, std::span positions); - -// Explore exactly depth more holds, retaining only common safe anchors. -template -void search( - const MovementPolicy& policy, const std::vector& positions, - size_t depth, std::vector& holds, - std::vector>& found -); - -// Sum held-tile distances for stable ordering among equal-length plans. -template -int total_tiles(const MovementPolicy& policy, std::span holds); - -template -bool converged(const MovementPolicy& policy, std::span positions){ +bool converged(std::span positions){ return !positions.empty() && std::all_of(positions.begin(), positions.end(), [&](const auto& position){ - return policy.position(position) == policy.position(positions.front()); + return MovementPolicy::position(position) == MovementPolicy::position(positions.front()); }); } -template +// Explore exactly depth more holds, retaining only common safe anchors. +template void search( - const MovementPolicy& policy, const std::vector& positions, + const std::vector& positions, const Context& context, size_t depth, std::vector& holds, std::vector>& found ){ for (Direction direction : ALL_DIRECTIONS){ - auto next = policy.hold(positions, direction); - if (!next || next->positions.empty() || next->positions == positions || policy.tiles(next->hold) == 0){ + 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(policy, next->positions)){ + if (converged(next->positions)){ if (depth == 1){ - found.push_back({holds, policy.position(next->positions.front())}); + found.push_back({holds, MovementPolicy::position(next->positions.front())}); } }else if (depth > 1){ - search(policy, next->positions, depth - 1, holds, found); + 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(const MovementPolicy& policy, std::span holds){ +int total_tiles(std::span holds){ int total = 0; for (const auto& hold : holds){ - total += policy.tiles(hold); + total += MovementPolicy::tiles(hold); } return total; } } // namespace WallRecoveryInternal -template +template std::vector> find_wall_anchors( - const MovementPolicy& policy, std::span positions, + std::span positions, const Context& context, const WallRecoveryLimits& limits ){ using Anchor = WallAnchor; - std::vector found; - if (WallRecoveryInternal::converged(policy, positions)){ - return {Anchor{{}, policy.position(positions.front())}}; + 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(policy, starts, depth, holds, found); + WallRecoveryInternal::search(starts, context, depth, holds, found); std::stable_sort(found.begin(), found.end(), [&](const Anchor& a, const Anchor& b){ - return WallRecoveryInternal::total_tiles(policy, a.holds) < WallRecoveryInternal::total_tiles(policy, b.holds); + return WallRecoveryInternal::total_tiles(a.holds) < WallRecoveryInternal::total_tiles(b.holds); }); } return found; diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp index 59aad2ec84..d6c0ccaf6d 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp @@ -23,27 +23,33 @@ struct TestHold{ bool operator==(const TestHold&) const = default; }; -class CorridorModel{ +// 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; - explicit CorridorModel(std::optional person = std::nullopt) - : m_person(person) - {} - Position position(Hypothesis at) const{ return at; } - int tiles(const Hold& hold) const{ return hold.tiles; } - std::optional> hold( - const std::vector& from, Direction direction - ) const{ + 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 (m_person && std::min(start, end) <= *m_person && *m_person <= std::max(start, end)){ + 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); @@ -51,21 +57,33 @@ class CorridorModel{ return WallRecoveryStep{{end}, {direction, longest}}; } -private: - std::optional m_person; }; -class CornerModel{ +// 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; - Position position(const Hypothesis& at) const{ return at; } - int tiles(const Hold& hold) const{ return hold.tiles; } - std::optional> hold( - const std::vector& from, Direction direction - ) const{ + 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){ @@ -118,7 +136,7 @@ class Test_WallRecovery : public UnitTest{ 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(CorridorModel(input.person), input.positions, {.max_holds = input.budget}); + 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).", @@ -194,7 +212,7 @@ class Test_WallRecoveryCorner : public UnitTest{ 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(CornerModel(), input.positions, {.max_holds = input.budget}); + 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).", From 7fe65bf5a5b4becdb74bfee42933dd56139ef31b Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 11:33:21 -0800 Subject: [PATCH 5/5] Keep corner policy beside its input and test cases --- .../Navigation/Pokemon_WallRecovery_Tests.cpp | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp index d6c0ccaf6d..21d1d10e6c 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_WallRecovery_Tests.cpp @@ -59,48 +59,6 @@ class CorridorPolicy{ }; -// 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}}; - } -}; - // Show both directions and distances when a selected recovery plan differs. std::string describe_holds(std::span holds){ std::string result; @@ -187,6 +145,48 @@ const CorridorTestCase CORRIDOR_CASES[] = { }, }; +// 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;