From af21f2750289adef1a370ba214eb557b26c9c0ec Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 02:25:33 -0800 Subject: [PATCH 01/12] 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 9fe6f718fdf4095918fde37b36b7131e71b4e7bc Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 02:43:20 -0800 Subject: [PATCH 02/12] Adapt shared wall recovery to BDSP maps with mine and r55 tests --- .../PokemonBDSP_AutoStory_WallRecovery.cpp | 88 +++++++++++++++++++ .../Data/PokemonBDSP_AutoStory_WallRecovery.h | 29 ++++++ ...kemonBDSP_AutoStory_RoutePlanner_Tests.cpp | 28 ++++++ SerialPrograms/cmake/SourceFiles.cmake | 2 + 4 files changed, 147 insertions(+) create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp new file mode 100644 index 0000000000..3bc132e8f4 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp @@ -0,0 +1,88 @@ +/* BDSP Wall Recovery + * + * From: https://github.com/PokemonAutomation/ + */ +#include +#include "PokemonBDSP_AutoStory_WallRecovery.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace{ + +using Pokemon::Direction; +using Pokemon::TilePos; + +// A wall ray must stay on the same map and walk one tile per step. A +// ledge may be legal travel but must not become a localization anchor. +std::optional safe_wall(const StoryWorld& world, TilePos from, Direction direction){ + const auto map = world.to_local(from); + if (!map || !standable(world, from)){ + return std::nullopt; + } + const WallHit wall = slide_to_wall(world, from, direction); + if (!wall.anchored){ + return std::nullopt; + } + while (from != wall.stop){ + const auto next = step(world, from, direction); + const auto local = next ? world.to_local(*next) : std::nullopt; + if (!next || *next != from.next(direction) || !local || local->map != map->map){ + return std::nullopt; + } + from = *next; + } + return wall; +} + +class WallModel{ +public: + using Hypothesis = TilePos; + using Position = TilePos; + using Hold = TileLeg; + + explicit WallModel(const StoryWorld& world) : m_world(&world){} + Position position(const Hypothesis& at) const{ return at; } + int tiles(const Hold& hold) const{ return (int)hold.tiles; } + std::optional> hold( + const std::vector& from, Direction direction + ) const{ + std::vector ends; + size_t longest = 0; + for (const auto& start : from){ + const auto wall = safe_wall(*m_world, start, direction); + if (!wall){ + return std::nullopt; + } + longest = std::max(longest, wall->tiles); + if (std::find(ends.begin(), ends.end(), wall->stop) == ends.end()){ + ends.push_back(wall->stop); + } + } + return Pokemon::WallRecoveryStep{ + .positions = std::move(ends), + .hold = {.direction = direction, .tiles = longest, .ends_at_wall = true}, + }; + } + +private: + const StoryWorld* m_world; +}; + +} + +std::vector> wall_recovery_candidates( + const StoryWorld& world, const std::vector& from, + const Pokemon::WallRecoveryLimits& limits +){ + for (const auto& tile : from){ + if (!standable(world, tile)){ + return {}; + } + } + return Pokemon::find_wall_anchors(WallModel(world), from, limits); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h new file mode 100644 index 0000000000..90e326e282 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h @@ -0,0 +1,29 @@ +/* BDSP Wall Recovery + * + * From: https://github.com/PokemonAutomation/ + * + * BDSP's movement adapter for shared native wall convergence. Predictions + * do not confirm controller execution; the walker must establish arrival. + */ +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_WallRecovery_H +#define PokemonAutomation_PokemonBDSP_AutoStory_WallRecovery_H + +#include "Pokemon/Navigation/Pokemon_WallRecovery.h" +#include "PokemonBDSP_AutoStory_RoutePlanner.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +// Safe holds for every possible position, within one explicitly placed map. +// Never anchors against a person, crosses a door/map edge or hops a ledge. +// Caller must uncover trainer sight/scene hazards absent from StoryWorld. +std::vector> wall_recovery_candidates( + const StoryWorld& world, const std::vector& from, + const Pokemon::WallRecoveryLimits& limits = Pokemon::WallRecoveryLimits() +); + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp index 15a8111056..0d8b4fcee3 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp @@ -15,6 +15,8 @@ #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTestDatabase.h" #include "Data/PokemonBDSP_AutoStory_RoutePlanner.h" +#include "Data/PokemonBDSP_AutoStory_WallRecovery.h" +#include "Data/PokemonBDSP_AutoStory_Maps.h" #include "PokemonBDSP_AutoStory_RoutePlanner_Tests.h" namespace PokemonAutomation{ @@ -155,6 +157,31 @@ class Test_PlanRobustRoute : public UnitTest{ // A trainer's sight stops at the first tile the player cannot stand on, // and a leg through it is reported; a leg that is stopped throws. +// November 5 mine report: row 15 instead of intended row 17. Diagnostic +// pre-east belief, not a claim that one interrupted tap caused two tiles. +class Test_SharedWallRecovery : public UnitTest{ +public: + Test_SharedWallRecovery() : UnitTest("PokemonBDSP::AutoStory - Route planner: shared mine and r55 recovery"){} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + StoryWorld mine; + mine.add("OreburghMine", story_map("OreburghMine"), {0, 0}); + const auto anchors = wall_recovery_candidates(mine, {{15, 15}, {15, 16}, {15, 17}}); + if (anchors.empty() || anchors.front().at != TilePos{15, 17} + || anchors.front().holds.size() != 1 || anchors.front().holds.front().direction != Direction::SOUTH){ + return "Mine rows 15/16/17 must converge south against row 18."; + } + // r55's post-dialogue count and trainer-sight possibilities. + StoryWorld route; + route.add("Route202", story_map("Route202"), {0, 0}); + const auto logan = wall_recovery_candidates(route, {{27, 5}, {26, 5}}); + if (logan.empty() || logan.front().at != TilePos{30, 5} + || logan.front().holds.front().direction != Direction::EAST){ + return "r55's two tiles must anchor east, clear of Logan."; + } + return true; + } +}; + class Test_TrainerSight : public UnitTest{ public: Test_TrainerSight() @@ -187,6 +214,7 @@ void add_tests_AutoStory_RoutePlanner(UnitTestDatabase& database){ database.add(); database.add(); database.add(); + database.add(); } diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 8f14359b61..f0b34e993f 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1728,6 +1728,8 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Maps.h Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RoutePlanner.cpp Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RoutePlanner.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.cpp Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Maps_Tests.cpp From 1f9f07eb9cdb88a9e554799f47428c8713bcbe46 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 02:48:43 -0800 Subject: [PATCH 03/12] Model completed and uncompleted encounter taps in the native BDSP simulator --- ...kemonBDSP_AutoStory_RoutePlanner_Tests.cpp | 23 ++++++++++++++++ .../Utils/PokemonBDSP_AutoStory_GridSim.cpp | 27 ++++++++++++++++++- .../Utils/PokemonBDSP_AutoStory_GridSim.h | 19 ++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp index 0d8b4fcee3..848f6f2359 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp @@ -17,6 +17,7 @@ #include "Data/PokemonBDSP_AutoStory_RoutePlanner.h" #include "Data/PokemonBDSP_AutoStory_WallRecovery.h" #include "Data/PokemonBDSP_AutoStory_Maps.h" +#include "Utils/PokemonBDSP_AutoStory_GridSim.h" #include "PokemonBDSP_AutoStory_RoutePlanner_Tests.h" namespace PokemonAutomation{ @@ -159,6 +160,27 @@ class Test_PlanRobustRoute : public UnitTest{ // and a leg through it is reported; a leg that is stopped throws. // November 5 mine report: row 15 instead of intended row 17. Diagnostic // pre-east belief, not a claim that one interrupted tap caused two tiles. +class Test_InterruptedMineTaps : public UnitTest{ +public: + Test_InterruptedMineTaps() : UnitTest("PokemonBDSP::AutoStory - Route planner: encounter credits do not establish pose"){} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + StoryWorld mine; + mine.add("OreburghMine", story_map("OreburghMine"), {0, 0}); + for (SimEncounterPhase phase : {SimEncounterPhase::BEFORE_MOVEMENT, SimEncounterPhase::AFTER_MOVEMENT}){ + GridSim sim({.world = &mine}, {15, 12}, Direction::SOUTH, /*can_run=*/false); + std::vector notes; + GridSimRouteIO io(sim, notes, {{.label = "south", .direction = Direction::SOUTH, + .requested_tiles = 5, .tap = 5, .phase = phase}}); + const auto result = io.walk_taps(Direction::SOUTH, 5, "south"); + const TilePos expected = phase == SimEncounterPhase::BEFORE_MOVEMENT ? TilePos{15, 16} : TilePos{15, 17}; + if (result.wild_battles != 1 || result.moved_chunks != 5 || nearest_tile(sim.player().pos) != expected){ + return "Same credited count must allow both uncompleted and completed encounter steps."; + } + } + return true; + } +}; + class Test_SharedWallRecovery : public UnitTest{ public: Test_SharedWallRecovery() : UnitTest("PokemonBDSP::AutoStory - Route planner: shared mine and r55 recovery"){} @@ -215,6 +237,7 @@ void add_tests_AutoStory_RoutePlanner(UnitTestDatabase& database){ database.add(); database.add(); database.add(); + database.add(); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp index a5eb74d4f2..11ea35b6e9 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp @@ -304,14 +304,39 @@ WalkResult GridSimRouteIO::walk_to_wall(const RouteWallWalk& walk){ return {.stop = walk_stop(move.stop), .walked = move.time, .wild_battles = 0, .moved_chunks = (size_t)(move.distance / SIM_TILE)}; } +std::optional GridSimRouteIO::take_encounter(Direction direction, size_t tiles, size_t tap, const std::string& label){ + const auto found = std::find_if(m_encounters.begin(), m_encounters.end(), [&](const SimWildTap& encounter){ + return encounter.direction == direction && encounter.requested_tiles == tiles + && encounter.tap == tap && encounter.label == label; + }); + if (found == m_encounters.end()){ + return std::nullopt; + } + SimWildTap encounter = *found; + m_encounters.erase(found); + return encounter; +} + // A tap a tile, as walk_tiles_dpad() taps: two taps in a row that move // nothing end the walk. WalkResult GridSimRouteIO::walk_taps(Direction direction, size_t tiles, const std::string& label){ WalkResult result{.stop = WalkStop::LIMIT, .walked = 0ms, .wild_battles = 0, .moved_chunks = 0}; size_t still = 0; for (size_t tap = 0; tap < tiles && result.stop == WalkStop::LIMIT; tap++){ - const SimMove move = m_sim.tap(direction, ROUTE_MS_PER_TILE_ACROSS, false); + const auto encounter = take_encounter(direction, tiles, tap + 1, label); + const SimMove move = encounter && encounter->phase == SimEncounterPhase::BEFORE_MOVEMENT + ? SimMove{} : m_sim.tap(direction, ROUTE_MS_PER_TILE_ACROSS, false); result.walked += 800ms; + if (encounter){ + // Match the production escape_battle() credit, independently + // of actual simulated displacement. Consumers must not infer + // pose solely from this credit after an interrupted input. + result.wild_battles++; + result.moved_chunks++; + still = 0; + m_notes.push_back("[Sim] cleared wild encounter during " + label); + continue; + } still = move.distance > 0 ? 0 : still + 1; result.moved_chunks += move.distance > 0 ? 1 : 0; if (move.stop == SimStop::DIALOG || move.stop == SimStop::WARP){ diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h index f28b20d067..5388a1510b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h @@ -26,6 +26,7 @@ #include #include +#include #include #include "Common/Cpp/Time.h" #include "Pokemon/Navigation/Pokemon_Grid.h" @@ -151,13 +152,26 @@ class GridSim{ bool sim_can_step(const GridSim& sim, Pokemon::Direction direction); +// A deterministic, already-cleared encounter at a tap boundary. This is +// normalized movement/escape evidence, not a model of image detectors or +// battle mechanics. BEFORE leaves the tile unchanged; AFTER completes it. +enum class SimEncounterPhase{ BEFORE_MOVEMENT, AFTER_MOVEMENT }; +struct SimWildTap{ + std::string label; + Pokemon::Direction direction = Pokemon::Direction::NORTH; + size_t requested_tiles = 0; + size_t tap = 0; // one-based within the matching walk_taps call + SimEncounterPhase phase = SimEncounterPhase::AFTER_MOVEMENT; +}; + // The walker's RouteIO on a GridSim. A failure throws GridSimFailure. class GridSimRouteIO : public RouteIO{ public: // Every line noted goes to `notes`, which must outlive this. - GridSimRouteIO(GridSim& sim, std::vector& notes) + GridSimRouteIO(GridSim& sim, std::vector& notes, std::vector encounters = {}) : m_sim(sim) , m_notes(notes) + , m_encounters(std::move(encounters)) {} virtual WalkResult walk_to_wall(const RouteWallWalk& walk) override; @@ -169,8 +183,11 @@ class GridSimRouteIO : public RouteIO{ virtual void note(const std::string& text, RouteNote kind) override; private: + std::optional take_encounter(Pokemon::Direction direction, size_t tiles, size_t tap, const std::string& label); + GridSim& m_sim; std::vector& m_notes; + std::vector m_encounters; }; // What GridSimRouteIO throws for RouteNote::FAILURE. From 2ac420dc12d4cc313d5697a694c83ed79ea9566b Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 07:04:22 -0800 Subject: [PATCH 04/12] Add native mine story interruption and recovery regressions --- .../Source/PokemonBDSP/PokemonBDSP_Tests.cpp | 2 + ...kemonBDSP_AutoStory_RecoveryWalk_Tests.cpp | 136 ++++++++++++++++++ ...PokemonBDSP_AutoStory_RecoveryWalk_Tests.h | 14 ++ .../Utils/PokemonBDSP_AutoStory_GridSim.cpp | 3 + .../Utils/PokemonBDSP_AutoStory_GridSim.h | 1 + .../Utils/PokemonBDSP_AutoStory_Walk.h | 5 + SerialPrograms/cmake/SourceFiles.cmake | 2 + 7 files changed, 163 insertions(+) create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h diff --git a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp index 621d4421ef..c5dfb7352a 100644 --- a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp @@ -7,6 +7,7 @@ #include "Common/Cpp/TestRunners/UnitTestDatabase.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_Actions_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.h" +#include "Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_Maps_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.h" @@ -37,6 +38,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStory_Walk(database); add_tests_AutoStory_PlannedRoute(database); add_tests_AutoStory_GridSim(database); + add_tests_AutoStory_RecoveryWalk(database); add_tests_AutoStory_StorySim(database); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp new file mode 100644 index 0000000000..ffa97b6af8 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp @@ -0,0 +1,136 @@ +/* BDSP Recovery Walk Tests + * + * From: https://github.com/PokemonAutomation/ + * + * November 5 mine report: EAST 14 moved 4 beside Machop on row 15. + * The exact deployed revision/run ID is unavailable. BEFORE/AFTER events + * and two interrupted advances are explicit diagnostics, not an inferred + * physical cause. Run the real story step and route walker on GridSim. + */ +#include +#include "Pokemon/AutoStory/Pokemon_TilePose.h" +#include "Utils/PokemonBDSP_AutoStory_GridSim.h" +#include "PokemonBDSP_AutoStory_StoryPlaces.h" +#include "PokemonBDSP_AutoStory_StoryStepIO.h" +#include "PokemonBDSP_AutoStory_StoryTable.h" +#include "PokemonBDSP_AutoStory_StoryWalkSteps.h" +#include "PokemonBDSP_AutoStory_RecoveryWalk_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace{ +using namespace std::chrono_literals; +using Pokemon::Direction; +using Pokemon::TilePos; + +class InterruptingRouteIO : public GridSimRouteIO{ +public: + InterruptingRouteIO(GridSim& sim, std::vector& notes, + std::vector taps, size_t interrupted_holds) + : GridSimRouteIO(sim, notes, std::move(taps)), m_left(interrupted_holds) + {} + virtual WalkResult walk_to_wall(const RouteWallWalk& walk) override{ + if (m_left > 0){ + m_left--; + return {.stop = WalkStop::LIMIT, .walked = 400ms, .wild_battles = 1}; + } + return GridSimRouteIO::walk_to_wall(walk); + } +private: + size_t m_left; +}; + +// Only walking is needed for this real LEGS step. Unexpected story actions +// fail the test. The actual RouteWalker executes through the GridSim RouteIO. +class RecoveryStepIO : public StoryStepIO{ +public: + explicit RecoveryStepIO(RouteIO& route) : m_route(route){} + virtual RouteIO& route() override{ return m_route; } + virtual void fight(const std::string&) override{ throw GridSimFailure{"unexpected trainer"}; } + virtual void walk_until_stopped(Direction, size_t, const std::string&) override{ throw GridSimFailure{"unexpected blind hold"}; } + virtual bool run_until_map(Direction, Milliseconds, const std::string&, const std::string&) override{ throw GridSimFailure{"unexpected banner walk"}; } + virtual bool run_into_door(Direction, bool) override{ throw GridSimFailure{"unexpected door"}; } + virtual std::optional tap_to_banner(Direction, const std::string&, size_t) override{ throw GridSimFailure{"unexpected banner"}; } + virtual void settle(Button, const std::string&, bool) override{ throw GridSimFailure{"unexpected scene"}; } +private: + RouteIO& m_route; +}; + +const Pokemon::StoryStep& mine_step(){ + for (const auto& row : story_checkpoints()){ + for (const auto& step : row.steps){ + if (row.id == "01.03" && step.name == "below_roark"){ + return step; + } + } + } + throw GridSimFailure{"missing native mine step"}; +} + +struct RecoveryCase{ + std::string name; + std::vector taps; + SimEncounterPhase phase = SimEncounterPhase::BEFORE_MOVEMENT; + WalkOrigin origin = WalkOrigin::CURRENT_LEG; + size_t interrupted_holds = 0; + std::string failure; +}; + +class Test_RecoveryWalk : public UnitTest{ +public: + explicit Test_RecoveryWalk(RecoveryCase data) + : UnitTest("PokemonBDSP::AutoStory - Recovery walk: " + data.name), m_case(std::move(data)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const StoryWorld* world = story_plan_world("OreburghMine", "OreburghMine"); + if (world == nullptr){ + return "Missing native mine world."; + } + GridSim sim({.world = world}, world->to_world("OreburghMine", {15, 10}), Direction::SOUTH, true); + std::vector taps; + for (size_t tap : m_case.taps){ + taps.push_back({.label = "below_roark", .direction = Direction::SOUTH, + .requested_tiles = 5, .tap = tap, .phase = m_case.phase, .origin = m_case.origin}); + } + std::vector notes; + InterruptingRouteIO route(sim, notes, std::move(taps), m_case.interrupted_holds); + RecoveryStepIO io(route); + StoryWalkSteps walks(io); + walks.begin_attempt("01.03"); + Pokemon::RunState state{.pose = Pokemon::tile_pose("OreburghMine", {{15, 10}}, Direction::SOUTH)}; + std::string failure; + try{ + const auto outcome = walks.legs(state, mine_step()); + failure = outcome.failure; + }catch (const GridSimFailure& error){ + failure = error.message; + } + if (!m_case.failure.empty()){ + return failure.find(m_case.failure) != std::string::npos ? UnitTestResult(true) + : UnitTestResult("Expected " + m_case.failure + ", got " + failure); + } + const auto end = story_to_local(*world, "OreburghMine", nearest_tile(sim.player().pos)); + if (!failure.empty() || !end || *end != TilePos{18, 29}){ + return "Expected below Roark (18,29), got " + to_string(sim.player().pos) + ": " + failure; + } + return true; + } +private: + RecoveryCase m_case; +}; +} + +void add_tests_AutoStory_RecoveryWalk(UnitTestDatabase& database){ + database.add(RecoveryCase{.name = "uninterrupted native mine step", .taps = {}}); + database.add(RecoveryCase{.name = "last tap interrupted before movement", .taps = {5}}); + database.add(RecoveryCase{.name = "last tap completed before battle", .taps = {5}, .phase = SimEncounterPhase::AFTER_MOVEMENT}); + database.add(RecoveryCase{.name = "two interrupted advances, row 15", .taps = {4, 5}}); + database.add(RecoveryCase{.name = "repeat the interrupted recovery hold", .taps = {5}, .interrupted_holds = 1}); + database.add(RecoveryCase{.name = "recovery hold budget exhausted", .taps = {5}, .interrupted_holds = 10, .failure = "recovery budget"}); + database.add(RecoveryCase{.name = "first input may belong to previous leg", .taps = {1}, .failure = "encounter_origin_unknown"}); + database.add(RecoveryCase{.name = "late encounter origin unknown", .taps = {5}, .origin = WalkOrigin::MAY_PRECEDE_LEG, .failure = "encounter_origin_unknown"}); +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h new file mode 100644 index 0000000000..89b319f025 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h @@ -0,0 +1,14 @@ +/* BDSP Recovery Walk Tests + * From: https://github.com/PokemonAutomation/ + */ +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_RecoveryWalk_Tests_H +#define PokemonAutomation_PokemonBDSP_AutoStory_RecoveryWalk_Tests_H +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +void add_tests_AutoStory_RecoveryWalk(UnitTestDatabase& database); +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp index 11ea35b6e9..6bdc0d7b82 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp @@ -332,6 +332,9 @@ WalkResult GridSimRouteIO::walk_taps(Direction direction, size_t tiles, const st // of actual simulated displacement. Consumers must not infer // pose solely from this credit after an interrupted input. result.wild_battles++; + if (tap == 0 || encounter->origin == WalkOrigin::MAY_PRECEDE_LEG){ + result.origin = WalkOrigin::MAY_PRECEDE_LEG; + } result.moved_chunks++; still = 0; m_notes.push_back("[Sim] cleared wild encounter during " + label); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h index 5388a1510b..e09a173f1c 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h @@ -162,6 +162,7 @@ struct SimWildTap{ size_t requested_tiles = 0; size_t tap = 0; // one-based within the matching walk_taps call SimEncounterPhase phase = SimEncounterPhase::AFTER_MOVEMENT; + WalkOrigin origin = WalkOrigin::CURRENT_LEG; }; // The walker's RouteIO on a GridSim. A failure throws GridSimFailure. diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h index 504edccfe8..03c1ab1439 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h @@ -41,6 +41,10 @@ const char* walk_stop_name(WalkStop stop); // Whether the walk has arrived, from the screen after each chunk. using WalkArrived = std::function; +// An encounter already active at the first input, or found only after a +// leg, may belong to earlier movement. Never localize from this leg alone. +enum class WalkOrigin{ CURRENT_LEG, MAY_PRECEDE_LEG }; + struct WalkResult{ WalkStop stop; Milliseconds walked; @@ -50,6 +54,7 @@ struct WalkResult{ // not count (r248a: "4 taps before the battle" were 2 steps and 2 // taps into Taylor's freeze). size_t moved_chunks = 0; + WalkOrigin origin = WalkOrigin::CURRENT_LEG; }; // Holds the left stick at `stick` until one of the WalkStop events. Wild battles on diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index f0b34e993f..f3cdef4b25 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1737,6 +1737,8 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.cpp + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.h From 9b6157f8c37428ca0d4cf346a8e7629ea250b57e Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 07:17:28 -0800 Subject: [PATCH 05/12] Re-anchor BDSP encounter-interrupted legs before continuing --- .../PokemonBDSP_AutoStory_WallRecovery.cpp | 67 ++++++++++++++ .../Data/PokemonBDSP_AutoStory_WallRecovery.h | 15 ++++ ...kemonBDSP_AutoStory_RecoveryWalk_Tests.cpp | 12 ++- .../PokemonBDSP_AutoStory_StoryWalkSteps.cpp | 87 +++++++++++++++---- .../PokemonBDSP_AutoStory_StoryWalkSteps.h | 3 + .../PokemonBDSP_AutoStory_PlannedRoute.cpp | 72 +++++++++++---- .../Utils/PokemonBDSP_AutoStory_Walk.cpp | 9 +- 7 files changed, 227 insertions(+), 38 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp index 3bc132e8f4..2d887666ad 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp @@ -83,6 +83,73 @@ std::vector> wall_recovery_candid return Pokemon::find_wall_anchors(WallModel(world), from, limits); } +std::vector interrupted_leg_positions( + const StoryWorld& world, const std::vector& from, Direction direction, size_t tiles +){ + std::vector positions; + for (TilePos at : from){ + for (size_t count = 0; count <= tiles; count++){ + if (std::find(positions.begin(), positions.end(), at) == positions.end()){ + positions.push_back(at); + } + const auto next = step(world, at, direction); + if (count == tiles || !next){ + break; + } + at = *next; + } + } + return positions; +} + +namespace{ +std::vector recovery_legs( + const StoryWorld& world, std::vector from, const std::vector& holds +){ + std::vector legs; + for (const auto& hold : holds){ + std::vector after; + for (const auto& at : from){ + const TilePos end = slide_to_wall(world, at, hold.direction).stop; + if (std::find(after.begin(), after.end(), end) == after.end()){ + after.push_back(end); + } + } + legs.push_back({.leg = hold, .after = after}); + from = std::move(after); + } + return legs; +} +} + +std::optional> plan_recovered_route( + const StoryWorld& world, const std::vector& from, + const std::function& goal, const RobustPlanCosts& costs +){ + if (from.empty()){ + return std::nullopt; + } + if (std::all_of(from.begin(), from.end(), goal)){ + return std::vector(); + } + if (from.size() == 1){ + return plan_robust_route(world, from, goal, costs); + } + auto anchors = wall_recovery_candidates(world, from); + std::stable_sort(anchors.begin(), anchors.end(), [&](const auto& a, const auto& b){ + return goal(a.at) && !goal(b.at); + }); + for (const auto& anchor : anchors){ + const auto back = plan_robust_route(world, std::vector{anchor.at}, goal, costs); + if (back){ + auto route = recovery_legs(world, from, anchor.holds); + route.insert(route.end(), back->begin(), back->end()); + return route; + } + } + return std::nullopt; +} + } } } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h index 90e326e282..23fee1885a 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h @@ -23,6 +23,21 @@ std::vector> wall_recovery_candid const Pokemon::WallRecoveryLimits& limits = Pokemon::WallRecoveryLimits() ); +// Every legal prefix, including zero movement, of an interrupted counted leg. +std::vector interrupted_leg_positions( + const StoryWorld& world, const std::vector& from, + Pokemon::Direction direction, size_t tiles +); + +// Establish a common wall anchor before continuing from an uncertain pose. +// Single positions retain the existing planner. Ambiguous positions without +// a safe anchor fail instead of choosing one. Goals already held need no walk. +std::optional> plan_recovered_route( + const StoryWorld& world, const std::vector& from, + const std::function& goal, + const RobustPlanCosts& costs +); + } } } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp index ffa97b6af8..6340c367b7 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp @@ -71,6 +71,8 @@ const Pokemon::StoryStep& mine_step(){ struct RecoveryCase{ std::string name; std::vector taps; + Direction direction = Direction::SOUTH; + size_t tiles = 5; SimEncounterPhase phase = SimEncounterPhase::BEFORE_MOVEMENT; WalkOrigin origin = WalkOrigin::CURRENT_LEG; size_t interrupted_holds = 0; @@ -90,8 +92,8 @@ class Test_RecoveryWalk : public UnitTest{ GridSim sim({.world = world}, world->to_world("OreburghMine", {15, 10}), Direction::SOUTH, true); std::vector taps; for (size_t tap : m_case.taps){ - taps.push_back({.label = "below_roark", .direction = Direction::SOUTH, - .requested_tiles = 5, .tap = tap, .phase = m_case.phase, .origin = m_case.origin}); + taps.push_back({.label = "below_roark", .direction = m_case.direction, + .requested_tiles = m_case.tiles, .tap = tap, .phase = m_case.phase, .origin = m_case.origin}); } std::vector notes; InterruptingRouteIO route(sim, notes, std::move(taps), m_case.interrupted_holds); @@ -100,9 +102,11 @@ class Test_RecoveryWalk : public UnitTest{ walks.begin_attempt("01.03"); Pokemon::RunState state{.pose = Pokemon::tile_pose("OreburghMine", {{15, 10}}, Direction::SOUTH)}; std::string failure; + std::optional declared; try{ const auto outcome = walks.legs(state, mine_step()); failure = outcome.failure; + declared = outcome.pose; }catch (const GridSimFailure& error){ failure = error.message; } @@ -111,7 +115,8 @@ class Test_RecoveryWalk : public UnitTest{ : UnitTestResult("Expected " + m_case.failure + ", got " + failure); } const auto end = story_to_local(*world, "OreburghMine", nearest_tile(sim.player().pos)); - if (!failure.empty() || !end || *end != TilePos{18, 29}){ + if (!failure.empty() || !end || *end != TilePos{18, 29} || !declared + || Pokemon::facing_of(*declared) != sim.player().facing || sim.player().facing != Direction::WEST){ return "Expected below Roark (18,29), got " + to_string(sim.player().pos) + ": " + failure; } return true; @@ -126,6 +131,7 @@ void add_tests_AutoStory_RecoveryWalk(UnitTestDatabase& database){ database.add(RecoveryCase{.name = "last tap interrupted before movement", .taps = {5}}); database.add(RecoveryCase{.name = "last tap completed before battle", .taps = {5}, .phase = SimEncounterPhase::AFTER_MOVEMENT}); database.add(RecoveryCase{.name = "two interrupted advances, row 15", .taps = {4, 5}}); + database.add(RecoveryCase{.name = "last west leg recovers position and facing", .taps = {9}, .direction = Direction::WEST, .tiles = 9}); database.add(RecoveryCase{.name = "repeat the interrupted recovery hold", .taps = {5}, .interrupted_holds = 1}); database.add(RecoveryCase{.name = "recovery hold budget exhausted", .taps = {5}, .interrupted_holds = 10, .failure = "recovery budget"}); database.add(RecoveryCase{.name = "first input may belong to previous leg", .taps = {1}, .failure = "encounter_origin_unknown"}); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp index 102dff3590..16d1481a10 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp @@ -9,6 +9,7 @@ #include "Common/Cpp/Color.h" #include "Common/Cpp/Exceptions.h" #include "Pokemon/AutoStory/Pokemon_TilePose.h" +#include "PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h" #include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PlannedRoute.h" #include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h" #include "PokemonBDSP_AutoStory_StoryExecutor.h" @@ -117,6 +118,9 @@ Outcome StoryWalkSteps::leg_to_dialogue(const StoryStep& step){ const Pokemon::StoryLeg& leg = step.legs.front(); turn(leg.direction); const WalkResult walk = m_io.route().walk_taps(leg.direction, (size_t)leg.tiles, step.name); + if (walk.wild_battles > 0){ + return Pokemon::outcome_failed("interrupted_dialogue_approach", step.name); + } log(std::format("[{}] {}: taps {} to a dialogue: {} tiles, {}.", m_row, step.name, direction_name(leg.direction), walk.moved_chunks, walk_stop_name(walk.stop))); if (walk.stop == WalkStop::DIALOG){ return Pokemon::outcome_ok(); @@ -153,33 +157,76 @@ Outcome StoryWalkSteps::tap_to_banner(const StoryStep& step){ return Pokemon::outcome_ok(); } -// Each leg in D-pad taps, turning first; "one_short_ok" lets a leg end a -// tile short. +namespace{ +enum class CountedEndPolicy{ EXACT, ONE_SHORT }; +std::vector counted_ends(const std::vector& starts, const Pokemon::StoryLeg& leg, CountedEndPolicy policy){ + std::vector ends; + for (TilePos at : starts){ + for (int i = 0; i < leg.tiles; i++){ + if (policy == CountedEndPolicy::ONE_SHORT && i + 1 == leg.tiles){ + ends.push_back(at); + } + at = at.next(leg.direction); + } + ends.push_back(at); + } + return ends; +} +} + +Pose StoryWalkSteps::recover_tapped_leg(const Pose& from, const std::vector& starts, + const std::vector& goals, const Pokemon::StoryLeg& leg, const StoryStep& step){ + const StoryWorld* world = story_plan_world(from.map, from.map); + if (world == nullptr){ + fail(m_io.route(), "interrupted leg has no recovery world: " + from.map); + } + std::vector global; + for (const TilePos& at : starts){ + global.push_back(story_to_world(*world, from.map, at)); + } + std::vector possible; + for (const TilePos& at : interrupted_leg_positions(*world, global, leg.direction, (size_t)leg.tiles)){ + const auto local = story_to_local(*world, from.map, at); + if (!local){ + fail(m_io.route(), "interrupted leg crossed an unconfirmed map transition"); + } + possible.push_back(*local); + } + StoryStep recovery = step; + recovery.name += "_recovery"; + recovery.variant.clear(); + const Pose found = walk_to(Pokemon::tile_pose(from.map, possible), Pokemon::tile_pose(from.map, goals, leg.direction), recovery); + // The route back can approach from another direction. Establish the + // original leg's facing before publishing that pose to the story runner. + turn(leg.direction); + return found; +} + +// Counted legs retain their normal path. An observed encounter invalidates +// the exact pose; localize from every legal prefix before the next leg. Outcome StoryWalkSteps::tap_legs(const Pose& from, const StoryStep& step){ const bool one_short_ok = step.variant == "one_short_ok"; std::vector at = Pokemon::tiles_of(from); for (const Pokemon::StoryLeg& leg : step.legs){ - // Facing another way, a tap only turns (r256): turn first. turn(leg.direction); const size_t tiles = (size_t)leg.tiles; const WalkResult walk = m_io.route().walk_taps(leg.direction, tiles, step.name); log(std::format("[{}] {}: taps {} moved {} of {} tiles.", m_row, step.name, direction_name(leg.direction), walk.moved_chunks, tiles)); + const auto next = counted_ends(at, leg, one_short_ok ? CountedEndPolicy::ONE_SHORT : CountedEndPolicy::EXACT); + if (walk.wild_battles > 0){ + if (walk.origin == WalkOrigin::MAY_PRECEDE_LEG){ + return Pokemon::outcome_failed("encounter_origin_unknown", step.name); + } + if (walk.stop != WalkStop::LIMIT && walk.stop != WalkStop::BLOCKED){ + return Pokemon::outcome_failed("interrupted_leg_unconfirmed", step.name); + } + at = Pokemon::tiles_of(recover_tapped_leg(from, at, next, leg, step)); + continue; + } if (walk.moved_chunks + (one_short_ok ? 1 : 0) < tiles){ return Pokemon::outcome_failed("short_leg", std::format("{} {} moved {}", direction_name(leg.direction), tiles, walk.moved_chunks)); } - // Where we may be: the end, or a tile short of it. - std::vector next; - for (const TilePos& start : at){ - TilePos end = start; - for (size_t i = 0; i < tiles; i++){ - if (one_short_ok && i + 1 == tiles){ - next.push_back(end); - } - end = end.next(leg.direction); - } - next.push_back(end); - } - at = std::move(next); + at = next; } return Pokemon::outcome_ok(Pokemon::tile_pose(from.map, at, step.legs.back().direction)); } @@ -211,7 +258,10 @@ Outcome StoryWalkSteps::step(RunState& state, const StoryStep& step){ if (step.variant == "tap"){ // No separate turn tap first: at 1 tile from the door, a turn tap // steps onto the warp, causing walk_taps to take an extra step on the far map. - m_io.route().walk_taps(step.direction, 1, step.name); + const WalkResult walk = m_io.route().walk_taps(step.direction, 1, step.name); + if (walk.wild_battles > 0){ + return Pokemon::outcome_failed("interrupted_step", step.name); + } m_io.settle(BUTTON_B, label_of(step), false); return Pokemon::outcome_ok(); } @@ -229,6 +279,9 @@ Outcome StoryWalkSteps::step(RunState& state, const StoryStep& step){ } turn(step.direction); const WalkResult walk = m_io.route().walk_taps(step.direction, 1, step.name); + if (walk.wild_battles > 0){ + return Pokemon::outcome_failed("interrupted_step", step.name); + } log(std::format("[{}] {}: a step {}, {}.", m_row, step.name, direction_name(step.direction), walk_stop_name(walk.stop))); std::vector at; for (const TilePos& pos : Pokemon::tiles_of(state.pose)){ diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.h index 8c21209913..60a06abad9 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.h @@ -50,6 +50,9 @@ class StoryWalkSteps{ private: Pokemon::Pose walk_to(const Pokemon::Pose& from, const Pokemon::Pose& goal, const Pokemon::StoryStep& step); + Pokemon::Pose recover_tapped_leg(const Pokemon::Pose& from, + const std::vector& starts, const std::vector& goals, + const Pokemon::StoryLeg& leg, const Pokemon::StoryStep& step); Pokemon::Outcome tap_legs(const Pokemon::Pose& from, const Pokemon::StoryStep& step); Pokemon::Outcome leg_to_dialogue(const Pokemon::StoryStep& step); Pokemon::Outcome leg_to_map(const Pokemon::StoryStep& step); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PlannedRoute.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PlannedRoute.cpp index bcc75aa7bc..e8323e72fd 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PlannedRoute.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PlannedRoute.cpp @@ -12,6 +12,7 @@ #include "Common/Cpp/Filesystem/Filesystem.h" #include "CommonFramework/GlobalAutoPaths.h" #include "Pokemon/Navigation/Pokemon_RouteLog.h" +#include "PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h" #include "PokemonBDSP_AutoStory_Location.h" #include "PokemonBDSP_AutoStory_RouteIO.h" #include "PokemonBDSP_AutoStory_RouteWalk.h" @@ -222,6 +223,7 @@ class RouteWalker{ // Legs to a wall. LegNext wall_leg(LegWalk& walk); + bool finish_interrupted_wall(LegWalk& walk, Milliseconds limit); size_t unstick_wall_leg(LegWalk& walk, size_t walked_tiles, size_t nearest); LegNext stopped_short_of_wall(LegWalk& walk, size_t walked_tiles); void check_at_wall(LegWalk& walk); @@ -229,6 +231,7 @@ class RouteWalker{ // Open legs, tapped. LegNext open_leg(LegWalk& walk); + LegNext interrupted_open_leg(LegWalk& walk); void turn_tap(LegWalk& walk); LegNext blocked_open_leg(LegWalk& walk, size_t moved); @@ -286,14 +289,14 @@ std::vector RouteWalker::run(std::vector from){ } std::vector RouteWalker::plan_legs() const{ - std::optional> legs = plan_robust_route(m_grid, m_from, m_plan.goal, m_plan.costs); + std::optional> legs = plan_recovered_route(m_grid, m_from, m_plan.goal, m_plan.costs); if (!legs){ // From a few possible tiles the exact-open-leg costs found no // plan where the default ones did (r240a, three tiles on Route // 204's row 27); the default plan is longer but sound. m_io.note("[Route] " + m_plan.label + ": no plan with the route costs from " + describe(m_maps, m_from) + "; trying the default costs.", RouteNote::WARNING); - legs = plan_robust_route(m_grid, m_from, m_plan.goal, RobustPlanCosts()); + legs = plan_recovered_route(m_grid, m_from, m_plan.goal, RobustPlanCosts()); } if (!legs){ fail(m_io, m_plan.label + ": no plan from " + describe(m_maps, m_from) + "."); @@ -360,16 +363,7 @@ LegNext RouteWalker::wall_leg(LegWalk& walk){ const TileLeg& leg = walk.planned.leg; const Milliseconds limit = wall_limit(leg); walk.result = m_io.walk_to_wall({.direction = walk.planned.leg.direction, .limit = limit, .label = walk.label, .options = run_watching(walk)}); - // A wild battle may end the walk early. Walking to a wall again ends in - // the same place, so finish the leg. - const bool interrupted = walk.result.wild_battles > 0; - for (size_t again = 0; again < 3 && walk.result.wild_battles > 0 && - (walk.result.stop == WalkStop::LIMIT || walk.result.stop == WalkStop::BLOCKED); again++ - ){ - m_io.note("[Route] " + walk.label + ": a wild battle on the way; walking to the wall again.", RouteNote::LOG); - walk.result = m_io.walk_to_wall({.direction = walk.planned.leg.direction, .limit = limit, .label = walk.label, .options = run_watching(walk)}); - } - if (interrupted){ + if (finish_interrupted_wall(walk, limit)){ return LegNext::NEXT_LEG; } // A wall well before the nearest one the map has: something the map @@ -406,6 +400,30 @@ LegNext RouteWalker::wall_leg(LegWalk& walk){ return LegNext::NEXT_LEG; } +bool RouteWalker::finish_interrupted_wall(LegWalk& walk, Milliseconds limit){ + if (walk.result.wild_battles == 0){ + return false; + } + for (size_t again = 0; again < 3 && walk.result.wild_battles > 0; again++){ + if (walk.result.origin == WalkOrigin::MAY_PRECEDE_LEG){ + fail(m_io, "encounter_origin_unknown: " + walk.label); + } + if (walk.result.stop != WalkStop::LIMIT && walk.result.stop != WalkStop::BLOCKED){ + fail(m_io, "unconfirmed transition/dialogue during wall recovery: " + walk.label); + } + m_io.note("[Route] " + walk.label + ": a wild battle on the way; walking to the wall again.", RouteNote::LOG); + walk.result = m_io.walk_to_wall({.direction = walk.planned.leg.direction, .limit = limit, + .label = walk.label, .options = run_watching(walk)}); + } + if (walk.result.wild_battles > 0){ + fail(m_io, "recovery budget exhausted: " + walk.label); + } + if (walk.result.stop != WalkStop::BLOCKED){ + check_at_wall(walk); + } + return true; +} + // A walk to the wall that stopped well short of it: tap through a corner, // then wait for someone in the way. Returns the tiles walked by then. size_t RouteWalker::unstick_wall_leg(LegWalk& walk, size_t walked_tiles, size_t nearest){ @@ -486,8 +504,12 @@ void RouteWalker::check_at_wall(LegWalk& walk){ // however far it got, so walk on to it and carry on. void RouteWalker::on_to_wall(LegWalk& walk){ m_io.note("[Route] " + walk.label + ": on to the wall after the battle.", RouteNote::WARNING); - m_io.walk_to_wall({.direction = walk.planned.leg.direction, .limit = wall_limit(walk.planned.leg), + const Milliseconds limit = wall_limit(walk.planned.leg); + walk.result = m_io.walk_to_wall({.direction = walk.planned.leg.direction, .limit = limit, .label = walk.label + ": on to the wall", .options = run_watching(walk)}); + if (!finish_interrupted_wall(walk, limit) && walk.result.stop != WalkStop::BLOCKED){ + check_at_wall(walk); + } m_from = walk.planned.after; log_leg_done(m_io, m_maps, m_plan.label, walk.planned, 0); } @@ -504,11 +526,8 @@ LegNext RouteWalker::open_leg(LegWalk& walk){ if (leg.tiles > walk.turn_moved){ walk.result = m_io.walk_taps(leg.direction, leg.tiles - walk.turn_moved, walk.label); } - // A wild battle starts once the step onto the grass is done, so the - // taps still count; the walk went on after it. if (walk.result.wild_battles > 0){ - m_io.note("[Route] " + walk.label + ": " + std::to_string(walk.result.wild_battles) + - " wild battle(s) on the way; the tap count holds.", RouteNote::WARNING); + return interrupted_open_leg(walk); } // Taps that moved nothing: someone stands in the way (the Youngster // below the Ravaged Path wanders across the row under the cliff, r239a, @@ -527,6 +546,12 @@ LegNext RouteWalker::open_leg(LegWalk& walk){ walk.result.stop = rest.stop; walk.result.walked += rest.walked; walk.result.wild_battles += rest.wild_battles; + if (rest.origin == WalkOrigin::MAY_PRECEDE_LEG){ + walk.result.origin = rest.origin; + } + } + if (walk.result.wild_battles > 0){ + return interrupted_open_leg(walk); } if (walk.result.stop == WalkStop::BLOCKED && moved < leg.tiles){ return blocked_open_leg(walk, moved); @@ -534,6 +559,19 @@ LegNext RouteWalker::open_leg(LegWalk& walk){ return LegNext::NEXT_LEG; } +LegNext RouteWalker::interrupted_open_leg(LegWalk& walk){ + if (walk.result.origin == WalkOrigin::MAY_PRECEDE_LEG){ + fail(m_io, "encounter_origin_unknown: " + walk.label); + } + if (walk.result.stop != WalkStop::LIMIT && walk.result.stop != WalkStop::BLOCKED){ + fail(m_io, "interrupted open leg has an unconfirmed dialogue/transition: " + walk.label); + } + auto possible = interrupted_leg_positions(m_grid, m_from, walk.planned.leg.direction, walk.planned.leg.tiles); + const auto next = replan_from(walk, std::move(possible), "encounter: credited taps do not establish pose"); + m_facing.reset(); + return next; +} + // Turns to the leg's way with a tap too short to step (50 ms: on the // console 40 to 80 ms taps only turned). The leg's taps then each move a // tile. The old 160 ms "turn" tap stepped whichever way we faced, and diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp index 13ef184933..0ecfc82a2e 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp @@ -237,6 +237,7 @@ class WalkUntil{ } const Milliseconds chunk = std::min(CHUNK, m_spec.limit - m_result.walked); const int ret = hold(chunk); + m_chunks++; m_result.walked += chunk; ChunkVerdict verdict = after_watchers(ret); if (verdict.next == ChunkNext::LOOK){ @@ -359,8 +360,12 @@ class WalkUntil{ void escape_battle(const std::string& what){ m_result.wild_battles++; + if (m_chunks <= 1){ + m_result.origin = WalkOrigin::MAY_PRECEDE_LEG; + } if (m_spec.input == WalkInput::TAPS){ - // A wild battle encounter triggers upon entering the tile; credit the moved tile. + // Preserve the reporting credit; callers must carry uncertain + // pose after an encounter rather than infer a completed tile. m_result.moved_chunks++; } m_stream.log("[Walk] " + m_spec.label + ": " + what); @@ -378,6 +383,7 @@ class WalkUntil{ ){ m_result.wild_battles++; late_battle = true; + m_result.origin = WalkOrigin::MAY_PRECEDE_LEG; } // When the final tap of a TAPS leg lagged during frame drops at its // 800ms check (m_unchanged == 1 at WalkStop::LIMIT), it finishes @@ -407,6 +413,7 @@ class WalkUntil{ VideoSnapshot m_before; VideoSnapshot m_before_still; size_t m_unchanged = 0; + size_t m_chunks = 0; double m_largest = 0; }; From 33898d825b68ac92aa9fdd1487b2dea3d6d25072 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 07:23:37 -0800 Subject: [PATCH 06/12] Exclude NPC wandering areas and declared warps from recovery anchors --- .../Data/PokemonBDSP_AutoStory_StoryMaps.cpp | 17 ++++- .../Data/PokemonBDSP_AutoStory_StoryWorld.cpp | 24 ++++++- .../Data/PokemonBDSP_AutoStory_StoryWorld.h | 7 ++- .../PokemonBDSP_AutoStory_WallRecovery.cpp | 9 ++- ...kemonBDSP_AutoStory_RoutePlanner_Tests.cpp | 62 +++++++++++++++++++ 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp index 02ab715514..0ee396fa1d 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp @@ -54,10 +54,25 @@ MapData crop(const MapData& map, const TilePos& corner, int width, int height){ for (Pokemon::MapObject object : map.objects){ object.x -= corner.x; object.y -= corner.y; - if (cropped.tiles.contains({object.x, object.y})){ + if (object.wander){ + object.wander->x0 -= corner.x; + object.wander->x1 -= corner.x; + object.wander->y0 -= corner.y; + object.wander->y1 -= corner.y; + } + if (cropped.tiles.contains({object.x, object.y}) || (object.wander + && object.wander->x1 >= 0 && object.wander->y1 >= 0 + && object.wander->x0 < width && object.wander->y0 < height)){ cropped.objects.push_back(std::move(object)); } } + for (auto warp : map.warps){ + warp.x -= corner.x; + warp.y -= corner.y; + if (cropped.tiles.contains({warp.x, warp.y})){ + cropped.warps.push_back(std::move(warp)); + } + } return cropped; } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.cpp index 9ae90edb75..454d9ded25 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.cpp @@ -56,12 +56,17 @@ void StoryWorld::add(std::string name, const Pokemon::MapData& map, const TilePo throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Map placed twice: " + name); } } - Placed placed{std::move(name), map.tiles, {}, offset}; + Placed placed{.name = std::move(name), .tiles = map.tiles, .occupied = {}, .wander = {}, .warps = {}, .offset = offset}; for (const Pokemon::MapObject& object : map.objects){ if (!object.wander){ placed.occupied.push_back({object.x, object.y}); + }else{ + placed.wander.push_back(*object.wander); } } + for (const auto& warp : map.warps){ + placed.warps.push_back({warp.x, warp.y}); + } check_overlap(placed); m_maps.push_back(std::move(placed)); rebuild(); @@ -138,6 +143,18 @@ void StoryWorld::paint(const Placed& placed, const TilePos& corner){ for (const TilePos& pos : placed.occupied){ Pokemon::cell_at(m_cells, {corner.x + pos.x, corner.y + pos.y}).occupant = PERSON; } + for (const auto& box : placed.wander){ + for (int y = std::max(0, box.y0); y <= std::min(placed.tiles.height - 1, box.y1); y++){ + for (int x = std::max(0, box.x0); x <= std::min(placed.tiles.width - 1, box.x1); x++){ + Pokemon::cell_at(m_cells, {corner.x + x, corner.y + y}).recovery_hazard = true; + } + } + } + for (const auto& warp : placed.warps){ + if (placed.tiles.contains(warp)){ + Pokemon::cell_at(m_cells, {corner.x + warp.x, corner.y + warp.y}).recovery_hazard = true; + } + } } const StoryWorld::Cell* StoryWorld::cell(const TilePos& pos) const{ @@ -162,8 +179,11 @@ std::optional StoryWorld::tile(const TilePos& pos) const{ return found->tile; } -StoryWorld::Occupant StoryWorld::occupied(const TilePos& pos) const{ +StoryWorld::Occupant StoryWorld::occupied(const TilePos& pos, OccupancyUse use) const{ const Cell* found = cell(pos); + if (found && use == OccupancyUse::WALL_RECOVERY && found->recovery_hazard){ + return PERSON; + } return found == nullptr ? NOBODY : found->occupant; } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.h index e78a7ee5ee..c6d7af49ba 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.h @@ -53,7 +53,9 @@ class StoryWorld{ PERSON, BLOCKED, }; - Occupant occupied(const Pokemon::TilePos& pos) const; + // Recovery also excludes wandering areas and declared warps. + enum class OccupancyUse{ MOVEMENT, WALL_RECOVERY }; + Occupant occupied(const Pokemon::TilePos& pos, OccupancyUse use = OccupancyUse::MOVEMENT) const; // Blocks a tile found in the way on the console. Tiles off the maps // are left alone. @@ -72,12 +74,15 @@ class StoryWorld{ std::string name; Pokemon::Grid tiles; std::vector occupied; // local tiles + std::vector wander; // recovery hazards, not terrain + std::vector warps; Pokemon::TilePos offset; }; struct Cell{ bool covered = false; Pokemon::TileKind tile = Pokemon::TileKind::WALL; Occupant occupant = NOBODY; + bool recovery_hazard = false; }; void check_overlap(const Placed& map) const; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp index 2d887666ad..96706a725c 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp @@ -17,17 +17,20 @@ using Pokemon::TilePos; // ledge may be legal travel but must not become a localization anchor. std::optional safe_wall(const StoryWorld& world, TilePos from, Direction direction){ const auto map = world.to_local(from); - if (!map || !standable(world, from)){ + if (!map || !standable(world, from) + || world.occupied(from, StoryWorld::OccupancyUse::WALL_RECOVERY) == StoryWorld::PERSON){ return std::nullopt; } const WallHit wall = slide_to_wall(world, from, direction); - if (!wall.anchored){ + if (!wall.anchored + || world.occupied(wall.wall, StoryWorld::OccupancyUse::WALL_RECOVERY) == StoryWorld::PERSON){ return std::nullopt; } while (from != wall.stop){ const auto next = step(world, from, direction); const auto local = next ? world.to_local(*next) : std::nullopt; - if (!next || *next != from.next(direction) || !local || local->map != map->map){ + if (!next || *next != from.next(direction) || !local || local->map != map->map + || world.occupied(*next, StoryWorld::OccupancyUse::WALL_RECOVERY) == StoryWorld::PERSON){ return std::nullopt; } from = *next; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp index 848f6f2359..f086aaa8b2 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp @@ -181,6 +181,65 @@ class Test_InterruptedMineTaps : public UnitTest{ } }; +class Test_RecoveryWanderHazard : public UnitTest{ +public: + Test_RecoveryWanderHazard() : UnitTest("PokemonBDSP::AutoStory - Route planner: wandering areas are not safe recovery rays"){} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + Pokemon::MapData map; + map.name = "WanderDiagnostic"; + map.tiles = Pokemon::Grid{7, 3, {}}; + for (const char* row : {"#######", "#.....#", "#######"}){ + for (char glyph : std::string(row)){ + map.tiles.cells.push_back(*Pokemon::tile_from_glyph(glyph)); + } + } + map.objects.push_back({.name = "wanderer", .x = 3, .y = 1, + .wander = Pokemon::TileBox{.x0 = 2, .y0 = 1, .x1 = 4, .y1 = 1}}); + StoryWorld world; + world.add(map.name, map, {0, 0}); + if (!standable(world, {3, 1}) || world.occupied({3, 1}) != StoryWorld::NOBODY){ + return "Recovery metadata must not change ordinary movement terrain."; + } + if (!wall_recovery_candidates(world, {{1, 1}, {5, 1}}).empty()){ + return "Neither common wall ray is safe through the wandering NPC's area."; + } + return true; + } +}; + +// Synthetic diagnostic: a declared warp is unsafe even if its glyph is wall. +class Test_RecoveryWarpHazard : public UnitTest{ +public: + explicit Test_RecoveryWarpHazard(bool wall) + : UnitTest(std::string("PokemonBDSP::AutoStory - Route planner: recovery rejects ") + + (wall ? "warps on blocked glyphs" : "warp crossings")), m_wall(wall) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + Pokemon::MapData map; + map.name = "WarpDiagnostic"; + map.tiles = Pokemon::Grid{7, 3, {}}; + for (const char* row : {"#######", "#.....#", "#######"}){ + for (char glyph : std::string(row)){ + map.tiles.cells.push_back(*Pokemon::tile_from_glyph(glyph)); + } + } + if (m_wall){ + map.warps.push_back({.x = 0, .y = 1}); + map.warps.push_back({.x = 6, .y = 1}); + }else{ + map.warps.push_back({.x = 3, .y = 1}); + } + StoryWorld world; + world.add(map.name, map, {0, 0}); + if (!wall_recovery_candidates(world, {{1, 1}, {5, 1}}).empty()){ + return "A declared warp must not become a recovery ray or anchor."; + } + return true; + } +private: + bool m_wall; +}; + class Test_SharedWallRecovery : public UnitTest{ public: Test_SharedWallRecovery() : UnitTest("PokemonBDSP::AutoStory - Route planner: shared mine and r55 recovery"){} @@ -238,6 +297,9 @@ void add_tests_AutoStory_RoutePlanner(UnitTestDatabase& database){ database.add(); database.add(); database.add(); + database.add(); + database.add(false); + database.add(true); } From a816258cdc62174dc43000f586d015cc4d7788f5 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 07:31:26 -0800 Subject: [PATCH 07/12] Initialize native recovery test fields for Windows Clang --- .../PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp | 4 ++-- .../PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp index 6340c367b7..940a3b80cd 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp @@ -76,7 +76,7 @@ struct RecoveryCase{ SimEncounterPhase phase = SimEncounterPhase::BEFORE_MOVEMENT; WalkOrigin origin = WalkOrigin::CURRENT_LEG; size_t interrupted_holds = 0; - std::string failure; + std::string failure = {}; }; class Test_RecoveryWalk : public UnitTest{ @@ -100,7 +100,7 @@ class Test_RecoveryWalk : public UnitTest{ RecoveryStepIO io(route); StoryWalkSteps walks(io); walks.begin_attempt("01.03"); - Pokemon::RunState state{.pose = Pokemon::tile_pose("OreburghMine", {{15, 10}}, Direction::SOUTH)}; + Pokemon::RunState state{.pose = Pokemon::tile_pose("OreburghMine", {{15, 10}}, Direction::SOUTH), .flags = {}}; std::string failure; std::optional declared; try{ diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp index f086aaa8b2..1dc53f3e59 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp @@ -193,8 +193,9 @@ class Test_RecoveryWanderHazard : public UnitTest{ map.tiles.cells.push_back(*Pokemon::tile_from_glyph(glyph)); } } - map.objects.push_back({.name = "wanderer", .x = 3, .y = 1, - .wander = Pokemon::TileBox{.x0 = 2, .y0 = 1, .x1 = 4, .y1 = 1}}); + map.objects.push_back({.name = "wanderer", .x = 3, .y = 1, .facing = std::nullopt, + .wander = Pokemon::TileBox{.x0 = 2, .y0 = 1, .x1 = 4, .y1 = 1}, + .trainer = std::nullopt, .hide_flag = std::nullopt}); StoryWorld world; world.add(map.name, map, {0, 0}); if (!standable(world, {3, 1}) || world.occupied({3, 1}) != StoryWorld::NOBODY){ @@ -224,10 +225,10 @@ class Test_RecoveryWarpHazard : public UnitTest{ } } if (m_wall){ - map.warps.push_back({.x = 0, .y = 1}); - map.warps.push_back({.x = 6, .y = 1}); + map.warps.push_back({.x = 0, .y = 1, .to = std::nullopt}); + map.warps.push_back({.x = 6, .y = 1, .to = std::nullopt}); }else{ - map.warps.push_back({.x = 3, .y = 1}); + map.warps.push_back({.x = 3, .y = 1, .to = std::nullopt}); } StoryWorld world; world.add(map.name, map, {0, 0}); From f1f7f3c6e5a84eaabcc5f9a80768a050071042dd Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 11:02:34 -0800 Subject: [PATCH 08/12] 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 09/12] 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 10/12] 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 e287144a9196999d7b1ac2a6206ad23f23bb3230 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 11:26:01 -0800 Subject: [PATCH 11/12] Use static BDSP recovery policy with the story world as context --- .../PokemonBDSP_AutoStory_WallRecovery.cpp | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp index 96706a725c..209e2b60c5 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp @@ -38,22 +38,21 @@ std::optional safe_wall(const StoryWorld& world, TilePos from, Directio return wall; } -class WallModel{ +class WallPolicy{ public: using Hypothesis = TilePos; using Position = TilePos; using Hold = TileLeg; - explicit WallModel(const StoryWorld& world) : m_world(&world){} - Position position(const Hypothesis& at) const{ return at; } - int tiles(const Hold& hold) const{ return (int)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 (int)hold.tiles; } + static std::optional> hold( + const StoryWorld& world, const std::vector& from, Direction direction + ){ std::vector ends; size_t longest = 0; for (const auto& start : from){ - const auto wall = safe_wall(*m_world, start, direction); + const auto wall = safe_wall(world, start, direction); if (!wall){ return std::nullopt; } @@ -68,8 +67,6 @@ class WallModel{ }; } -private: - const StoryWorld* m_world; }; } @@ -83,7 +80,7 @@ std::vector> wall_recovery_candid return {}; } } - return Pokemon::find_wall_anchors(WallModel(world), from, limits); + return Pokemon::find_wall_anchors(from, world, limits); } std::vector interrupted_leg_positions( From 17c50cfb11db1a7755c664da9e5390e533bf23bb Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 5 Nov 2026 12:07:20 -0800 Subject: [PATCH 12/12] Adopt merged native test tables and header documentation conventions --- .../PokemonBDSP_AutoStory_WallRecovery.cpp | 10 +- .../Data/PokemonBDSP_AutoStory_WallRecovery.h | 6 +- ...kemonBDSP_AutoStory_RecoveryWalk_Tests.cpp | 61 +++-- ...PokemonBDSP_AutoStory_RecoveryWalk_Tests.h | 7 +- ...kemonBDSP_AutoStory_RoutePlanner_Tests.cpp | 213 +++++++++++------- .../PokemonBDSP_AutoStory_StoryWalkSteps.cpp | 2 +- .../Utils/PokemonBDSP_AutoStory_GridSim.h | 1 + 7 files changed, 177 insertions(+), 123 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp index 209e2b60c5..7ccc8549c5 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp @@ -69,7 +69,7 @@ class WallPolicy{ }; -} +} // namespace std::vector> wall_recovery_candidates( const StoryWorld& world, const std::vector& from, @@ -120,7 +120,7 @@ std::vector recovery_legs( } return legs; } -} +} // namespace std::optional> plan_recovered_route( const StoryWorld& world, const std::vector& from, @@ -150,6 +150,6 @@ std::optional> plan_recovered_route( return std::nullopt; } -} -} -} +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h index 23fee1885a..862e2a2f14 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h @@ -38,7 +38,7 @@ std::optional> plan_recovered_route( const RobustPlanCosts& costs ); -} -} -} +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation #endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp index 940a3b80cd..8b9c5dd4c8 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp @@ -68,7 +68,7 @@ const Pokemon::StoryStep& mine_step(){ throw GridSimFailure{"missing native mine step"}; } -struct RecoveryCase{ +struct RecoveryInput{ std::string name; std::vector taps; Direction direction = Direction::SOUTH; @@ -76,13 +76,21 @@ struct RecoveryCase{ SimEncounterPhase phase = SimEncounterPhase::BEFORE_MOVEMENT; WalkOrigin origin = WalkOrigin::CURRENT_LEG; size_t interrupted_holds = 0; +}; +struct RecoveryExpectation{ std::string failure = {}; + TilePos end = {.x = 18, .y = 29}; + Direction facing = Direction::WEST; +}; +struct RecoveryCase{ + RecoveryInput input; + RecoveryExpectation expectation; }; class Test_RecoveryWalk : public UnitTest{ public: explicit Test_RecoveryWalk(RecoveryCase data) - : UnitTest("PokemonBDSP::AutoStory - Recovery walk: " + data.name), m_case(std::move(data)) + : UnitTest("PokemonBDSP::AutoStory - Recovery walk: " + data.input.name), m_case(std::move(data)) {} virtual UnitTestResult run(Logger&, CancellableScope&) const override{ const StoryWorld* world = story_plan_world("OreburghMine", "OreburghMine"); @@ -91,12 +99,12 @@ class Test_RecoveryWalk : public UnitTest{ } GridSim sim({.world = world}, world->to_world("OreburghMine", {15, 10}), Direction::SOUTH, true); std::vector taps; - for (size_t tap : m_case.taps){ - taps.push_back({.label = "below_roark", .direction = m_case.direction, - .requested_tiles = m_case.tiles, .tap = tap, .phase = m_case.phase, .origin = m_case.origin}); + for (size_t tap : m_case.input.taps){ + taps.push_back({.label = "below_roark", .direction = m_case.input.direction, + .requested_tiles = m_case.input.tiles, .tap = tap, .phase = m_case.input.phase, .origin = m_case.input.origin}); } std::vector notes; - InterruptingRouteIO route(sim, notes, std::move(taps), m_case.interrupted_holds); + InterruptingRouteIO route(sim, notes, std::move(taps), m_case.input.interrupted_holds); RecoveryStepIO io(route); StoryWalkSteps walks(io); walks.begin_attempt("01.03"); @@ -110,13 +118,13 @@ class Test_RecoveryWalk : public UnitTest{ }catch (const GridSimFailure& error){ failure = error.message; } - if (!m_case.failure.empty()){ - return failure.find(m_case.failure) != std::string::npos ? UnitTestResult(true) - : UnitTestResult("Expected " + m_case.failure + ", got " + failure); + if (!m_case.expectation.failure.empty()){ + return failure.find(m_case.expectation.failure) != std::string::npos ? UnitTestResult(true) + : UnitTestResult("Expected " + m_case.expectation.failure + ", got " + failure); } const auto end = story_to_local(*world, "OreburghMine", nearest_tile(sim.player().pos)); - if (!failure.empty() || !end || *end != TilePos{18, 29} || !declared - || Pokemon::facing_of(*declared) != sim.player().facing || sim.player().facing != Direction::WEST){ + if (!failure.empty() || !end || *end != m_case.expectation.end || !declared + || Pokemon::facing_of(*declared) != sim.player().facing || sim.player().facing != m_case.expectation.facing){ return "Expected below Roark (18,29), got " + to_string(sim.player().pos) + ": " + failure; } return true; @@ -124,19 +132,24 @@ class Test_RecoveryWalk : public UnitTest{ private: RecoveryCase m_case; }; -} +} // namespace void add_tests_AutoStory_RecoveryWalk(UnitTestDatabase& database){ - database.add(RecoveryCase{.name = "uninterrupted native mine step", .taps = {}}); - database.add(RecoveryCase{.name = "last tap interrupted before movement", .taps = {5}}); - database.add(RecoveryCase{.name = "last tap completed before battle", .taps = {5}, .phase = SimEncounterPhase::AFTER_MOVEMENT}); - database.add(RecoveryCase{.name = "two interrupted advances, row 15", .taps = {4, 5}}); - database.add(RecoveryCase{.name = "last west leg recovers position and facing", .taps = {9}, .direction = Direction::WEST, .tiles = 9}); - database.add(RecoveryCase{.name = "repeat the interrupted recovery hold", .taps = {5}, .interrupted_holds = 1}); - database.add(RecoveryCase{.name = "recovery hold budget exhausted", .taps = {5}, .interrupted_holds = 10, .failure = "recovery budget"}); - database.add(RecoveryCase{.name = "first input may belong to previous leg", .taps = {1}, .failure = "encounter_origin_unknown"}); - database.add(RecoveryCase{.name = "late encounter origin unknown", .taps = {5}, .origin = WalkOrigin::MAY_PRECEDE_LEG, .failure = "encounter_origin_unknown"}); -} -} -} + const RecoveryCase CASES[] = { + {.input = {.name = "uninterrupted native mine step", .taps = {}}, .expectation = {.failure = ""}}, + {.input = {.name = "last tap interrupted before movement", .taps = {5}}, .expectation = {.failure = ""}}, + {.input = {.name = "last tap completed before battle", .taps = {5}, .phase = SimEncounterPhase::AFTER_MOVEMENT}, .expectation = {.failure = ""}}, + {.input = {.name = "two interrupted advances, row 15", .taps = {4, 5}}, .expectation = {.failure = ""}}, + {.input = {.name = "last west leg recovers position and facing", .taps = {9}, .direction = Direction::WEST, .tiles = 9}, .expectation = {.failure = ""}}, + {.input = {.name = "repeat the interrupted recovery hold", .taps = {5}, .interrupted_holds = 1}, .expectation = {.failure = ""}}, + {.input = {.name = "recovery hold budget exhausted", .taps = {5}, .interrupted_holds = 10}, .expectation = {.failure = "recovery budget"}}, + {.input = {.name = "first input may belong to previous leg", .taps = {1}}, .expectation = {.failure = "encounter_origin_unknown"}}, + {.input = {.name = "late encounter origin unknown", .taps = {5}, .origin = WalkOrigin::MAY_PRECEDE_LEG}, .expectation = {.failure = "encounter_origin_unknown"}}, + }; + for (const RecoveryCase& scenario : CASES){ + database.add(scenario); + } } +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h index 89b319f025..9ee770013b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h @@ -7,8 +7,9 @@ namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonBDSP{ +// Register mine interruption, localization and bounded recovery scenarios. void add_tests_AutoStory_RecoveryWalk(UnitTestDatabase& database); -} -} -} +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation #endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp index 1dc53f3e59..05c3ad5e1e 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp @@ -156,114 +156,128 @@ class Test_PlanRobustRoute : public UnitTest{ }; -// A trainer's sight stops at the first tile the player cannot stand on, -// and a leg through it is reported; a leg that is stopped throws. -// November 5 mine report: row 15 instead of intended row 17. Diagnostic -// pre-east belief, not a claim that one interrupted tap caused two tiles. +// November 5 mine report: credited taps need not establish physical pose. +// The two phases are explicit diagnostics, not inferred console timing. +struct EncounterInput{ + std::string name; + SimEncounterPhase phase; +}; +struct EncounterExpectation{ + TilePos end; + size_t battles = 1; + size_t credited = 5; +}; +struct EncounterCase{ + EncounterInput input; + EncounterExpectation expectation; +}; class Test_InterruptedMineTaps : public UnitTest{ public: - Test_InterruptedMineTaps() : UnitTest("PokemonBDSP::AutoStory - Route planner: encounter credits do not establish pose"){} + explicit Test_InterruptedMineTaps(EncounterCase scenario) + : UnitTest("PokemonBDSP::AutoStory - Encounter credits: " + scenario.input.name), m_case(std::move(scenario)) + {} virtual UnitTestResult run(Logger&, CancellableScope&) const override{ StoryWorld mine; mine.add("OreburghMine", story_map("OreburghMine"), {0, 0}); - for (SimEncounterPhase phase : {SimEncounterPhase::BEFORE_MOVEMENT, SimEncounterPhase::AFTER_MOVEMENT}){ - GridSim sim({.world = &mine}, {15, 12}, Direction::SOUTH, /*can_run=*/false); - std::vector notes; - GridSimRouteIO io(sim, notes, {{.label = "south", .direction = Direction::SOUTH, - .requested_tiles = 5, .tap = 5, .phase = phase}}); - const auto result = io.walk_taps(Direction::SOUTH, 5, "south"); - const TilePos expected = phase == SimEncounterPhase::BEFORE_MOVEMENT ? TilePos{15, 16} : TilePos{15, 17}; - if (result.wild_battles != 1 || result.moved_chunks != 5 || nearest_tile(sim.player().pos) != expected){ - return "Same credited count must allow both uncompleted and completed encounter steps."; - } + GridSim sim({.world = &mine}, {15, 12}, Direction::SOUTH, /*can_run=*/false); + std::vector notes; + GridSimRouteIO io(sim, notes, {{.label = "south", .direction = Direction::SOUTH, + .requested_tiles = 5, .tap = 5, .phase = m_case.input.phase}}); + const auto result = io.walk_taps(Direction::SOUTH, 5, "south"); + const auto& expected = m_case.expectation; + const TilePos actual = nearest_tile(sim.player().pos); + if (result.wild_battles != expected.battles || result.moved_chunks != expected.credited || actual != expected.end){ + return std::format("Expected {} battles, {} credits, tile {}; got {}, {}, {}.", + expected.battles, expected.credited, Pokemon::to_string(expected.end), + result.wild_battles, result.moved_chunks, Pokemon::to_string(actual)); } return true; } +private: + EncounterCase m_case; }; -class Test_RecoveryWanderHazard : public UnitTest{ -public: - Test_RecoveryWanderHazard() : UnitTest("PokemonBDSP::AutoStory - Route planner: wandering areas are not safe recovery rays"){} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - Pokemon::MapData map; - map.name = "WanderDiagnostic"; - map.tiles = Pokemon::Grid{7, 3, {}}; - for (const char* row : {"#######", "#.....#", "#######"}){ - for (char glyph : std::string(row)){ - map.tiles.cells.push_back(*Pokemon::tile_from_glyph(glyph)); - } +// Empty map name selects a synthetic corridor: ####### / #.....# / #######. +// Both possible starts (1,1)/(5,1) must share a safe wall ray. NPCs and +// warps on that ray or its wall invalidate it; ordinary terrain is unchanged. +struct RecoveryGeometryInput{ + std::string name; + std::string map = {}; + std::vector from; + std::optional wander = {}; + std::vector warps = {}; +}; +struct RecoveryGeometryExpectation{ + std::optional anchor; + Direction direction = Direction::SOUTH; + size_t holds = 1; + std::optional free_tile = {}; +}; +struct RecoveryGeometryCase{ + RecoveryGeometryInput input; + RecoveryGeometryExpectation expectation; +}; + +Pokemon::MapData recovery_map(const RecoveryGeometryInput& input){ + Pokemon::MapData map; + if (!input.map.empty()){ + return story_map(input.map); + } + map.name = "RecoveryDiagnostic"; + map.tiles = Pokemon::Grid{7, 3, {}}; + for (const char* row : {"#######", "#.....#", "#######"}){ + for (char glyph : std::string(row)){ + map.tiles.cells.push_back(*Pokemon::tile_from_glyph(glyph)); } + } + if (input.wander){ map.objects.push_back({.name = "wanderer", .x = 3, .y = 1, .facing = std::nullopt, - .wander = Pokemon::TileBox{.x0 = 2, .y0 = 1, .x1 = 4, .y1 = 1}, - .trainer = std::nullopt, .hide_flag = std::nullopt}); - StoryWorld world; - world.add(map.name, map, {0, 0}); - if (!standable(world, {3, 1}) || world.occupied({3, 1}) != StoryWorld::NOBODY){ - return "Recovery metadata must not change ordinary movement terrain."; - } - if (!wall_recovery_candidates(world, {{1, 1}, {5, 1}}).empty()){ - return "Neither common wall ray is safe through the wandering NPC's area."; - } - return true; + .wander = input.wander, .trainer = std::nullopt, .hide_flag = std::nullopt}); } -}; + for (const auto& warp : input.warps){ + map.warps.push_back({.x = warp.x, .y = warp.y, .to = std::nullopt}); + } + return map; +} -// Synthetic diagnostic: a declared warp is unsafe even if its glyph is wall. -class Test_RecoveryWarpHazard : public UnitTest{ +class Test_RecoveryGeometry : public UnitTest{ public: - explicit Test_RecoveryWarpHazard(bool wall) - : UnitTest(std::string("PokemonBDSP::AutoStory - Route planner: recovery rejects ") + - (wall ? "warps on blocked glyphs" : "warp crossings")), m_wall(wall) + explicit Test_RecoveryGeometry(RecoveryGeometryCase scenario) + : UnitTest("PokemonBDSP::AutoStory - Recovery geometry: " + scenario.input.name), m_case(std::move(scenario)) {} virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - Pokemon::MapData map; - map.name = "WarpDiagnostic"; - map.tiles = Pokemon::Grid{7, 3, {}}; - for (const char* row : {"#######", "#.....#", "#######"}){ - for (char glyph : std::string(row)){ - map.tiles.cells.push_back(*Pokemon::tile_from_glyph(glyph)); - } - } - if (m_wall){ - map.warps.push_back({.x = 0, .y = 1, .to = std::nullopt}); - map.warps.push_back({.x = 6, .y = 1, .to = std::nullopt}); - }else{ - map.warps.push_back({.x = 3, .y = 1, .to = std::nullopt}); - } + const auto& expected = m_case.expectation; + const auto map = recovery_map(m_case.input); StoryWorld world; world.add(map.name, map, {0, 0}); - if (!wall_recovery_candidates(world, {{1, 1}, {5, 1}}).empty()){ - return "A declared warp must not become a recovery ray or anchor."; + if (expected.free_tile && (!standable(world, *expected.free_tile) + || world.occupied(*expected.free_tile) != StoryWorld::NOBODY)){ + return "Expected ordinary movement to remain free at " + Pokemon::to_string(*expected.free_tile); } - return true; - } -private: - bool m_wall; -}; - -class Test_SharedWallRecovery : public UnitTest{ -public: - Test_SharedWallRecovery() : UnitTest("PokemonBDSP::AutoStory - Route planner: shared mine and r55 recovery"){} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - StoryWorld mine; - mine.add("OreburghMine", story_map("OreburghMine"), {0, 0}); - const auto anchors = wall_recovery_candidates(mine, {{15, 15}, {15, 16}, {15, 17}}); - if (anchors.empty() || anchors.front().at != TilePos{15, 17} - || anchors.front().holds.size() != 1 || anchors.front().holds.front().direction != Direction::SOUTH){ - return "Mine rows 15/16/17 must converge south against row 18."; + const auto anchors = wall_recovery_candidates(world, m_case.input.from); + const auto actual = anchors.empty() ? std::optional() : std::optional(anchors.front().at); + if (actual != expected.anchor){ + return std::format("Expected anchor {}, got {}.", + expected.anchor ? Pokemon::to_string(*expected.anchor) : "none", + actual ? Pokemon::to_string(*actual) : "none"); } - // r55's post-dialogue count and trainer-sight possibilities. - StoryWorld route; - route.add("Route202", story_map("Route202"), {0, 0}); - const auto logan = wall_recovery_candidates(route, {{27, 5}, {26, 5}}); - if (logan.empty() || logan.front().at != TilePos{30, 5} - || logan.front().holds.front().direction != Direction::EAST){ - return "r55's two tiles must anchor east, clear of Logan."; + if (anchors.empty()){ + return true; + } + const auto& holds = anchors.front().holds; + if (holds.size() != expected.holds || holds.empty() || holds.front().direction != expected.direction){ + return std::format("Expected {} holds beginning {}, got {} beginning {}.", + expected.holds, Pokemon::direction_name(expected.direction), holds.size(), + holds.empty() ? "none" : Pokemon::direction_name(holds.front().direction)); } return true; } +private: + RecoveryGeometryCase m_case; }; +// A trainer's sight stops at the first tile the player cannot stand on, +// and a leg through it is reported; a leg that is stopped throws. class Test_TrainerSight : public UnitTest{ public: Test_TrainerSight() @@ -288,6 +302,28 @@ class Test_TrainerSight : public UnitTest{ } }; + +const EncounterCase ENCOUNTER_CASES[] = { + {.input = {.name = "before movement", .phase = SimEncounterPhase::BEFORE_MOVEMENT}, + .expectation = {.end = {.x = 15, .y = 16}}}, + {.input = {.name = "after movement", .phase = SimEncounterPhase::AFTER_MOVEMENT}, + .expectation = {.end = {.x = 15, .y = 17}}}, +}; + +const RecoveryGeometryCase GEOMETRY_CASES[] = { + {.input = {.name = "mine rows converge south", .map = "OreburghMine", .from = {{15, 15}, {15, 16}, {15, 17}}}, + .expectation = {.anchor = TilePos{.x = 15, .y = 17}}}, + {.input = {.name = "r55 tiles converge east", .map = "Route202", .from = {{27, 5}, {26, 5}}}, + .expectation = {.anchor = TilePos{.x = 30, .y = 5}, .direction = Direction::EAST}}, + {.input = {.name = "wandering area is unsafe", .from = {{1, 1}, {5, 1}}, + .wander = Pokemon::TileBox{.x0 = 2, .y0 = 1, .x1 = 4, .y1 = 1}}, + .expectation = {.anchor = std::nullopt, .free_tile = TilePos{.x = 3, .y = 1}}}, + {.input = {.name = "warp crossings are unsafe", .from = {{1, 1}, {5, 1}}, .warps = {{3, 1}}}, + .expectation = {.anchor = std::nullopt}}, + {.input = {.name = "warps on blocked glyphs are unsafe", .from = {{1, 1}, {5, 1}}, .warps = {{0, 1}, {6, 1}}}, + .expectation = {.anchor = std::nullopt}}, +}; + } @@ -296,11 +332,14 @@ void add_tests_AutoStory_RoutePlanner(UnitTestDatabase& database){ database.add(); database.add(); database.add(); - database.add(); - database.add(); - database.add(); - database.add(false); - database.add(true); + + for (const EncounterCase& scenario : ENCOUNTER_CASES){ + database.add(scenario); + } + + for (const RecoveryGeometryCase& scenario : GEOMETRY_CASES){ + database.add(scenario); + } } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp index 16d1481a10..0196b32971 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp @@ -172,7 +172,7 @@ std::vector counted_ends(const std::vector& starts, const Poke } return ends; } -} +} // namespace Pose StoryWalkSteps::recover_tapped_leg(const Pose& from, const std::vector& starts, const std::vector& goals, const Pokemon::StoryLeg& leg, const StoryStep& step){ diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h index e09a173f1c..494a88d29f 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h @@ -156,6 +156,7 @@ bool sim_can_step(const GridSim& sim, Pokemon::Direction direction); // normalized movement/escape evidence, not a model of image detectors or // battle mechanics. BEFORE leaves the tile unchanged; AFTER completes it. enum class SimEncounterPhase{ BEFORE_MOVEMENT, AFTER_MOVEMENT }; +// Match one tap and supply its cleared encounter phase and origin evidence. struct SimWildTap{ std::string label; Pokemon::Direction direction = Pokemon::Direction::NORTH;