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/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 new file mode 100644 index 0000000000..7ccc8549c5 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.cpp @@ -0,0 +1,155 @@ +/* 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) + || world.occupied(from, StoryWorld::OccupancyUse::WALL_RECOVERY) == StoryWorld::PERSON){ + return std::nullopt; + } + const WallHit wall = slide_to_wall(world, from, direction); + 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 + || world.occupied(*next, StoryWorld::OccupancyUse::WALL_RECOVERY) == StoryWorld::PERSON){ + return std::nullopt; + } + from = *next; + } + return wall; +} + +class WallPolicy{ +public: + using Hypothesis = TilePos; + using Position = TilePos; + using Hold = TileLeg; + + 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(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}, + }; + } + +}; + +} // namespace + +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(from, world, 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; +} +} // namespace + +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; +} + +} // 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 new file mode 100644 index 0000000000..862e2a2f14 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_WallRecovery.h @@ -0,0 +1,44 @@ +/* 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() +); + +// 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 +); + +} // 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 new file mode 100644 index 0000000000..8b9c5dd4c8 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.cpp @@ -0,0 +1,155 @@ +/* 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 RecoveryInput{ + 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; +}; +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.input.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.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.input.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), .flags = {}}; + 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; + } + 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 != 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; + } +private: + RecoveryCase m_case; +}; +} // namespace + +void add_tests_AutoStory_RecoveryWalk(UnitTestDatabase& database){ + 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 new file mode 100644 index 0000000000..9ee770013b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RecoveryWalk_Tests.h @@ -0,0 +1,15 @@ +/* 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{ +// 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 15a8111056..05c3ad5e1e 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,9 @@ #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 "Utils/PokemonBDSP_AutoStory_GridSim.h" #include "PokemonBDSP_AutoStory_RoutePlanner_Tests.h" namespace PokemonAutomation{ @@ -153,6 +156,126 @@ class Test_PlanRobustRoute : public UnitTest{ }; +// 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: + 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}); + 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; +}; + +// 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 = 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; +} + +class Test_RecoveryGeometry : public UnitTest{ +public: + 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{ + const auto& expected = m_case.expectation; + const auto map = recovery_map(m_case.input); + StoryWorld world; + world.add(map.name, map, {0, 0}); + 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); + } + 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"); + } + 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{ @@ -179,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}}, +}; + } @@ -187,6 +332,14 @@ void add_tests_AutoStory_RoutePlanner(UnitTestDatabase& database){ database.add(); database.add(); database.add(); + + 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 102dff3590..0196b32971 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; +} +} // namespace + +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_GridSim.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp index a5eb74d4f2..6bdc0d7b82 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,42 @@ 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++; + 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); + 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..494a88d29f 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,28 @@ 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 }; +// Match one tap and supply its cleared encounter phase and origin evidence. +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; + WalkOrigin origin = WalkOrigin::CURRENT_LEG; +}; + // 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 +185,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. 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; }; 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 8f14359b61..f3cdef4b25 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 @@ -1735,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