From 90ac25d3d78866ab13fabd5bb50a3ff6da699649 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 01:18:34 -0700 Subject: [PATCH 01/25] FRLG: walk open legs through wild tiles a tile at a time A wild battle's flash can show anywhere from ~150ms to ~650ms after the step that set it off, longer than a tile (267ms). Held through an open leg, the walker placed the battle from its timing and got it wrong both ways on console B (2026-11-01, Route 1): a battle on the 3rd tile of "west 4" was seen after the hold and taken for the end of the leg, and one on the 1st tile of "south 7" counted as no tiles walked. The next legs then ran a column off and into a sign. Open legs that can meet a wild battle are now walked one press per tile, each watched for 900ms. A step's interrupt then shows in its own window, or less than a tile into the next one, whose press it swallowed; either way it is placed on its step without a measured delay, and the leg ends on its last tile. This was chosen over calibrating the delay (it varied by more than a tile in one run) and over making the planner end such legs on a wall (not every leg has one, and it would only fix the axis of that leg). It costs about 0.6s per tile on those legs; legs to a wall and legs off wild tiles are still held through. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Pokemon/Navigation/Pokemon_LegWalker.cpp | 52 +++++- .../Pokemon/Navigation/Pokemon_LegWalker.h | 32 +++- .../Navigation/Pokemon_LegWalker_Tests.cpp | 150 ++++++++++++++++-- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 13 +- .../Overworld/PokemonFRLG_OverworldWalk.h | 3 +- 5 files changed, 232 insertions(+), 18 deletions(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp index 3c6613295b..8ed3611ea1 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp @@ -31,12 +31,62 @@ int tiles_before_interrupt(const LegSpec& leg, Milliseconds elapsed, Millisecond return std::clamp((int)(since_first_step / leg.tile_time), 0, remaining); } +int steps_back_to_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay){ + const int whole_windows = (int)(detection_delay / leg.step_window); + return whole_windows + (elapsed < leg.tile_time ? 1 : 0); +} + + +namespace{ + +// An open leg walked a tile at a time. Since each interrupt is placed on the +// step that set it off, the leg ends on its last tile even when an interrupt +// is only seen after that step. +LegResult walk_leg_tile_by_tile(const LegSpec& leg, const LegCalls& calls, size_t max_interruptions){ + LegResult result; + int walked = 0; + // Presses since the last interrupt: an interrupt can't reach back past + // one already cleared. + int pressed = 0; + while (walked < leg.tiles){ + const std::optional outcome = calls.hold_watching(leg_hold(leg, 1), leg.step_window); + walked++; + pressed++; + if (!outcome){ + continue; + } + result.interruptions++; + if (result.interruptions > max_interruptions){ + calls.fail(std::format("Interrupted {} times on: {}", result.interruptions, to_string(leg))); + } + calls.clear(outcome->interrupt); + const Milliseconds delay = calls.detection_delay(outcome->interrupt); + walked -= std::min(steps_back_to_interrupt(leg, outcome->elapsed, delay), pressed); + pressed = 0; + if (delay >= leg.step_window){ + result.exact = false; + } + calls.log(std::format( + "{} {}ms after a press, {} of {} tiles walked.", + calls.name(outcome->interrupt), outcome->elapsed.count(), walked, leg.tiles + )); + } + result.tiles = leg.tiles; + return result; +} + +} + LegResult walk_leg_with(const LegSpec& leg, const LegCalls& calls, size_t max_interruptions){ + if (!leg.to_wall && leg.step_window > Milliseconds::zero()){ + return walk_leg_tile_by_tile(leg, calls, max_interruptions); + } LegResult result; int remaining = leg.tiles; while (true){ - const std::optional outcome = calls.hold_watching(leg_hold(leg, remaining)); + const Milliseconds hold = leg_hold(leg, remaining); + const std::optional outcome = calls.hold_watching(hold, hold); if (!outcome){ result.tiles = leg.tiles; return result; diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h index 14e44f44c3..68352784f1 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h @@ -7,8 +7,10 @@ * message, a trainer). Each of those is a LegInterrupt the game supplies. * When one shows, the hold stops, the game clears it, and the rest of the * leg is walked: a leg to a wall is simply held again, since the wall stops - * the player on its tile; for an open leg, how far the player got is worked - * out from when the interrupt was seen. + * the player on its tile. An open leg can't be checked like that, so where + * an interrupt is likely it is walked a tile at a time, each step watched + * long enough to see what it set off; elsewhere how far the player got is + * worked out from when the interrupt was seen. * * How a direction is held (D-pad or stick) and how long a tile takes are * the game's too, through LegMover. Templated on the console and the @@ -80,6 +82,11 @@ struct LegSpec{ bool to_wall = false; // How long one tile takes, for this leg's direction and way of moving. Milliseconds tile_time{0}; + // If set, an open leg is walked a tile at a time: one short press per + // step, watched for this long from the press before the next. It must be + // longer than the interrupts' detection delays. See + // steps_back_to_interrupt(). + Milliseconds step_window{0}; }; std::string to_string(const LegSpec& leg); @@ -102,6 +109,16 @@ Milliseconds leg_hold(const LegSpec& leg, int tiles); // a whole number of tiles into the hold, so this rounds to the nearest. int tiles_before_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay, int remaining); +// For a leg walked a tile at a time: how many steps before the one just +// pressed set off an interrupt seen `elapsed` after that press. A step's +// interrupt shows after the step lands, a tile into its window, and so +// within its window when the delay is shorter than the window, or else +// before the next press lands, which it then swallows. So one seen less than +// a tile after a press is the previous step's. This needs no measured delay, +// only one shorter than the window. An interrupt slower than that (whole +// windows of `detection_delay`) is counted back further, which is a guess. +int steps_back_to_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay); + // What one hold reported: nothing if it ran out, or which interrupt showed // and how long into the hold. @@ -111,12 +128,13 @@ struct HoldOutcome{ }; // The leg's bookkeeping, with the console side passed in, so it can be -// tested with scripted outcomes. `hold_watching` holds for the duration and -// reports the first interrupt seen; `clear` clears interrupt i; +// tested with scripted outcomes. `hold_watching` holds for `hold`, watches +// until at least `window` after the hold started, and reports the first +// interrupt seen; `clear` clears interrupt i; // `detection_delay` is interrupt i's delay; `log` reports progress; `fail` // throws, and is called after more than `max_interruptions`. struct LegCalls{ - std::function(Milliseconds hold)> hold_watching; + std::function(Milliseconds hold, Milliseconds window)> hold_watching; std::function clear; std::function detection_delay; std::function name; @@ -136,7 +154,7 @@ LegResult walk_leg( const LegSpec& leg, size_t max_interruptions = 5 ){ - auto hold_watching = [&](Milliseconds hold) -> std::optional{ + auto hold_watching = [&](Milliseconds hold, Milliseconds window) -> std::optional{ std::vector> watchers; std::vector callbacks; for (const auto& interrupt : interrupts){ @@ -150,6 +168,8 @@ LegResult walk_leg( [&](Context& context){ start = current_time(); mover.hold(context, leg.direction, hold); + context.wait_for_all_requests(); + context.wait_until(start + window); }, callbacks ); diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp index ac3aa379ee..e1c5e16f20 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp @@ -27,17 +27,22 @@ using namespace std::chrono_literals; constexpr Milliseconds TILE = 267ms; constexpr Milliseconds DELAY = 300ms; +constexpr Milliseconds WINDOW = 900ms; +constexpr Milliseconds NO_WINDOW = 0ms; // Holds that report the scripted outcomes in order, then run out. struct ScriptedLeg{ std::deque> outcomes; + Milliseconds delay = DELAY; std::vector holds; + std::vector windows; std::vector cleared; LegCalls calls(){ LegCalls calls; - calls.hold_watching = [this](Milliseconds hold) -> std::optional{ + calls.hold_watching = [this](Milliseconds hold, Milliseconds window) -> std::optional{ holds.push_back(hold); + windows.push_back(window); if (outcomes.empty()){ return std::nullopt; } @@ -46,7 +51,7 @@ struct ScriptedLeg{ return outcome; }; calls.clear = [this](size_t index){ cleared.push_back(index); }; - calls.detection_delay = [](size_t){ return DELAY; }; + calls.detection_delay = [this](size_t){ return delay; }; calls.name = [](size_t){ return std::string("a battle"); }; calls.log = [](const std::string&){}; calls.fail = [](const std::string& message){ throw std::runtime_error(message); }; @@ -61,8 +66,8 @@ HoldOutcome after_step(int step){ UnitTestResult test_hold_times(){ - const LegSpec open{Direction::EAST, 4, false, TILE}; - const LegSpec wall{Direction::EAST, 4, true, TILE}; + const LegSpec open{Direction::EAST, 4, false, TILE, NO_WINDOW}; + const LegSpec wall{Direction::EAST, 4, true, TILE, NO_WINDOW}; if (leg_hold(open, 4) != TILE * 3 + TILE / 2){ return std::format("Open leg: {}ms", leg_hold(open, 4).count()); } @@ -73,7 +78,7 @@ UnitTestResult test_hold_times(){ } UnitTestResult test_tiles_before_interrupt(){ - const LegSpec leg{Direction::EAST, 8, false, TILE}; + const LegSpec leg{Direction::EAST, 8, false, TILE, NO_WINDOW}; for (int step = 0; step <= 8; step++){ for (Milliseconds jitter : {-100ms, 0ms, 100ms}){ const int walked = tiles_before_interrupt(leg, TILE * step + DELAY + jitter, DELAY, 8); @@ -94,7 +99,7 @@ UnitTestResult test_tiles_before_interrupt(){ UnitTestResult test_wall_leg_walked_again(){ ScriptedLeg script; script.outcomes = {after_step(2)}; - const LegSpec leg{Direction::NORTH, 6, true, TILE}; + const LegSpec leg{Direction::NORTH, 6, true, TILE, NO_WINDOW}; const LegResult result = walk_leg_with(leg, script.calls(), 5); if (script.holds.size() != 2 || script.holds[1] != leg_hold(leg, 6)){ return "Expected the whole leg held again."; @@ -108,7 +113,7 @@ UnitTestResult test_wall_leg_walked_again(){ UnitTestResult test_open_leg_rest_walked(){ ScriptedLeg script; script.outcomes = {after_step(2)}; - const LegSpec leg{Direction::NORTH, 6, false, TILE}; + const LegSpec leg{Direction::NORTH, 6, false, TILE, NO_WINDOW}; const LegResult result = walk_leg_with(leg, script.calls(), 5); if (script.holds.size() != 2 || script.holds[1] != leg_hold(leg, 4)){ return "Expected the remaining 4 tiles held."; @@ -122,7 +127,7 @@ UnitTestResult test_open_leg_rest_walked(){ UnitTestResult test_open_leg_interrupted_at_end(){ ScriptedLeg script; script.outcomes = {after_step(6)}; - const LegSpec leg{Direction::NORTH, 6, false, TILE}; + const LegSpec leg{Direction::NORTH, 6, false, TILE, NO_WINDOW}; walk_leg_with(leg, script.calls(), 5); if (script.holds.size() != 1){ return "Nothing was left to walk after the last step."; @@ -135,7 +140,7 @@ UnitTestResult test_too_many_interruptions(){ for (int c = 0; c < 4; c++){ script.outcomes.push_back(after_step(0)); } - const LegSpec leg{Direction::NORTH, 6, true, TILE}; + const LegSpec leg{Direction::NORTH, 6, true, TILE, NO_WINDOW}; try{ walk_leg_with(leg, script.calls(), 3); }catch (std::runtime_error&){ @@ -145,6 +150,126 @@ UnitTestResult test_too_many_interruptions(){ } +// Tile by tile: the outcome of each press in turn, nothing for one that +// ran out. +UnitTestResult test_steps_back_to_interrupt(){ + const LegSpec leg{Direction::WEST, 4, false, TILE, WINDOW}; + // Seen in the window of the step that set it off, however slow. + for (Milliseconds elapsed : {TILE, TILE + 100ms, TILE + 600ms, WINDOW - 1ms}){ + if (steps_back_to_interrupt(leg, elapsed, DELAY) != 0){ + return std::format("Seen {}ms after a press: should be that press's step.", elapsed.count()); + } + } + // Seen before the press could have landed: the previous step's. + for (Milliseconds elapsed : {0ms, 100ms, TILE - 1ms}){ + if (steps_back_to_interrupt(leg, elapsed, DELAY) != 1){ + return std::format("Seen {}ms after a press: should be the previous step.", elapsed.count()); + } + } + // Slower than a window: counted back by whole windows. + if (steps_back_to_interrupt(leg, TILE + 100ms, 2000ms) != 2){ + return "A 2s delay should reach back two windows."; + } + return true; +} + +// Console B, 2026-11-01: a battle on the 3rd tile of "west 4", seen only +// after the hold, was taken for the end of the leg. +UnitTestResult test_tile_by_tile_battle_mid_leg(){ + ScriptedLeg script; + script.outcomes = {std::nullopt, std::nullopt, HoldOutcome{0, TILE + 650ms}}; + const LegSpec leg{Direction::WEST, 4, false, TILE, WINDOW}; + const LegResult result = walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 4){ + return std::format("Expected 4 presses, got {}.", script.holds.size()); + } + for (size_t c = 0; c < script.holds.size(); c++){ + if (script.holds[c] != leg_hold(leg, 1) || script.windows[c] != WINDOW){ + return "Each press should be one tile, watched for the window."; + } + } + if (!result.exact || result.interruptions != 1 || script.cleared.size() != 1){ + return "Expected an exact result after one interruption."; + } + return true; +} + +// A battle seen early in the next window: that press was swallowed, so the +// tile is walked again. +UnitTestResult test_tile_by_tile_battle_seen_next_window(){ + ScriptedLeg script; + script.outcomes = {std::nullopt, std::nullopt, std::nullopt, HoldOutcome{0, 100ms}}; + const LegSpec leg{Direction::SOUTH, 4, false, TILE, WINDOW}; + const LegResult result = walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 5){ + return std::format("Expected the 4th tile pressed again (5 presses), got {}.", script.holds.size()); + } + if (!result.exact){ + return "A battle placed by its window is exact."; + } + return true; +} + +// The first tile's battle, seen early in the second window, costs the +// second press; one seen just after the first press came from before the +// leg, and the player hasn't moved. +UnitTestResult test_tile_by_tile_battle_on_first_tile(){ + ScriptedLeg script; + script.outcomes = {std::nullopt, HoldOutcome{0, 50ms}}; + const LegSpec leg{Direction::SOUTH, 7, false, TILE, WINDOW}; + walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 8){ + return std::format("Expected 7 tiles in 8 presses, got {}.", script.holds.size()); + } + ScriptedLeg before; + before.outcomes = {HoldOutcome{0, 50ms}}; + walk_leg_with(leg, before.calls(), 5); + if (before.holds.size() != 8){ + return std::format("Expected 7 tiles in 8 presses after an interrupt from before the leg, got {}.", before.holds.size()); + } + return true; +} + +// A battle at the last tile ends the leg. +UnitTestResult test_tile_by_tile_battle_on_last_tile(){ + ScriptedLeg script; + script.outcomes = {std::nullopt, std::nullopt, std::nullopt, HoldOutcome{0, TILE + 300ms}}; + const LegSpec leg{Direction::WEST, 4, false, TILE, WINDOW}; + const LegResult result = walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 4 || result.tiles != 4){ + return std::format("Expected the leg done in 4 presses, got {}.", script.holds.size()); + } + return true; +} + +// An interrupt slower than the window is placed by its delay: a guess. +UnitTestResult test_tile_by_tile_slow_interrupt(){ + ScriptedLeg script; + script.delay = 2000ms; + script.outcomes = {std::nullopt, std::nullopt, std::nullopt, HoldOutcome{0, TILE + 100ms}}; + const LegSpec leg{Direction::WEST, 5, false, TILE, WINDOW}; + const LegResult result = walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 7){ + return std::format("Expected the 2nd step's battle to cost 2 presses (7), got {}.", script.holds.size()); + } + if (result.exact){ + return "An interrupt slower than the window is an estimate."; + } + return true; +} + +// A leg to a wall is still held through, window or not. +UnitTestResult test_wall_leg_held_through(){ + ScriptedLeg script; + const LegSpec leg{Direction::NORTH, 5, true, TILE, WINDOW}; + walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 1 || script.holds[0] != leg_hold(leg, 5)){ + return "Expected one hold to the wall."; + } + return true; +} + + class Test_LegWalker : public UnitTest{ public: Test_LegWalker(std::string name, std::function body) @@ -169,6 +294,13 @@ void add_tests_LegWalker(UnitTestDatabase& database){ database.add("an open leg walks the rest", test_open_leg_rest_walked); database.add("an open leg interrupted at its end", test_open_leg_interrupted_at_end); database.add("gives up after too many interruptions", test_too_many_interruptions); + database.add("steps back to an interrupt", test_steps_back_to_interrupt); + database.add("tile by tile: a battle mid-leg", test_tile_by_tile_battle_mid_leg); + database.add("tile by tile: a battle seen in the next window", test_tile_by_tile_battle_seen_next_window); + database.add("tile by tile: a battle on the first tile", test_tile_by_tile_battle_on_first_tile); + database.add("tile by tile: a battle on the last tile", test_tile_by_tile_battle_on_last_tile); + database.add("tile by tile: an interrupt slower than the window", test_tile_by_tile_slow_interrupt); + database.add("a wall leg is held through", test_wall_leg_held_through); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index de4ffbb73c..3cd049b229 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -44,6 +44,13 @@ constexpr Milliseconds AFTER_WARP = 1500ms; // A message set off by a leg's last step shows after the hold ends. Caught // here, before the next leg's turn tap, which the message would swallow. constexpr Milliseconds AFTER_LEG_WATCH = 500ms; +// An open leg where a wild battle can start is walked a tile at a time, each +// press watched this long. Held through, a battle's flash came too late to +// tell which step set it off: one on the 3rd tile of 4 was seen after the +// hold and taken for the 4th, and one on the 1st for none (console B, +// 2026-11-01, Route 1). From that log the flash was seen roughly 600ms +// after its step landed. +constexpr Milliseconds STEP_WINDOW = 900ms; constexpr size_t MAX_INTERRUPTIONS_PER_LEG = 5; @@ -160,7 +167,11 @@ class DpadMover : public Pokemon::LegMover{ }; Pokemon::LegSpec leg_spec(const RouteStep& step){ - return Pokemon::LegSpec{step.direction, step.tiles, step.to_wall, step.surfing ? SURF_TILE_TIME : TILE_TIME}; + return Pokemon::LegSpec{ + step.direction, step.tiles, step.to_wall, + step.surfing ? SURF_TILE_TIME : TILE_TIME, + step.wild ? STEP_WINDOW : 0ms, + }; } // One watcher for the whole fade: a ladder's black is short enough to be diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index 2df795b774..fe11924b58 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -10,7 +10,8 @@ * A message or a wild battle can stop the player part-way through a leg. * Each kind is an OverworldInterrupt: the walker watches for all of them * while it holds the D-pad, and when one shows it stops, has it cleared and - * walks the rest of the leg. + * walks the rest of the leg. Open legs where a wild battle can start are + * walked a tile at a time, so that the battle is placed on its step. * */ From 8b1de515ab1d44252669a98bc25f8a0654069bce Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 01:43:43 -0700 Subject: [PATCH 02/25] FRLG: never plan a hold into a ledge A leg to a wall is held two tiles longer than it takes, so a ledge the plan took for a wall was hopped: in Viridian City "south 2 (to wall)" from the Pokemon Center ended on (26,31), below the ledge, instead of (26,29), and every later leg was off (console B, 2026-11-01). The maps don't record which way a ledge faces, so a leg now never ends held into any ledge; it counts its tiles instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Overworld/PokemonFRLG_OverworldRoute.cpp | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 25c1d40b1e..41157c41f8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -207,11 +207,11 @@ std::optional press(const std::vector& maps, const Located& return transition; } -// Whether holding `direction` stops the player where they are: a wall, a -// ledge, or water they'd need to Surf onto. A leg that ends there can be -// held for longer than it takes, and walked again after an interruption. A -// person in the way doesn't count, since they may have wandered off, and -// neither does an edge to a map that isn't loaded. +// Whether holding `direction` stops the player where they are: a wall, or +// water they'd need to Surf onto. A leg that ends there can be held for +// longer than it takes, and walked again after an interruption. A person in +// the way doesn't count, since they may have wandered off, and neither does +// an edge to a map that isn't loaded. bool stops_at(const std::vector& maps, const Located& player, Direction direction){ const std::optional transition = press(maps, player, direction); if (transition){ @@ -225,6 +225,13 @@ bool stops_at(const std::vector& maps, const Located& player, Direction if (tile_at(map, ahead) == Tile::SIGN && (direction == Direction::NORTH || direction == Direction::SOUTH)){ return false; } + // Held into a ledge the way it faces, the player hops down it. The maps + // don't keep which way a ledge faces, so none is held into (console B, + // 2026-11-01, Viridian City: "south 2 (to wall)" from the Pokemon + // Center hopped the ledge two rows down). + if (is_ledge(map, ahead)){ + return false; + } if (in_bounds(map, ahead)){ return object_at(map, ahead) == nullptr; } @@ -703,6 +710,43 @@ UnitTestResult test_no_hold_into_sign(){ return true; } +// A yard with a row of ledges across it. +const char* const TEST_LEDGE_YARD_JSON = R"({ + "name": "LedgeYard", "width": 5, "height": 6, + "tiles": ["#####", "#...#", "#...#", "#JJJ#", "#...#", "#####"], + "elevation": ["33333", "33333", "33333", "33333", "33333", "33333"], + "warps": [], "objects": [], + "connections": [], "points": [], "flag_sets": [] +})"; + +// A ledge isn't a wall to hold into: the player would hop it. Viridian City, +// from the Pokemon Center's door south to the row above the ledge. +UnitTestResult test_no_hold_into_ledge(){ + const std::vector maps{parse_map(TEST_LEDGE_YARD_JSON, "LedgeYard")}; + std::vector route; + UnitTestResult result = check_route(maps, {"LedgeYard", {2, 1}}, {{"LedgeYard", {2, 2}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.to_wall){ + return "Expected no hold into the ledge:" + describe(route); + } + } + route.clear(); + const std::vector world = overworld_maps(); + result = check_route(world, {"ViridianCity", {26, 27}}, {{"ViridianCity", {26, 29}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.to_wall && step.end.point == TilePos{26, 29}){ + return "Expected no hold into the ledge below the Pokemon Center:" + describe(route); + } + } + return true; +} + // Two small maps that don't need the map files: a room left by the exit mat // at its bottom, onto the door of a yard, out of which the game walks the // player. @@ -783,6 +827,7 @@ void add_tests_OverworldRoute(UnitTestDatabase& database){ database.add("Viridian Mart to Pewter Gym", test_viridian_mart_to_pewter_gym); database.add("wild steps", test_wild_steps); database.add("no hold into a sign", test_no_hold_into_sign); + database.add("no hold into a ledge", test_no_hold_into_ledge); database.add("step rules", test_step_rules); database.add("maps", test_maps); database.add("Cerulean Cave entrance to Mewtwo", test_entrance_to_mewtwo); From 4e7b75ff4e6962a83cc4d5d31f720a596d711cae Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 01:44:48 -0700 Subject: [PATCH 03/25] FRLG: check the overworld before each step, and watch for a battle after a route Two failures on console B (2026-11-01): - With the START menu still open, a Viridian Forest route of three legs to a wall reported each one done; the D-pad only moved the menu's cursor. Before each step the route now fails if the START menu, a selection menu or a message is on screen. This catches the cause; a check that the player actually moved would need a calibrated screen comparison, left for later. - A wild battle started as the last leg, "west 11 (to wall)" through grass, ended, and the route returned before it was seen; its flash was missed, and "Wild ... appeared!" shows about 2s on. A route whose last leg can meet a wild battle now watches 2.5s more before returning. The step's switch moves into walk_step(), unchanged, to keep the loop short. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 91 ++++++++++++++----- .../Overworld/PokemonFRLG_OverworldWalk.h | 5 +- 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 3cd049b229..78cf53ffd9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -12,6 +12,7 @@ #include "CommonFramework/VideoPipeline/VideoFeed.h" #include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_BattleDialogs.h" #include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_StartMenuDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "PokemonFRLG_PlayerFacingReader.h" @@ -51,6 +52,11 @@ constexpr Milliseconds AFTER_LEG_WATCH = 500ms; // 2026-11-01, Route 1). From that log the flash was seen roughly 600ms // after its step landed. constexpr Milliseconds STEP_WINDOW = 900ms; +// After a route whose last leg can meet a wild battle. A battle can start on +// its last step, and one whose flash is missed is only seen by its "Wild +// appeared!", about 2s on: the route had returned by then +// (console B, 2026-11-01, Viridian Forest, "west 11 (to wall)"). +constexpr Milliseconds AFTER_WILD_ROUTE_WATCH = 2500ms; constexpr size_t MAX_INTERRUPTIONS_PER_LEG = 5; @@ -188,6 +194,33 @@ void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const } } +// Fails unless the overworld is up. Under the START menu or a message the +// D-pad only moves a cursor, and legs to a wall seem to go fine: three +// "walked" that way in Viridian Forest without the player moving (console B, +// 2026-11-01). +void check_overworld(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + if (!screen){ + return; + } + const char* open = nullptr; + if (StartMenuDetector(COLOR_RED).detect(screen)){ + open = "the START menu"; + }else if (SelectionDialogDetector(COLOR_RED).detect(screen)){ + open = "a menu"; + }else if (WhiteDialogDetector(COLOR_RED).detect(screen)){ + open = "a message"; + } + if (open != nullptr){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + std::string("walk_overworld_route(): ") + open + " is open, before: " + to_string(step), + console + ); + } +} + void start_surf(ConsoleHandle& console, ProControllerContext& context, Direction direction, bool facing_water){ // "The water is a deep blue... Would you like to SURF?", with YES selected. // An A pressed while the player is still turning is lost. @@ -218,6 +251,35 @@ void start_surf(ConsoleHandle& console, ProControllerContext& context, Direction pbf_wait(context, SURF_ANIMATION); } +// One step of a route, for a player facing `facing`. +void walk_step( + ConsoleHandle& console, ProControllerContext& context, + const RouteStep& step, std::optional facing, + const OverworldInterruptSet& interrupts +){ + switch (step.action){ + case RouteAction::WALK: + // Turning first, with a tap, makes the hold start from a standstill + // facing the right way: holding through a turn lost a tile on the + // console, while holds from a standstill were exact over 15 tiles. + if (facing != step.direction){ + pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TURN_SETTLE); + } + Pokemon::walk_leg(console, context, DpadMover(), step.wild ? interrupts.wild : interrupts.other, leg_spec(step), MAX_INTERRUPTIONS_PER_LEG); + if (step.warps){ + wait_out_warp(console, context, step); + pbf_wait(context, AFTER_WARP); + } + break; + case RouteAction::SURF: + start_surf(console, context, step.direction, facing == step.direction); + break; + case RouteAction::FACE: + pbf_press_dpad(context, dpad(step.direction), FACE_HOLD, STEP_RELEASE); + break; + } +} + } @@ -288,35 +350,20 @@ void walk_overworld_route( } console.log(std::string("Route: the player faces ") + (facing ? Pokemon::direction_name(*facing) : "an unknown way") + "."); } - const DpadMover mover; + bool last_leg_wild = false; for (const RouteStep& step : route){ console.log("Route: " + to_string(step)); - switch (step.action){ - case RouteAction::WALK: - // Turning first, with a tap, makes the hold start from a standstill - // facing the right way: holding through a turn lost a tile on the - // console, while holds from a standstill were exact over 15 tiles. - if (facing != step.direction){ - pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TURN_SETTLE); - } - Pokemon::walk_leg(console, context, mover, step.wild ? interrupts.wild : interrupts.other, leg_spec(step), MAX_INTERRUPTIONS_PER_LEG); - if (step.warps){ - wait_out_warp(console, context, step); - pbf_wait(context, AFTER_WARP); - } - break; - case RouteAction::SURF: - start_surf(console, context, step.direction, facing == step.direction); - break; - case RouteAction::FACE: - pbf_press_dpad(context, dpad(step.direction), FACE_HOLD, STEP_RELEASE); - break; - } + check_overworld(console, context, step); + walk_step(console, context, step, facing, interrupts); if (step.action != RouteAction::FACE){ Pokemon::clear_late_interrupts(console, context, step.wild ? interrupts.wild : interrupts.other, AFTER_LEG_WATCH, MAX_INTERRUPTIONS_PER_LEG); + last_leg_wild = step.wild; } facing = step.facing_after; } + if (last_leg_wild){ + Pokemon::clear_late_interrupts(console, context, interrupts.wild, AFTER_WILD_ROUTE_WATCH, MAX_INTERRUPTIONS_PER_LEG); + } context.wait_for_all_requests(); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index fe11924b58..cdcb29dc94 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -63,8 +63,9 @@ void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& contex // Walks `route` from where it was planned. The player must be standing still // on the overworld, not running and not on the bike. `facing` is the way the // player faces at the start; if not given, it is read from the screen. Throws OperationFailedException if a -// warp's fade or the Surf prompt doesn't show, or a leg is interrupted too -// many times. +// menu or message is open before a step, a warp's fade or the Surf prompt +// doesn't show, or a leg is interrupted too many times. A wild battle that +// starts as the last leg ends is cleared before it returns. void walk_overworld_route( ConsoleHandle& console, ProControllerContext& context, const std::vector& route, From de08068db2bfa8bc82f91415919b8ad1a052ff1a Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 01:51:57 -0700 Subject: [PATCH 04/25] FRLG: don't watch the step onto a warp for a battle Walked a tile at a time, a leg's last step onto a warp was watched for 900ms, long enough for the warp's fade out and in to pass for a battle's flash. On console B (2026-11-01) the player was already inside Viridian Forest's north gate when the walker tried to flee, and failed after 5 attempts. The step onto a warp is now pressed without watching; a battle set off by the step before still shows in that step's window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Pokemon/Navigation/Pokemon_LegWalker.cpp | 8 ++++++++ .../Pokemon/Navigation/Pokemon_LegWalker.h | 10 +++++++++- .../Navigation/Pokemon_LegWalker_Tests.cpp | 19 +++++++++++++++++++ .../Overworld/PokemonFRLG_OverworldWalk.cpp | 1 + 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp index 8ed3611ea1..4f72ce0d51 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp @@ -49,6 +49,14 @@ LegResult walk_leg_tile_by_tile(const LegSpec& leg, const LegCalls& calls, size_ // one already cleared. int pressed = 0; while (walked < leg.tiles){ + if (leg.warps && walked == leg.tiles - 1){ + // Onto the warp, whose fade would be taken for a battle's flash + // (console B, 2026-11-01: the player, already in Viridian + // Forest's north gate, tried to flee a battle). + calls.hold(leg_hold(leg, 1)); + walked++; + break; + } const std::optional outcome = calls.hold_watching(leg_hold(leg, 1), leg.step_window); walked++; pressed++; diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h index 68352784f1..2bb5e6c3b1 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h @@ -87,6 +87,9 @@ struct LegSpec{ // longer than the interrupts' detection delays. See // steps_back_to_interrupt(). Milliseconds step_window{0}; + // The last step lands on a warp. Walked a tile at a time, that step + // isn't watched: the warp's fade looks like a battle's flash. + bool warps = false; }; std::string to_string(const LegSpec& leg); @@ -130,11 +133,12 @@ struct HoldOutcome{ // The leg's bookkeeping, with the console side passed in, so it can be // tested with scripted outcomes. `hold_watching` holds for `hold`, watches // until at least `window` after the hold started, and reports the first -// interrupt seen; `clear` clears interrupt i; +// interrupt seen; `hold` only holds; `clear` clears interrupt i; // `detection_delay` is interrupt i's delay; `log` reports progress; `fail` // throws, and is called after more than `max_interruptions`. struct LegCalls{ std::function(Milliseconds hold, Milliseconds window)> hold_watching; + std::function hold; std::function clear; std::function detection_delay; std::function name; @@ -180,6 +184,10 @@ LegResult walk_leg( }; LegCalls calls; calls.hold_watching = hold_watching; + calls.hold = [&](Milliseconds hold){ + mover.hold(context, leg.direction, hold); + context.wait_for_all_requests(); + }; calls.clear = [&](size_t index){ interrupts[index]->clear(console, context); }; calls.detection_delay = [&](size_t index){ return interrupts[index]->detection_delay(); }; calls.name = [&](size_t index){ return interrupts[index]->name(); }; diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp index e1c5e16f20..88305e4874 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp @@ -37,9 +37,15 @@ struct ScriptedLeg{ std::vector holds; std::vector windows; std::vector cleared; + size_t unwatched = 0; LegCalls calls(){ LegCalls calls; + calls.hold = [this](Milliseconds hold){ + holds.push_back(hold); + windows.push_back(NO_WINDOW); + unwatched++; + }; calls.hold_watching = [this](Milliseconds hold, Milliseconds window) -> std::optional{ holds.push_back(hold); windows.push_back(window); @@ -258,6 +264,18 @@ UnitTestResult test_tile_by_tile_slow_interrupt(){ return true; } +// The step onto a warp isn't watched: its fade looks like a flash. +UnitTestResult test_tile_by_tile_warp_not_watched(){ + ScriptedLeg script; + LegSpec leg{Direction::NORTH, 4, false, TILE, WINDOW}; + leg.warps = true; + walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 4 || script.unwatched != 1 || script.windows.back() != NO_WINDOW){ + return std::format("Expected 3 watched presses and an unwatched one, got {} and {}.", script.holds.size() - script.unwatched, script.unwatched); + } + return true; +} + // A leg to a wall is still held through, window or not. UnitTestResult test_wall_leg_held_through(){ ScriptedLeg script; @@ -301,6 +319,7 @@ void add_tests_LegWalker(UnitTestDatabase& database){ database.add("tile by tile: a battle on the last tile", test_tile_by_tile_battle_on_last_tile); database.add("tile by tile: an interrupt slower than the window", test_tile_by_tile_slow_interrupt); database.add("a wall leg is held through", test_wall_leg_held_through); + database.add("tile by tile: the step onto a warp isn't watched", test_tile_by_tile_warp_not_watched); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 78cf53ffd9..e2e9fad35f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -177,6 +177,7 @@ Pokemon::LegSpec leg_spec(const RouteStep& step){ step.direction, step.tiles, step.to_wall, step.surfing ? SURF_TILE_TIME : TILE_TIME, step.wild ? STEP_WINDOW : 0ms, + step.warps, }; } From 3cc13ad118d056926bd800595bcb7e963c84f392 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 01:51:57 -0700 Subject: [PATCH 05/25] FRLG: stop on a trainer battle instead of fleeing it A trainer who sees the player mid-leg speaks first; the walker closed the message as an overworld one, then saw the battle and tried to flee it (console B, 2026-11-01, Viridian Forest). After any message on the overworld the walker now watches 6s for a battle's dialog, and if one shows it stops with "trainer battle" in the error, without an error report, for the caller to fight it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index e2e9fad35f..d67c64fb1a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -57,6 +57,9 @@ constexpr Milliseconds STEP_WINDOW = 900ms; // appeared!", about 2s on: the route had returned by then // (console B, 2026-11-01, Viridian Forest, "west 11 (to wall)"). constexpr Milliseconds AFTER_WILD_ROUTE_WATCH = 2500ms; +// After a message closes: a trainer who saw the player speaks first, then +// the battle starts, with " would like to battle!" seconds later. +constexpr Milliseconds TRAINER_BATTLE_WATCH = 6000ms; constexpr size_t MAX_INTERRUPTIONS_PER_LEG = 5; @@ -127,6 +130,22 @@ class MessageInterrupt : public OverworldInterrupt{ console ); } + check_no_trainer_battle(console, context); + } + +private: + // A trainer's battle can't be fled, and the walker must not try (console + // B, 2026-11-01, Viridian Forest): it stops here for the caller. + static void check_no_trainer_battle(ConsoleHandle& console, ProControllerContext& context){ + BattleDialogWatcher battle(COLOR_RED); + context.wait_for_all_requests(); + if (wait_until(console, context, TRAINER_BATTLE_WATCH, {battle}) >= 0){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::NO_ERROR_REPORT, + "walk_overworld_route(): trainer battle: a battle started after a message on the overworld.", + console + ); + } } }; From 6a22c70748b7c147b42e9345d965ad2e89465af0 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 03:28:07 -0800 Subject: [PATCH 06/25] FRLG: wait out a missed battle on a leg's last step Run 7 on console B (2026-11-01, Route 1) lost a whole leg twice. A battle on the last step of a leg through grass had its flashes missed; its "Wild ... appeared!" showed 3.7s after that press, by which time the next leg ("east 4", "west 4", off the grass and so not watched for battles) had been walked into the battle without moving the player. The next leg through grass then saw the dialog 353 and 409ms after its first press and rightly counted 0 tiles of its own, but the column was lost. Now the last watched step of an open leg through grass is watched 3.4s longer, and a leg to a wall through grass is watched 3.5s after it, so a late battle is placed on the leg it belongs to. A late sighting is placed on the step that landed nearest its delay before, and that delay is 3.7s as measured, not the 2s guessed (other maps open battles with other wipes, so there it's still a guess). This replaces the 2.5s watch after a route's last leg. It costs about 3.5s per leg through grass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Pokemon/Navigation/Pokemon_LegWalker.cpp | 15 +++++-- .../Pokemon/Navigation/Pokemon_LegWalker.h | 9 ++++- .../Navigation/Pokemon_LegWalker_Tests.cpp | 39 +++++++++++++++++++ .../Overworld/PokemonFRLG_OverworldWalk.cpp | 29 +++++++------- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp index 4f72ce0d51..0d96fca6f5 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp @@ -4,6 +4,7 @@ * */ +#include #include #include "Pokemon_LegWalker.h" @@ -32,8 +33,13 @@ int tiles_before_interrupt(const LegSpec& leg, Milliseconds elapsed, Millisecond } int steps_back_to_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay){ - const int whole_windows = (int)(detection_delay / leg.step_window); - return whole_windows + (elapsed < leg.tile_time ? 1 : 0); + if (detection_delay < leg.step_window){ + return elapsed < leg.tile_time ? 1 : 0; + } + // Steps land a tile after their press, a window apart. + const Milliseconds before_landing = leg.tile_time - (elapsed - detection_delay); + const int steps = (int)std::lround((double)before_landing.count() / leg.step_window.count()); + return std::max(steps, 0); } @@ -57,7 +63,10 @@ LegResult walk_leg_tile_by_tile(const LegSpec& leg, const LegCalls& calls, size_ walked++; break; } - const std::optional outcome = calls.hold_watching(leg_hold(leg, 1), leg.step_window); + const bool last_watched = walked == leg.tiles - (leg.warps ? 2 : 1); + const std::optional outcome = calls.hold_watching( + leg_hold(leg, 1), leg.step_window + (last_watched ? leg.settle : Milliseconds::zero()) + ); walked++; pressed++; if (!outcome){ diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h index 2bb5e6c3b1..fb54444b81 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h @@ -90,6 +90,10 @@ struct LegSpec{ // The last step lands on a warp. Walked a tile at a time, that step // isn't watched: the warp's fade looks like a battle's flash. bool warps = false; + // Walked a tile at a time, the last watched step is watched this much + // longer, for an interrupt whose first sign was missed and that shows + // only later: it is then placed before the leg ends, not after. + Milliseconds settle{0}; }; std::string to_string(const LegSpec& leg); @@ -118,8 +122,9 @@ int tiles_before_interrupt(const LegSpec& leg, Milliseconds elapsed, Millisecond // within its window when the delay is shorter than the window, or else // before the next press lands, which it then swallows. So one seen less than // a tile after a press is the previous step's. This needs no measured delay, -// only one shorter than the window. An interrupt slower than that (whole -// windows of `detection_delay`) is counted back further, which is a guess. +// only one shorter than the window. An interrupt slower than that is placed +// on the step that landed nearest `detection_delay` before it was seen, +// which is only as good as the delay. int steps_back_to_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay); diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp index 88305e4874..dbfe52e6cf 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp @@ -29,6 +29,8 @@ constexpr Milliseconds TILE = 267ms; constexpr Milliseconds DELAY = 300ms; constexpr Milliseconds WINDOW = 900ms; constexpr Milliseconds NO_WINDOW = 0ms; +constexpr Milliseconds SETTLE = 3400ms; +constexpr Milliseconds LATE_DELAY = 3700ms; // Holds that report the scripted outcomes in order, then run out. struct ScriptedLeg{ @@ -264,6 +266,41 @@ UnitTestResult test_tile_by_tile_slow_interrupt(){ return true; } +// Console B, 2026-11-01, run 7: a battle on the last step of "south 3" +// whose flashes were missed showed by its dialog 3.7s on, after the next +// leg had been walked into the battle. The last step's window now runs +// until the dialog would show, and places the battle on that step. +UnitTestResult test_tile_by_tile_settle_last_step(){ + ScriptedLeg script; + script.delay = LATE_DELAY; + script.outcomes = {std::nullopt, std::nullopt, HoldOutcome{0, TILE + LATE_DELAY}}; + LegSpec leg{Direction::SOUTH, 3, false, TILE, WINDOW}; + leg.settle = SETTLE; + const LegResult result = walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 3 || result.interruptions != 1){ + return std::format("Expected the leg done in 3 presses, got {}.", script.holds.size()); + } + if (script.windows[1] != WINDOW || script.windows[2] != WINDOW + SETTLE){ + return "Only the last step should be watched longer."; + } + return true; +} + +// A missed battle on the 2nd step of 3, seen by its dialog in the last +// step's longer window: the 3rd press was swallowed and is pressed again. +UnitTestResult test_tile_by_tile_settle_earlier_step(){ + ScriptedLeg script; + script.delay = LATE_DELAY; + script.outcomes = {std::nullopt, std::nullopt, HoldOutcome{0, TILE + LATE_DELAY - WINDOW}}; + LegSpec leg{Direction::SOUTH, 3, false, TILE, WINDOW}; + leg.settle = SETTLE; + walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 4 || script.windows[3] != WINDOW + SETTLE){ + return std::format("Expected the last tile pressed again (4 presses), got {}.", script.holds.size()); + } + return true; +} + // The step onto a warp isn't watched: its fade looks like a flash. UnitTestResult test_tile_by_tile_warp_not_watched(){ ScriptedLeg script; @@ -320,6 +357,8 @@ void add_tests_LegWalker(UnitTestDatabase& database){ database.add("tile by tile: an interrupt slower than the window", test_tile_by_tile_slow_interrupt); database.add("a wall leg is held through", test_wall_leg_held_through); database.add("tile by tile: the step onto a warp isn't watched", test_tile_by_tile_warp_not_watched); + database.add("tile by tile: the last step settles", test_tile_by_tile_settle_last_step); + database.add("tile by tile: a late battle before the last step", test_tile_by_tile_settle_earlier_step); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index d67c64fb1a..514cd63a1a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -52,11 +52,14 @@ constexpr Milliseconds AFTER_LEG_WATCH = 500ms; // 2026-11-01, Route 1). From that log the flash was seen roughly 600ms // after its step landed. constexpr Milliseconds STEP_WINDOW = 900ms; -// After a route whose last leg can meet a wild battle. A battle can start on -// its last step, and one whose flash is missed is only seen by its "Wild -// appeared!", about 2s on: the route had returned by then -// (console B, 2026-11-01, Viridian Forest, "west 11 (to wall)"). -constexpr Milliseconds AFTER_WILD_ROUTE_WATCH = 2500ms; +// A battle whose flashes are missed is seen only by its "Wild +// appeared!", 3.7s after its step's press (twice on Route 1, console B, +// 2026-11-01, run 7). Unless the walker waits that long after a leg's last +// step, the next leg is walked into the battle and lost: there, "east 4" +// and "west 4". So the last step of an open leg is watched this much longer, +// and a leg to a wall is watched this long after it. +constexpr Milliseconds STEP_SETTLE = 3400ms; +constexpr Milliseconds AFTER_WILD_LEG_WATCH = 3500ms; // After a message closes: a trainer who saw the player speaks first, then // the battle starts, with " would like to battle!" seconds later. constexpr Milliseconds TRAINER_BATTLE_WATCH = 6000ms; @@ -92,9 +95,11 @@ class LateWildBattleInterrupt : public WildBattleInterrupt{ virtual std::unique_ptr make_watcher() const override{ return std::make_unique(COLOR_RED); } - // The flashes, the wipe and the opening animation. Not calibrated yet. + // The flashes, the wipe and the opening animation: 3.7s after the step's + // press, twice on Route 1 (console B, 2026-11-01). Other maps open + // battles with other wipes, so elsewhere this is a guess. virtual Milliseconds detection_delay() const override{ - return 2000ms; + return 3700ms; } }; @@ -197,6 +202,7 @@ Pokemon::LegSpec leg_spec(const RouteStep& step){ step.surfing ? SURF_TILE_TIME : TILE_TIME, step.wild ? STEP_WINDOW : 0ms, step.warps, + step.wild ? STEP_SETTLE : 0ms, }; } @@ -370,20 +376,17 @@ void walk_overworld_route( } console.log(std::string("Route: the player faces ") + (facing ? Pokemon::direction_name(*facing) : "an unknown way") + "."); } - bool last_leg_wild = false; for (const RouteStep& step : route){ console.log("Route: " + to_string(step)); check_overworld(console, context, step); walk_step(console, context, step, facing, interrupts); if (step.action != RouteAction::FACE){ - Pokemon::clear_late_interrupts(console, context, step.wild ? interrupts.wild : interrupts.other, AFTER_LEG_WATCH, MAX_INTERRUPTIONS_PER_LEG); - last_leg_wild = step.wild; + // An open leg through grass has already waited on its last step. + const Milliseconds watch = step.wild && step.to_wall ? AFTER_WILD_LEG_WATCH : AFTER_LEG_WATCH; + Pokemon::clear_late_interrupts(console, context, step.wild ? interrupts.wild : interrupts.other, watch, MAX_INTERRUPTIONS_PER_LEG); } facing = step.facing_after; } - if (last_leg_wild){ - Pokemon::clear_late_interrupts(console, context, interrupts.wild, AFTER_WILD_ROUTE_WATCH, MAX_INTERRUPTIONS_PER_LEG); - } context.wait_for_all_requests(); } From e6197d9bb825980078a5700e892e921c8958f5ca Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 03:28:08 -0800 Subject: [PATCH 07/25] FRLG: check a warp's fade wasn't a wild battle's In run 7 (console B, 2026-11-01) the player had drifted onto Route 1's grass, and the step meant for Oak's Lab's door set off a wild battle instead. Its flashes and wipe passed for the warp's fade, the route went on inside a "lab" and returned done, and the next screen was the battle's FIGHT menu. After a warp's fade the walker now watches 3s (it waited 1.5s before) for a battle's dialog; if one shows, it flees and fails with "a wild battle, not the warp to ...: the player is off the route", for the caller to re-plan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 23 ++++++++++++++++++- .../Overworld/PokemonFRLG_OverworldWalk.h | 5 ++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 514cd63a1a..edb631d4b7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -60,6 +60,10 @@ constexpr Milliseconds STEP_WINDOW = 900ms; // and a leg to a wall is watched this long after it. constexpr Milliseconds STEP_SETTLE = 3400ms; constexpr Milliseconds AFTER_WILD_LEG_WATCH = 3500ms; +// After a warp's fade. A wild battle's flashes and wipe pass for one, and +// its dialog shows within this long; a warp's doesn't (run 7: a "warp" into +// Oak's Lab from the grass of Route 1, where the player had drifted). +constexpr Milliseconds AFTER_WARP_BATTLE_WATCH = 3000ms; // After a message closes: a trainer who saw the player speaks first, then // the battle starts, with " would like to battle!" seconds later. constexpr Milliseconds TRAINER_BATTLE_WATCH = 6000ms; @@ -220,6 +224,22 @@ void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const } } +// After a warp's fade, which a wild battle's can pass for: fails, once the +// battle is fled, if it was one. +void check_warp_was_no_battle(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ + BattleDialogWatcher battle(COLOR_RED); + context.wait_for_all_requests(); + if (wait_until(console, context, AFTER_WARP_BATTLE_WATCH, {battle}) < 0){ + return; + } + flee_battle(console, context); + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "walk_overworld_route(): a wild battle, not the warp to " + step.end.map + ": the player is off the route.", + console + ); +} + // Fails unless the overworld is up. Under the START menu or a message the // D-pad only moves a cursor, and legs to a wall seem to go fine: three // "walked" that way in Viridian Forest without the player moving (console B, @@ -294,7 +314,8 @@ void walk_step( Pokemon::walk_leg(console, context, DpadMover(), step.wild ? interrupts.wild : interrupts.other, leg_spec(step), MAX_INTERRUPTIONS_PER_LEG); if (step.warps){ wait_out_warp(console, context, step); - pbf_wait(context, AFTER_WARP); + static_assert(AFTER_WARP_BATTLE_WATCH >= AFTER_WARP); + check_warp_was_no_battle(console, context, step); } break; case RouteAction::SURF: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index cdcb29dc94..3b40bc2994 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -64,8 +64,9 @@ void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& contex // on the overworld, not running and not on the bike. `facing` is the way the // player faces at the start; if not given, it is read from the screen. Throws OperationFailedException if a // menu or message is open before a step, a warp's fade or the Surf prompt -// doesn't show, or a leg is interrupted too many times. A wild battle that -// starts as the last leg ends is cleared before it returns. +// doesn't show, a warp's fade turns out to be a wild battle's, or a leg is +// interrupted too many times. A wild battle that starts as the last leg +// ends is cleared before it returns. void walk_overworld_route( ConsoleHandle& console, ProControllerContext& context, const std::vector& route, From d1732143997a91856ba5331b7dbdf705bb996ae6 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 04:07:45 -0800 Subject: [PATCH 08/25] FRLG: after a warp, watch for the opponent's HP box, not the battle text box The battle text box's detector fires on the black below a small room's map: on Oak's Lab mat the walker tried to flee a battle that wasn't there, twice (console B). The HP box shows a little later, so the watch is 5 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index edb631d4b7..add0029096 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -14,6 +14,7 @@ #include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_StartMenuDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattlePokemonDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_OverworldWalk.h" @@ -63,7 +64,7 @@ constexpr Milliseconds AFTER_WILD_LEG_WATCH = 3500ms; // After a warp's fade. A wild battle's flashes and wipe pass for one, and // its dialog shows within this long; a warp's doesn't (run 7: a "warp" into // Oak's Lab from the grass of Route 1, where the player had drifted). -constexpr Milliseconds AFTER_WARP_BATTLE_WATCH = 3000ms; +constexpr Milliseconds AFTER_WARP_BATTLE_WATCH = 5000ms; // After a message closes: a trainer who saw the player speaks first, then // the battle starts, with " would like to battle!" seconds later. constexpr Milliseconds TRAINER_BATTLE_WATCH = 6000ms; @@ -227,7 +228,10 @@ void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const // After a warp's fade, which a wild battle's can pass for: fails, once the // battle is fled, if it was one. void check_warp_was_no_battle(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ - BattleDialogWatcher battle(COLOR_RED); + // Watch for the opponent's HP box, not the battle text box: in small rooms + // the black below the map reads as that box (Oak's Lab mat, console B, + // 2026-11-01: "Failed to flee" with no battle on screen). + DetectorToFinder battle("WarpBattleOpponent", DetectorToFinder::FinderType::PRESENT, std::chrono::milliseconds(300), COLOR_RED); context.wait_for_all_requests(); if (wait_until(console, context, AFTER_WARP_BATTLE_WATCH, {battle}) < 0){ return; From b4d1976a3ffebc86b07013dfdf52aff45dfd62f7 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 04:28:27 -0800 Subject: [PATCH 09/25] FRLG: weigh open stops after grass, and stop at ledges moving north Three planner changes from the Route 1 runs (console B, 2026-11-01): - A leg's wildness is now worked out per step by the planner, so a leg that crosses a map's edge counts the grass on the far side: "north 22" from Pallet Town through Route 1's grass was marked not wild, and walked without watching for battles. - An open stop after a wild step costs OPEN_WILD_STOP (300) on top, so routes end legs through grass at a wall where one is close. On Route 1 there mostly isn't one; finding the player again after a battle is the next commit. - A ledge stops a player moving north. The maps don't record which way a ledge faces, but none of the loaded maps has a north-facing one (checked against pokefirered's metatile behaviors: south ledges on Route 1, 2, 22, 24, Viridian, Pewter and Cerulean, east and west ones on Route 24 only). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Pokemon/Navigation/Pokemon_LegPlanner.h | 32 +++++++++++++++---- .../Overworld/PokemonFRLG_OverworldRoute.cpp | 29 +++++++++-------- .../Overworld/PokemonFRLG_OverworldRoute.h | 7 +++- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner.h index c9d49735d9..e4c64949c1 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner.h @@ -6,7 +6,11 @@ * game decides what a single press does through its step model; the * planner searches, joins steps into legs, and prefers legs that end * against a wall, because the player stops there however long the button - * is held, while a leg that ends in the open depends on timing. + * is held, while a leg that ends in the open depends on timing. It can + * weigh more heavily a leg ending in the open that went through tiles + * where the walk is likely to be interrupted (tall grass): after an + * interruption a leg to a wall is simply walked again, while how far an + * open one got is a guess. * * A step model is a class with: * @@ -60,6 +64,9 @@ struct Step{ Direction facing = Direction::NORTH; // Cost on top of LegCosts::per_tile: tall grass, a warp's fade, a dialog. int extra_cost = 0; + // The walk can be interrupted on this step, e.g. by a wild battle in + // tall grass. See LegCosts::per_open_wild_stop. + bool wild = false; }; @@ -80,6 +87,8 @@ struct Leg{ // WALK: holding on keeps the player on the last tile, so the leg can be // held for longer than it takes and walked again after an interruption. bool to_wall = false; + // A step of the leg is wild (Step::wild). + bool wild = false; Direction facing_after = Direction::NORTH; State start; State end; @@ -93,6 +102,9 @@ struct LegCosts{ // Extra for ending a leg where the player could keep going: it has to // be timed, and where an interruption leaves the player is a guess. int per_open_stop = 8; + // Extra again when that leg has a wild step, where an interruption is + // likely. + int per_open_wild_stop = 0; }; @@ -112,13 +124,15 @@ std::optional>> plan_legs( namespace LegPlannerInternal{ -// A search node: the player, the way they face (4: not known), and whether -// they are in the middle of a leg that could go on. +// A search node: the player, the way they face (4: not known), whether +// they are in the middle of a leg that could go on, and whether that leg +// had a wild step. template struct Node{ State state; int facing = 4; bool moving = false; + bool wild = false; bool operator==(const Node& other) const = default; }; @@ -126,7 +140,7 @@ struct Node{ template struct NodeHash{ size_t operator()(const Node& node) const{ - return StateHash()(node.state) * 10 + node.facing * 2 + (node.moving ? 1 : 0); + return StateHash()(node.state) * 20 + node.facing * 4 + (node.moving ? 2 : 0) + (node.wild ? 1 : 0); } }; @@ -142,7 +156,7 @@ int stop_cost(const Model& model, const Node& node, const LegCosts& costs if (!node.moving || model.held_at(node.state, (Direction)node.facing)){ return 0; } - return costs.per_open_stop; + return costs.per_open_stop + (node.wild ? costs.per_open_wild_stop : 0); } template @@ -156,12 +170,14 @@ std::vector> join_legs(const Model& model, const State& start, const && legs.back().kind == LegKind::WALK && legs.back().direction == move.direction; if (extends){ legs.back().tiles++; + legs.back().wild |= step.wild; }else{ Leg leg; leg.kind = step.action == 0 ? LegKind::WALK : LegKind::ACTION; leg.action = step.action; leg.direction = move.direction; leg.tiles = 1; + leg.wild = step.wild; leg.start = before; legs.push_back(leg); } @@ -220,7 +236,9 @@ void expand( continue; } const bool continues = step->action == 0 && !step->ends_leg; - const Node next{step->next, (int)step->facing, continues}; + const bool extends = current.moving && current.facing == (int)direction + && step->action == 0 && !step->starts_leg; + const Node next{step->next, (int)step->facing, continues, (extends && current.wild) || step->wild}; int step_cost = costs.per_tile + step->extra_cost; if (current.facing != (int)direction){ step_cost += costs.per_turn + stop_cost(model, current, costs); @@ -317,7 +335,7 @@ std::optional>> plan_legs( using State = typename Model::State; using Node = LegPlannerInternal::Node; - const Node first{start, start_facing ? (int)*start_facing : 4, false}; + const Node first{start, start_facing ? (int)*start_facing : 4, false, false}; LegPlannerInternal::Search search; const std::optional end = LegPlannerInternal::search_goal(model, search, first, at_goal, goal_facing, costs); if (!end){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 41157c41f8..49897c413a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -54,6 +54,7 @@ const RouteCosts& default_route_costs(){ {RouteCost::SURF, 40}, {RouteCost::GRASS_TILE, 20}, {RouteCost::OPEN_STOP, 8}, + {RouteCost::OPEN_WILD_STOP, 300}, }; return COSTS; } @@ -225,12 +226,14 @@ bool stops_at(const std::vector& maps, const Located& player, Direction if (tile_at(map, ahead) == Tile::SIGN && (direction == Direction::NORTH || direction == Direction::SOUTH)){ return false; } - // Held into a ledge the way it faces, the player hops down it. The maps - // don't keep which way a ledge faces, so none is held into (console B, - // 2026-11-01, Viridian City: "south 2 (to wall)" from the Pokemon - // Center hopped the ledge two rows down). + // Held into a ledge the way it faces, the player hops down it (console + // B, 2026-11-01, Viridian City: "south 2 (to wall)" from the Pokemon + // Center hopped the ledge two rows down). The maps don't keep which way + // a ledge faces, but none faces north (pokefirered has no + // MB_JUMP_NORTH on any map loaded here), so it stops a player moving + // north. if (is_ledge(map, ahead)){ - return false; + return direction == Direction::NORTH; } if (in_bounds(map, ahead)){ return object_at(map, ahead) == nullptr; @@ -278,6 +281,10 @@ class StepModel{ } if (is_tall_grass(m_maps[step.next.map], step.next.state.position)){ step.extra_cost += route_cost(m_costs, RouteCost::GRASS_TILE); + step.wild = true; + } + if (step.next.state.surfing){ + step.wild = true; } return step; } @@ -297,6 +304,7 @@ Pokemon::LegCosts to_leg_costs(const RouteCosts& costs){ leg_costs.per_tile = route_cost(costs, RouteCost::TILE); leg_costs.per_turn = route_cost(costs, RouteCost::TURN); leg_costs.per_open_stop = route_cost(costs, RouteCost::OPEN_STOP); + leg_costs.per_open_wild_stop = route_cost(costs, RouteCost::OPEN_WILD_STOP); return leg_costs; } @@ -321,15 +329,8 @@ RouteStep to_route_step(const std::vector& maps, const Pokemon::Leg; -// TILE 1, TURN 2, WARP 30, SURF 40, GRASS_TILE 20, OPEN_STOP 8. +// TILE 1, TURN 2, WARP 30, SURF 40, GRASS_TILE 20, OPEN_STOP 8, +// OPEN_WILD_STOP 300. const RouteCosts& default_route_costs(); // `costs`'s cost for `kind`, or the default if it has none. From 0d220a5848a8629d46c29ed5c6b3ea5565850b25 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 04:28:38 -0800 Subject: [PATCH 10/25] FRLG: find the player again at a wall when an open leg is stopped Counting tiles after a battle went wrong in a new way on almost every console run on Route 1 (console B, 2026-11-01): the flash missed, the dialog late, the next leg walked into the battle. Timing can't say where the player is, so the walker no longer guesses. An open leg now stops at its first interruption (and a leg after which one is seen counts as stopped too). plan_reanchor() then finds, on the map grid, a wall straight along the leg, ahead past its end or else back past its start, which holding that way reaches from any tile the player may be on. The walker holds to it, as a leg to a wall, and plans a new route from there to the same end. Warps, Surf, ledges, people and unloaded maps along the line rule a wall out; with none, the route fails. A leg to a wall seen interrupted after its hold is simply walked again. The walker loads overworld_maps() for this unless it is given maps. The tile-at-a-time walk, the settle and the window rule stay in the shared walker but FRLG no longer uses them: legs are held through again. Tests: the rules on a small map, and for Viridian Mart to Oak's Lab and Pallet Town to Viridian Mart on the real maps, that every open leg through grass has such a wall and a route on from it (skipped in CI, which has no map files; run locally). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Pokemon/Navigation/Pokemon_LegWalker.cpp | 12 ++ .../Pokemon/Navigation/Pokemon_LegWalker.h | 15 +- .../Navigation/Pokemon_LegWalker_Tests.cpp | 14 ++ .../PokemonFRLG_OverworldReanchor_Tests.cpp | 149 +++++++++++++++ .../Overworld/PokemonFRLG_OverworldRoute.cpp | 106 +++++++++++ .../Overworld/PokemonFRLG_OverworldRoute.h | 13 ++ .../Overworld/PokemonFRLG_OverworldWalk.cpp | 175 ++++++++++++++---- .../Overworld/PokemonFRLG_OverworldWalk.h | 18 +- .../PokemonFRLG/PokemonFRLG_Tests.cpp | 1 + SerialPrograms/cmake/SourceFiles.cmake | 1 + 10 files changed, 454 insertions(+), 50 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp index 0d96fca6f5..cdb06088d6 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp @@ -77,6 +77,12 @@ LegResult walk_leg_tile_by_tile(const LegSpec& leg, const LegCalls& calls, size_ calls.fail(std::format("Interrupted {} times on: {}", result.interruptions, to_string(leg))); } calls.clear(outcome->interrupt); + if (leg.stop_on_interrupt){ + calls.log(std::format("{} {}ms after a press, stopping the leg.", calls.name(outcome->interrupt), outcome->elapsed.count())); + result.exact = false; + result.stopped = true; + return result; + } const Milliseconds delay = calls.detection_delay(outcome->interrupt); walked -= std::min(steps_back_to_interrupt(leg, outcome->elapsed, delay), pressed); pressed = 0; @@ -114,6 +120,12 @@ LegResult walk_leg_with(const LegSpec& leg, const LegCalls& calls, size_t max_in } calls.clear(outcome->interrupt); const std::string seen = std::format("{} after {}ms", calls.name(outcome->interrupt), outcome->elapsed.count()); + if (!leg.to_wall && leg.stop_on_interrupt){ + calls.log(seen + ", stopping the leg."); + result.exact = false; + result.stopped = true; + return result; + } if (leg.to_wall){ // The wall stops the player on the leg's last tile however far // they got, so the leg is simply held again. diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h index fb54444b81..d43a74c287 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h @@ -94,6 +94,9 @@ struct LegSpec{ // longer, for an interrupt whose first sign was missed and that shows // only later: it is then placed before the leg ends, not after. Milliseconds settle{0}; + // An open leg stops at its first interrupt, once cleared, instead of + // walking on from an estimate; the caller finds the player again. + bool stop_on_interrupt = false; }; std::string to_string(const LegSpec& leg); @@ -103,6 +106,9 @@ struct LegResult{ // Whether `tiles` is certain: an interrupted open leg is an estimate. bool exact = true; size_t interruptions = 0; + // The leg stopped at an interrupt (LegSpec::stop_on_interrupt): the + // player is somewhere along it, and `tiles` is not known. + bool stopped = false; }; @@ -205,10 +211,11 @@ LegResult walk_leg( // After a leg: waits `watch` for an interrupt the last step set off, which -// shows after the hold has ended, and clears it. Throws -// OperationFailedException after `max_interruptions` in a row. +// shows after the hold has ended, and clears it. Returns how many it +// cleared. Throws OperationFailedException after `max_interruptions` in a +// row. template -void clear_late_interrupts( +size_t clear_late_interrupts( Console& console, Context& context, const LegInterrupts& interrupts, Milliseconds watch, @@ -224,7 +231,7 @@ void clear_late_interrupts( context.wait_for_all_requests(); const int ret = wait_until(console, context, watch, callbacks); if (ret < 0){ - return; + return count; } if (count >= max_interruptions){ OperationFailedExceptionWithScreenshot::fire( diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp index dbfe52e6cf..2a38c666ed 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp @@ -313,6 +313,19 @@ UnitTestResult test_tile_by_tile_warp_not_watched(){ return true; } +// An open leg set to stop at an interrupt doesn't walk on from a guess. +UnitTestResult test_open_leg_stops(){ + ScriptedLeg script; + script.outcomes = {after_step(2)}; + LegSpec leg{Direction::NORTH, 6, false, TILE, NO_WINDOW}; + leg.stop_on_interrupt = true; + const LegResult result = walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 1 || !result.stopped || result.exact || script.cleared.size() != 1){ + return "Expected one hold, the interrupt cleared, and the leg stopped."; + } + return true; +} + // A leg to a wall is still held through, window or not. UnitTestResult test_wall_leg_held_through(){ ScriptedLeg script; @@ -356,6 +369,7 @@ void add_tests_LegWalker(UnitTestDatabase& database){ database.add("tile by tile: a battle on the last tile", test_tile_by_tile_battle_on_last_tile); database.add("tile by tile: an interrupt slower than the window", test_tile_by_tile_slow_interrupt); database.add("a wall leg is held through", test_wall_leg_held_through); + database.add("an open leg stops at an interrupt", test_open_leg_stops); database.add("tile by tile: the step onto a warp isn't watched", test_tile_by_tile_warp_not_watched); database.add("tile by tile: the last step settles", test_tile_by_tile_settle_last_step); database.add("tile by tile: a late battle before the last step", test_tile_by_tile_settle_earlier_step); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp new file mode 100644 index 0000000000..a3c4d1b8d8 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp @@ -0,0 +1,149 @@ +/* Overworld Re-anchor Tests + * + * From: https://github.com/PokemonAutomation/ + * + * plan_reanchor(): finding the player again at a wall after an open leg + * stopped part-way. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class Test_OverworldReanchor : public UnitTest{ +public: + Test_OverworldReanchor(std::string name, std::function body) + : UnitTest("PokemonFRLG::OverworldReanchor - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_body(); + }catch (FileException& e){ + // CI runs with upstream Packages, which may not have the maps yet. + return UnitTestResult(UnitTestResult::SKIPPED, "Map files not found: " + e.message()); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + std::function m_body; +}; + +std::string describe(const std::vector& route){ + std::string text; + for (const RouteStep& step : route){ + text += "\n " + to_string(step); + } + return text; +} + +// Plans from `start` to `goal`, then checks that every leg through tall +// grass that ends in the open can be re-found at a wall if a battle stops +// it, and that a route on to the goal can be planned from there. +UnitTestResult check_reanchors(const std::vector& maps, const MapPosition& start, const RouteGoal& goal){ + const std::optional> route = plan_overworld_route(maps, start, goal); + if (!route){ + return "No route from " + to_string(start) + "."; + } + std::optional at = start; + for (const RouteStep& step : *route){ + if (step.action == RouteAction::WALK && step.wild && !step.to_wall){ + const std::optional anchor = plan_reanchor(maps, at, step); + if (!anchor || !anchor->to_wall){ + return "No wall to re-find the player on " + to_string(step) + ":" + describe(*route); + } + const std::optional> rest = plan_overworld_route( + maps, anchor->end, goal, default_route_costs(), anchor->direction + ); + if (!rest || !(follow_overworld_route(maps, anchor->end, *rest) == goal.position)){ + return "No route on from " + to_string(anchor->end) + ", after " + to_string(step) + "."; + } + } + at = step.end; + } + return true; +} + +// Console B, 2026-11-01: battles on Route 1 left the walker a tile or a +// whole leg off its count, four times. +UnitTestResult test_viridian_mart_to_oaks_lab(){ + const std::vector maps = overworld_maps(); + return check_reanchors( + maps, {"ViridianCity_Mart", map_point(maps, "ViridianCity_Mart", "clerk_front")}, + {{"PalletTown_ProfessorOaksLab", {6, 4}}, std::nullopt} + ); +} +UnitTestResult test_pallet_to_viridian_mart(){ + const std::vector maps = overworld_maps(); + return check_reanchors( + maps, {"PalletTown", map_point(maps, "PalletTown", "oaks_lab_front")}, + {{"ViridianCity_Mart", map_point(maps, "ViridianCity_Mart", "clerk_front")}, std::nullopt} + ); +} + +// A field with a wall at its east end and a row of ledges to the south. +const char* const TEST_FIELD_JSON = R"({ + "name": "Field", "width": 8, "height": 5, + "tiles": ["########", "#......#", "#......#", "#JJJJJJ#", "#......#"], + "elevation": ["33333333", "33333333", "33333333", "33333333", "33333333"], + "warps": [], "objects": [], + "connections": [], "points": [], "flag_sets": [] +})"; + +std::string describe(const std::optional& anchor){ + return anchor ? to_string(*anchor) : std::string("none"); +} + +// Stopped part-way east, the player holds on east to the wall. Stopped +// part-way south above the ledges, which would be hopped, they hold back +// north instead. +UnitTestResult test_rules(){ + const std::vector maps{parse_map(TEST_FIELD_JSON, "Field")}; + RouteStep east; + east.direction = Direction::EAST; + east.tiles = 3; + east.end = {"Field", {4, 1}}; + std::optional anchor = plan_reanchor(maps, MapPosition{"Field", {1, 1}}, east); + if (!anchor || anchor->direction != Direction::EAST || anchor->tiles != 5 || !(anchor->end.point == TilePos{6, 1})){ + return "Expected east 5 to the wall at (6, 1), got " + describe(anchor); + } + RouteStep south; + south.direction = Direction::SOUTH; + south.tiles = 1; + south.end = {"Field", {3, 2}}; + anchor = plan_reanchor(maps, std::nullopt, south); + if (!anchor || anchor->direction != Direction::NORTH || anchor->tiles != 1 || !(anchor->end.point == TilePos{3, 1})){ + return "Expected north 1 back to the wall at (3, 1), got " + describe(anchor); + } + return true; +} + +} + + +void add_tests_OverworldReanchor(UnitTestDatabase& database){ + database.add("rules", test_rules); + database.add("Viridian Mart to Oak's Lab", test_viridian_mart_to_oaks_lab); + database.add("Pallet Town to Viridian Mart", test_pallet_to_viridian_mart); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 49897c413a..16420f392f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -377,6 +377,112 @@ std::optional> plan_overworld_route( } +namespace{ + +// Further than any straight line on a map. +constexpr int MAX_ANCHOR_TILES = 128; + +Direction opposite(Direction direction){ + return (Direction)(((int)direction + 2) % 4); +} + +std::optional locate(const std::vector& maps, const MapPosition& position, bool surfing){ + const int map = find_map(maps, position.map); + if (map < 0){ + return std::nullopt; + } + Located player; + player.map = map; + player.state = PlayerState{position.point, elevation_at(maps[map], position.point), surfing}; + return player; +} + +// Presses `direction` from `player` until holding it keeps them on their +// tile, and returns how many steps that took, or nothing if one on the way +// is anything but a plain walk (a warp, Surf, a ledge, a person). +std::optional presses_to_stop(const std::vector& maps, Located& player, Direction direction){ + for (int c = 0; c <= MAX_ANCHOR_TILES; c++){ + if (stops_at(maps, player, direction)){ + return c; + } + const std::optional transition = press(maps, player, direction); + if (!transition || transition->warped || transition->dismounted || transition->action != RouteAction::WALK){ + return std::nullopt; + } + player = transition->next; + } + return std::nullopt; +} + +// Where `step` started: `tiles` presses back from its end. +std::optional walk_back(const std::vector& maps, const RouteStep& step){ + std::optional player = locate(maps, step.end, step.surfing); + for (int c = 0; player && c < step.tiles; c++){ + const std::optional transition = press(maps, *player, opposite(step.direction)); + if (!transition || transition->warped || transition->action != RouteAction::WALK){ + return std::nullopt; + } + player = transition->next; + } + return player; +} + +RouteStep anchor_step(const std::vector& maps, const RouteStep& step, Direction direction, int tiles, const Located& end){ + RouteStep anchor; + anchor.direction = direction; + anchor.tiles = tiles; + anchor.surfing = step.surfing; + anchor.to_wall = true; + anchor.wild = step.wild; + anchor.facing_after = direction; + anchor.end = MapPosition{maps[end.map].name, end.state.position}; + return anchor; +} + +} + + +std::optional plan_reanchor( + const std::vector& maps, + const std::optional& start, + const RouteStep& step +){ + if (step.action != RouteAction::WALK || step.to_wall){ + return std::nullopt; + } + std::optional from = start ? locate(maps, *start, step.surfing) : std::nullopt; + if (!start && !step.warps){ + from = walk_back(maps, step); + } + if (!from){ + return std::nullopt; + } + // The tiles the player may be on, short of a warp at the end. + const int along = step.tiles - (step.warps ? 1 : 0); + Located last = *from; + for (int c = 0; c < along; c++){ + const std::optional transition = press(maps, last, step.direction); + if (!transition || transition->warped || transition->action != RouteAction::WALK){ + return std::nullopt; + } + last = transition->next; + } + // On to the next wall ahead, or else back past the start to the one + // behind: from any of those tiles the hold ends there. + Located ahead = last; + if (!step.warps){ + if (const std::optional more = presses_to_stop(maps, ahead, step.direction)){ + return anchor_step(maps, step, step.direction, along + *more, ahead); + } + } + Located behind = *from; + if (const std::optional more = presses_to_stop(maps, behind, opposite(step.direction))){ + return anchor_step(maps, step, opposite(step.direction), along + *more, behind); + } + return std::nullopt; +} + + MapPosition follow_overworld_route( const std::vector& maps, const MapPosition& start, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index 7bdf0da8a8..f327bc6aa3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -109,6 +109,18 @@ std::optional> plan_overworld_route( std::optional start_facing = std::nullopt ); +// After `step`, an open walk, stopped part-way (a battle, a message): a leg +// to a wall that ends on the same tile from anywhere the player may be +// along it, ahead or back, so where they are is known again. `start` is +// where `step` started, if known; else it is worked back from its end. +// Nothing if no such wall lies along it: a warp, a ledge, a person or the +// edge of the loaded maps is in the way. +std::optional plan_reanchor( + const std::vector& maps, + const std::optional& start, + const RouteStep& step +); + // Replays `route` from `start` with the game's step rules and returns where // it ends. Throws InternalProgramError if a step can't be taken as planned. MapPosition follow_overworld_route( @@ -119,6 +131,7 @@ MapPosition follow_overworld_route( void add_tests_OverworldRoute(UnitTestDatabase& database); +void add_tests_OverworldReanchor(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index add0029096..44d12de076 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -16,6 +16,7 @@ #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattlePokemonDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "PokemonFRLG_OverworldMaps.h" #include "PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_OverworldWalk.h" @@ -46,21 +47,14 @@ constexpr Milliseconds AFTER_WARP = 1500ms; // A message set off by a leg's last step shows after the hold ends. Caught // here, before the next leg's turn tap, which the message would swallow. constexpr Milliseconds AFTER_LEG_WATCH = 500ms; -// An open leg where a wild battle can start is walked a tile at a time, each -// press watched this long. Held through, a battle's flash came too late to -// tell which step set it off: one on the 3rd tile of 4 was seen after the -// hold and taken for the 4th, and one on the 1st for none (console B, -// 2026-11-01, Route 1). From that log the flash was seen roughly 600ms -// after its step landed. -constexpr Milliseconds STEP_WINDOW = 900ms; -// A battle whose flashes are missed is seen only by its "Wild -// appeared!", 3.7s after its step's press (twice on Route 1, console B, -// 2026-11-01, run 7). Unless the walker waits that long after a leg's last -// step, the next leg is walked into the battle and lost: there, "east 4" -// and "west 4". So the last step of an open leg is watched this much longer, -// and a leg to a wall is watched this long after it. -constexpr Milliseconds STEP_SETTLE = 3400ms; +// After a leg through grass. A battle whose flashes are missed is seen only +// by its "Wild appeared!", 3.7s after its step's press (twice on +// Route 1, console B, 2026-11-01, run 7). Unless the walker waits that long, +// the next leg is walked into the battle and lost: there, "east 4" and +// "west 4". constexpr Milliseconds AFTER_WILD_LEG_WATCH = 3500ms; +// How often a route may stop to find the player again before giving up. +constexpr size_t MAX_REANCHORS = 8; // After a warp's fade. A wild battle's flashes and wipe pass for one, and // its dialog shows within this long; a warp's doesn't (run 7: a "warp" into // Oak's Lab from the grass of Route 1, where the player had drifted). @@ -201,14 +195,18 @@ class DpadMover : public Pokemon::LegMover{ } }; +// An open leg stops at its first interruption: how far it got isn't +// counted (that went wrong too often on the console), the route finds the +// player again at a wall instead. See walk_overworld_route(). Pokemon::LegSpec leg_spec(const RouteStep& step){ - return Pokemon::LegSpec{ - step.direction, step.tiles, step.to_wall, - step.surfing ? SURF_TILE_TIME : TILE_TIME, - step.wild ? STEP_WINDOW : 0ms, - step.warps, - step.wild ? STEP_SETTLE : 0ms, - }; + Pokemon::LegSpec leg; + leg.direction = step.direction; + leg.tiles = step.tiles; + leg.to_wall = step.to_wall; + leg.tile_time = step.surfing ? SURF_TILE_TIME : TILE_TIME; + leg.warps = step.warps; + leg.stop_on_interrupt = !step.to_wall; + return leg; } // One watcher for the whole fade: a ladder's black is short enough to be @@ -301,8 +299,9 @@ void start_surf(ConsoleHandle& console, ProControllerContext& context, Direction pbf_wait(context, SURF_ANIMATION); } -// One step of a route, for a player facing `facing`. -void walk_step( +// One step of a route, for a player facing `facing`. Returns false if an +// open leg stopped part-way, so where the player is isn't known. +bool walk_step( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, const OverworldInterruptSet& interrupts @@ -315,7 +314,9 @@ void walk_step( if (facing != step.direction){ pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TURN_SETTLE); } - Pokemon::walk_leg(console, context, DpadMover(), step.wild ? interrupts.wild : interrupts.other, leg_spec(step), MAX_INTERRUPTIONS_PER_LEG); + if (Pokemon::walk_leg(console, context, DpadMover(), step.wild ? interrupts.wild : interrupts.other, leg_spec(step), MAX_INTERRUPTIONS_PER_LEG).stopped){ + return false; + } if (step.warps){ wait_out_warp(console, context, step); static_assert(AFTER_WARP_BATTLE_WATCH >= AFTER_WARP); @@ -329,6 +330,80 @@ void walk_step( pbf_press_dpad(context, dpad(step.direction), FACE_HOLD, STEP_RELEASE); break; } + return true; +} + +// walk_step(), then a watch for what its last step set off. Returns false +// if the player may have stopped short of the step's end: an open leg +// stopped, or something seen after the hold, which may have stopped it +// before the end (after a warp, it was checked for already). +bool walk_and_watch( + ConsoleHandle& console, ProControllerContext& context, + const RouteStep& step, std::optional facing, + const OverworldInterruptSet& interrupts +){ + if (!walk_step(console, context, step, facing, interrupts)){ + return false; + } + if (step.action == RouteAction::FACE){ + return true; + } + const Milliseconds watch = step.wild ? AFTER_WILD_LEG_WATCH : AFTER_LEG_WATCH; + const size_t late = Pokemon::clear_late_interrupts(console, context, step.wild ? interrupts.wild : interrupts.other, watch, MAX_INTERRUPTIONS_PER_LEG); + return late == 0 || step.warps || step.action != RouteAction::WALK; +} + +// Each leg starts with a turn tap unless it goes the way the player faces. +// A tap while already facing that way takes a step, so a facing not given +// is read from the screen. If that fails too it taps anyway. +std::optional read_facing(ConsoleHandle& console, ProControllerContext& context){ + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + std::optional facing; + if (screen){ + facing = read_player_facing(screen); + } + console.log(std::string("Route: the player faces ") + (facing ? Pokemon::direction_name(*facing) : "an unknown way") + "."); + return facing; +} + +// Where the route ends, and which way the player faces there. +RouteGoal route_goal(const std::vector& route){ + RouteGoal goal; + goal.position = route.back().end; + if (route.back().action == RouteAction::FACE){ + goal.facing = route.back().direction; + } + return goal; +} + +// After `step`, from `start`, stopped part-way: a leg to a wall that puts +// the player on a known tile, then a new route from there to `goal`. +std::vector reanchor_route( + ConsoleHandle& console, + const std::vector& maps, + const std::optional& start, const RouteStep& step, + const RouteGoal& goal +){ + const std::optional anchor = plan_reanchor(maps, start, step); + if (!anchor){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "walk_overworld_route(): stopped part-way on " + to_string(step) + ", with no wall along it to find the player again.", + console + ); + } + std::optional> rest = plan_overworld_route(maps, anchor->end, goal, default_route_costs(), anchor->direction); + if (!rest){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "walk_overworld_route(): no route from " + to_string(anchor->end) + " to " + to_string(goal.position) + ".", + console + ); + } + console.log("Route: stopped part-way, finding the player again: " + to_string(*anchor), COLOR_ORANGE); + rest->insert(rest->begin(), *anchor); + return std::move(*rest); } } @@ -388,29 +463,47 @@ void walk_overworld_route( ConsoleHandle& console, ProControllerContext& context, const std::vector& route, std::optional facing, - const OverworldInterruptSet& interrupts + const OverworldInterruptSet& interrupts, + const std::vector* maps ){ - // Each leg starts with a turn tap unless it goes the way the player faces. - // A tap while already facing that way takes a step, so a facing not - // given is read from the screen. If that fails too it taps anyway. + if (route.empty()){ + return; + } if (!facing){ - context.wait_for_all_requests(); - VideoSnapshot screen = console.video().snapshot(); - if (screen){ - facing = read_player_facing(screen); - } - console.log(std::string("Route: the player faces ") + (facing ? Pokemon::direction_name(*facing) : "an unknown way") + "."); + facing = read_facing(console, context); } - for (const RouteStep& step : route){ + std::vector loaded; + std::vector plan = route; + std::optional at; + size_t reanchors = 0; + for (size_t i = 0; i < plan.size();){ + const RouteStep step = plan[i]; console.log("Route: " + to_string(step)); check_overworld(console, context, step); - walk_step(console, context, step, facing, interrupts); - if (step.action != RouteAction::FACE){ - // An open leg through grass has already waited on its last step. - const Milliseconds watch = step.wild && step.to_wall ? AFTER_WILD_LEG_WATCH : AFTER_LEG_WATCH; - Pokemon::clear_late_interrupts(console, context, step.wild ? interrupts.wild : interrupts.other, watch, MAX_INTERRUPTIONS_PER_LEG); + if (walk_and_watch(console, context, step, facing, interrupts)){ + at = step.end; + facing = step.facing_after; + i++; + continue; + } + facing = step.direction; + if (++reanchors > MAX_REANCHORS){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + std::format("walk_overworld_route(): stopped part-way {} times.", reanchors), + console + ); + } + if (step.to_wall){ + // Walking it again ends at its wall. + continue; + } + if (maps == nullptr){ + loaded = overworld_maps(); + maps = &loaded; } - facing = step.facing_after; + plan = reanchor_route(console, *maps, at, step, route_goal(route)); + i = 0; } context.wait_for_all_requests(); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index 3b40bc2994..d75a4b55ff 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -62,16 +62,24 @@ void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& contex // Walks `route` from where it was planned. The player must be standing still // on the overworld, not running and not on the bike. `facing` is the way the -// player faces at the start; if not given, it is read from the screen. Throws OperationFailedException if a -// menu or message is open before a step, a warp's fade or the Surf prompt -// doesn't show, a warp's fade turns out to be a wild battle's, or a leg is -// interrupted too many times. A wild battle that starts as the last leg +// player faces at the start; if not given, it is read from the screen. +// +// When a battle or a message stops an open leg part-way, where the player +// is isn't guessed: they hold on to a wall along that leg (plan_reanchor()), +// and a new route to the same end is planned from there on `maps`, or on +// overworld_maps() if not given. +// +// Throws OperationFailedException if a menu or message is open before a +// step, a warp's fade or the Surf prompt doesn't show, a warp's fade turns +// out to be a wild battle's, a leg is interrupted too many times, or the +// player can't be found again. A wild battle that starts as the last leg // ends is cleared before it returns. void walk_overworld_route( ConsoleHandle& console, ProControllerContext& context, const std::vector& route, std::optional facing = std::nullopt, - const OverworldInterruptSet& interrupts = default_overworld_interrupts() + const OverworldInterruptSet& interrupts = default_overworld_interrupts(), + const std::vector* maps = nullptr ); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 0400b3f051..dca7881413 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -289,6 +289,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_ExitWildBattle(database); add_tests_OverworldMaps(database); add_tests_OverworldRoute(database); + add_tests_OverworldReanchor(database); add_tests_PlayerFacingReader(database); } diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 787541e0e8..e980fad9d6 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1757,6 +1757,7 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp From 59538a4f500de6ecca2249ede06b03d7e8a75d8b Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 07:23:44 -0800 Subject: [PATCH 11/25] FRLG: find the player again without trusting the side they're on Run 16 on console B (2026-11-01, Viridian Forest): "west 6" from (11, 33) walked 2 tiles, unseen, and the next leg, "north 24" toward the exit, went up column 9, not 5. A battle stopped it; finding the player again held south down column 5 to the wall at (5, 33), but from column 9 that hold walks into the TRAINER TIPS sign at (9, 29). Its message stopped the hold six times and the route failed. - Finding the player again no longer assumes the column (or row) of the stopped leg. It takes every tile they may be on: along the leg, and up to 4 tiles to either side. Then it looks for one, two or three holds to walls that end on the same tile from all of them, with the fewest tiles held. A hold that would meet a sign, a ledge, a warp or a person from any of them is never used. If no holds work for that many tiles, it tries fewer to the side. This moves from the route planner to its own file, PokemonFRLG_OverworldReanchor; the planner keeps hold_to_wall() and leg_tiles() for it. - A leg to a wall that is interrupted again right away when held again (within 3 tiles) stops instead of holding six times: something on the way, a sign, interrupts every hold. The player is then beside a sign: if only one sign is next to a tile they may be on, that's where they are; otherwise the holds above find them. Why "west 6" fell short: not its timing. Its hold, 1468ms, is right for 6 tiles and the same as the 6-tile legs on Route 1 that ended where planned, after the same 40ms turn tap. A battle seen 353ms into the next leg had started about 3s before, during "west 6", which the map has on floor tiles and so watched only for messages. So the forest's grid may miss encounter tiles there (rows 28-33); that needs a look at the screen. The holds above cover it either way. Tests: OverworldReanchor (rules on small maps; the run 16 case on the real Viridian Forest map, from every column 3 to 9; Viridian Mart to Oak's Lab and Pallet Town to Viridian Mart), and LegWalker (a leg to a wall blocked by a sign stops). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Pokemon/Navigation/Pokemon_LegWalker.cpp | 6 + .../Pokemon/Navigation/Pokemon_LegWalker.h | 4 + .../Navigation/Pokemon_LegWalker_Tests.cpp | 15 ++ .../PokemonFRLG_OverworldReanchor.cpp | 206 ++++++++++++++++++ .../Overworld/PokemonFRLG_OverworldReanchor.h | 55 +++++ .../PokemonFRLG_OverworldReanchor_Tests.cpp | 103 +++++++-- .../Overworld/PokemonFRLG_OverworldRoute.cpp | 111 +++------- .../Overworld/PokemonFRLG_OverworldRoute.h | 30 ++- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 47 ++-- .../PokemonFRLG/PokemonFRLG_Tests.cpp | 1 + SerialPrograms/cmake/SourceFiles.cmake | 2 + 11 files changed, 456 insertions(+), 124 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp index cdb06088d6..3c98714af9 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp @@ -126,6 +126,12 @@ LegResult walk_leg_with(const LegSpec& leg, const LegCalls& calls, size_t max_in result.stopped = true; return result; } + if (leg.to_wall && leg.stop_if_blocked && result.interruptions > 1 && outcome->elapsed < leg.tile_time * 3){ + calls.log(seen + ", again at once: something on the way stops every hold."); + result.exact = false; + result.stopped = true; + return result; + } if (leg.to_wall){ // The wall stops the player on the leg's last tile however far // they got, so the leg is simply held again. diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h index d43a74c287..62b88e3040 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h @@ -97,6 +97,10 @@ struct LegSpec{ // An open leg stops at its first interrupt, once cleared, instead of // walking on from an estimate; the caller finds the player again. bool stop_on_interrupt = false; + // A leg to a wall stops when, held again after an interrupt, it is + // interrupted again right away: something on the way, like a sign, + // interrupts every hold, so the wall is never reached. + bool stop_if_blocked = false; }; std::string to_string(const LegSpec& leg); diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp index 2a38c666ed..61adf84f47 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp @@ -326,6 +326,20 @@ UnitTestResult test_open_leg_stops(){ return true; } +// Console B, 2026-11-01, run 16: held south into a sign, the leg to a wall +// was interrupted by its message 600ms into every hold, six times. +UnitTestResult test_wall_leg_blocked(){ + ScriptedLeg script; + script.outcomes = {HoldOutcome{0, 2200ms}, HoldOutcome{0, 600ms}, HoldOutcome{0, 600ms}}; + LegSpec leg{Direction::SOUTH, 23, true, TILE, NO_WINDOW}; + leg.stop_if_blocked = true; + const LegResult result = walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 2 || !result.stopped){ + return std::format("Expected the leg stopped after 2 holds, got {}.", script.holds.size()); + } + return true; +} + // A leg to a wall is still held through, window or not. UnitTestResult test_wall_leg_held_through(){ ScriptedLeg script; @@ -370,6 +384,7 @@ void add_tests_LegWalker(UnitTestDatabase& database){ database.add("tile by tile: an interrupt slower than the window", test_tile_by_tile_slow_interrupt); database.add("a wall leg is held through", test_wall_leg_held_through); database.add("an open leg stops at an interrupt", test_open_leg_stops); + database.add("a wall leg blocked by a sign stops", test_wall_leg_blocked); database.add("tile by tile: the step onto a warp isn't watched", test_tile_by_tile_warp_not_watched); database.add("tile by tile: the last step settles", test_tile_by_tile_settle_last_step); database.add("tile by tile: a late battle before the last step", test_tile_by_tile_settle_earlier_step); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp new file mode 100644 index 0000000000..50d6e944e0 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -0,0 +1,206 @@ +/* Overworld Re-anchor + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "PokemonFRLG_OverworldReanchor.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// How far to the side of a leg the player may be: an earlier open leg can +// leave them tiles off (console B, 2026-11-01, Viridian Forest: "west 6" +// walked 2 of its 6 tiles, unseen, and the next leg went up a column 4 +// tiles over). +constexpr int MAX_SPREAD = 4; +constexpr size_t MAX_HOLDS = 3; + +Direction opposite(Direction direction){ + return (Direction)(((int)direction + 2) % 4); +} +Direction across(Direction direction){ + return (Direction)(((int)direction + 1) % 4); +} + +const MapData* map_named(const std::vector& maps, const std::string& name){ + for (const MapData& map : maps){ + if (map.name == name){ + return ↦ + } + } + return nullptr; +} + +// Whether the player could stand on `position`. +bool standable(const std::vector& maps, const MapPosition& position, bool surfing){ + const MapData* map = map_named(maps, position.map); + return map != nullptr && in_bounds(*map, position.point) + && !is_wall(*map, position.point) && object_at(*map, position.point) == nullptr + && is_water(*map, position.point) == surfing; +} + +void add_unique(std::vector& positions, const MapPosition& position){ + if (std::find(positions.begin(), positions.end(), position) == positions.end()){ + positions.push_back(position); + } +} + +// Where `step` started: `tiles` back from its end, on the same map. +std::optional walk_back(const std::vector& maps, const RouteStep& step){ + MapPosition position = step.end; + for (int c = 0; c < step.tiles; c++){ + position.point = position.point.next(opposite(step.direction)); + if (!standable(maps, position, step.surfing)){ + return std::nullopt; + } + } + return position; +} + +// Every tile the player may be on: along the leg, and up to `spread` to +// either side of it. +std::vector candidates( + const std::vector& maps, const MapPosition& start, const RouteStep& step, int spread +){ + std::vector positions; + for (const MapPosition& tile : leg_tiles(maps, start, step)){ + for (int side = -spread; side <= spread; side++){ + MapPosition position = tile; + for (int c = 0; c < std::abs(side); c++){ + position.point = position.point.next(side < 0 ? opposite(across(step.direction)) : across(step.direction)); + } + if (standable(maps, position, step.surfing)){ + add_unique(positions, position); + } + } + } + return positions; +} + +// Holding `direction` from each of `positions`: where they end up, as a leg +// to a wall as long as the longest of them. Nothing if any isn't stopped +// by a wall. +std::optional, RouteStep>> hold_all( + const std::vector& maps, const std::vector& positions, + bool surfing, Direction direction +){ + std::vector ends; + RouteStep hold; + hold.direction = direction; + hold.surfing = surfing; + hold.to_wall = true; + hold.facing_after = direction; + for (const MapPosition& position : positions){ + const std::optional wall = hold_to_wall(maps, position, surfing, direction); + if (!wall){ + return std::nullopt; + } + add_unique(ends, wall->end); + hold.tiles = std::max(hold.tiles, wall->tiles); + hold.wild = hold.wild || wall->wild; + hold.end = wall->end; + } + return std::make_pair(std::move(ends), hold); +} + +int total_tiles(const std::vector& legs){ + int total = 0; + for (const RouteStep& leg : legs){ + total += leg.tiles; + } + return total; +} + +// Up to `depth` holds that bring all of `positions` to one tile; the one +// with the fewest tiles held. +void search( + const std::vector& maps, const std::vector& positions, bool surfing, + size_t depth, std::vector& holds, std::optional& best +){ + for (Direction direction : Pokemon::ALL_DIRECTIONS){ + auto next = hold_all(maps, positions, surfing, direction); + if (!next || next->first == positions || next->second.tiles == 0){ + continue; + } + holds.push_back(next->second); + if (next->first.size() == 1){ + if (!best || total_tiles(holds) < total_tiles(best->holds)){ + best = Reanchor{holds, next->first[0]}; + } + }else if (depth > 1){ + search(maps, next->first, surfing, depth - 1, holds, best); + } + holds.pop_back(); + } +} + +// Held toward a sign moving north or south, the player stops beside it and +// it speaks: if only one of `positions` is beside one, that's where they are. +std::optional beside_sign( + const std::vector& maps, const std::vector& positions, Direction direction +){ + if (direction != Direction::NORTH && direction != Direction::SOUTH){ + return std::nullopt; + } + std::optional found; + for (const MapPosition& position : positions){ + const MapData* map = map_named(maps, position.map); + if (map != nullptr && tile_at(*map, position.point.next(direction)) == Tile::SIGN){ + if (found){ + return std::nullopt; + } + found = position; + } + } + return found; +} + +} + + +std::optional plan_reanchor( + const std::vector& maps, + const std::optional& start, + const RouteStep& step, + bool at_sign +){ + if (step.action != RouteAction::WALK){ + return std::nullopt; + } + // A leg to a wall may have been held from anywhere along its line. + const std::optional from = start && !step.to_wall ? start : walk_back(maps, step); + if (!from){ + return std::nullopt; + } + for (int spread = MAX_SPREAD; spread >= 0; spread--){ + const std::vector positions = candidates(maps, *from, step, spread); + // Only with every tile counted: a sign beside one of fewer could be + // the wrong sign. + if (at_sign && spread == MAX_SPREAD){ + if (const std::optional beside = beside_sign(maps, positions, step.direction)){ + return Reanchor{{}, *beside}; + } + } + for (size_t depth = 1; depth <= MAX_HOLDS; depth++){ + std::vector holds; + std::optional best; + search(maps, positions, step.surfing, depth, holds, best); + if (best){ + return best; + } + } + } + return std::nullopt; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h new file mode 100644 index 0000000000..6c7bf2a3c6 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -0,0 +1,55 @@ +/* Overworld Re-anchor + * + * From: https://github.com/PokemonAutomation/ + * + * Finding the player again after a battle or a message stopped a leg + * part-way. How far they got can't be timed reliably, and earlier open + * legs may have left them a tile or two off to the side too. So this + * takes every tile they may be on, near the leg, and looks for holds to + * walls, one to three, that end on the same tile from all of them. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldReanchor_H +#define PokemonAutomation_PokemonFRLG_OverworldReanchor_H + +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_OverworldMap.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +struct Reanchor{ + // Legs to walls, in order. Empty when the stop itself told where the + // player is. Each but the last ends on a tile that depends on where + // the player was; its `end` is one of them. + std::vector holds; + // Where the player is after them. + MapPosition end; +}; + +// After `step` stopped part-way. `start` is where it started, if known; +// else it is worked back from its end. `at_sign`: `step`, held to a wall, +// was stopped again and again by a message right away, as by a sign it +// walked into. Nothing if no holds find the player. +std::optional plan_reanchor( + const std::vector& maps, + const std::optional& start, + const RouteStep& step, + bool at_sign = false +); + + +void add_tests_OverworldReanchor(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp index a3c4d1b8d8..3196a05f4c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp @@ -14,7 +14,7 @@ #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTest.h" #include "PokemonFRLG_OverworldMaps.h" -#include "PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_OverworldReanchor.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -52,9 +52,9 @@ std::string describe(const std::vector& route){ return text; } -// Plans from `start` to `goal`, then checks that every leg through tall -// grass that ends in the open can be re-found at a wall if a battle stops -// it, and that a route on to the goal can be planned from there. +// Plans from `start` to `goal`, then checks that the player can be found +// again after any open leg through tall grass is stopped, and that a route +// on to the goal can be planned from there. UnitTestResult check_reanchors(const std::vector& maps, const MapPosition& start, const RouteGoal& goal){ const std::optional> route = plan_overworld_route(maps, start, goal); if (!route){ @@ -63,13 +63,11 @@ UnitTestResult check_reanchors(const std::vector& maps, const MapPositi std::optional at = start; for (const RouteStep& step : *route){ if (step.action == RouteAction::WALK && step.wild && !step.to_wall){ - const std::optional anchor = plan_reanchor(maps, at, step); - if (!anchor || !anchor->to_wall){ - return "No wall to re-find the player on " + to_string(step) + ":" + describe(*route); + const std::optional anchor = plan_reanchor(maps, at, step); + if (!anchor){ + return "Can't find the player again on " + to_string(step) + ":" + describe(*route); } - const std::optional> rest = plan_overworld_route( - maps, anchor->end, goal, default_route_costs(), anchor->direction - ); + const std::optional> rest = plan_overworld_route(maps, anchor->end, goal); if (!rest || !(follow_overworld_route(maps, anchor->end, *rest) == goal.position)){ return "No route on from " + to_string(anchor->end) + ", after " + to_string(step) + "."; } @@ -96,6 +94,15 @@ UnitTestResult test_pallet_to_viridian_mart(){ ); } +// A room with a sign in the middle of its floor. +const char* const TEST_SIGNS_JSON = R"({ + "name": "Signs", "width": 8, "height": 7, + "tiles": ["########", "#......#", "#......#", "#....S.#", "#......#", "#......#", "########"], + "elevation": ["33333333", "33333333", "33333333", "33333333", "33333333", "33333333", "33333333"], + "warps": [], "objects": [], + "connections": [], "points": [], "flag_sets": [] +})"; + // A field with a wall at its east end and a row of ledges to the south. const char* const TEST_FIELD_JSON = R"({ "name": "Field", "width": 8, "height": 5, @@ -105,30 +112,78 @@ const char* const TEST_FIELD_JSON = R"({ "connections": [], "points": [], "flag_sets": [] })"; -std::string describe(const std::optional& anchor){ - return anchor ? to_string(*anchor) : std::string("none"); +std::string describe(const std::optional& anchor){ + if (!anchor){ + return "none"; + } + std::string text = to_string(anchor->end) + " after"; + for (const RouteStep& hold : anchor->holds){ + text += " " + to_string(hold); + } + return text; } -// Stopped part-way east, the player holds on east to the wall. Stopped -// part-way south above the ledges, which would be hopped, they hold back -// north instead. +// Stopped part-way east, the player may also be a row off, so one hold +// doesn't find them: two do, the shortest being north then back west. UnitTestResult test_rules(){ const std::vector maps{parse_map(TEST_FIELD_JSON, "Field")}; RouteStep east; east.direction = Direction::EAST; east.tiles = 3; east.end = {"Field", {4, 1}}; - std::optional anchor = plan_reanchor(maps, MapPosition{"Field", {1, 1}}, east); - if (!anchor || anchor->direction != Direction::EAST || anchor->tiles != 5 || !(anchor->end.point == TilePos{6, 1})){ - return "Expected east 5 to the wall at (6, 1), got " + describe(anchor); + const std::optional anchor = plan_reanchor(maps, MapPosition{"Field", {1, 1}}, east); + if (!anchor || anchor->holds.size() != 2 || !(anchor->end.point == TilePos{1, 1})){ + return "Expected two holds to (1, 1), got " + describe(anchor); } + return true; +} + +// Console B, 2026-11-01, run 16: "west 6" walked 2 tiles unseen, the next +// leg went north up column 9 instead of 5, and finding the player again by +// holding south down column 5 walked into the TRAINER TIPS sign at (9, 29) +// six times. Every tile the player might be on now ends at one place, +// and none of the holds walks into a sign. +UnitTestResult test_forest_exit_columns(){ + const std::vector maps = overworld_maps(); + RouteStep north; + north.direction = Direction::NORTH; + north.tiles = 24; + north.warps = true; + north.wild = true; + north.end = {"Route2_ViridianForest_NorthEntrance", {7, 10}}; + const std::optional anchor = plan_reanchor(maps, MapPosition{"ViridianForest", {5, 33}}, north); + if (!anchor || anchor->holds.empty()){ + return "Expected holds to find the player, got " + describe(anchor); + } + for (int x = 3; x <= 9; x++){ + MapPosition at{"ViridianForest", {x, 28}}; + for (const RouteStep& hold : anchor->holds){ + const std::optional wall = hold_to_wall(maps, at, false, hold.direction); + if (!wall){ + return "From " + to_string(MapPosition{"ViridianForest", {x, 28}}) + " a hold isn't stopped by a wall: " + describe(anchor); + } + at = wall->end; + } + if (!(at == anchor->end)){ + return "From column " + std::to_string(x) + " the holds end at " + to_string(at) + ": " + describe(anchor); + } + } + return true; +} + +// The same run: held south toward the wall at (5, 33), a sign spoke at +// once, again and again. Beside a sign on the leg's line, the player is +// found by it, if it is the only one near enough. +UnitTestResult test_found_by_sign(){ + const std::vector maps{parse_map(TEST_SIGNS_JSON, "Signs")}; RouteStep south; south.direction = Direction::SOUTH; - south.tiles = 1; - south.end = {"Field", {3, 2}}; - anchor = plan_reanchor(maps, std::nullopt, south); - if (!anchor || anchor->direction != Direction::NORTH || anchor->tiles != 1 || !(anchor->end.point == TilePos{3, 1})){ - return "Expected north 1 back to the wall at (3, 1), got " + describe(anchor); + south.tiles = 4; + south.to_wall = true; + south.end = {"Signs", {1, 5}}; + const std::optional anchor = plan_reanchor(maps, std::nullopt, south, true); + if (!anchor || !anchor->holds.empty() || !(anchor->end.point == TilePos{5, 2})){ + return "Expected to be found beside the sign at (5, 3), got " + describe(anchor); } return true; } @@ -140,6 +195,8 @@ void add_tests_OverworldReanchor(UnitTestDatabase& database){ database.add("rules", test_rules); database.add("Viridian Mart to Oak's Lab", test_viridian_mart_to_oaks_lab); database.add("Pallet Town to Viridian Mart", test_pallet_to_viridian_mart); + database.add("Viridian Forest exit, any column", test_forest_exit_columns); + database.add("found by a sign", test_found_by_sign); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 16420f392f..c5304dc8f3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -380,11 +380,7 @@ std::optional> plan_overworld_route( namespace{ // Further than any straight line on a map. -constexpr int MAX_ANCHOR_TILES = 128; - -Direction opposite(Direction direction){ - return (Direction)(((int)direction + 2) % 4); -} +constexpr int MAX_HOLD_TILES = 128; std::optional locate(const std::vector& maps, const MapPosition& position, bool surfing){ const int map = find_map(maps, position.map); @@ -397,91 +393,54 @@ std::optional locate(const std::vector& maps, const MapPositio return player; } -// Presses `direction` from `player` until holding it keeps them on their -// tile, and returns how many steps that took, or nothing if one on the way -// is anything but a plain walk (a warp, Surf, a ledge, a person). -std::optional presses_to_stop(const std::vector& maps, Located& player, Direction direction){ - for (int c = 0; c <= MAX_ANCHOR_TILES; c++){ - if (stops_at(maps, player, direction)){ - return c; - } - const std::optional transition = press(maps, player, direction); - if (!transition || transition->warped || transition->dismounted || transition->action != RouteAction::WALK){ - return std::nullopt; - } - player = transition->next; - } - return std::nullopt; -} - -// Where `step` started: `tiles` presses back from its end. -std::optional walk_back(const std::vector& maps, const RouteStep& step){ - std::optional player = locate(maps, step.end, step.surfing); - for (int c = 0; player && c < step.tiles; c++){ - const std::optional transition = press(maps, *player, opposite(step.direction)); - if (!transition || transition->warped || transition->action != RouteAction::WALK){ - return std::nullopt; - } - player = transition->next; +// A plain step: no warp, no Surf, no getting off the water. +std::optional plain_step(const std::vector& maps, const Located& player, Direction direction){ + const std::optional transition = press(maps, player, direction); + if (!transition || transition->warped || transition->dismounted || transition->action != RouteAction::WALK){ + return std::nullopt; } - return player; -} - -RouteStep anchor_step(const std::vector& maps, const RouteStep& step, Direction direction, int tiles, const Located& end){ - RouteStep anchor; - anchor.direction = direction; - anchor.tiles = tiles; - anchor.surfing = step.surfing; - anchor.to_wall = true; - anchor.wild = step.wild; - anchor.facing_after = direction; - anchor.end = MapPosition{maps[end.map].name, end.state.position}; - return anchor; + return transition->next; } } -std::optional plan_reanchor( +std::optional hold_to_wall( const std::vector& maps, - const std::optional& start, - const RouteStep& step + const MapPosition& from, bool surfing, Direction direction ){ - if (step.action != RouteAction::WALK || step.to_wall){ - return std::nullopt; - } - std::optional from = start ? locate(maps, *start, step.surfing) : std::nullopt; - if (!start && !step.warps){ - from = walk_back(maps, step); - } - if (!from){ - return std::nullopt; - } - // The tiles the player may be on, short of a warp at the end. - const int along = step.tiles - (step.warps ? 1 : 0); - Located last = *from; - for (int c = 0; c < along; c++){ - const std::optional transition = press(maps, last, step.direction); - if (!transition || transition->warped || transition->action != RouteAction::WALK){ - return std::nullopt; + std::optional player = locate(maps, from, surfing); + WallHold hold; + for (int c = 0; player && c <= MAX_HOLD_TILES; c++){ + if (stops_at(maps, *player, direction)){ + hold.end = MapPosition{maps[player->map].name, player->state.position}; + hold.tiles = c; + return hold; } - last = transition->next; - } - // On to the next wall ahead, or else back past the start to the one - // behind: from any of those tiles the hold ends there. - Located ahead = last; - if (!step.warps){ - if (const std::optional more = presses_to_stop(maps, ahead, step.direction)){ - return anchor_step(maps, step, step.direction, along + *more, ahead); + player = plain_step(maps, *player, direction); + if (player){ + const MapData& map = maps[player->map]; + hold.wild = hold.wild || is_tall_grass(map, player->state.position) || player->state.surfing; } } - Located behind = *from; - if (const std::optional more = presses_to_stop(maps, behind, opposite(step.direction))){ - return anchor_step(maps, step, opposite(step.direction), along + *more, behind); - } return std::nullopt; } +std::vector leg_tiles( + const std::vector& maps, + const MapPosition& start, const RouteStep& step +){ + std::vector tiles{start}; + std::optional player = locate(maps, start, step.surfing); + const int along = step.tiles - (step.warps ? 1 : 0); + for (int c = 0; player && c < along; c++){ + player = plain_step(maps, *player, step.direction); + if (player){ + tiles.push_back(MapPosition{maps[player->map].name, player->state.position}); + } + } + return tiles; +} MapPosition follow_overworld_route( const std::vector& maps, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index f327bc6aa3..ce5d6f872b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -109,16 +109,27 @@ std::optional> plan_overworld_route( std::optional start_facing = std::nullopt ); -// After `step`, an open walk, stopped part-way (a battle, a message): a leg -// to a wall that ends on the same tile from anywhere the player may be -// along it, ahead or back, so where they are is known again. `start` is -// where `step` started, if known; else it is worked back from its end. -// Nothing if no such wall lies along it: a warp, a ledge, a person or the -// edge of the loaded maps is in the way. -std::optional plan_reanchor( +// Holding `direction` from `from`, on the water if `surfing`: where a wall +// stops the player, how many tiles on, and whether any tile on the way is +// tall grass or water. +struct WallHold{ + MapPosition end; + int tiles = 0; + bool wild = false; +}; +// Nothing unless every step on the way is a plain walk: a warp, Surf, a +// ledge, a person, a sign that would speak, or the edge of the loaded maps +// comes first. +std::optional hold_to_wall( + const std::vector& maps, + const MapPosition& from, bool surfing, Direction direction +); + +// The tiles a WALK `step` from `start` passes through, `start` first, short +// of a warp at its end. +std::vector leg_tiles( const std::vector& maps, - const std::optional& start, - const RouteStep& step + const MapPosition& start, const RouteStep& step ); // Replays `route` from `start` with the game's step rules and returns where @@ -131,7 +142,6 @@ MapPosition follow_overworld_route( void add_tests_OverworldRoute(UnitTestDatabase& database); -void add_tests_OverworldReanchor(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 44d12de076..2bdb40bfa1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -17,6 +17,7 @@ #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattlePokemonDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldReanchor.h" #include "PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_OverworldWalk.h" @@ -206,6 +207,7 @@ Pokemon::LegSpec leg_spec(const RouteStep& step){ leg.tile_time = step.surfing ? SURF_TILE_TIME : TILE_TIME; leg.warps = step.warps; leg.stop_on_interrupt = !step.to_wall; + leg.stop_if_blocked = step.to_wall; return leg; } @@ -337,20 +339,27 @@ bool walk_step( // if the player may have stopped short of the step's end: an open leg // stopped, or something seen after the hold, which may have stopped it // before the end (after a warp, it was checked for already). -bool walk_and_watch( +enum class StepEnd{ + DONE, + STOPPED, // walk_step() returned false + LATE, // something was seen after the hold, which may have + // stopped the player before the end +}; +StepEnd walk_and_watch( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, const OverworldInterruptSet& interrupts ){ if (!walk_step(console, context, step, facing, interrupts)){ - return false; + return StepEnd::STOPPED; } if (step.action == RouteAction::FACE){ - return true; + return StepEnd::DONE; } const Milliseconds watch = step.wild ? AFTER_WILD_LEG_WATCH : AFTER_LEG_WATCH; const size_t late = Pokemon::clear_late_interrupts(console, context, step.wild ? interrupts.wild : interrupts.other, watch, MAX_INTERRUPTIONS_PER_LEG); - return late == 0 || step.warps || step.action != RouteAction::WALK; + // After a warp it was checked for already. + return late == 0 || step.warps || step.action != RouteAction::WALK ? StepEnd::DONE : StepEnd::LATE; } // Each leg starts with a turn tap unless it goes the way the player faces. @@ -377,23 +386,26 @@ RouteGoal route_goal(const std::vector& route){ return goal; } -// After `step`, from `start`, stopped part-way: a leg to a wall that puts -// the player on a known tile, then a new route from there to `goal`. +// After `step`, from `start`, stopped part-way: holds to walls that put the +// player on a known tile, then a new route from there to `goal`. A leg to a +// wall only stops when interrupted at once, hold after hold: by a sign it +// walked into, so `at_sign`. std::vector reanchor_route( ConsoleHandle& console, const std::vector& maps, - const std::optional& start, const RouteStep& step, + const std::optional& start, const RouteStep& step, bool at_sign, const RouteGoal& goal ){ - const std::optional anchor = plan_reanchor(maps, start, step); + const std::optional anchor = plan_reanchor(maps, start, step, at_sign); if (!anchor){ OperationFailedExceptionWithScreenshot::fire( ErrorReportMode::SEND_ERROR_REPORT, - "walk_overworld_route(): stopped part-way on " + to_string(step) + ", with no wall along it to find the player again.", + "walk_overworld_route(): stopped part-way on " + to_string(step) + ", with no walls near it to find the player again.", console ); } - std::optional> rest = plan_overworld_route(maps, anchor->end, goal, default_route_costs(), anchor->direction); + const Direction facing = anchor->holds.empty() ? step.direction : anchor->holds.back().direction; + std::optional> rest = plan_overworld_route(maps, anchor->end, goal, default_route_costs(), facing); if (!rest){ OperationFailedExceptionWithScreenshot::fire( ErrorReportMode::SEND_ERROR_REPORT, @@ -401,8 +413,12 @@ std::vector reanchor_route( console ); } - console.log("Route: stopped part-way, finding the player again: " + to_string(*anchor), COLOR_ORANGE); - rest->insert(rest->begin(), *anchor); + std::string holds; + for (const RouteStep& hold : anchor->holds){ + holds += "; " + to_string(hold); + } + console.log("Route: stopped part-way, finding the player again at " + to_string(anchor->end) + holds, COLOR_ORANGE); + rest->insert(rest->begin(), anchor->holds.begin(), anchor->holds.end()); return std::move(*rest); } @@ -480,7 +496,8 @@ void walk_overworld_route( const RouteStep step = plan[i]; console.log("Route: " + to_string(step)); check_overworld(console, context, step); - if (walk_and_watch(console, context, step, facing, interrupts)){ + const StepEnd end = walk_and_watch(console, context, step, facing, interrupts); + if (end == StepEnd::DONE){ at = step.end; facing = step.facing_after; i++; @@ -494,7 +511,7 @@ void walk_overworld_route( console ); } - if (step.to_wall){ + if (step.to_wall && end == StepEnd::LATE){ // Walking it again ends at its wall. continue; } @@ -502,7 +519,7 @@ void walk_overworld_route( loaded = overworld_maps(); maps = &loaded; } - plan = reanchor_route(console, *maps, at, step, route_goal(route)); + plan = reanchor_route(console, *maps, at, step, step.to_wall, route_goal(route)); i = 0; } context.wait_for_all_requests(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index dca7881413..1ef0e88752 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -26,6 +26,7 @@ #include "Inference/PokemonFRLG_TrainerIdReader.h" #include "Inference/PokemonFRLG_WildEncounterReader.h" #include "Overworld/PokemonFRLG_OverworldMaps.h" +#include "Overworld/PokemonFRLG_OverworldReanchor.h" #include "Overworld/PokemonFRLG_OverworldRoute.h" #include "Overworld/PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_Navigation.h" diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index e980fad9d6..9bd2de51ba 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1757,6 +1757,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h From c7a9fcbfb52ad521d3cd05ab2708691172480464 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 09:07:54 -0800 Subject: [PATCH 12/25] FRLG: after a trainer stops a leg to a warp, hold on to the warp Console B, 2026-11-01, checkpoint 00.08: Camper Liam stopped "south 9 (warp)" out of Pewter Gym, the story fought him, and finding the player again failed: the gym's trainers block every hold to a wall. Off the grass only a message stops a leg (a trainer who saw the player, a Repel wearing off), and the player stands on the leg's line, so on a leg that ends at a warp the walker now just holds on toward it, turning first (the trainer turned the player to face them), until the warp's fade. The fade ends the hold, so it doesn't matter how far the player had got. Not counted from the hold time: the trainer's approach delays the message by a second or more. Open legs that end in the open, and legs through grass, still find the player again at walls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 73 +++++++++++++++++-- .../Overworld/PokemonFRLG_OverworldWalk.h | 5 +- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 2bdb40bfa1..1054281e15 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -362,6 +362,62 @@ StepEnd walk_and_watch( return late == 0 || step.warps || step.action != RouteAction::WALK ? StepEnd::DONE : StepEnd::LATE; } +// Holds on along `step`, stopped part-way off the grass, until its warp's +// fade. Off the grass only a message stops a leg (a trainer who saw the +// player, a Repel wearing off), and the player then stands on the leg's +// line, so holding on reaches the warp however far they got (console B, +// 2026-11-01: Camper Liam stopped "south 9 (warp)" out of Pewter Gym). +// After a trainer the player faces them, so this turns first. +void hold_to_warp( + ConsoleHandle& console, ProControllerContext& context, + const RouteStep& step, const OverworldInterrupts& interrupts +){ + for (size_t count = 0; count <= MAX_INTERRUPTIONS_PER_LEG; count++){ + BlackScreenWatcher fade(COLOR_RED); + std::vector> watchers; + std::vector callbacks{fade}; + for (const auto& interrupt : interrupts){ + watchers.emplace_back(interrupt->make_watcher()); + callbacks.emplace_back(*watchers.back()); + } + context.wait_for_all_requests(); + const int ret = run_until( + console, context, + [&](ProControllerContext& context){ + pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TURN_SETTLE); + pbf_press_dpad(context, dpad(step.direction), TILE_TIME * (step.tiles + 2), STEP_RELEASE); + }, + callbacks + ); + if (ret == 0){ + wait_out_warp(console, context, step); + check_warp_was_no_battle(console, context, step); + return; + } + if (ret < 0){ + break; + } + console.log("Route: " + interrupts[ret - 1]->name() + " on the way to the warp.", COLOR_ORANGE); + interrupts[ret - 1]->clear(console, context); + } + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "walk_overworld_route(): no fade holding on toward the warp to " + step.end.map + ".", + console + ); +} + +// How many times a route has stopped part-way; throws past MAX_REANCHORS. +void count_stop(ConsoleHandle& console, size_t& stops){ + if (++stops > MAX_REANCHORS){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + std::format("walk_overworld_route(): stopped part-way {} times.", stops), + console + ); + } +} + // Each leg starts with a turn tap unless it goes the way the player faces. // A tap while already facing that way takes a step, so a facing not given // is read from the screen. If that fails too it taps anyway. @@ -503,18 +559,19 @@ void walk_overworld_route( i++; continue; } - facing = step.direction; - if (++reanchors > MAX_REANCHORS){ - OperationFailedExceptionWithScreenshot::fire( - ErrorReportMode::SEND_ERROR_REPORT, - std::format("walk_overworld_route(): stopped part-way {} times.", reanchors), - console - ); - } + facing = std::nullopt; // a trainer turns the player to face them + count_stop(console, reanchors); if (step.to_wall && end == StepEnd::LATE){ // Walking it again ends at its wall. continue; } + if (end == StepEnd::STOPPED && step.warps && !step.wild){ + hold_to_warp(console, context, step, interrupts.other); + at = step.end; + facing = step.facing_after; + i++; + continue; + } if (maps == nullptr){ loaded = overworld_maps(); maps = &loaded; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index d75a4b55ff..138b029e60 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -65,8 +65,9 @@ void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& contex // player faces at the start; if not given, it is read from the screen. // // When a battle or a message stops an open leg part-way, where the player -// is isn't guessed: they hold on to a wall along that leg (plan_reanchor()), -// and a new route to the same end is planned from there on `maps`, or on +// is isn't guessed. Off the grass, on a leg to a warp, they hold on to the +// warp. Otherwise they hold to walls that find them (plan_reanchor()), and +// a new route to the same end is planned from there on `maps`, or on // overworld_maps() if not given. // // Throws OperationFailedException if a menu or message is open before a From 5266c44fe9058e5c547b593b60c5b36c66b0dfbd Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 09:31:45 -0800 Subject: [PATCH 13/25] FRLG: run the re-anchor and ledge route tests on the compiled-in maps The re-anchor tests and the ledge route test planned on overworld_maps(), so CI, which has no Packages maps, skipped them. They now use test_overworld_maps() (#118), the same maps compiled into the program. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp | 10 ++++------ .../Overworld/PokemonFRLG_OverworldRoute.cpp | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp index 3196a05f4c..2c369e0f0c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp @@ -14,6 +14,7 @@ #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTest.h" #include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldMapsTestData.h" #include "PokemonFRLG_OverworldReanchor.h" namespace PokemonAutomation{ @@ -32,9 +33,6 @@ class Test_OverworldReanchor : public UnitTest{ virtual UnitTestResult run(Logger&, CancellableScope&) const override{ try{ return m_body(); - }catch (FileException& e){ - // CI runs with upstream Packages, which may not have the maps yet. - return UnitTestResult(UnitTestResult::SKIPPED, "Map files not found: " + e.message()); }catch (Exception& e){ return std::string(e.name()) + ": " + e.message(); } @@ -80,14 +78,14 @@ UnitTestResult check_reanchors(const std::vector& maps, const MapPositi // Console B, 2026-11-01: battles on Route 1 left the walker a tile or a // whole leg off its count, four times. UnitTestResult test_viridian_mart_to_oaks_lab(){ - const std::vector maps = overworld_maps(); + const std::vector maps = test_overworld_maps(); return check_reanchors( maps, {"ViridianCity_Mart", map_point(maps, "ViridianCity_Mart", "clerk_front")}, {{"PalletTown_ProfessorOaksLab", {6, 4}}, std::nullopt} ); } UnitTestResult test_pallet_to_viridian_mart(){ - const std::vector maps = overworld_maps(); + const std::vector maps = test_overworld_maps(); return check_reanchors( maps, {"PalletTown", map_point(maps, "PalletTown", "oaks_lab_front")}, {{"ViridianCity_Mart", map_point(maps, "ViridianCity_Mart", "clerk_front")}, std::nullopt} @@ -144,7 +142,7 @@ UnitTestResult test_rules(){ // six times. Every tile the player might be on now ends at one place, // and none of the holds walks into a sign. UnitTestResult test_forest_exit_columns(){ - const std::vector maps = overworld_maps(); + const std::vector maps = test_overworld_maps(); RouteStep north; north.direction = Direction::NORTH; north.tiles = 24; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 91ce2b2dc8..cf91c27cc7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -798,7 +798,7 @@ UnitTestResult test_no_hold_into_ledge(){ } } route.clear(); - const std::vector world = overworld_maps(); + const std::vector world = test_overworld_maps(); result = check_route(world, {"ViridianCity", {26, 27}}, {{"ViridianCity", {26, 29}}, std::nullopt}, route); if (result.result != UnitTestResult::PASSED){ return result; From cd301897e8d470baf0f540bbc1915302fdfd0835 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 09:38:50 -0800 Subject: [PATCH 14/25] Pokemon navigation: drop the tile-at-a-time walk; keep only the stop hooks FRLG walked open legs through grass a tile at a time for a while, placing battles on steps by their timing; it no longer does, and nothing else uses it. Removed: LegSpec's step_window, warps and settle, the window rule (steps_back_to_interrupt), the watch-only hold, and their tests. The walker keeps what position recovery needs: stop_on_interrupt (an open leg stops at its first interrupt), stop_if_blocked (a leg to a wall interrupted again right away when held again stops, as at a sign), the stopped result, and the count clear_late_interrupts() returns. Each has a test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Pokemon/Navigation/Pokemon_LegWalker.cpp | 82 +------ .../Pokemon/Navigation/Pokemon_LegWalker.h | 49 +--- .../Navigation/Pokemon_LegWalker_Tests.cpp | 230 ++---------------- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 1 - 4 files changed, 37 insertions(+), 325 deletions(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp index 3c98714af9..fbb88051fb 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp @@ -4,7 +4,6 @@ * */ -#include #include #include "Pokemon_LegWalker.h" @@ -32,84 +31,27 @@ int tiles_before_interrupt(const LegSpec& leg, Milliseconds elapsed, Millisecond return std::clamp((int)(since_first_step / leg.tile_time), 0, remaining); } -int steps_back_to_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay){ - if (detection_delay < leg.step_window){ - return elapsed < leg.tile_time ? 1 : 0; - } - // Steps land a tile after their press, a window apart. - const Milliseconds before_landing = leg.tile_time - (elapsed - detection_delay); - const int steps = (int)std::lround((double)before_landing.count() / leg.step_window.count()); - return std::max(steps, 0); -} - namespace{ -// An open leg walked a tile at a time. Since each interrupt is placed on the -// step that set it off, the leg ends on its last tile even when an interrupt -// is only seen after that step. -LegResult walk_leg_tile_by_tile(const LegSpec& leg, const LegCalls& calls, size_t max_interruptions){ - LegResult result; - int walked = 0; - // Presses since the last interrupt: an interrupt can't reach back past - // one already cleared. - int pressed = 0; - while (walked < leg.tiles){ - if (leg.warps && walked == leg.tiles - 1){ - // Onto the warp, whose fade would be taken for a battle's flash - // (console B, 2026-11-01: the player, already in Viridian - // Forest's north gate, tried to flee a battle). - calls.hold(leg_hold(leg, 1)); - walked++; - break; - } - const bool last_watched = walked == leg.tiles - (leg.warps ? 2 : 1); - const std::optional outcome = calls.hold_watching( - leg_hold(leg, 1), leg.step_window + (last_watched ? leg.settle : Milliseconds::zero()) - ); - walked++; - pressed++; - if (!outcome){ - continue; - } - result.interruptions++; - if (result.interruptions > max_interruptions){ - calls.fail(std::format("Interrupted {} times on: {}", result.interruptions, to_string(leg))); - } - calls.clear(outcome->interrupt); - if (leg.stop_on_interrupt){ - calls.log(std::format("{} {}ms after a press, stopping the leg.", calls.name(outcome->interrupt), outcome->elapsed.count())); - result.exact = false; - result.stopped = true; - return result; - } - const Milliseconds delay = calls.detection_delay(outcome->interrupt); - walked -= std::min(steps_back_to_interrupt(leg, outcome->elapsed, delay), pressed); - pressed = 0; - if (delay >= leg.step_window){ - result.exact = false; - } - calls.log(std::format( - "{} {}ms after a press, {} of {} tiles walked.", - calls.name(outcome->interrupt), outcome->elapsed.count(), walked, leg.tiles - )); +// Whether `leg` stops at the interrupt just cleared: an open leg asked to +// stop at any, or a leg to a wall interrupted again within three tiles of +// being held again (a message there lags the step by about two tiles). +bool stops_here(const LegSpec& leg, const LegResult& result, const HoldOutcome& outcome){ + if (!leg.to_wall){ + return leg.stop_on_interrupt; } - result.tiles = leg.tiles; - return result; + return leg.stop_if_blocked && result.interruptions > 1 && outcome.elapsed < leg.tile_time * 3; } } LegResult walk_leg_with(const LegSpec& leg, const LegCalls& calls, size_t max_interruptions){ - if (!leg.to_wall && leg.step_window > Milliseconds::zero()){ - return walk_leg_tile_by_tile(leg, calls, max_interruptions); - } LegResult result; int remaining = leg.tiles; while (true){ - const Milliseconds hold = leg_hold(leg, remaining); - const std::optional outcome = calls.hold_watching(hold, hold); + const std::optional outcome = calls.hold_watching(leg_hold(leg, remaining)); if (!outcome){ result.tiles = leg.tiles; return result; @@ -120,18 +62,12 @@ LegResult walk_leg_with(const LegSpec& leg, const LegCalls& calls, size_t max_in } calls.clear(outcome->interrupt); const std::string seen = std::format("{} after {}ms", calls.name(outcome->interrupt), outcome->elapsed.count()); - if (!leg.to_wall && leg.stop_on_interrupt){ + if (stops_here(leg, result, *outcome)){ calls.log(seen + ", stopping the leg."); result.exact = false; result.stopped = true; return result; } - if (leg.to_wall && leg.stop_if_blocked && result.interruptions > 1 && outcome->elapsed < leg.tile_time * 3){ - calls.log(seen + ", again at once: something on the way stops every hold."); - result.exact = false; - result.stopped = true; - return result; - } if (leg.to_wall){ // The wall stops the player on the leg's last tile however far // they got, so the leg is simply held again. diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h index 62b88e3040..0c8018e8ed 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h @@ -7,10 +7,8 @@ * message, a trainer). Each of those is a LegInterrupt the game supplies. * When one shows, the hold stops, the game clears it, and the rest of the * leg is walked: a leg to a wall is simply held again, since the wall stops - * the player on its tile. An open leg can't be checked like that, so where - * an interrupt is likely it is walked a tile at a time, each step watched - * long enough to see what it set off; elsewhere how far the player got is - * worked out from when the interrupt was seen. + * the player on its tile; for an open leg, how far the player got is worked + * out from when the interrupt was seen. * * How a direction is held (D-pad or stick) and how long a tile takes are * the game's too, through LegMover. Templated on the console and the @@ -82,18 +80,6 @@ struct LegSpec{ bool to_wall = false; // How long one tile takes, for this leg's direction and way of moving. Milliseconds tile_time{0}; - // If set, an open leg is walked a tile at a time: one short press per - // step, watched for this long from the press before the next. It must be - // longer than the interrupts' detection delays. See - // steps_back_to_interrupt(). - Milliseconds step_window{0}; - // The last step lands on a warp. Walked a tile at a time, that step - // isn't watched: the warp's fade looks like a battle's flash. - bool warps = false; - // Walked a tile at a time, the last watched step is watched this much - // longer, for an interrupt whose first sign was missed and that shows - // only later: it is then placed before the leg ends, not after. - Milliseconds settle{0}; // An open leg stops at its first interrupt, once cleared, instead of // walking on from an estimate; the caller finds the player again. bool stop_on_interrupt = false; @@ -110,8 +96,8 @@ struct LegResult{ // Whether `tiles` is certain: an interrupted open leg is an estimate. bool exact = true; size_t interruptions = 0; - // The leg stopped at an interrupt (LegSpec::stop_on_interrupt): the - // player is somewhere along it, and `tiles` is not known. + // The leg stopped at an interrupt (stop_on_interrupt, stop_if_blocked): + // the player is somewhere along it, and `tiles` is not known. bool stopped = false; }; @@ -126,17 +112,6 @@ Milliseconds leg_hold(const LegSpec& leg, int tiles); // a whole number of tiles into the hold, so this rounds to the nearest. int tiles_before_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay, int remaining); -// For a leg walked a tile at a time: how many steps before the one just -// pressed set off an interrupt seen `elapsed` after that press. A step's -// interrupt shows after the step lands, a tile into its window, and so -// within its window when the delay is shorter than the window, or else -// before the next press lands, which it then swallows. So one seen less than -// a tile after a press is the previous step's. This needs no measured delay, -// only one shorter than the window. An interrupt slower than that is placed -// on the step that landed nearest `detection_delay` before it was seen, -// which is only as good as the delay. -int steps_back_to_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay); - // What one hold reported: nothing if it ran out, or which interrupt showed // and how long into the hold. @@ -146,14 +121,12 @@ struct HoldOutcome{ }; // The leg's bookkeeping, with the console side passed in, so it can be -// tested with scripted outcomes. `hold_watching` holds for `hold`, watches -// until at least `window` after the hold started, and reports the first -// interrupt seen; `hold` only holds; `clear` clears interrupt i; +// tested with scripted outcomes. `hold_watching` holds for the duration and +// reports the first interrupt seen; `clear` clears interrupt i; // `detection_delay` is interrupt i's delay; `log` reports progress; `fail` // throws, and is called after more than `max_interruptions`. struct LegCalls{ - std::function(Milliseconds hold, Milliseconds window)> hold_watching; - std::function hold; + std::function(Milliseconds hold)> hold_watching; std::function clear; std::function detection_delay; std::function name; @@ -173,7 +146,7 @@ LegResult walk_leg( const LegSpec& leg, size_t max_interruptions = 5 ){ - auto hold_watching = [&](Milliseconds hold, Milliseconds window) -> std::optional{ + auto hold_watching = [&](Milliseconds hold) -> std::optional{ std::vector> watchers; std::vector callbacks; for (const auto& interrupt : interrupts){ @@ -187,8 +160,6 @@ LegResult walk_leg( [&](Context& context){ start = current_time(); mover.hold(context, leg.direction, hold); - context.wait_for_all_requests(); - context.wait_until(start + window); }, callbacks ); @@ -199,10 +170,6 @@ LegResult walk_leg( }; LegCalls calls; calls.hold_watching = hold_watching; - calls.hold = [&](Milliseconds hold){ - mover.hold(context, leg.direction, hold); - context.wait_for_all_requests(); - }; calls.clear = [&](size_t index){ interrupts[index]->clear(console, context); }; calls.detection_delay = [&](size_t index){ return interrupts[index]->detection_delay(); }; calls.name = [&](size_t index){ return interrupts[index]->name(); }; diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp index 61adf84f47..667fb55d64 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp @@ -27,30 +27,17 @@ using namespace std::chrono_literals; constexpr Milliseconds TILE = 267ms; constexpr Milliseconds DELAY = 300ms; -constexpr Milliseconds WINDOW = 900ms; -constexpr Milliseconds NO_WINDOW = 0ms; -constexpr Milliseconds SETTLE = 3400ms; -constexpr Milliseconds LATE_DELAY = 3700ms; // Holds that report the scripted outcomes in order, then run out. struct ScriptedLeg{ std::deque> outcomes; - Milliseconds delay = DELAY; std::vector holds; - std::vector windows; std::vector cleared; - size_t unwatched = 0; LegCalls calls(){ LegCalls calls; - calls.hold = [this](Milliseconds hold){ + calls.hold_watching = [this](Milliseconds hold) -> std::optional{ holds.push_back(hold); - windows.push_back(NO_WINDOW); - unwatched++; - }; - calls.hold_watching = [this](Milliseconds hold, Milliseconds window) -> std::optional{ - holds.push_back(hold); - windows.push_back(window); if (outcomes.empty()){ return std::nullopt; } @@ -59,7 +46,7 @@ struct ScriptedLeg{ return outcome; }; calls.clear = [this](size_t index){ cleared.push_back(index); }; - calls.detection_delay = [this](size_t){ return delay; }; + calls.detection_delay = [](size_t){ return DELAY; }; calls.name = [](size_t){ return std::string("a battle"); }; calls.log = [](const std::string&){}; calls.fail = [](const std::string& message){ throw std::runtime_error(message); }; @@ -74,8 +61,8 @@ HoldOutcome after_step(int step){ UnitTestResult test_hold_times(){ - const LegSpec open{Direction::EAST, 4, false, TILE, NO_WINDOW}; - const LegSpec wall{Direction::EAST, 4, true, TILE, NO_WINDOW}; + const LegSpec open{Direction::EAST, 4, false, TILE}; + const LegSpec wall{Direction::EAST, 4, true, TILE}; if (leg_hold(open, 4) != TILE * 3 + TILE / 2){ return std::format("Open leg: {}ms", leg_hold(open, 4).count()); } @@ -86,7 +73,7 @@ UnitTestResult test_hold_times(){ } UnitTestResult test_tiles_before_interrupt(){ - const LegSpec leg{Direction::EAST, 8, false, TILE, NO_WINDOW}; + const LegSpec leg{Direction::EAST, 8, false, TILE}; for (int step = 0; step <= 8; step++){ for (Milliseconds jitter : {-100ms, 0ms, 100ms}){ const int walked = tiles_before_interrupt(leg, TILE * step + DELAY + jitter, DELAY, 8); @@ -107,7 +94,7 @@ UnitTestResult test_tiles_before_interrupt(){ UnitTestResult test_wall_leg_walked_again(){ ScriptedLeg script; script.outcomes = {after_step(2)}; - const LegSpec leg{Direction::NORTH, 6, true, TILE, NO_WINDOW}; + const LegSpec leg{Direction::NORTH, 6, true, TILE}; const LegResult result = walk_leg_with(leg, script.calls(), 5); if (script.holds.size() != 2 || script.holds[1] != leg_hold(leg, 6)){ return "Expected the whole leg held again."; @@ -121,7 +108,7 @@ UnitTestResult test_wall_leg_walked_again(){ UnitTestResult test_open_leg_rest_walked(){ ScriptedLeg script; script.outcomes = {after_step(2)}; - const LegSpec leg{Direction::NORTH, 6, false, TILE, NO_WINDOW}; + const LegSpec leg{Direction::NORTH, 6, false, TILE}; const LegResult result = walk_leg_with(leg, script.calls(), 5); if (script.holds.size() != 2 || script.holds[1] != leg_hold(leg, 4)){ return "Expected the remaining 4 tiles held."; @@ -135,7 +122,7 @@ UnitTestResult test_open_leg_rest_walked(){ UnitTestResult test_open_leg_interrupted_at_end(){ ScriptedLeg script; script.outcomes = {after_step(6)}; - const LegSpec leg{Direction::NORTH, 6, false, TILE, NO_WINDOW}; + const LegSpec leg{Direction::NORTH, 6, false, TILE}; walk_leg_with(leg, script.calls(), 5); if (script.holds.size() != 1){ return "Nothing was left to walk after the last step."; @@ -143,181 +130,11 @@ UnitTestResult test_open_leg_interrupted_at_end(){ return true; } -UnitTestResult test_too_many_interruptions(){ - ScriptedLeg script; - for (int c = 0; c < 4; c++){ - script.outcomes.push_back(after_step(0)); - } - const LegSpec leg{Direction::NORTH, 6, true, TILE, NO_WINDOW}; - try{ - walk_leg_with(leg, script.calls(), 3); - }catch (std::runtime_error&){ - return true; - } - return "Expected to give up after 3 interruptions."; -} - - -// Tile by tile: the outcome of each press in turn, nothing for one that -// ran out. -UnitTestResult test_steps_back_to_interrupt(){ - const LegSpec leg{Direction::WEST, 4, false, TILE, WINDOW}; - // Seen in the window of the step that set it off, however slow. - for (Milliseconds elapsed : {TILE, TILE + 100ms, TILE + 600ms, WINDOW - 1ms}){ - if (steps_back_to_interrupt(leg, elapsed, DELAY) != 0){ - return std::format("Seen {}ms after a press: should be that press's step.", elapsed.count()); - } - } - // Seen before the press could have landed: the previous step's. - for (Milliseconds elapsed : {0ms, 100ms, TILE - 1ms}){ - if (steps_back_to_interrupt(leg, elapsed, DELAY) != 1){ - return std::format("Seen {}ms after a press: should be the previous step.", elapsed.count()); - } - } - // Slower than a window: counted back by whole windows. - if (steps_back_to_interrupt(leg, TILE + 100ms, 2000ms) != 2){ - return "A 2s delay should reach back two windows."; - } - return true; -} - -// Console B, 2026-11-01: a battle on the 3rd tile of "west 4", seen only -// after the hold, was taken for the end of the leg. -UnitTestResult test_tile_by_tile_battle_mid_leg(){ - ScriptedLeg script; - script.outcomes = {std::nullopt, std::nullopt, HoldOutcome{0, TILE + 650ms}}; - const LegSpec leg{Direction::WEST, 4, false, TILE, WINDOW}; - const LegResult result = walk_leg_with(leg, script.calls(), 5); - if (script.holds.size() != 4){ - return std::format("Expected 4 presses, got {}.", script.holds.size()); - } - for (size_t c = 0; c < script.holds.size(); c++){ - if (script.holds[c] != leg_hold(leg, 1) || script.windows[c] != WINDOW){ - return "Each press should be one tile, watched for the window."; - } - } - if (!result.exact || result.interruptions != 1 || script.cleared.size() != 1){ - return "Expected an exact result after one interruption."; - } - return true; -} - -// A battle seen early in the next window: that press was swallowed, so the -// tile is walked again. -UnitTestResult test_tile_by_tile_battle_seen_next_window(){ - ScriptedLeg script; - script.outcomes = {std::nullopt, std::nullopt, std::nullopt, HoldOutcome{0, 100ms}}; - const LegSpec leg{Direction::SOUTH, 4, false, TILE, WINDOW}; - const LegResult result = walk_leg_with(leg, script.calls(), 5); - if (script.holds.size() != 5){ - return std::format("Expected the 4th tile pressed again (5 presses), got {}.", script.holds.size()); - } - if (!result.exact){ - return "A battle placed by its window is exact."; - } - return true; -} - -// The first tile's battle, seen early in the second window, costs the -// second press; one seen just after the first press came from before the -// leg, and the player hasn't moved. -UnitTestResult test_tile_by_tile_battle_on_first_tile(){ - ScriptedLeg script; - script.outcomes = {std::nullopt, HoldOutcome{0, 50ms}}; - const LegSpec leg{Direction::SOUTH, 7, false, TILE, WINDOW}; - walk_leg_with(leg, script.calls(), 5); - if (script.holds.size() != 8){ - return std::format("Expected 7 tiles in 8 presses, got {}.", script.holds.size()); - } - ScriptedLeg before; - before.outcomes = {HoldOutcome{0, 50ms}}; - walk_leg_with(leg, before.calls(), 5); - if (before.holds.size() != 8){ - return std::format("Expected 7 tiles in 8 presses after an interrupt from before the leg, got {}.", before.holds.size()); - } - return true; -} - -// A battle at the last tile ends the leg. -UnitTestResult test_tile_by_tile_battle_on_last_tile(){ - ScriptedLeg script; - script.outcomes = {std::nullopt, std::nullopt, std::nullopt, HoldOutcome{0, TILE + 300ms}}; - const LegSpec leg{Direction::WEST, 4, false, TILE, WINDOW}; - const LegResult result = walk_leg_with(leg, script.calls(), 5); - if (script.holds.size() != 4 || result.tiles != 4){ - return std::format("Expected the leg done in 4 presses, got {}.", script.holds.size()); - } - return true; -} - -// An interrupt slower than the window is placed by its delay: a guess. -UnitTestResult test_tile_by_tile_slow_interrupt(){ - ScriptedLeg script; - script.delay = 2000ms; - script.outcomes = {std::nullopt, std::nullopt, std::nullopt, HoldOutcome{0, TILE + 100ms}}; - const LegSpec leg{Direction::WEST, 5, false, TILE, WINDOW}; - const LegResult result = walk_leg_with(leg, script.calls(), 5); - if (script.holds.size() != 7){ - return std::format("Expected the 2nd step's battle to cost 2 presses (7), got {}.", script.holds.size()); - } - if (result.exact){ - return "An interrupt slower than the window is an estimate."; - } - return true; -} - -// Console B, 2026-11-01, run 7: a battle on the last step of "south 3" -// whose flashes were missed showed by its dialog 3.7s on, after the next -// leg had been walked into the battle. The last step's window now runs -// until the dialog would show, and places the battle on that step. -UnitTestResult test_tile_by_tile_settle_last_step(){ - ScriptedLeg script; - script.delay = LATE_DELAY; - script.outcomes = {std::nullopt, std::nullopt, HoldOutcome{0, TILE + LATE_DELAY}}; - LegSpec leg{Direction::SOUTH, 3, false, TILE, WINDOW}; - leg.settle = SETTLE; - const LegResult result = walk_leg_with(leg, script.calls(), 5); - if (script.holds.size() != 3 || result.interruptions != 1){ - return std::format("Expected the leg done in 3 presses, got {}.", script.holds.size()); - } - if (script.windows[1] != WINDOW || script.windows[2] != WINDOW + SETTLE){ - return "Only the last step should be watched longer."; - } - return true; -} - -// A missed battle on the 2nd step of 3, seen by its dialog in the last -// step's longer window: the 3rd press was swallowed and is pressed again. -UnitTestResult test_tile_by_tile_settle_earlier_step(){ - ScriptedLeg script; - script.delay = LATE_DELAY; - script.outcomes = {std::nullopt, std::nullopt, HoldOutcome{0, TILE + LATE_DELAY - WINDOW}}; - LegSpec leg{Direction::SOUTH, 3, false, TILE, WINDOW}; - leg.settle = SETTLE; - walk_leg_with(leg, script.calls(), 5); - if (script.holds.size() != 4 || script.windows[3] != WINDOW + SETTLE){ - return std::format("Expected the last tile pressed again (4 presses), got {}.", script.holds.size()); - } - return true; -} - -// The step onto a warp isn't watched: its fade looks like a flash. -UnitTestResult test_tile_by_tile_warp_not_watched(){ - ScriptedLeg script; - LegSpec leg{Direction::NORTH, 4, false, TILE, WINDOW}; - leg.warps = true; - walk_leg_with(leg, script.calls(), 5); - if (script.holds.size() != 4 || script.unwatched != 1 || script.windows.back() != NO_WINDOW){ - return std::format("Expected 3 watched presses and an unwatched one, got {} and {}.", script.holds.size() - script.unwatched, script.unwatched); - } - return true; -} - // An open leg set to stop at an interrupt doesn't walk on from a guess. UnitTestResult test_open_leg_stops(){ ScriptedLeg script; script.outcomes = {after_step(2)}; - LegSpec leg{Direction::NORTH, 6, false, TILE, NO_WINDOW}; + LegSpec leg{Direction::NORTH, 6, false, TILE}; leg.stop_on_interrupt = true; const LegResult result = walk_leg_with(leg, script.calls(), 5); if (script.holds.size() != 1 || !result.stopped || result.exact || script.cleared.size() != 1){ @@ -331,7 +148,7 @@ UnitTestResult test_open_leg_stops(){ UnitTestResult test_wall_leg_blocked(){ ScriptedLeg script; script.outcomes = {HoldOutcome{0, 2200ms}, HoldOutcome{0, 600ms}, HoldOutcome{0, 600ms}}; - LegSpec leg{Direction::SOUTH, 23, true, TILE, NO_WINDOW}; + LegSpec leg{Direction::SOUTH, 23, true, TILE}; leg.stop_if_blocked = true; const LegResult result = walk_leg_with(leg, script.calls(), 5); if (script.holds.size() != 2 || !result.stopped){ @@ -340,15 +157,18 @@ UnitTestResult test_wall_leg_blocked(){ return true; } -// A leg to a wall is still held through, window or not. -UnitTestResult test_wall_leg_held_through(){ +UnitTestResult test_too_many_interruptions(){ ScriptedLeg script; - const LegSpec leg{Direction::NORTH, 5, true, TILE, WINDOW}; - walk_leg_with(leg, script.calls(), 5); - if (script.holds.size() != 1 || script.holds[0] != leg_hold(leg, 5)){ - return "Expected one hold to the wall."; + for (int c = 0; c < 4; c++){ + script.outcomes.push_back(after_step(0)); } - return true; + const LegSpec leg{Direction::NORTH, 6, true, TILE}; + try{ + walk_leg_with(leg, script.calls(), 3); + }catch (std::runtime_error&){ + return true; + } + return "Expected to give up after 3 interruptions."; } @@ -376,18 +196,8 @@ void add_tests_LegWalker(UnitTestDatabase& database){ database.add("an open leg walks the rest", test_open_leg_rest_walked); database.add("an open leg interrupted at its end", test_open_leg_interrupted_at_end); database.add("gives up after too many interruptions", test_too_many_interruptions); - database.add("steps back to an interrupt", test_steps_back_to_interrupt); - database.add("tile by tile: a battle mid-leg", test_tile_by_tile_battle_mid_leg); - database.add("tile by tile: a battle seen in the next window", test_tile_by_tile_battle_seen_next_window); - database.add("tile by tile: a battle on the first tile", test_tile_by_tile_battle_on_first_tile); - database.add("tile by tile: a battle on the last tile", test_tile_by_tile_battle_on_last_tile); - database.add("tile by tile: an interrupt slower than the window", test_tile_by_tile_slow_interrupt); - database.add("a wall leg is held through", test_wall_leg_held_through); database.add("an open leg stops at an interrupt", test_open_leg_stops); database.add("a wall leg blocked by a sign stops", test_wall_leg_blocked); - database.add("tile by tile: the step onto a warp isn't watched", test_tile_by_tile_warp_not_watched); - database.add("tile by tile: the last step settles", test_tile_by_tile_settle_last_step); - database.add("tile by tile: a late battle before the last step", test_tile_by_tile_settle_earlier_step); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 74b645fbeb..0ddf0366ef 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -204,7 +204,6 @@ Pokemon::LegSpec leg_spec(const RouteStep& step){ leg.tiles = step.tiles; leg.to_wall = step.to_wall; leg.tile_time = step.surfing ? SURF_TILE_TIME : TILE_TIME; - leg.warps = step.warps; leg.stop_on_interrupt = !step.to_wall; leg.stop_if_blocked = step.to_wall; return leg; From df2a10a7cdd7d87f73a4da54f4c98e99b07a3fa5 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 09:50:14 -0800 Subject: [PATCH 15/25] FRLG recovery: search from a fixed set of tiles, the leg's own plan_reanchor() widened the tiles the player may be on to 4 either side of the stopped leg, and when no holds found them from all of those it tried 3, 2, 1 and 0. Failing to find holds is no reason to think the player is closer to the line, so the set is now fixed. It is the leg's own tiles. Measured on the compiled-in maps, with even one tile to either side Route 1's people and ledges cut some hold for some tile, and no open leg through its grass could be recovered; on the leg's line every one of them can (both route tests). Sideways drift, as in run 16 on console B, is not recovered: there, a hold that meets a sign at once, again and again, now fails the route instead of guessing (next commit). The sign lookup that went with the wider set is removed. Recovery holds take a ledge to stop a player moving north: FRLG's ledges face south, east or west on every map loaded (pokefirered has no MB_JUMP_NORTH there), and Route 1 has no other walls on most of its legs. Planned routes still never hold into a ledge (#118). The maps don't record which way a ledge faces; recording it would let this go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldReanchor.cpp | 82 +++---------------- .../Overworld/PokemonFRLG_OverworldReanchor.h | 26 +++--- .../PokemonFRLG_OverworldReanchor_Tests.cpp | 78 ++++-------------- .../Overworld/PokemonFRLG_OverworldRoute.cpp | 14 +++- 4 files changed, 54 insertions(+), 146 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index 50d6e944e0..8704ed64f8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -14,19 +14,11 @@ namespace PokemonFRLG{ namespace{ -// How far to the side of a leg the player may be: an earlier open leg can -// leave them tiles off (console B, 2026-11-01, Viridian Forest: "west 6" -// walked 2 of its 6 tiles, unseen, and the next leg went up a column 4 -// tiles over). -constexpr int MAX_SPREAD = 4; constexpr size_t MAX_HOLDS = 3; Direction opposite(Direction direction){ return (Direction)(((int)direction + 2) % 4); } -Direction across(Direction direction){ - return (Direction)(((int)direction + 1) % 4); -} const MapData* map_named(const std::vector& maps, const std::string& name){ for (const MapData& map : maps){ @@ -63,26 +55,6 @@ std::optional walk_back(const std::vector& maps, const Rou return position; } -// Every tile the player may be on: along the leg, and up to `spread` to -// either side of it. -std::vector candidates( - const std::vector& maps, const MapPosition& start, const RouteStep& step, int spread -){ - std::vector positions; - for (const MapPosition& tile : leg_tiles(maps, start, step)){ - for (int side = -spread; side <= spread; side++){ - MapPosition position = tile; - for (int c = 0; c < std::abs(side); c++){ - position.point = position.point.next(side < 0 ? opposite(across(step.direction)) : across(step.direction)); - } - if (standable(maps, position, step.surfing)){ - add_unique(positions, position); - } - } - } - return positions; -} - // Holding `direction` from each of `positions`: where they end up, as a leg // to a wall as long as the longest of them. Nothing if any isn't stopped // by a wall. @@ -140,60 +112,30 @@ void search( } } -// Held toward a sign moving north or south, the player stops beside it and -// it speaks: if only one of `positions` is beside one, that's where they are. -std::optional beside_sign( - const std::vector& maps, const std::vector& positions, Direction direction -){ - if (direction != Direction::NORTH && direction != Direction::SOUTH){ - return std::nullopt; - } - std::optional found; - for (const MapPosition& position : positions){ - const MapData* map = map_named(maps, position.map); - if (map != nullptr && tile_at(*map, position.point.next(direction)) == Tile::SIGN){ - if (found){ - return std::nullopt; - } - found = position; - } - } - return found; -} - } std::optional plan_reanchor( const std::vector& maps, const std::optional& start, - const RouteStep& step, - bool at_sign + const RouteStep& step ){ - if (step.action != RouteAction::WALK){ + if (step.action != RouteAction::WALK || step.to_wall){ return std::nullopt; } - // A leg to a wall may have been held from anywhere along its line. - const std::optional from = start && !step.to_wall ? start : walk_back(maps, step); + const std::optional from = start ? start : walk_back(maps, step); if (!from){ return std::nullopt; } - for (int spread = MAX_SPREAD; spread >= 0; spread--){ - const std::vector positions = candidates(maps, *from, step, spread); - // Only with every tile counted: a sign beside one of fewer could be - // the wrong sign. - if (at_sign && spread == MAX_SPREAD){ - if (const std::optional beside = beside_sign(maps, positions, step.direction)){ - return Reanchor{{}, *beside}; - } - } - for (size_t depth = 1; depth <= MAX_HOLDS; depth++){ - std::vector holds; - std::optional best; - search(maps, positions, step.surfing, depth, holds, best); - if (best){ - return best; - } + // Every tile the player may be on. Fixed: that no holds find them from + // all of these is no reason to think they are on fewer. + const std::vector positions = leg_tiles(maps, *from, step); + for (size_t depth = 1; depth <= MAX_HOLDS; depth++){ + std::vector holds; + std::optional best; + search(maps, positions, step.surfing, depth, holds, best); + if (best){ + return best; } } return std::nullopt; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index 6c7bf2a3c6..1828c24f22 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -3,10 +3,13 @@ * From: https://github.com/PokemonAutomation/ * * Finding the player again after a battle or a message stopped a leg - * part-way. How far they got can't be timed reliably, and earlier open - * legs may have left them a tile or two off to the side too. So this - * takes every tile they may be on, near the leg, and looks for holds to - * walls, one to three, that end on the same tile from all of them. + * part-way. How far they got can't be timed reliably, so this takes every + * tile of the leg they may be on and looks for holds to walls, one to + * three, that end on the same tile from all of them. + * + * The player is taken to be on the leg's line. Wider sets were tried: + * with even one tile to either side, Route 1's people and ledges cut some + * hold for some tile, and nothing on its grass legs could be found. * */ @@ -25,23 +28,20 @@ namespace PokemonFRLG{ struct Reanchor{ - // Legs to walls, in order. Empty when the stop itself told where the - // player is. Each but the last ends on a tile that depends on where - // the player was; its `end` is one of them. + // Legs to walls, in order. Each but the last ends on a tile that + // depends on where the player was; its `end` is one of them. std::vector holds; // Where the player is after them. MapPosition end; }; -// After `step` stopped part-way. `start` is where it started, if known; -// else it is worked back from its end. `at_sign`: `step`, held to a wall, -// was stopped again and again by a message right away, as by a sign it -// walked into. Nothing if no holds find the player. +// After `step`, an open WALK, stopped part-way. `start` is where it +// started, if known; else it is worked back from its end. Nothing if no +// holds find the player. std::optional plan_reanchor( const std::vector& maps, const std::optional& start, - const RouteStep& step, - bool at_sign = false + const RouteStep& step ); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp index 2c369e0f0c..1d5282b182 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp @@ -92,15 +92,6 @@ UnitTestResult test_pallet_to_viridian_mart(){ ); } -// A room with a sign in the middle of its floor. -const char* const TEST_SIGNS_JSON = R"({ - "name": "Signs", "width": 8, "height": 7, - "tiles": ["########", "#......#", "#......#", "#....S.#", "#......#", "#......#", "########"], - "elevation": ["33333333", "33333333", "33333333", "33333333", "33333333", "33333333", "33333333"], - "warps": [], "objects": [], - "connections": [], "points": [], "flag_sets": [] -})"; - // A field with a wall at its east end and a row of ledges to the south. const char* const TEST_FIELD_JSON = R"({ "name": "Field", "width": 8, "height": 5, @@ -121,71 +112,36 @@ std::string describe(const std::optional& anchor){ return text; } -// Stopped part-way east, the player may also be a row off, so one hold -// doesn't find them: two do, the shortest being north then back west. +// Stopped part-way east, the player holds back west to the wall, the +// shorter way. Stopped part-way south above the ledges, which would be +// hopped, they hold back north. UnitTestResult test_rules(){ const std::vector maps{parse_map(TEST_FIELD_JSON, "Field")}; RouteStep east; east.direction = Direction::EAST; east.tiles = 3; east.end = {"Field", {4, 1}}; - const std::optional anchor = plan_reanchor(maps, MapPosition{"Field", {1, 1}}, east); - if (!anchor || anchor->holds.size() != 2 || !(anchor->end.point == TilePos{1, 1})){ - return "Expected two holds to (1, 1), got " + describe(anchor); - } - return true; -} - -// Console B, 2026-11-01, run 16: "west 6" walked 2 tiles unseen, the next -// leg went north up column 9 instead of 5, and finding the player again by -// holding south down column 5 walked into the TRAINER TIPS sign at (9, 29) -// six times. Every tile the player might be on now ends at one place, -// and none of the holds walks into a sign. -UnitTestResult test_forest_exit_columns(){ - const std::vector maps = test_overworld_maps(); - RouteStep north; - north.direction = Direction::NORTH; - north.tiles = 24; - north.warps = true; - north.wild = true; - north.end = {"Route2_ViridianForest_NorthEntrance", {7, 10}}; - const std::optional anchor = plan_reanchor(maps, MapPosition{"ViridianForest", {5, 33}}, north); - if (!anchor || anchor->holds.empty()){ - return "Expected holds to find the player, got " + describe(anchor); + std::optional anchor = plan_reanchor(maps, MapPosition{"Field", {1, 1}}, east); + if (!anchor || anchor->holds.size() != 1 || anchor->holds[0].direction != Direction::WEST || !(anchor->end.point == TilePos{1, 1})){ + return "Expected a hold west to (1, 1), got " + describe(anchor); } - for (int x = 3; x <= 9; x++){ - MapPosition at{"ViridianForest", {x, 28}}; - for (const RouteStep& hold : anchor->holds){ - const std::optional wall = hold_to_wall(maps, at, false, hold.direction); - if (!wall){ - return "From " + to_string(MapPosition{"ViridianForest", {x, 28}}) + " a hold isn't stopped by a wall: " + describe(anchor); - } - at = wall->end; - } - if (!(at == anchor->end)){ - return "From column " + std::to_string(x) + " the holds end at " + to_string(at) + ": " + describe(anchor); - } - } - return true; -} - -// The same run: held south toward the wall at (5, 33), a sign spoke at -// once, again and again. Beside a sign on the leg's line, the player is -// found by it, if it is the only one near enough. -UnitTestResult test_found_by_sign(){ - const std::vector maps{parse_map(TEST_SIGNS_JSON, "Signs")}; RouteStep south; south.direction = Direction::SOUTH; - south.tiles = 4; + south.tiles = 1; + south.end = {"Field", {3, 2}}; + anchor = plan_reanchor(maps, std::nullopt, south); + if (!anchor || anchor->holds.size() != 1 || anchor->holds[0].direction != Direction::NORTH || !(anchor->end.point == TilePos{3, 1})){ + return "Expected a hold north back to (3, 1), got " + describe(anchor); + } + // A leg to a wall is walked again instead. south.to_wall = true; - south.end = {"Signs", {1, 5}}; - const std::optional anchor = plan_reanchor(maps, std::nullopt, south, true); - if (!anchor || !anchor->holds.empty() || !(anchor->end.point == TilePos{5, 2})){ - return "Expected to be found beside the sign at (5, 3), got " + describe(anchor); + if (plan_reanchor(maps, std::nullopt, south)){ + return "Expected nothing for a leg to a wall."; } return true; } + } @@ -193,8 +149,6 @@ void add_tests_OverworldReanchor(UnitTestDatabase& database){ database.add("rules", test_rules); database.add("Viridian Mart to Oak's Lab", test_viridian_mart_to_oaks_lab); database.add("Pallet Town to Viridian Mart", test_pallet_to_viridian_mart); - database.add("Viridian Forest exit, any column", test_forest_exit_columns); - database.add("found by a sign", test_found_by_sign); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 72d31a57aa..7fd4645006 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -406,6 +406,18 @@ std::optional plain_step(const std::vector& maps, const Locate } +namespace{ + +// Holding north into a ledge stops the player: the maps don't keep which way +// a ledge faces, but FRLG's face south, east or west (pokefirered has no +// MB_JUMP_NORTH on any map these load). Only finding the player again +// relies on this; routes never hold into a ledge (stops_at()). +bool ledge_stops_north(const std::vector& maps, const Located& player, Direction direction){ + return direction == Direction::NORTH && is_ledge(maps[player.map], player.state.position.next(direction)); +} + +} + std::optional hold_to_wall( const std::vector& maps, const MapPosition& from, bool surfing, Direction direction @@ -413,7 +425,7 @@ std::optional hold_to_wall( std::optional player = locate(maps, from, surfing); WallHold hold; for (int c = 0; player && c <= MAX_HOLD_TILES; c++){ - if (stops_at(maps, *player, direction)){ + if (stops_at(maps, *player, direction) || ledge_stops_north(maps, *player, direction)){ hold.end = MapPosition{maps[player->map].name, player->state.position}; hold.tiles = c; return hold; From 4521543c82434d496f8de0fa3e8c74ae7f58c41a Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 09:50:14 -0800 Subject: [PATCH 16/25] FRLG recovery: rejoin the route at the stopped leg's end, on the caller's maps Recovery planned a new route from where it found the player to the route's last tile, on default costs, and on maps loaded with no event flags when the caller passed none. That drops the caller's flags and costs, and any shape the route had on purpose (AutoStory's fixed legs). The route loop moves into run_route() (PokemonFRLG_OverworldRecovery), with the console side passed in. After the holds that find the player, it plans only back to the stopped leg's end, with the caller's maps and costs (RoutePlanning), then walks the rest of the route as planned. walk_overworld_route() takes a RoutePlanning in place of the maps; without one, a stop that needs the player found again fails the route. A leg to a wall that is interrupted at once, hold after hold, as by a sign the plan didn't have on its line, now fails the route rather than being recovered from a guess. Tests (OverworldRecovery), with scripted steps: Viridian Mart to Oak's Lab, stopped on its first open leg through grass, is found again, walked back to that leg's end and finished as planned; with no maps, or with no walls to find the player by, the route fails; a leg to a wall seen interrupted late is walked again, and one blocked at once fails; a leg to a warp off the grass is held on to the warp. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldRecovery.cpp | 101 ++++++++ .../Overworld/PokemonFRLG_OverworldRecovery.h | 78 ++++++ .../PokemonFRLG_OverworldRecovery_Tests.cpp | 226 ++++++++++++++++++ .../Overworld/PokemonFRLG_OverworldWalk.cpp | 114 ++------- .../Overworld/PokemonFRLG_OverworldWalk.h | 9 +- .../PokemonFRLG/PokemonFRLG_Tests.cpp | 2 + SerialPrograms/cmake/SourceFiles.cmake | 3 + 7 files changed, 428 insertions(+), 105 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp new file mode 100644 index 0000000000..0ce1064811 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -0,0 +1,101 @@ +/* Overworld Recovery + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "PokemonFRLG_OverworldReanchor.h" +#include "PokemonFRLG_OverworldRecovery.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// The route from `plan[stopped]`, stopped part-way from `start`: holds that +// find the player, a way back to that step's end, then the rest as planned. +std::vector rejoin( + const std::vector& plan, size_t stopped, const std::optional& start, + const RoutePlanning* planning, const RouteCalls& calls +){ + const RouteStep& step = plan[stopped]; + if (planning == nullptr || planning->maps == nullptr){ + calls.fail("walk_overworld_route(): stopped part-way on " + to_string(step) + ", with no maps to find the player again."); + return {}; + } + const std::optional anchor = plan_reanchor(*planning->maps, start, step); + if (!anchor){ + calls.fail("walk_overworld_route(): stopped part-way on " + to_string(step) + ", with no walls along it to find the player again."); + return {}; + } + const std::optional> back = plan_overworld_route( + *planning->maps, anchor->end, RouteGoal{step.end, std::nullopt}, planning->costs, anchor->holds.back().direction + ); + if (!back){ + calls.fail("walk_overworld_route(): no route from " + to_string(anchor->end) + " back to " + to_string(step.end) + "."); + return {}; + } + std::string holds; + for (const RouteStep& hold : anchor->holds){ + holds += "; " + to_string(hold); + } + calls.log("Route: stopped part-way, finding the player again at " + to_string(anchor->end) + holds); + std::vector ret = anchor->holds; + ret.insert(ret.end(), back->begin(), back->end()); + ret.insert(ret.end(), plan.begin() + stopped + 1, plan.end()); + return ret; +} + +} + + +void run_route( + const std::vector& route, std::optional facing, + const RoutePlanning* planning, const RouteCalls& calls +){ + std::vector plan = route; + std::optional at; + size_t stops = 0; + for (size_t i = 0; i < plan.size();){ + const RouteStep step = plan[i]; + const StepEnd end = calls.walk(step, facing); + if (end == StepEnd::DONE){ + at = step.end; + facing = step.facing_after; + i++; + continue; + } + facing = std::nullopt; // a trainer turns the player to face them + if (++stops > MAX_ROUTE_STOPS){ + calls.fail(std::format("walk_overworld_route(): stopped part-way {} times.", stops)); + return; + } + if (step.to_wall && end == StepEnd::LATE){ + continue; // walking it again ends at its wall + } + if (step.to_wall){ + // Interrupted at once, hold after hold: by something on the + // way that speaks, a sign, which the plan didn't have there. + calls.fail("walk_overworld_route(): a hold toward a wall is interrupted right away, again: the player is off the route, on " + to_string(step) + "."); + return; + } + if (end == StepEnd::STOPPED && step.warps && !step.wild){ + calls.hold_to_warp(step); + at = step.end; + facing = step.facing_after; + i++; + continue; + } + plan = rejoin(plan, i, at, planning, calls); + i = 0; + } +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h new file mode 100644 index 0000000000..4cabe75272 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h @@ -0,0 +1,78 @@ +/* Overworld Recovery + * + * From: https://github.com/PokemonAutomation/ + * + * Runs a planned route step by step, and when a step ends short of where + * it was planned to, gets the player back onto it: + * + * - a leg to a wall interrupted after its hold is walked again; + * - an open leg to a warp, off the grass, stopped by a message (a + * trainer), is held on toward the warp; + * - any other open leg stopped part-way is found again with holds to + * walls (plan_reanchor()), and rejoins the route at that leg's end by + * a route planned on the caller's maps and costs. The rest of the + * route is then walked as planned. + * + * The console side is passed in, so this is tested with scripted steps. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldRecovery_H +#define PokemonAutomation_PokemonFRLG_OverworldRecovery_H + +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// How one step of a route ended. +enum class StepEnd{ + DONE, + STOPPED, // stopped part-way: where the player is isn't known + LATE, // something was seen after the hold, which may have + // stopped the player before the end +}; + +// What the route was planned with, to plan the way back onto it: the maps +// (with the event flags the caller set) and the costs. +struct RoutePlanning{ + const std::vector* maps = nullptr; + RouteCosts costs = default_route_costs(); +}; + +// The console side. `walk` walks one step from `facing` (unknown after a +// stop); `hold_to_warp` holds on along a stopped leg until its warp; +// `fail` throws. +struct RouteCalls{ + std::function facing)> walk; + std::function hold_to_warp; + std::function log; + std::function fail; +}; + +// How often a route may stop part-way before it gives up. +constexpr size_t MAX_ROUTE_STOPS = 8; + +// Walks `route` from `facing`. Without `planning`, a stop that needs the +// player found again fails instead. +void run_route( + const std::vector& route, std::optional facing, + const RoutePlanning* planning, const RouteCalls& calls +); + + +void add_tests_OverworldRecovery(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp new file mode 100644 index 0000000000..f32517b84d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp @@ -0,0 +1,226 @@ +/* Overworld Recovery Tests + * + * From: https://github.com/PokemonAutomation/ + * + * run_route() with scripted steps: walk, interruption, recovery, resume. + * + */ + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldReanchor.h" +#include "PokemonFRLG_OverworldRecovery.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class Test_OverworldRecovery : public UnitTest{ +public: + Test_OverworldRecovery(std::string name, std::function body) + : UnitTest("PokemonFRLG::OverworldRecovery - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + std::function m_body; +}; + +// Steps that end as scripted, by the order they are walked in; DONE if not. +struct ScriptedRoute{ + std::map ends; + std::vector walked; + std::vector held_to_warp; + std::string failure; + + RouteCalls calls(){ + RouteCalls calls; + calls.walk = [this](const RouteStep& step, std::optional){ + walked.push_back(step); + auto iter = ends.find(walked.size() - 1); + return iter == ends.end() ? StepEnd::DONE : iter->second; + }; + calls.hold_to_warp = [this](const RouteStep& step){ held_to_warp.push_back(step); }; + calls.log = [](const std::string&){}; + calls.fail = [this](const std::string& message){ + failure = message; + throw std::runtime_error(message); + }; + return calls; + } + // Runs, and returns whether it failed. + bool run(const std::vector& route, const RoutePlanning* planning){ + try{ + run_route(route, std::nullopt, planning, calls()); + }catch (std::runtime_error&){ + return true; + } + return false; + } +}; + +std::string describe(const std::vector& route){ + std::string text; + for (const RouteStep& step : route){ + text += "\n " + to_string(step); + } + return text; +} + +bool same(const RouteStep& a, const RouteStep& b){ + return to_string(a) == to_string(b); +} + +// Viridian Mart to Oak's Lab; the first open leg through grass is stopped +// by a battle. The player is found again, walked back to that leg's end on +// the same maps, and the rest of the route is walked as planned. +UnitTestResult test_stop_recover_resume(){ + const std::vector maps = overworld_maps(); + const MapPosition start{"ViridianCity_Mart", map_point(maps, "ViridianCity_Mart", "clerk_front")}; + const std::optional> route = plan_overworld_route( + maps, start, RouteGoal{{"PalletTown_ProfessorOaksLab", {6, 4}}, std::nullopt} + ); + if (!route){ + return "No route."; + } + size_t stopped = 0; + while (stopped < route->size() && !((*route)[stopped].wild && !(*route)[stopped].to_wall)){ + stopped++; + } + if (stopped == 0 || stopped == route->size()){ + return "Expected an open leg through grass:" + describe(*route); + } + ScriptedRoute script; + script.ends[stopped] = StepEnd::STOPPED; + const RoutePlanning planning{&maps, default_route_costs()}; + if (script.run(*route, &planning)){ + return "Failed: " + script.failure; + } + + const RouteStep& step = (*route)[stopped]; + const std::optional anchor = plan_reanchor(maps, (*route)[stopped - 1].end, step); + const std::optional> back = anchor + ? plan_overworld_route(maps, anchor->end, RouteGoal{step.end, std::nullopt}, default_route_costs(), anchor->holds.back().direction) + : std::nullopt; + if (!back || !(follow_overworld_route(maps, anchor->end, *back) == step.end)){ + return "Expected a way back to " + to_string(step.end) + "."; + } + std::vector expected(route->begin(), route->begin() + stopped + 1); + expected.insert(expected.end(), anchor->holds.begin(), anchor->holds.end()); + expected.insert(expected.end(), back->begin(), back->end()); + expected.insert(expected.end(), route->begin() + stopped + 1, route->end()); + bool match = expected.size() == script.walked.size(); + for (size_t c = 0; match && c < expected.size(); c++){ + match = same(expected[c], script.walked[c]); + } + if (!match){ + return "Walked:" + describe(script.walked) + "\nexpected:" + describe(expected); + } + return true; +} + +// A corridor with a person at each end: nothing finds the player again. +const char* const TEST_CORRIDOR_JSON = R"({ + "name": "Corridor", "width": 7, "height": 3, + "tiles": ["#######", "#.....#", "#######"], + "elevation": ["3333333", "3333333", "3333333"], + "warps": [], + "objects": [ + {"x": 1, "y": 1, "kind": "youngster", "flag": "", "wander_x": 0, "wander_y": 0}, + {"x": 5, "y": 1, "kind": "lass", "flag": "", "wander_x": 0, "wander_y": 0} + ], + "connections": [], "points": [], "flag_sets": [] +})"; + +RouteStep walk(Direction direction, int tiles, MapPosition end){ + RouteStep step; + step.direction = direction; + step.tiles = tiles; + step.facing_after = direction; + step.end = std::move(end); + return step; +} + +// With no maps, or no walls to find the player by, a stop fails the route +// instead of guessing. +UnitTestResult test_no_recovery(){ + const std::vector maps{parse_map(TEST_CORRIDOR_JSON, "Corridor")}; + const std::vector route{walk(Direction::EAST, 2, {"Corridor", {4, 1}})}; + ScriptedRoute without_maps; + without_maps.ends[0] = StepEnd::STOPPED; + if (!without_maps.run(route, nullptr) || without_maps.failure.find("no maps") == std::string::npos){ + return "Expected a failure without maps, got: " + without_maps.failure; + } + ScriptedRoute without_walls; + without_walls.ends[0] = StepEnd::STOPPED; + const RoutePlanning planning{&maps, default_route_costs()}; + if (!without_walls.run(route, &planning) || without_walls.failure.find("no walls") == std::string::npos){ + return "Expected a failure without walls, got: " + without_walls.failure; + } + return true; +} + +// A leg to a wall seen interrupted after its hold is walked again; one +// interrupted at once, again and again, fails. +UnitTestResult test_wall_legs(){ + RouteStep wall = walk(Direction::NORTH, 3, {"Corridor", {1, 1}}); + wall.to_wall = true; + ScriptedRoute late; + late.ends[0] = StepEnd::LATE; + if (late.run({wall}, nullptr) || late.walked.size() != 2){ + return "Expected the leg walked again."; + } + ScriptedRoute blocked; + blocked.ends[0] = StepEnd::STOPPED; + if (!blocked.run({wall}, nullptr)){ + return "Expected a blocked leg to a wall to fail."; + } + return true; +} + +// Off the grass, a leg to a warp stopped by a trainer is held on to the +// warp, and the route goes on. +UnitTestResult test_warp_leg(){ + RouteStep door = walk(Direction::SOUTH, 9, {"PewterCity", {16, 18}}); + door.warps = true; + const RouteStep after = walk(Direction::EAST, 2, {"PewterCity", {18, 18}}); + ScriptedRoute script; + script.ends[0] = StepEnd::STOPPED; + if (script.run({door, after}, nullptr) || script.held_to_warp.size() != 1 || script.walked.size() != 2){ + return "Expected a hold to the warp, then the next step."; + } + return true; +} + +} + + +void add_tests_OverworldRecovery(UnitTestDatabase& database){ + database.add("stop, recover, resume", test_stop_recover_resume); + database.add("no recovery", test_no_recovery); + database.add("legs to a wall", test_wall_legs); + database.add("a leg to a warp", test_warp_leg); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 0ddf0366ef..0fa3760383 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -17,7 +17,7 @@ #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "PokemonFRLG_OverworldMaps.h" -#include "PokemonFRLG_OverworldReanchor.h" +#include "PokemonFRLG_OverworldRecovery.h" #include "PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_OverworldWalk.h" @@ -57,8 +57,6 @@ constexpr Milliseconds AFTER_LEG_WATCH = 500ms; // the next leg is walked into the battle and lost: there, "east 4" and // "west 4". constexpr Milliseconds AFTER_WILD_LEG_WATCH = 3500ms; -// How often a route may stop to find the player again before giving up. -constexpr size_t MAX_REANCHORS = 8; // After a message closes: a trainer who saw the player speaks first, then // the battle starts, with " would like to battle!" seconds later. constexpr Milliseconds TRAINER_BATTLE_WATCH = 6000ms; @@ -337,12 +335,6 @@ bool walk_step( // if the player may have stopped short of the step's end: an open leg // stopped, or something seen after the hold, which may have stopped it // before the end (after a warp, it was checked for already). -enum class StepEnd{ - DONE, - STOPPED, // walk_step() returned false - LATE, // something was seen after the hold, which may have - // stopped the player before the end -}; StepEnd walk_and_watch( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, @@ -405,17 +397,6 @@ void hold_to_warp( ); } -// How many times a route has stopped part-way; throws past MAX_REANCHORS. -void count_stop(ConsoleHandle& console, size_t& stops){ - if (++stops > MAX_REANCHORS){ - OperationFailedExceptionWithScreenshot::fire( - ErrorReportMode::SEND_ERROR_REPORT, - std::format("walk_overworld_route(): stopped part-way {} times.", stops), - console - ); - } -} - // Each leg starts with a turn tap unless it goes the way the player faces. // A tap while already facing that way takes a step, so a facing not given // is read from the screen. If that fails too it taps anyway. @@ -430,52 +411,6 @@ std::optional read_facing(ConsoleHandle& console, ProControllerContex return facing; } -// Where the route ends, and which way the player faces there. -RouteGoal route_goal(const std::vector& route){ - RouteGoal goal; - goal.position = route.back().end; - if (route.back().action == RouteAction::FACE){ - goal.facing = route.back().direction; - } - return goal; -} - -// After `step`, from `start`, stopped part-way: holds to walls that put the -// player on a known tile, then a new route from there to `goal`. A leg to a -// wall only stops when interrupted at once, hold after hold: by a sign it -// walked into, so `at_sign`. -std::vector reanchor_route( - ConsoleHandle& console, - const std::vector& maps, - const std::optional& start, const RouteStep& step, bool at_sign, - const RouteGoal& goal -){ - const std::optional anchor = plan_reanchor(maps, start, step, at_sign); - if (!anchor){ - OperationFailedExceptionWithScreenshot::fire( - ErrorReportMode::SEND_ERROR_REPORT, - "walk_overworld_route(): stopped part-way on " + to_string(step) + ", with no walls near it to find the player again.", - console - ); - } - const Direction facing = anchor->holds.empty() ? step.direction : anchor->holds.back().direction; - std::optional> rest = plan_overworld_route(maps, anchor->end, goal, default_route_costs(), facing); - if (!rest){ - OperationFailedExceptionWithScreenshot::fire( - ErrorReportMode::SEND_ERROR_REPORT, - "walk_overworld_route(): no route from " + to_string(anchor->end) + " to " + to_string(goal.position) + ".", - console - ); - } - std::string holds; - for (const RouteStep& hold : anchor->holds){ - holds += "; " + to_string(hold); - } - console.log("Route: stopped part-way, finding the player again at " + to_string(anchor->end) + holds, COLOR_ORANGE); - rest->insert(rest->begin(), anchor->holds.begin(), anchor->holds.end()); - return std::move(*rest); -} - } @@ -534,7 +469,7 @@ void walk_overworld_route( const std::vector& route, std::optional facing, const OverworldInterruptSet& interrupts, - const std::vector* maps + const RoutePlanning* planning ){ if (route.empty()){ return; @@ -542,41 +477,20 @@ void walk_overworld_route( if (!facing){ facing = read_facing(console, context); } - std::vector loaded; - std::vector plan = route; - std::optional at; - size_t reanchors = 0; - for (size_t i = 0; i < plan.size();){ - const RouteStep step = plan[i]; + RouteCalls calls; + calls.walk = [&](const RouteStep& step, std::optional from_facing){ console.log("Route: " + to_string(step)); check_overworld(console, context, step); - const StepEnd end = walk_and_watch(console, context, step, facing, interrupts); - if (end == StepEnd::DONE){ - at = step.end; - facing = step.facing_after; - i++; - continue; - } - facing = std::nullopt; // a trainer turns the player to face them - count_stop(console, reanchors); - if (step.to_wall && end == StepEnd::LATE){ - // Walking it again ends at its wall. - continue; - } - if (end == StepEnd::STOPPED && step.warps && !step.wild){ - hold_to_warp(console, context, step, interrupts.other); - at = step.end; - facing = step.facing_after; - i++; - continue; - } - if (maps == nullptr){ - loaded = overworld_maps(); - maps = &loaded; - } - plan = reanchor_route(console, *maps, at, step, step.to_wall, route_goal(route)); - i = 0; - } + return walk_and_watch(console, context, step, from_facing, interrupts); + }; + calls.hold_to_warp = [&](const RouteStep& step){ + hold_to_warp(console, context, step, interrupts.other); + }; + calls.log = [&](const std::string& message){ console.log(message, COLOR_ORANGE); }; + calls.fail = [&](const std::string& message){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, message, console); + }; + run_route(route, facing, planning, calls); context.wait_for_all_requests(); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index 138b029e60..bf10625604 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -24,6 +24,7 @@ #include "Pokemon/Navigation/Pokemon_LegWalker.h" #include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" #include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "PokemonFRLG_OverworldRecovery.h" #include "PokemonFRLG_OverworldRoute.h" namespace PokemonAutomation{ @@ -65,10 +66,8 @@ void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& contex // player faces at the start; if not given, it is read from the screen. // // When a battle or a message stops an open leg part-way, where the player -// is isn't guessed. Off the grass, on a leg to a warp, they hold on to the -// warp. Otherwise they hold to walls that find them (plan_reanchor()), and -// a new route to the same end is planned from there on `maps`, or on -// overworld_maps() if not given. +// is isn't guessed: see run_route(). Finding them again needs `planning`, +// what the route was planned with; without it the route fails instead. // // Throws OperationFailedException if a menu or message is open before a // step, a warp's fade or the Surf prompt doesn't show, a warp's fade turns @@ -80,7 +79,7 @@ void walk_overworld_route( const std::vector& route, std::optional facing = std::nullopt, const OverworldInterruptSet& interrupts = default_overworld_interrupts(), - const std::vector* maps = nullptr + const RoutePlanning* planning = nullptr ); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 01fd50b73c..3006c72515 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -28,6 +28,7 @@ #include "Inference/PokemonFRLG_WildEncounterReader.h" #include "Overworld/PokemonFRLG_OverworldMaps.h" #include "Overworld/PokemonFRLG_OverworldReanchor.h" +#include "Overworld/PokemonFRLG_OverworldRecovery.h" #include "Overworld/PokemonFRLG_OverworldRoute.h" #include "Overworld/PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_Navigation.h" @@ -293,6 +294,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_OverworldMaps(database); add_tests_OverworldRoute(database); add_tests_OverworldReanchor(database); + add_tests_OverworldRecovery(database); add_tests_PlayerFacingReader(database); } diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 609cef3b73..80ee75a713 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1774,6 +1774,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp From c0a3ca3ab90e9a7b864fc22c2fc2dc67c880bc19 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 10:36:44 -0800 Subject: [PATCH 17/25] FRLG walker: log the whole route before walking it run_route() logs "Route: plan, N steps: ; ; ..." first, so a run's log has the plan even when the walk fails part-way, and devtools/maps can draw the planned route next to the walked one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldRecovery.cpp | 12 ++++++++++ .../Overworld/PokemonFRLG_OverworldRecovery.h | 4 ++-- .../PokemonFRLG_OverworldRecovery_Tests.cpp | 22 ++++++++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index 0ce1064811..4252fc20da 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -15,6 +15,17 @@ namespace PokemonFRLG{ namespace{ +// "Route: plan, 2 steps: walk east 4 -> ...; face north -> ...": the whole +// route before any of it is walked, so that a run's log has the plan even +// when the walk fails part-way (devtools/maps draws it next to the walk). +std::string plan_line(const std::vector& route){ + std::string text = std::format("Route: plan, {} steps:", route.size()); + for (size_t c = 0; c < route.size(); c++){ + text += (c == 0 ? " " : "; ") + to_string(route[c]); + } + return text; +} + // The route from `plan[stopped]`, stopped part-way from `start`: holds that // find the player, a way back to that step's end, then the rest as planned. std::vector rejoin( @@ -56,6 +67,7 @@ void run_route( const std::vector& route, std::optional facing, const RoutePlanning* planning, const RouteCalls& calls ){ + calls.log(plan_line(route)); std::vector plan = route; std::optional at; size_t stops = 0; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h index 4cabe75272..8cbbffc239 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h @@ -60,8 +60,8 @@ struct RouteCalls{ // How often a route may stop part-way before it gives up. constexpr size_t MAX_ROUTE_STOPS = 8; -// Walks `route` from `facing`. Without `planning`, a stop that needs the -// player found again fails instead. +// Walks `route` from `facing`, after logging it whole. Without `planning`, +// a stop that needs the player found again fails instead. void run_route( const std::vector& route, std::optional facing, const RoutePlanning* planning, const RouteCalls& calls diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp index f32517b84d..a2a3791727 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp @@ -48,6 +48,7 @@ struct ScriptedRoute{ std::map ends; std::vector walked; std::vector held_to_warp; + std::vector logs; std::string failure; RouteCalls calls(){ @@ -58,7 +59,7 @@ struct ScriptedRoute{ return iter == ends.end() ? StepEnd::DONE : iter->second; }; calls.hold_to_warp = [this](const RouteStep& step){ held_to_warp.push_back(step); }; - calls.log = [](const std::string&){}; + calls.log = [this](const std::string& message){ logs.push_back(message); }; calls.fail = [this](const std::string& message){ failure = message; throw std::runtime_error(message); @@ -209,6 +210,24 @@ UnitTestResult test_warp_leg(){ return true; } +// The plan is logged whole before the first step, for the run's log. +UnitTestResult test_plan_logged(){ + const std::vector route{ + walk(Direction::EAST, 2, {"Corridor", {4, 1}}), + walk(Direction::WEST, 1, {"Corridor", {3, 1}}), + }; + ScriptedRoute script; + if (script.run(route, nullptr)){ + return "Failed: " + script.failure; + } + const std::string expected = "Route: plan, 2 steps: " + to_string(route[0]) + "; " + to_string(route[1]); + if (script.logs.empty() || script.logs[0] != expected){ + return "Expected the first log line \"" + expected + "\", got: " + + (script.logs.empty() ? std::string("nothing") : "\"" + script.logs[0] + "\""); + } + return true; +} + } @@ -217,6 +236,7 @@ void add_tests_OverworldRecovery(UnitTestDatabase& database){ database.add("no recovery", test_no_recovery); database.add("legs to a wall", test_wall_legs); database.add("a leg to a warp", test_warp_leg); + database.add("the plan is logged", test_plan_logged); } From 4c33feb9712ce41c62027617e2a8d9fb3a58eba8 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 11:10:49 -0800 Subject: [PATCH 18/25] FRLG walker: log the route in the shared route log lines run_route() logs what BDSP's walker logs, through Pokemon_RouteLog.h: the plan (from the caller's start, under its label, both now in RoutePlanning), each leg as it starts, and how it ended: walked, assumed (a stopped leg held on to its warp) or unknown (stopped part-way, a hold from an unknown tile, something seen after a hold); a stop's rejoined route as a new plan, and where the holds found the player again. This replaces the "Route: plan" and "Route: stopped part-way, finding the player again" lines and the walker's own per-step "Route:" line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldRecovery.cpp | 110 ++++++++++++++---- .../Overworld/PokemonFRLG_OverworldRecovery.h | 11 +- .../PokemonFRLG_OverworldRecovery_Tests.cpp | 45 +++++-- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 1 - 4 files changed, 131 insertions(+), 36 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index 4252fc20da..d362594518 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -5,6 +5,7 @@ */ #include +#include "Pokemon/Navigation/Pokemon_RouteLog.h" #include "PokemonFRLG_OverworldReanchor.h" #include "PokemonFRLG_OverworldRecovery.h" @@ -15,22 +16,83 @@ namespace PokemonFRLG{ namespace{ -// "Route: plan, 2 steps: walk east 4 -> ...; face north -> ...": the whole -// route before any of it is walked, so that a run's log has the plan even -// when the walk fails part-way (devtools/maps draws it next to the walk). -std::string plan_line(const std::vector& route){ - std::string text = std::format("Route: plan, {} steps:", route.size()); - for (size_t c = 0; c < route.size(); c++){ - text += (c == 0 ? " " : "; ") + to_string(route[c]); +using Pokemon::EndBasis; +using Pokemon::ROUTE_LOG_TAG; + +std::vector route_tiles(const std::optional& position){ + if (!position){ + return {}; + } + return {{position->map, position->point}}; +} + +Pokemon::RouteLogLeg route_log_leg(const RouteStep& step){ + Pokemon::RouteLogLeg leg{.action = Pokemon::RouteLegAction::WALK, .direction = step.direction, .tiles = (size_t)step.tiles, .end = Pokemon::RouteLegEnd::OPEN}; + if (step.action == RouteAction::SURF){ + leg.action = Pokemon::RouteLegAction::SURF; + }else if (step.action == RouteAction::FACE){ + leg.action = Pokemon::RouteLegAction::FACE; + }else if (step.warps){ + leg.end = Pokemon::RouteLegEnd::WARP; + }else if (step.to_wall){ + leg.end = Pokemon::RouteLegEnd::WALL; } - return text; + return leg; } +// The route's lines in the route log the games share, as BDSP's walker +// writes them, under the caller's label. +class RouteLogLines{ +public: + RouteLogLines(const RouteCalls& calls, const RoutePlanning* planning) + : m_calls(calls) + , m_label(planning != nullptr ? planning->label : "Route") + {} + void plan(const std::optional& from, const std::vector& steps) const{ + std::vector legs; + for (const RouteStep& step : steps){ + legs.push_back({.leg = route_log_leg(step), .after = route_tiles(step.end)}); + } + m_calls.log(ROUTE_LOG_TAG + Pokemon::route_plan_line(m_label, route_tiles(from), legs)); + } + void leg(size_t index, const std::vector& steps) const{ + const RouteStep& step = steps[index]; + m_calls.log(ROUTE_LOG_TAG + Pokemon::route_leg_line(index + 1, steps.size(), {.leg = route_log_leg(step), .after = route_tiles(step.end)})); + } + void end(const RouteStep& step, const std::optional& at, EndBasis basis, const std::string& why) const{ + m_calls.log(ROUTE_LOG_TAG + Pokemon::route_leg_end_line(m_label, route_log_leg(step), route_tiles(at), basis, why)); + } + // Step `number` (from 1) of a plan whose first `holds` steps are holds + // to walls, walked to its end. + void done(const RouteStep& step, size_t number, size_t holds) const{ + if (number < holds){ + // Where a hold but the last ends depends on where it started. + end(step, std::nullopt, EndBasis::UNKNOWN, "a hold from an unknown tile"); + return; + } + end(step, step.end, EndBasis::WALKED, ""); + if (number == holds){ + m_calls.log(ROUTE_LOG_TAG + Pokemon::route_found_line(m_label, route_tiles(step.end), holds)); + } + } + +private: + const RouteCalls& m_calls; + std::string m_label; +}; + +// A route rejoined after a stop: `holds` holds to walls, then the rest. +struct Rejoined{ + std::vector steps; + size_t holds = 0; +}; + // The route from `plan[stopped]`, stopped part-way from `start`: holds that // find the player, a way back to that step's end, then the rest as planned. -std::vector rejoin( +// Logs it as a new plan, from a tile not known. +Rejoined rejoin( const std::vector& plan, size_t stopped, const std::optional& start, - const RoutePlanning* planning, const RouteCalls& calls + const RoutePlanning* planning, const RouteCalls& calls, const RouteLogLines& lines ){ const RouteStep& step = plan[stopped]; if (planning == nullptr || planning->maps == nullptr){ @@ -49,14 +111,10 @@ std::vector rejoin( calls.fail("walk_overworld_route(): no route from " + to_string(anchor->end) + " back to " + to_string(step.end) + "."); return {}; } - std::string holds; - for (const RouteStep& hold : anchor->holds){ - holds += "; " + to_string(hold); - } - calls.log("Route: stopped part-way, finding the player again at " + to_string(anchor->end) + holds); - std::vector ret = anchor->holds; - ret.insert(ret.end(), back->begin(), back->end()); - ret.insert(ret.end(), plan.begin() + stopped + 1, plan.end()); + Rejoined ret{.steps = anchor->holds, .holds = anchor->holds.size()}; + ret.steps.insert(ret.steps.end(), back->begin(), back->end()); + ret.steps.insert(ret.steps.end(), plan.begin() + stopped + 1, plan.end()); + lines.plan(std::nullopt, ret.steps); return ret; } @@ -67,17 +125,20 @@ void run_route( const std::vector& route, std::optional facing, const RoutePlanning* planning, const RouteCalls& calls ){ - calls.log(plan_line(route)); + const RouteLogLines lines(calls, planning); std::vector plan = route; - std::optional at; + std::optional at = planning != nullptr ? planning->start : std::nullopt; + lines.plan(at, plan); size_t stops = 0; + size_t holds = 0; // of the plan, holds to walls that find the player for (size_t i = 0; i < plan.size();){ const RouteStep step = plan[i]; + lines.leg(i, plan); const StepEnd end = calls.walk(step, facing); if (end == StepEnd::DONE){ at = step.end; facing = step.facing_after; - i++; + lines.done(step, ++i, holds); continue; } facing = std::nullopt; // a trainer turns the player to face them @@ -86,6 +147,7 @@ void run_route( return; } if (step.to_wall && end == StepEnd::LATE){ + lines.end(step, std::nullopt, EndBasis::UNKNOWN, "something seen after the hold; walking it again"); continue; // walking it again ends at its wall } if (step.to_wall){ @@ -99,9 +161,13 @@ void run_route( at = step.end; facing = step.facing_after; i++; + lines.end(step, at, EndBasis::ASSUMED, "stopped, then held on to the warp"); continue; } - plan = rejoin(plan, i, at, planning, calls); + lines.end(step, std::nullopt, EndBasis::UNKNOWN, "stopped part-way"); + Rejoined rejoined = rejoin(plan, i, at, planning, calls, lines); + plan = std::move(rejoined.steps); + holds = rejoined.holds; i = 0; } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h index 8cbbffc239..21dca34809 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h @@ -41,10 +41,13 @@ enum class StepEnd{ }; // What the route was planned with, to plan the way back onto it: the maps -// (with the event flags the caller set) and the costs. +// (with the event flags the caller set) and the costs; and, for the route +// log (Pokemon_RouteLog.h), where it starts and what the caller calls it. struct RoutePlanning{ const std::vector* maps = nullptr; RouteCosts costs = default_route_costs(); + std::optional start; + std::string label = "Route"; }; // The console side. `walk` walks one step from `facing` (unknown after a @@ -60,8 +63,10 @@ struct RouteCalls{ // How often a route may stop part-way before it gives up. constexpr size_t MAX_ROUTE_STOPS = 8; -// Walks `route` from `facing`, after logging it whole. Without `planning`, -// a stop that needs the player found again fails instead. +// Walks `route` from `facing`, logging it in the shared route log lines: +// the plan, each leg as it starts and how it ended, and where the player +// was found again. Without `planning`, a stop that needs the player found +// again fails instead. void run_route( const std::vector& route, std::optional facing, const RoutePlanning* planning, const RouteCalls& calls diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp index a2a3791727..cfb8af77cf 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp @@ -110,7 +110,7 @@ UnitTestResult test_stop_recover_resume(){ } ScriptedRoute script; script.ends[stopped] = StepEnd::STOPPED; - const RoutePlanning planning{&maps, default_route_costs()}; + const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = std::nullopt, .label = "Route"}; if (script.run(*route, &planning)){ return "Failed: " + script.failure; } @@ -134,6 +134,19 @@ UnitTestResult test_stop_recover_resume(){ if (!match){ return "Walked:" + describe(script.walked) + "\nexpected:" + describe(expected); } + const std::vector logged_parts{ + "): ended at an unknown tile (unknown: stopped part-way).", + ": found again at " + anchor->end.map + " " + Pokemon::to_string(anchor->end.point), + }; + for (const std::string& part : logged_parts){ + bool found = false; + for (const std::string& line : script.logs){ + found = found || line.find(part) != std::string::npos; + } + if (!found){ + return "Expected a log line with \"" + part + "\"."; + } + } return true; } @@ -171,7 +184,7 @@ UnitTestResult test_no_recovery(){ } ScriptedRoute without_walls; without_walls.ends[0] = StepEnd::STOPPED; - const RoutePlanning planning{&maps, default_route_costs()}; + const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = std::nullopt, .label = "Route"}; if (!without_walls.run(route, &planning) || without_walls.failure.find("no walls") == std::string::npos){ return "Expected a failure without walls, got: " + without_walls.failure; } @@ -210,20 +223,32 @@ UnitTestResult test_warp_leg(){ return true; } -// The plan is logged whole before the first step, for the run's log. -UnitTestResult test_plan_logged(){ +// The route log lines the games share (Pokemon_RouteLog.h): the plan from +// the caller's start under its label, then each leg and how it ended. +UnitTestResult test_route_logged(){ const std::vector route{ walk(Direction::EAST, 2, {"Corridor", {4, 1}}), walk(Direction::WEST, 1, {"Corridor", {3, 1}}), }; + const RoutePlanning planning{.maps = nullptr, .costs = default_route_costs(), .start = MapPosition{"Corridor", {2, 1}}, .label = "test walk"}; ScriptedRoute script; - if (script.run(route, nullptr)){ + if (script.run(route, &planning)){ return "Failed: " + script.failure; } - const std::string expected = "Route: plan, 2 steps: " + to_string(route[0]) + "; " + to_string(route[1]); - if (script.logs.empty() || script.logs[0] != expected){ - return "Expected the first log line \"" + expected + "\", got: " - + (script.logs.empty() ? std::string("nothing") : "\"" + script.logs[0] + "\""); + const std::vector expected{ + "[Route] test walk: plan from Corridor (2, 1):\n" + " east 2 (open) to Corridor (4, 1)\n" + " west 1 (open) to Corridor (3, 1)", + "[Route] Leg 1/2: east 2 (open) to Corridor (4, 1)", + "[Route] test walk: east 2 (open): ended at Corridor (4, 1) (walked).", + "[Route] Leg 2/2: west 1 (open) to Corridor (3, 1)", + "[Route] test walk: west 1 (open): ended at Corridor (3, 1) (walked).", + }; + for (size_t c = 0; c < expected.size(); c++){ + const std::string got = c < script.logs.size() ? "\"" + script.logs[c] + "\"" : std::string("nothing"); + if (c >= script.logs.size() || script.logs[c] != expected[c]){ + return "Log line " + std::to_string(c) + ": expected \"" + expected[c] + "\", got " + got + "."; + } } return true; } @@ -236,7 +261,7 @@ void add_tests_OverworldRecovery(UnitTestDatabase& database){ database.add("no recovery", test_no_recovery); database.add("legs to a wall", test_wall_legs); database.add("a leg to a warp", test_warp_leg); - database.add("the plan is logged", test_plan_logged); + database.add("the route is logged", test_route_logged); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 0fa3760383..332aa34c7b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -479,7 +479,6 @@ void walk_overworld_route( } RouteCalls calls; calls.walk = [&](const RouteStep& step, std::optional from_facing){ - console.log("Route: " + to_string(step)); check_overworld(console, context, step); return walk_and_watch(console, context, step, from_facing, interrupts); }; From de84c7d9329f8fcdb5fec1436a153bda27f4c52f Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 11:15:21 -0800 Subject: [PATCH 19/25] FRLG recovery tests: check the logged lines in a helper, for the function size limit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldRecovery_Tests.cpp | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp index cfb8af77cf..32eb900a1d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp @@ -85,6 +85,20 @@ std::string describe(const std::vector& route){ return text; } +// Passes if each of `parts` is in some line of `logs`. +UnitTestResult expect_logged(const std::vector& logs, const std::vector& parts){ + for (const std::string& part : parts){ + bool found = false; + for (const std::string& line : logs){ + found = found || line.find(part) != std::string::npos; + } + if (!found){ + return "Expected a log line with \"" + part + "\"."; + } + } + return true; +} + bool same(const RouteStep& a, const RouteStep& b){ return to_string(a) == to_string(b); } @@ -134,20 +148,10 @@ UnitTestResult test_stop_recover_resume(){ if (!match){ return "Walked:" + describe(script.walked) + "\nexpected:" + describe(expected); } - const std::vector logged_parts{ + return expect_logged(script.logs, { "): ended at an unknown tile (unknown: stopped part-way).", ": found again at " + anchor->end.map + " " + Pokemon::to_string(anchor->end.point), - }; - for (const std::string& part : logged_parts){ - bool found = false; - for (const std::string& line : script.logs){ - found = found || line.find(part) != std::string::npos; - } - if (!found){ - return "Expected a log line with \"" + part + "\"."; - } - } - return true; + }); } // A corridor with a person at each end: nothing finds the player again. From 9c70639416fcf1d147fe1c1a5d564440f39f5e66 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 12:21:50 -0800 Subject: [PATCH 20/25] FRLG walker: the header says what an interrupted open leg does now Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index bf10625604..e0b0387f0d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -10,8 +10,8 @@ * A message or a wild battle can stop the player part-way through a leg. * Each kind is an OverworldInterrupt: the walker watches for all of them * while it holds the D-pad, and when one shows it stops, has it cleared and - * walks the rest of the leg. Open legs where a wild battle can start are - * walked a tile at a time, so that the battle is placed on its step. + * walks the rest of the leg; an open leg stops there instead, and the + * route finds the player again (PokemonFRLG_OverworldRecovery.h). * */ From be72296d43b6ecf390c1032dc89a95b6a6c94647 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 13:26:50 -0800 Subject: [PATCH 21/25] FRLG walker: check each leg to a wall ended at its wall (forest drift diagnostic) Four runs on 2026-11-01 ended the walk to Viridian Forest's north gate off its column, which 52 legs walked by hand didn't reproduce. After every leg to a wall that ended, the walker now taps once more toward the wall; if the map scrolls, the player wasn't at it, and the route fails with "position check: did not end at its wall", logging the leg, its re-holds for something seen late and the believed tile. Marked as a diagnostic to remove or turn into a recovery once the cause is known. RoutePlanning::wall_check SKIP turns it off (the Fast checks level). The movement threshold is a guess on the safe side, not yet measured. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldRecovery.cpp | 59 ++++++++++++++++++- .../Overworld/PokemonFRLG_OverworldRecovery.h | 12 ++++ .../PokemonFRLG_OverworldRecovery_Tests.cpp | 50 +++++++++++++++- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 32 ++++++++++ .../Overworld/PokemonFRLG_OverworldWalk.h | 4 ++ 5 files changed, 152 insertions(+), 5 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index d362594518..776cf1ef45 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -81,6 +81,61 @@ class RouteLogLines{ std::string m_label; }; +// Walks each step through `calls`; logs a leg to a wall seen interrupted +// after its hold, which run_route() walks again, and checks a leg to a wall +// that ended did end at its wall. +class CheckedSteps{ +public: + CheckedSteps(const RouteCalls& calls, const RouteLogLines& lines, WallCheck wall_check) + : m_calls(calls) + , m_lines(lines) + , m_wall_check(wall_check) + {} + StepEnd walk(const RouteStep& step, std::optional facing){ + const std::string leg = to_string(step); + m_reholds = m_last_late && leg == m_last ? m_reholds + 1 : 0; + const StepEnd end = m_calls.walk(step, facing); + m_last = leg; + m_last_late = end == StepEnd::LATE; + if (step.to_wall && end == StepEnd::LATE){ + m_lines.end(step, std::nullopt, EndBasis::UNKNOWN, "something seen after the hold; walking it again"); + } + if (end == StepEnd::DONE){ + check_wall(step); + } + return end; + } + +private: + // TODO(forest drift, 2026-11-01): diagnostic; remove or turn into a recovery once the cause is known. + // Four runs ended the walk to Viridian Forest's north gate off its + // column, which 52 legs walked by hand didn't reproduce: one more tap + // toward a leg's wall moves nobody who stands at it. + void check_wall(const RouteStep& step) const{ + if (!step.to_wall || step.warps || step.action != RouteAction::WALK + || m_wall_check == WallCheck::SKIP || !m_calls.moved_past_wall + ){ + return; + } + if (!m_calls.moved_past_wall(step)){ + return; + } + m_calls.log(std::format( + "Position check: {} moved on past where its wall should be; believed at {}, after {} re-hold(s) " + "for something seen late (the leg's interrupts are the \"Leg:\" lines above).", + to_string(step), to_string(step.end), m_reholds + )); + m_calls.fail("position check: " + to_string(step) + " did not end at its wall"); + } + + const RouteCalls& m_calls; + const RouteLogLines& m_lines; + WallCheck m_wall_check = WallCheck::TAP; + std::string m_last; + bool m_last_late = false; + size_t m_reholds = 0; +}; + // A route rejoined after a stop: `holds` holds to walls, then the rest. struct Rejoined{ std::vector steps; @@ -126,6 +181,7 @@ void run_route( const RoutePlanning* planning, const RouteCalls& calls ){ const RouteLogLines lines(calls, planning); + CheckedSteps steps(calls, lines, planning != nullptr ? planning->wall_check : WallCheck::TAP); std::vector plan = route; std::optional at = planning != nullptr ? planning->start : std::nullopt; lines.plan(at, plan); @@ -134,7 +190,7 @@ void run_route( for (size_t i = 0; i < plan.size();){ const RouteStep step = plan[i]; lines.leg(i, plan); - const StepEnd end = calls.walk(step, facing); + const StepEnd end = steps.walk(step, facing); if (end == StepEnd::DONE){ at = step.end; facing = step.facing_after; @@ -147,7 +203,6 @@ void run_route( return; } if (step.to_wall && end == StepEnd::LATE){ - lines.end(step, std::nullopt, EndBasis::UNKNOWN, "something seen after the hold; walking it again"); continue; // walking it again ends at its wall } if (step.to_wall){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h index 21dca34809..bd16372e5f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h @@ -40,6 +40,13 @@ enum class StepEnd{ // stopped the player before the end }; +// Whether, after each leg to a wall, the walker taps once more toward the +// wall to check the player really stands at it (see RouteCalls). +enum class WallCheck{ + TAP, + SKIP, +}; + // What the route was planned with, to plan the way back onto it: the maps // (with the event flags the caller set) and the costs; and, for the route // log (Pokemon_RouteLog.h), where it starts and what the caller calls it. @@ -48,6 +55,7 @@ struct RoutePlanning{ RouteCosts costs = default_route_costs(); std::optional start; std::string label = "Route"; + WallCheck wall_check = WallCheck::TAP; }; // The console side. `walk` walks one step from `facing` (unknown after a @@ -58,6 +66,10 @@ struct RouteCalls{ std::function hold_to_warp; std::function log; std::function fail; + // Taps once more toward the wall a leg to a wall ended at, and says + // whether the player moved: if so, the leg didn't end at its wall. Not + // set (or WallCheck::SKIP): not checked. + std::function moved_past_wall; }; // How often a route may stop part-way before it gives up. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp index 32eb900a1d..84ef5e485b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp @@ -6,6 +6,7 @@ * */ +#include #include #include #include @@ -50,6 +51,9 @@ struct ScriptedRoute{ std::vector held_to_warp; std::vector logs; std::string failure; + // Whether the tap past a leg's wall moves the player, and the taps made. + bool moves_past_wall = false; + size_t wall_taps = 0; RouteCalls calls(){ RouteCalls calls; @@ -60,6 +64,10 @@ struct ScriptedRoute{ }; calls.hold_to_warp = [this](const RouteStep& step){ held_to_warp.push_back(step); }; calls.log = [this](const std::string& message){ logs.push_back(message); }; + calls.moved_past_wall = [this](const RouteStep&){ + wall_taps++; + return moves_past_wall; + }; calls.fail = [this](const std::string& message){ failure = message; throw std::runtime_error(message); @@ -124,7 +132,7 @@ UnitTestResult test_stop_recover_resume(){ } ScriptedRoute script; script.ends[stopped] = StepEnd::STOPPED; - const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = std::nullopt, .label = "Route"}; + const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = std::nullopt, .label = "Route", .wall_check = WallCheck::TAP}; if (script.run(*route, &planning)){ return "Failed: " + script.failure; } @@ -188,7 +196,7 @@ UnitTestResult test_no_recovery(){ } ScriptedRoute without_walls; without_walls.ends[0] = StepEnd::STOPPED; - const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = std::nullopt, .label = "Route"}; + const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = std::nullopt, .label = "Route", .wall_check = WallCheck::TAP}; if (!without_walls.run(route, &planning) || without_walls.failure.find("no walls") == std::string::npos){ return "Expected a failure without walls, got: " + without_walls.failure; } @@ -227,6 +235,41 @@ UnitTestResult test_warp_leg(){ return true; } +// After a leg to a wall, a tap toward it that moves the player fails the +// route (the forest drift diagnostic); one that doesn't, or with the check +// off, goes on. Open legs aren't tapped. +UnitTestResult test_wall_check(){ + RouteStep wall = walk(Direction::NORTH, 3, {"Corridor", {1, 1}}); + wall.to_wall = true; + const RouteStep open = walk(Direction::EAST, 2, {"Corridor", {3, 1}}); + struct Case{ + const char* name; + bool moves; + WallCheck check; + bool fails; + size_t taps; + }; + const Case cases[] = { + {.name = "the tap moves", .moves = true, .check = WallCheck::TAP, .fails = true, .taps = 1}, + {.name = "the tap bumps", .moves = false, .check = WallCheck::TAP, .fails = false, .taps = 1}, + {.name = "the check is off", .moves = true, .check = WallCheck::SKIP, .fails = false, .taps = 0}, + }; + for (const Case& c : cases){ + const RoutePlanning planning{.maps = nullptr, .costs = default_route_costs(), .start = std::nullopt, .label = "Route", .wall_check = c.check}; + ScriptedRoute script; + script.moves_past_wall = c.moves; + const bool failed = script.run({wall, open}, &planning); + if (failed != c.fails || script.wall_taps != c.taps){ + return std::format("{}: expected {} after {} tap(s), got {} after {}: {}", + c.name, c.fails ? "a failure" : "no failure", c.taps, failed ? "a failure" : "none", script.wall_taps, script.failure); + } + if (c.fails && script.failure != "position check: " + to_string(wall) + " did not end at its wall"){ + return std::string(c.name) + ": wrong failure: " + script.failure; + } + } + return true; +} + // The route log lines the games share (Pokemon_RouteLog.h): the plan from // the caller's start under its label, then each leg and how it ended. UnitTestResult test_route_logged(){ @@ -234,7 +277,7 @@ UnitTestResult test_route_logged(){ walk(Direction::EAST, 2, {"Corridor", {4, 1}}), walk(Direction::WEST, 1, {"Corridor", {3, 1}}), }; - const RoutePlanning planning{.maps = nullptr, .costs = default_route_costs(), .start = MapPosition{"Corridor", {2, 1}}, .label = "test walk"}; + const RoutePlanning planning{.maps = nullptr, .costs = default_route_costs(), .start = MapPosition{"Corridor", {2, 1}}, .label = "test walk", .wall_check = WallCheck::TAP}; ScriptedRoute script; if (script.run(route, &planning)){ return "Failed: " + script.failure; @@ -266,6 +309,7 @@ void add_tests_OverworldRecovery(UnitTestDatabase& database){ database.add("legs to a wall", test_wall_legs); database.add("a leg to a warp", test_warp_leg); database.add("the route is logged", test_route_logged); + database.add("a leg to a wall ends at it", test_wall_check); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 332aa34c7b..c3873ad4c5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -7,6 +7,8 @@ #include #include #include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageDiff.h" #include "CommonTools/Async/InferenceRoutines.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "CommonFramework/VideoPipeline/VideoFeed.h" @@ -16,6 +18,7 @@ #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_OverworldMaps.h" #include "PokemonFRLG_OverworldRecovery.h" #include "PokemonFRLG_PlayerFacingReader.h" @@ -397,6 +400,32 @@ void hold_to_warp( ); } +// TODO(forest drift, 2026-11-01): diagnostic; remove or turn into a recovery once the cause is known. +// How much the game screen's top quarter, clear of the player in the +// middle, may change when a tap toward a wall only bumps the player (the +// player's own animation, an animated tile). A step scrolls the whole map +// by a tile. Not yet measured on the console: a guess on the safe side. +constexpr double WALL_TAP_MOVED_RMSD = 20.0; + +// Taps once toward the wall `step` ended at (the player faces it, so a tap +// steps if it can) and says whether the map scrolled: whether the player +// moved instead of bumping into the wall. +bool moved_past_wall(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ + context.wait_for_all_requests(); + VideoSnapshot before = console.video().snapshot(); + pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TILE_TIME + TURN_SETTLE); + context.wait_for_all_requests(); + VideoSnapshot after = console.video().snapshot(); + if (!before || !after){ + return false; + } + const ImageFloatBox game = GameSettings::instance().GAME_BOX; + const ImageFloatBox band(game.x, game.y, game.width, game.height * 0.25); + const double change = ImageMatch::pixel_RMSD(extract_box_reference(before, band), extract_box_reference(after, band)); + console.log(std::format("Position check: a tap toward the wall of {} changed the screen by {:.1f}.", to_string(step), change)); + return change > WALL_TAP_MOVED_RMSD; +} + // Each leg starts with a turn tap unless it goes the way the player faces. // A tap while already facing that way takes a step, so a facing not given // is read from the screen. If that fails too it taps anyway. @@ -489,6 +518,9 @@ void walk_overworld_route( calls.fail = [&](const std::string& message){ OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, message, console); }; + calls.moved_past_wall = [&](const RouteStep& step){ + return moved_past_wall(console, context, step); + }; run_route(route, facing, planning, calls); context.wait_for_all_requests(); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index e0b0387f0d..007dd3a13e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -69,6 +69,10 @@ void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& contex // is isn't guessed: see run_route(). Finding them again needs `planning`, // what the route was planned with; without it the route fails instead. // +// After each leg to a wall it taps once more toward the wall, and fails if +// the player moves (a diagnostic; RoutePlanning::wall_check SKIP turns it +// off, as the Fast checks level does). +// // Throws OperationFailedException if a menu or message is open before a // step, a warp's fade or the Surf prompt doesn't show, a warp's fade turns // out to be a wild battle's, a leg is interrupted too many times, or the From 4e9ec1d756ad56bcdcc1aadc19e3e9c876729481 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 1 Nov 2026 16:44:38 -0800 Subject: [PATCH 22/25] FRLG: re-anchor and recovery test maps in the shared schema The field and corridor test maps are written as Pokemon::MapData maps (#117 moved FRLG onto it): a source, no width or height, the field's ledges as south-facing 'v', and the corridor's people as named npc objects. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp | 8 +++----- .../Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp | 10 ++++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp index b42c15f4c6..49b42cbcdb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp @@ -93,11 +93,9 @@ UnitTestResult test_pallet_to_viridian_mart(){ // A field with a wall at its east end and a row of ledges to the south. const char* const TEST_FIELD_JSON = R"({ - "name": "Field", "width": 8, "height": 5, - "tiles": ["########", "#......#", "#......#", "#JJJJJJ#", "#......#"], - "elevation": ["33333333", "33333333", "33333333", "33333333", "33333333"], - "warps": [], "objects": [], - "connections": [], "points": [], "flag_sets": [] + "name": "Field", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["########", "#......#", "#......#", "#vvvvvv#", "#......#"], + "elevation": ["33333333", "33333333", "33333333", "33333333", "33333333"] })"; std::string describe(const std::optional& anchor){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp index 84ef5e485b..17b7fcb4ce 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp @@ -164,15 +164,13 @@ UnitTestResult test_stop_recover_resume(){ // A corridor with a person at each end: nothing finds the player again. const char* const TEST_CORRIDOR_JSON = R"({ - "name": "Corridor", "width": 7, "height": 3, + "name": "Corridor", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, "tiles": ["#######", "#.....#", "#######"], "elevation": ["3333333", "3333333", "3333333"], - "warps": [], "objects": [ - {"x": 1, "y": 1, "kind": "youngster", "flag": "", "wander_x": 0, "wander_y": 0}, - {"x": 5, "y": 1, "kind": "lass", "flag": "", "wander_x": 0, "wander_y": 0} - ], - "connections": [], "points": [], "flag_sets": [] + {"name": "youngster", "kind": "npc", "x": 1, "y": 1}, + {"name": "lass", "kind": "npc", "x": 5, "y": 1} + ] })"; RouteStep walk(Direction direction, int tiles, MapPosition end){ From a0cbf74678e30a8d5a3b42ed3e343092304fbb78 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 2 Nov 2026 12:44:47 -0800 Subject: [PATCH 23/25] FRLG recovery walk: walk at a given speed walk_overworld_route_with_recovery() takes the walker's MoveSpeed and uses it for every leg, the hold on toward a warp after a stop, and the [Speed] line of each leg to a wall. Holds back to a wall after a stop run too; the wall-check tap stays a plain tap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldRecoveryWalk.cpp | 27 +++++++++++-------- .../PokemonFRLG_OverworldRecoveryWalk.h | 10 ++++--- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 258cd683c4..9fab5fe3f2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -28,12 +28,12 @@ namespace{ // An open leg stops at its first interruption: how far it got isn't // counted (that went wrong too often on the console), the route finds the // player again at a wall instead. See walk_overworld_route_with_recovery(). -Pokemon::LegSpec leg_spec(const RouteStep& step){ +Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ Pokemon::LegSpec leg; leg.direction = step.direction; leg.tiles = step.tiles; leg.to_wall = step.to_wall; - leg.tile_time = step.surfing ? SURF_TILE_TIME : TILE_TIME; + leg.tile_time = tile_time(step, speed); leg.stop_on_interrupt = !step.to_wall; leg.stop_if_blocked = step.to_wall; return leg; @@ -44,7 +44,7 @@ Pokemon::LegSpec leg_spec(const RouteStep& step){ bool walk_step( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, - const OverworldInterruptSet& interrupts + const OverworldInterruptSet& interrupts, MoveSpeed speed ){ switch (step.action){ case RouteAction::WALK: @@ -54,7 +54,10 @@ bool walk_step( if (facing != step.direction){ pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TURN_SETTLE); } - if (Pokemon::walk_leg(console, context, hold_dpad, step.wild ? interrupts.wild : interrupts.other, leg_spec(step), MAX_INTERRUPTIONS_PER_LEG).stopped){ + if (const std::string note = speed_note(step, speed); !note.empty()){ + console.log(note); + } + if (Pokemon::walk_leg(console, context, leg_mover(step, speed), step.wild ? interrupts.wild : interrupts.other, leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG).stopped){ return false; } if (step.warps){ @@ -80,9 +83,9 @@ bool walk_step( StepEnd walk_and_watch( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, - const OverworldInterruptSet& interrupts + const OverworldInterruptSet& interrupts, MoveSpeed speed ){ - if (!walk_step(console, context, step, facing, interrupts)){ + if (!walk_step(console, context, step, facing, interrupts, speed)){ return StepEnd::STOPPED; } if (step.action == RouteAction::FACE){ @@ -102,8 +105,9 @@ StepEnd walk_and_watch( // After a trainer the player faces them, so this turns first. void hold_to_warp( ConsoleHandle& console, ProControllerContext& context, - const RouteStep& step, const OverworldInterrupts& interrupts + const RouteStep& step, const OverworldInterrupts& interrupts, MoveSpeed speed ){ + const Pokemon::LegMover mover = leg_mover(step, speed); for (size_t count = 0; count <= MAX_INTERRUPTIONS_PER_LEG; count++){ BlackScreenWatcher fade(COLOR_RED); std::vector> watchers; @@ -117,7 +121,7 @@ void hold_to_warp( console, context, [&](ProControllerContext& context){ pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TURN_SETTLE); - pbf_press_dpad(context, dpad(step.direction), TILE_TIME * (step.tiles + 2), STEP_RELEASE); + mover(context, step.direction, tile_time(step, speed) * (step.tiles + 2)); }, callbacks ); @@ -173,7 +177,8 @@ void walk_overworld_route_with_recovery( const std::vector& route, const RoutePlanning& planning, std::optional facing, - const OverworldInterruptSet& interrupts + const OverworldInterruptSet& interrupts, + MoveSpeed speed ){ if (route.empty()){ return; @@ -184,10 +189,10 @@ void walk_overworld_route_with_recovery( RouteCalls calls; calls.walk = [&](const RouteStep& step, std::optional from_facing){ check_overworld(console, context, step); - return walk_and_watch(console, context, step, from_facing, interrupts); + return walk_and_watch(console, context, step, from_facing, interrupts, speed); }; calls.hold_to_warp = [&](const RouteStep& step){ - hold_to_warp(console, context, step, interrupts.other); + hold_to_warp(console, context, step, interrupts.other, speed); }; calls.log = [&](const std::string& message){ console.log(message, COLOR_ORANGE); }; calls.fail = [&](const std::string& message){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h index 9cf58dc0a7..78804e56cd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h @@ -25,9 +25,10 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -// Walks `route`, planned with `planning`, as walk_overworld_route() does, -// from the same start: standing still on the overworld, not running and -// not on the bike, facing `facing` (read from the screen if not given). +// Walks `route`, planned with `planning`, at `speed`, as +// walk_overworld_route() does, from the same start: standing still on the +// overworld, not on the bike, facing `facing` (read from the screen if not +// given). // // When a battle or a message stops an open leg part-way, where the player // is isn't guessed: run_route() finds them again with `planning`'s maps @@ -47,7 +48,8 @@ void walk_overworld_route_with_recovery( const std::vector& route, const RoutePlanning& planning, std::optional facing = std::nullopt, - const OverworldInterruptSet& interrupts = default_overworld_interrupts() + const OverworldInterruptSet& interrupts = default_overworld_interrupts(), + MoveSpeed speed = MoveSpeed::WALK ); From 852cde976e1f463acd06a06e8d90e1d1c0ecf8b4 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 2 Nov 2026 20:14:48 -0800 Subject: [PATCH 24/25] FRLG walker: fight a trainer who spots the player, and go on from where they stopped them On Route 3 (console B, 2026-11-02) Lass Robin, wandering x 40, y 10..12, saw the player on (40, 9) during "east 11 (to wall)". Her message showed after the leg's 500ms watch, and the next leg's overworld check failed the walk. A leg that crosses a trainer's sight on the maps (trainer_crossing(), from where they stand or wander and each way they look) is now watched after it for the trainer's "!", walk-up and message, and what shows goes to the leg's interrupts (AutoStory's StoryMessage fights the trainer). When the leg has a single tile in any trainer's sight and is off the grass, the player is known to stand there facing the leg's way, so the route is planned again from that tile; otherwise the existing recovery runs. A message open before a leg goes to the interrupts once (clear_open_message()). run_route() moves into a RouteRun class to stay under the function length limit; its other steps are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldRecovery.cpp | 214 ++++++++++++++---- .../Overworld/PokemonFRLG_OverworldRecovery.h | 26 ++- .../PokemonFRLG_OverworldRecoveryWalk.cpp | 27 +++ .../PokemonFRLG_OverworldRecoveryWalk.h | 14 +- .../PokemonFRLG_OverworldRecovery_Tests.cpp | 132 ++++++++++- 5 files changed, 362 insertions(+), 51 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index 776cf1ef45..f03c45b34d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -4,6 +4,7 @@ * */ +#include #include #include "Pokemon/Navigation/Pokemon_RouteLog.h" #include "PokemonFRLG_OverworldReanchor.h" @@ -40,6 +41,33 @@ Pokemon::RouteLogLeg route_log_leg(const RouteStep& step){ return leg; } +// The farthest sight of the trainers on `maps` who may see `tile`; 0 if +// none does. +int sight_onto(const std::vector& maps, const MapPosition& tile){ + int sight = 0; + for (const MapData& map : maps){ + if (map.name != tile.map){ + continue; + } + for (const MapObject& object : map.objects){ + if (trainer_may_see(map, object, tile.point)){ + sight = std::max(sight, object.trainer->sight); + } + } + } + return sight; +} + +// Where a trainer stopped `step`, if the maps say: the one tile of the leg +// a trainer may see. Not on the grass, where a stop may be a wild +// battle's, nor on a leg to a warp, which is held on to its warp instead. +std::optional trainer_stop(const std::optional& crossing, const RouteStep& step){ + if (!crossing || crossing->seen.size() != 1 || step.wild || step.warps){ + return std::nullopt; + } + return crossing->seen[0]; +} + // The route's lines in the route log the games share, as BDSP's walker // writes them, under the caller's label. class RouteLogLines{ @@ -62,6 +90,9 @@ class RouteLogLines{ void end(const RouteStep& step, const std::optional& at, EndBasis basis, const std::string& why) const{ m_calls.log(ROUTE_LOG_TAG + Pokemon::route_leg_end_line(m_label, route_log_leg(step), route_tiles(at), basis, why)); } + void trainer_stopped(const RouteStep& step, const MapPosition& at) const{ + m_calls.log(ROUTE_LOG_TAG + Pokemon::route_replan_line(m_label, route_log_leg(step), "stopped by a trainer who saw the player", route_tiles(at))); + } // Step `number` (from 1) of a plan whose first `holds` steps are holds // to walls, walked to its end. void done(const RouteStep& step, size_t number, size_t holds) const{ @@ -81,9 +112,10 @@ class RouteLogLines{ std::string m_label; }; -// Walks each step through `calls`; logs a leg to a wall seen interrupted -// after its hold, which run_route() walks again, and checks a leg to a wall -// that ended did end at its wall. +// Walks each step through `calls`; watches a leg that crosses a trainer's +// sight for the trainer; logs a leg to a wall seen interrupted after its +// hold, which run_route() walks again, and checks a leg to a wall that +// ended did end at its wall. class CheckedSteps{ public: CheckedSteps(const RouteCalls& calls, const RouteLogLines& lines, WallCheck wall_check) @@ -91,13 +123,16 @@ class CheckedSteps{ , m_lines(lines) , m_wall_check(wall_check) {} - StepEnd walk(const RouteStep& step, std::optional facing){ + StepEnd walk(const RouteStep& step, std::optional facing, const std::optional& crossing){ const std::string leg = to_string(step); m_reholds = m_last_late && leg == m_last ? m_reholds + 1 : 0; - const StepEnd end = m_calls.walk(step, facing); + StepEnd end = m_calls.walk(step, facing); + if (end == StepEnd::DONE && crossing && m_calls.watch_for_trainer){ + end = m_calls.watch_for_trainer(step, crossing->approach_tiles); + } m_last = leg; m_last_late = end == StepEnd::LATE; - if (step.to_wall && end == StepEnd::LATE){ + if (step.to_wall && end == StepEnd::LATE && !trainer_stop(crossing, step)){ m_lines.end(step, std::nullopt, EndBasis::UNKNOWN, "something seen after the hold; walking it again"); } if (end == StepEnd::DONE){ @@ -173,58 +208,147 @@ Rejoined rejoin( return ret; } -} +// run_route()'s walk: the plan, as rejoined or planned again after a +// stop, which step is next, where the player is and which way they face. +class RouteRun{ +public: + RouteRun(const std::vector& route, std::optional facing, const RoutePlanning* planning, const RouteCalls& calls) + : m_planning(planning) + , m_calls(calls) + , m_lines(calls, planning) + , m_steps(calls, m_lines, planning != nullptr ? planning->wall_check : WallCheck::TAP) + , m_plan(route) + , m_at(planning != nullptr ? planning->start : std::nullopt) + , m_facing(facing) + {} + void run(){ + m_lines.plan(m_at, m_plan); + while (m_next < m_plan.size()){ + const RouteStep step = m_plan[m_next]; + m_lines.leg(m_next, m_plan); + // Where a hold that finds the player starts isn't known. + const std::optional crossing = m_next >= m_holds ? crossing_here(step) : std::nullopt; + const StepEnd end = m_steps.walk(step, m_facing, crossing); + if (end == StepEnd::DONE){ + m_at = step.end; + m_facing = step.facing_after; + m_lines.done(step, ++m_next, m_holds); + continue; + } + m_facing = std::nullopt; // not known after a stop + if (++m_stops > MAX_ROUTE_STOPS){ + m_calls.fail(std::format("walk_overworld_route(): stopped part-way {} times.", m_stops)); + return; + } + if (!after_trainer(step, crossing) && !after_stop(step, end)){ + return; + } + } + } -void run_route( - const std::vector& route, std::optional facing, - const RoutePlanning* planning, const RouteCalls& calls -){ - const RouteLogLines lines(calls, planning); - CheckedSteps steps(calls, lines, planning != nullptr ? planning->wall_check : WallCheck::TAP); - std::vector plan = route; - std::optional at = planning != nullptr ? planning->start : std::nullopt; - lines.plan(at, plan); - size_t stops = 0; - size_t holds = 0; // of the plan, holds to walls that find the player - for (size_t i = 0; i < plan.size();){ - const RouteStep step = plan[i]; - lines.leg(i, plan); - const StepEnd end = steps.walk(step, facing); - if (end == StepEnd::DONE){ - at = step.end; - facing = step.facing_after; - lines.done(step, ++i, holds); - continue; +private: + std::optional crossing_here(const RouteStep& step) const{ + if (!m_at || m_planning == nullptr || m_planning->maps == nullptr){ + return std::nullopt; } - facing = std::nullopt; // a trainer turns the player to face them - if (++stops > MAX_ROUTE_STOPS){ - calls.fail(std::format("walk_overworld_route(): stopped part-way {} times.", stops)); - return; + return trainer_crossing(*m_planning->maps, *m_at, step); + } + + // A stop the maps place (trainer_stop()): the trainer walks up to the + // player, who keeps facing the leg's way (Camper Liam, console B, + // 2026-11-01), so the route is planned again from there. False if the + // maps don't place it or there is no way on. + bool after_trainer(const RouteStep& step, const std::optional& crossing){ + const std::optional stop = trainer_stop(crossing, step); + if (!stop || m_planning == nullptr || m_planning->maps == nullptr){ + return false; } + std::optional> way = plan_overworld_route( + *m_planning->maps, *stop, RouteGoal{step.end, std::nullopt}, m_planning->costs, step.direction + ); + if (!way){ + return false; + } + way->insert(way->end(), m_plan.begin() + m_next + 1, m_plan.end()); + m_lines.trainer_stopped(step, *stop); + m_plan = std::move(*way); + m_at = stop; + m_facing = step.direction; + m_holds = 0; + m_next = 0; + m_lines.plan(m_at, m_plan); + return true; + } + + // Any other stop. False once the route has failed. + bool after_stop(const RouteStep& step, StepEnd end){ if (step.to_wall && end == StepEnd::LATE){ - continue; // walking it again ends at its wall + return true; // walking it again ends at its wall } if (step.to_wall){ // Interrupted at once, hold after hold: by something on the // way that speaks, a sign, which the plan didn't have there. - calls.fail("walk_overworld_route(): a hold toward a wall is interrupted right away, again: the player is off the route, on " + to_string(step) + "."); - return; + m_calls.fail("walk_overworld_route(): a hold toward a wall is interrupted right away, again: the player is off the route, on " + to_string(step) + "."); + return false; } if (end == StepEnd::STOPPED && step.warps && !step.wild){ - calls.hold_to_warp(step); - at = step.end; - facing = step.facing_after; - i++; - lines.end(step, at, EndBasis::ASSUMED, "stopped, then held on to the warp"); - continue; + m_calls.hold_to_warp(step); + m_at = step.end; + m_facing = step.facing_after; + m_next++; + m_lines.end(step, m_at, EndBasis::ASSUMED, "stopped, then held on to the warp"); + return true; } - lines.end(step, std::nullopt, EndBasis::UNKNOWN, "stopped part-way"); - Rejoined rejoined = rejoin(plan, i, at, planning, calls, lines); - plan = std::move(rejoined.steps); - holds = rejoined.holds; - i = 0; + m_lines.end(step, std::nullopt, EndBasis::UNKNOWN, "stopped part-way"); + Rejoined rejoined = rejoin(m_plan, m_next, m_at, m_planning, m_calls, m_lines); + m_plan = std::move(rejoined.steps); + m_holds = rejoined.holds; + m_next = 0; + return true; } + + const RoutePlanning* m_planning; + const RouteCalls& m_calls; + const RouteLogLines m_lines; + CheckedSteps m_steps; + std::vector m_plan; + std::optional m_at; + std::optional m_facing; + size_t m_next = 0; + size_t m_stops = 0; + size_t m_holds = 0; // of the plan, holds to walls that find the player +}; + +} + + +std::optional trainer_crossing( + const std::vector& maps, const MapPosition& start, const RouteStep& step +){ + if (step.action != RouteAction::WALK){ + return std::nullopt; + } + TrainerCrossing crossing; + const std::vector tiles = leg_tiles(maps, start, step); + for (size_t c = 1; c < tiles.size(); c++){ + const int sight = sight_onto(maps, tiles[c]); + if (sight > 0){ + crossing.seen.push_back(tiles[c]); + crossing.approach_tiles = std::max(crossing.approach_tiles, sight); + } + } + if (crossing.seen.empty()){ + return std::nullopt; + } + return crossing; +} + +void run_route( + const std::vector& route, std::optional facing, + const RoutePlanning* planning, const RouteCalls& calls +){ + RouteRun(route, facing, planning, calls).run(); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h index bd16372e5f..21c6c325fb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h @@ -8,6 +8,10 @@ * - a leg to a wall interrupted after its hold is walked again; * - an open leg to a warp, off the grass, stopped by a message (a * trainer), is held on toward the warp; + * - a leg that crosses a trainer's sight (trainer_crossing()) is watched + * after for long enough that the trainer, if they saw the player, has + * walked up and spoken; when they stopped it on the one tile of the leg + * they can see, the route is planned again from that tile; * - any other open leg stopped part-way is found again with holds to * walls (plan_reanchor()), and rejoins the route at that leg's end by * a route planned on the caller's maps and costs. The rest of the @@ -70,15 +74,35 @@ struct RouteCalls{ // whether the player moved: if so, the leg didn't end at its wall. Not // set (or WallCheck::SKIP): not checked. std::function moved_past_wall; + // After a leg that ended as planned but crosses a trainer's sight: + // watches long enough for a trainer who saw the player to walk up + // `approach_tiles` tiles and speak, has what shows cleared (the + // caller's interrupts fight the trainer), and returns LATE if anything + // showed, DONE if not. Not set: not watched. + std::function watch_for_trainer; }; +// Where a WALK leg crosses the sight of its maps' trainers +// (trainer_may_see()): the tiles of the leg after its start that one may +// see, in order, and the farthest any of them walks up to the player +// (their sight). A trainer who sees the player stops them on that tile. +// Trainers already beaten are counted too: the maps don't know them. +struct TrainerCrossing{ + std::vector seen; + int approach_tiles = 0; +}; +// Nothing if `step`, from `start`, crosses no trainer's sight. +std::optional trainer_crossing( + const std::vector& maps, const MapPosition& start, const RouteStep& step +); + // How often a route may stop part-way before it gives up. constexpr size_t MAX_ROUTE_STOPS = 8; // Walks `route` from `facing`, logging it in the shared route log lines: // the plan, each leg as it starts and how it ended, and where the player // was found again. Without `planning`, a stop that needs the player found -// again fails instead. +// again fails instead, and no leg is watched for trainers. void run_route( const std::vector& route, std::optional facing, const RoutePlanning* planning, const RouteCalls& calls diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 9fab5fe3f2..5513426593 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -143,6 +143,29 @@ void hold_to_warp( ); } +// A trainer who sees the player stops them, a "!" shows over the trainer, +// the trainer walks up, then speaks. The leg's hold may run on past the +// tile the player was stopped on, so the watch counts from the hold's end, +// with room to spare: on Route 3 (console B, 2026-11-02) Lass Robin's +// message showed after the 500ms watch after the leg had ended. Not +// measured on the console yet. +constexpr Milliseconds TRAINER_NOTICE(1500); +constexpr Milliseconds TRAINER_SPEAKS(1000); + +// After `step`, which crosses a trainer's sight: watches for the trainer +// to walk up `approach_tiles` tiles and speak, and clears what shows. +StepEnd watch_for_trainer( + ConsoleHandle& console, ProControllerContext& context, + const RouteStep& step, int approach_tiles, const OverworldInterruptSet& interrupts +){ + const Milliseconds watch = TRAINER_NOTICE + TILE_TIME * approach_tiles + TRAINER_SPEAKS; + console.log(std::format("Route: {} crosses a trainer's sight; watching {} ms for them.", to_string(step), watch.count())); + const size_t seen = Pokemon::clear_late_interrupts( + console, context, step.wild ? interrupts.wild : interrupts.other, watch, MAX_INTERRUPTIONS_PER_LEG + ); + return seen == 0 ? StepEnd::DONE : StepEnd::LATE; +} + // TODO(forest drift, 2026-11-01): diagnostic; remove or turn into a recovery once the cause is known. // How much the game screen's top quarter, clear of the player in the // middle, may change when a tap toward a wall only bumps the player (the @@ -188,6 +211,7 @@ void walk_overworld_route_with_recovery( } RouteCalls calls; calls.walk = [&](const RouteStep& step, std::optional from_facing){ + clear_open_message(console, context, step.wild ? interrupts.wild : interrupts.other, step); check_overworld(console, context, step); return walk_and_watch(console, context, step, from_facing, interrupts, speed); }; @@ -201,6 +225,9 @@ void walk_overworld_route_with_recovery( calls.moved_past_wall = [&](const RouteStep& step){ return moved_past_wall(console, context, step); }; + calls.watch_for_trainer = [&](const RouteStep& step, int approach_tiles){ + return watch_for_trainer(console, context, step, approach_tiles, interrupts); + }; run_route(route, facing, &planning, calls); context.wait_for_all_requests(); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h index 78804e56cd..5cd35f24bd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h @@ -34,14 +34,20 @@ namespace PokemonFRLG{ // is isn't guessed: run_route() finds them again with `planning`'s maps // and costs and walks the rest of the route as planned. // +// After a leg that crosses a trainer's sight it watches for the trainer, +// long enough for them to walk up and speak, and hands what shows to +// `interrupts`; where the maps place the stop, the route goes on from +// there (run_route()). +// // After each leg to a wall it taps once more toward the wall, and fails if // the player moves (a diagnostic; RoutePlanning::wall_check SKIP turns it // off, as the Fast checks level does). // -// Throws OperationFailedException if a menu or message is open before a -// step, a warp's fade or the Surf prompt doesn't show, a warp's fade turns -// out to be a wild battle's, a leg is interrupted too many times, or the -// player can't be found again. A wild battle that starts as the last leg +// A message open before a step is handed to `interrupts` once +// (clear_open_message()). Throws OperationFailedException if a menu or +// message is still open before a step, a warp's fade or the Surf prompt +// doesn't show, a warp's fade turns out to be a wild battle's, a leg is +// interrupted too many times, or the player can't be found again. A wild battle that starts as the last leg // ends is cleared before it returns. void walk_overworld_route_with_recovery( ConsoleHandle& console, ProControllerContext& context, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp index 17b7fcb4ce..4a2fe82c93 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp @@ -54,11 +54,18 @@ struct ScriptedRoute{ // Whether the tap past a leg's wall moves the player, and the taps made. bool moves_past_wall = false; size_t wall_taps = 0; + // The way each walked step started facing. + std::vector> facings; + // How the watches for a trainer end, in order; DONE after these. The + // approach of each watch made. + std::vector watch_ends; + std::vector watches; RouteCalls calls(){ RouteCalls calls; - calls.walk = [this](const RouteStep& step, std::optional){ + calls.walk = [this](const RouteStep& step, std::optional facing){ walked.push_back(step); + facings.push_back(facing); auto iter = ends.find(walked.size() - 1); return iter == ends.end() ? StepEnd::DONE : iter->second; }; @@ -72,6 +79,10 @@ struct ScriptedRoute{ failure = message; throw std::runtime_error(message); }; + calls.watch_for_trainer = [this](const RouteStep&, int approach_tiles){ + watches.push_back(approach_tiles); + return watches.size() <= watch_ends.size() ? watch_ends[watches.size() - 1] : StepEnd::DONE; + }; return calls; } // Runs, and returns whether it failed. @@ -298,6 +309,123 @@ UnitTestResult test_route_logged(){ return true; } +// Route 3's Lass Robin (console B, 2026-11-02), shrunk: a corridor along +// row 1, walled at x 6, and below it Robin's column x 3, where she wanders +// y 2..4 looking north and south, sight 3. Every way east along the +// corridor crosses her sight at (3, 1), and nowhere else. +const char* const TEST_ROBIN_JSON = R"({ + "name": "Robin", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-02"}, + "tiles": ["#######", "......#", "###.###", "###.###", "###.###", "#######"], + "objects": [ + {"name": "lass", "kind": "npc", "x": 3, "y": 3, "wander": {"x0": 3, "y0": 2, "x1": 3, "y1": 4}, "trainer": {"sight": 3, "looks": ["north", "south"]}} + ] +})"; + +MapPosition robin(int x, int y){ + return MapPosition{"Robin", {x, y}}; +} + +// East along the corridor crosses Robin's sight on (3, 1) only; west of +// her, or back from the wall, it doesn't. +UnitTestResult test_trainer_crossing(){ + const std::vector maps{parse_map(TEST_ROBIN_JSON, "Robin")}; + RouteStep east = walk(Direction::EAST, 5, robin(5, 1)); + east.to_wall = true; + const std::optional crossing = trainer_crossing(maps, robin(0, 1), east); + if (!crossing || crossing->seen != std::vector{robin(3, 1)} || crossing->approach_tiles != 3){ + return "Expected east along the corridor to cross her sight on (3, 1) only, with an approach of 3."; + } + if (trainer_crossing(maps, robin(0, 1), walk(Direction::EAST, 2, robin(2, 1))) + || trainer_crossing(maps, robin(5, 1), walk(Direction::WEST, 1, robin(4, 1))) + ){ + return "Expected legs off her column to cross no sight."; + } + return true; +} + +// A leg east past Robin, then a turn north: how the leg and the watch for +// her after it end, and whether she saw the player. +struct TrainerCase{ + const char* name; + RouteStep leg; + StepEnd walk_end; + StepEnd watch_end; + size_t watches; + bool seen; +}; + +// What `c`'s route should walk: the leg, then, if she saw the player, the +// way on from (3, 1) to its end, then the turn. +std::optional> expected_walk(const std::vector& maps, const TrainerCase& c, const RouteStep& face){ + std::vector expected{c.leg}; + if (c.seen){ + const std::optional> way = plan_overworld_route(maps, robin(3, 1), RouteGoal{c.leg.end, std::nullopt}, default_route_costs(), Direction::EAST); + if (!way || way->empty()){ + return std::nullopt; + } + expected.insert(expected.end(), way->begin(), way->end()); + } + expected.push_back(face); + return expected; +} + +UnitTestResult run_trainer_case(const std::vector& maps, const TrainerCase& c){ + RouteStep face = walk(Direction::NORTH, 0, c.leg.end); + face.action = RouteAction::FACE; + const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = robin(0, 1), .label = "Route", .wall_check = WallCheck::SKIP}; + ScriptedRoute script; + script.ends[0] = c.walk_end; + script.watch_ends = {c.watch_end}; + if (script.run({c.leg, face}, &planning)){ + return "failed: " + script.failure; + } + const std::optional> expected = expected_walk(maps, c, face); + if (!expected){ + return "expected a way on from (3, 1)."; + } + bool match = expected->size() == script.walked.size() && script.watches.size() == c.watches; + for (size_t i = 0; match && i < expected->size(); i++){ + match = same((*expected)[i], script.walked[i]); + } + if (!match){ + return std::format("walked:{}\nexpected:{}\nafter {} watch(es)", describe(script.walked), describe(*expected), script.watches.size()); + } + if (c.watches > 0 && script.watches[0] != 3){ + return "expected a watch for an approach of 3."; + } + if (c.seen && script.facings[1] != Direction::EAST){ + return "expected the route on from (3, 1) to start facing east."; + } + const UnitTestResult logged = expect_logged(script.logs, {"stopped by a trainer who saw the player, at Robin (3, 1); planning again."}); + if ((logged.result == UnitTestResult::PASSED) != c.seen){ + return c.seen ? "expected the stop logged at (3, 1)." : "didn't expect a stop logged."; + } + return true; +} + +// She sees the player: the leg's watch for her (or the leg itself) is +// interrupted by her message, which the interrupts fight, and the route is +// planned again from (3, 1), facing east. When she doesn't, the route is +// walked as planned, after one watch. +UnitTestResult test_trainer_stops_leg(){ + const std::vector maps{parse_map(TEST_ROBIN_JSON, "Robin")}; + RouteStep wall = walk(Direction::EAST, 5, robin(5, 1)); + wall.to_wall = true; + const RouteStep open = walk(Direction::EAST, 4, robin(4, 1)); + const TrainerCase cases[] = { + {.name = "to a wall, seen", .leg = wall, .walk_end = StepEnd::DONE, .watch_end = StepEnd::LATE, .watches = 1, .seen = true}, + {.name = "open, seen", .leg = open, .walk_end = StepEnd::STOPPED, .watch_end = StepEnd::DONE, .watches = 0, .seen = true}, + {.name = "to a wall, not seen", .leg = wall, .walk_end = StepEnd::DONE, .watch_end = StepEnd::DONE, .watches = 1, .seen = false}, + }; + for (const TrainerCase& c : cases){ + const UnitTestResult result = run_trainer_case(maps, c); + if (result.result != UnitTestResult::PASSED){ + return std::string(c.name) + ": " + result.message; + } + } + return true; +} + } @@ -308,6 +436,8 @@ void add_tests_OverworldRecovery(UnitTestDatabase& database){ database.add("a leg to a warp", test_warp_leg); database.add("the route is logged", test_route_logged); database.add("a leg to a wall ends at it", test_wall_check); + database.add("a leg crosses a trainer's sight", test_trainer_crossing); + database.add("a trainer stops a leg", test_trainer_stops_leg); } From a038b853b46de8afebfa4c9497fe6a8456a4ffff Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 2 Nov 2026 21:29:32 -0800 Subject: [PATCH 25/25] FRLG recovery: find the player from the tiles the stop's timing allows, or stop After a battle or a message stopped an open leg, recovery looked for the player along the whole leg, and a trainer's stop was placed only when the leg crossed one tile of sight off the grass. Now: - the stop's timing (#117's estimate) narrows the player to a tile either side walking, two running (walked_near()); - stop_candidates() adds each tile up to there a trainer may see, with the trainer standing next to the player afterwards, since a trainer's message comes later than the timing says; off the grass a message is the trainer's; - plan_reanchor() finds holds to walls that end on one tile from every candidate, on the maps as each one stands (a walked-up trainer blocks; a person on the way rejects a hold), and a way back to the leg's end that walks the same from there for all of them. One candidate needs no holds. None: the route fails rather than guess; - the tap after a leg to a wall is only logged (it can't tell a bump from a step). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldReanchor.cpp | 292 ++++++++++++++++-- .../Overworld/PokemonFRLG_OverworldReanchor.h | 84 ++++- .../PokemonFRLG_OverworldReanchor_Tests.cpp | 153 +++++++-- .../PokemonFRLG_OverworldRecovery.cpp | 222 ++++++------- .../Overworld/PokemonFRLG_OverworldRecovery.h | 37 ++- .../PokemonFRLG_OverworldRecoveryWalk.cpp | 41 ++- .../PokemonFRLG_OverworldRecoveryWalk.h | 17 +- .../PokemonFRLG_OverworldRecovery_Tests.cpp | 146 ++++++--- 8 files changed, 725 insertions(+), 267 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index 8704ed64f8..27b28bb54d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -5,6 +5,7 @@ */ #include +#include #include "PokemonFRLG_OverworldReanchor.h" namespace PokemonAutomation{ @@ -20,6 +21,14 @@ Direction opposite(Direction direction){ return (Direction)(((int)direction + 2) % 4); } +// The way from `from` to `to`, on the same row or column. +Direction towards(const TilePos& from, const TilePos& to){ + if (to.x == from.x){ + return to.y < from.y ? Direction::NORTH : Direction::SOUTH; + } + return to.x < from.x ? Direction::WEST : Direction::EAST; +} + const MapData* map_named(const std::vector& maps, const std::string& name){ for (const MapData& map : maps){ if (map.name == name){ @@ -37,9 +46,10 @@ bool standable(const std::vector& maps, const MapPosition& position, bo && is_water(*map, position.point) == surfing; } -void add_unique(std::vector& positions, const MapPosition& position){ - if (std::find(positions.begin(), positions.end(), position) == positions.end()){ - positions.push_back(position); +template +void add_unique(std::vector& items, const Type& item){ + if (std::find(items.begin(), items.end(), item) == items.end()){ + items.push_back(item); } } @@ -55,25 +65,116 @@ std::optional walk_back(const std::vector& maps, const Rou return position; } +// Where trainer `object` of `map`, who may see the player on `tile`, +// stands once walked up: next to the player, on the side they came from. +// Each tile a wanderer may stand on is checked on its own, since the side +// depends on it. +void add_trainer_stops( + const MapData& map, size_t object, const MapPosition& tile, std::vector& stops +){ + const MapObject& trainer = map.objects[object]; + const Pokemon::TileBox box = trainer.wander.value_or( + Pokemon::TileBox{.x0 = trainer.x, .y0 = trainer.y, .x1 = trainer.x, .y1 = trainer.y} + ); + const int width = box.x1 - box.x0 + 1; + const int count = width * (box.y1 - box.y0 + 1); + for (int c = 0; c < count; c++){ + MapObject standing = trainer; + standing.wander.reset(); + standing.x = box.x0 + c % width; + standing.y = box.y0 + c / width; + if (trainer_may_see(map, standing, tile.point)){ + const TilePos beside = tile.point.next(towards(tile.point, TilePos{standing.x, standing.y})); + add_unique(stops, TrainerStop{.object = object, .at = MapPosition{tile.map, beside}}); + } + } +} + +// The trainers who may see the player on `tile`, and where each stands +// once walked up. +std::vector trainers_seeing(const std::vector& maps, const MapPosition& tile){ + std::vector stops; + const MapData* map = map_named(maps, tile.map); + for (size_t c = 0; map != nullptr && c < map->objects.size(); c++){ + if (trainer_may_see(*map, map->objects[c], tile.point)){ + add_trainer_stops(*map, c, tile, stops); + } + } + return stops; +} + +// `maps` with a trainer who walked up standing on each of `trainers`: they +// stay there after the battle, so the player can't walk through. +std::vector with_trainers(const std::vector& maps, const std::vector& trainers){ + std::vector ret = maps; + for (const MapPosition& at : trainers){ + for (MapData& map : ret){ + if (map.name == at.map && in_bounds(map, at.point)){ + map.tiles.cells[(size_t)at.point.y * map.tiles.width + at.point.x] = TileKind::WALL; + } + } + } + return ret; +} + +// Where the player may be, on which of the maps as they may stand (with +// or without a trainer next to them). +struct Hypothesis{ + size_t world = 0; + MapPosition at; + + bool operator==(const Hypothesis& other) const = default; +}; + +bool converged(const std::vector& positions){ + for (const Hypothesis& position : positions){ + if (!(position.at == positions[0].at)){ + return false; + } + } + return true; +} + +// Whether a person stands, or may wander, on a tile `wall` passes after +// `from`. The step rules don't have people, but one there stops the player +// short of the wall. +bool person_on_the_way( + const std::vector& world, const MapPosition& from, + bool surfing, Direction direction, const WallHold& wall +){ + RouteStep hold; + hold.direction = direction; + hold.tiles = wall.tiles; + hold.surfing = surfing; + const std::vector tiles = leg_tiles(world, from, hold); + for (size_t c = 1; c < tiles.size(); c++){ + const MapData* map = map_named(world, tiles[c].map); + if (map != nullptr && object_at(*map, tiles[c].point) != nullptr){ + return true; + } + } + return false; +} + // Holding `direction` from each of `positions`: where they end up, as a leg // to a wall as long as the longest of them. Nothing if any isn't stopped -// by a wall. -std::optional, RouteStep>> hold_all( - const std::vector& maps, const std::vector& positions, +// by a wall, or may be stopped short by a person. +std::optional, RouteStep>> hold_all( + const std::vector>& worlds, const std::vector& positions, bool surfing, Direction direction ){ - std::vector ends; + std::vector ends; RouteStep hold; hold.direction = direction; hold.surfing = surfing; hold.to_wall = true; hold.facing_after = direction; - for (const MapPosition& position : positions){ - const std::optional wall = hold_to_wall(maps, position, surfing, direction); - if (!wall){ + for (const Hypothesis& position : positions){ + const std::optional wall = hold_to_wall(worlds[position.world], position.at, surfing, direction); + if (!wall || person_on_the_way(worlds[position.world], position.at, surfing, direction, *wall)){ return std::nullopt; } - add_unique(ends, wall->end); + add_unique(ends, Hypothesis{position.world, wall->end}); hold.tiles = std::max(hold.tiles, wall->tiles); hold.wild = hold.wild || wall->wild; hold.end = wall->end; @@ -89,53 +190,178 @@ int total_tiles(const std::vector& legs){ return total; } -// Up to `depth` holds that bring all of `positions` to one tile; the one -// with the fewest tiles held. +struct Found{ + std::vector holds; + MapPosition at; +}; + +// Every way of exactly `depth` holds that brings all of `positions` to one +// tile, in a fixed order. void search( - const std::vector& maps, const std::vector& positions, bool surfing, - size_t depth, std::vector& holds, std::optional& best + const std::vector>& worlds, const std::vector& positions, bool surfing, + size_t depth, std::vector& holds, std::vector& found ){ for (Direction direction : Pokemon::ALL_DIRECTIONS){ - auto next = hold_all(maps, positions, surfing, direction); + auto next = hold_all(worlds, positions, surfing, direction); if (!next || next->first == positions || next->second.tiles == 0){ continue; } holds.push_back(next->second); - if (next->first.size() == 1){ - if (!best || total_tiles(holds) < total_tiles(best->holds)){ - best = Reanchor{holds, next->first[0]}; + if (converged(next->first)){ + if (depth == 1){ + found.push_back(Found{holds, next->first[0].at}); } }else if (depth > 1){ - search(maps, next->first, surfing, depth - 1, holds, best); + search(worlds, next->first, surfing, depth - 1, holds, found); } holds.pop_back(); } } +// Whether `route`, planned from `at` on other maps, walks the same on +// `world`: each leg to a wall stops at its end, each open leg gets there. +bool walks_same(const std::vector& world, MapPosition at, const std::vector& route){ + for (const RouteStep& step : route){ + if (step.action == RouteAction::WALK && step.to_wall){ + const std::optional hold = hold_to_wall(world, at, step.surfing, step.direction); + if (!hold || !(hold->end == step.end)){ + return false; + } + }else if (step.action == RouteAction::WALK){ + const std::vector tiles = leg_tiles(world, at, step); + const size_t expected = (size_t)(step.tiles - (step.warps ? 1 : 0)) + 1; + if (tiles.size() != expected || (!step.warps && !(tiles.back() == step.end))){ + return false; + } + } + at = step.end; + } + return true; +} + +bool walks_same_everywhere( + const std::vector>& worlds, const std::vector& positions, + const MapPosition& from, const std::vector& route +){ + for (const Hypothesis& position : positions){ + if (!walks_same(worlds[position.world], from, route)){ + return false; + } + } + return true; } +// The ways to find the player from all of `positions`: fewest holds first, +// then fewest tiles held. None needed if they are all on one tile. +std::vector find_player( + const std::vector>& worlds, const std::vector& positions, bool surfing +){ + std::vector found; + if (converged(positions)){ + found.push_back(Found{{}, positions[0].at}); + } + for (size_t depth = 1; found.empty() && depth <= MAX_HOLDS; depth++){ + std::vector holds; + search(worlds, positions, surfing, depth, holds, found); + std::stable_sort(found.begin(), found.end(), [](const Found& a, const Found& b){ + return total_tiles(a.holds) < total_tiles(b.holds); + }); + } + return found; +} -std::optional plan_reanchor( +} + + +WalkedTiles walked_near(const RouteStep& step, int estimate, Milliseconds tile_time){ + // The estimate is rounded to the nearest tile already. + const int spread = tile_time.count() > 0 + ? (int)((STOP_TIMING_SLACK + tile_time / 2) / tile_time) + : step.tiles; + return WalkedTiles{ + .min = std::clamp(estimate - spread, 0, step.tiles), + .max = std::clamp(estimate + spread, 0, step.tiles), + }; +} + +WalkedTiles whole_leg(const RouteStep& step){ + return WalkedTiles{.min = 0, .max = step.tiles}; +} + +std::string to_string(const StopCandidate& candidate){ + std::string text = to_string(candidate.player); + if (candidate.trainer){ + text += std::format(" (stopped by a trainer, now at {})", Pokemon::to_string(candidate.trainer->at.point)); + } + return text; +} + +std::vector stop_candidates( const std::vector& maps, const std::optional& start, - const RouteStep& step + const RouteStep& step, + WalkedTiles walked ){ - if (step.action != RouteAction::WALK || step.to_wall){ - return std::nullopt; + std::vector candidates; + if (step.action != RouteAction::WALK){ + return candidates; } const std::optional from = start ? start : walk_back(maps, step); if (!from){ + return candidates; + } + const std::vector tiles = leg_tiles(maps, *from, step); + const int last = std::min((int)tiles.size() - 1, walked.max); + for (int c = 1; c <= last; c++){ + for (const TrainerStop& trainer : trainers_seeing(maps, tiles[c])){ + candidates.push_back(StopCandidate{.player = tiles[c], .trainer = trainer}); + } + } + if (!candidates.empty() && !step.wild){ + return candidates; + } + for (int c = std::max(0, walked.min); c <= last; c++){ + add_unique(candidates, StopCandidate{.player = tiles[c], .trainer = std::nullopt}); + } + return candidates; +} + +std::optional plan_reanchor( + const std::vector& maps, + const std::vector& candidates, + const RouteStep& step, + const RouteCosts& costs +){ + if (candidates.empty() || step.action != RouteAction::WALK){ return std::nullopt; } - // Every tile the player may be on. Fixed: that no holds find them from - // all of these is no reason to think they are on fewer. - const std::vector positions = leg_tiles(maps, *from, step); - for (size_t depth = 1; depth <= MAX_HOLDS; depth++){ - std::vector holds; - std::optional best; - search(maps, positions, step.surfing, depth, holds, best); - if (best){ - return best; + + // The maps as they may stand: as they are, and with each trainer who + // may have walked up. + std::vector> worlds{maps}; + std::vector trainers; + std::vector positions; + for (const StopCandidate& candidate : candidates){ + size_t world = 0; + if (candidate.trainer){ + worlds.push_back(with_trainers(maps, {candidate.trainer->at})); + world = worlds.size() - 1; + add_unique(trainers, candidate.trainer->at); + } + add_unique(positions, Hypothesis{world, candidate.player}); + } + + const std::vector clear_of_trainers = with_trainers(maps, trainers); + for (const Found& at : find_player(worlds, positions, step.surfing)){ + const Direction facing = at.holds.empty() ? step.direction : at.holds.back().direction; + // The way back keeps clear of every trainer who may stand in it if + // it can, and must walk the same whether they do or not. + std::optional> back = plan_overworld_route(clear_of_trainers, at.at, RouteGoal{step.end, std::nullopt}, costs, facing); + if (!back && !trainers.empty()){ + back = plan_overworld_route(maps, at.at, RouteGoal{step.end, std::nullopt}, costs, facing); + } + if (back && (trainers.empty() || walks_same_everywhere(worlds, positions, at.at, *back))){ + return Reanchor{.holds = at.holds, .found = at.at, .back = std::move(*back)}; } } return std::nullopt; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index 1828c24f22..9037aa83ce 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -3,9 +3,12 @@ * From: https://github.com/PokemonAutomation/ * * Finding the player again after a battle or a message stopped a leg - * part-way. How far they got can't be timed reliably, so this takes every - * tile of the leg they may be on and looks for holds to walls, one to - * three, that end on the same tile from all of them. + * part-way. The timing of the stop narrows where they are to a few tiles of + * the leg (and a trainer who saw them may have stopped them earlier, on a + * tile in their sight); this looks for holds to walls, none to three, that + * end on the same tile from every one of them, and a way back to the leg's + * end that walks the same from there whichever it was. If there are none, + * the caller stops: the player is never guessed to be on one of them. * * The player is taken to be on the leg's line. Wider sets were tried: * with even one tile to either side, Route 1's people and ledges cut some @@ -17,7 +20,9 @@ #define PokemonAutomation_PokemonFRLG_OverworldReanchor_H #include +#include #include +#include "Common/Cpp/Time.h" #include "Common/Cpp/TestRunners/UnitTestDatabase.h" #include "PokemonFRLG_OverworldMap.h" #include "PokemonFRLG_OverworldRoute.h" @@ -27,21 +32,76 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ +// How many tiles of a stopped leg the player may have walked: from `min` +// to `max`, both included. +struct WalkedTiles{ + int min = 0; + int max = 0; +}; + +// How far off the timing of a stop may be: the interrupts' detection delays +// aren't calibrated, and video lag and the watchers' period add to them. +constexpr Milliseconds STOP_TIMING_SLACK(300); + +// Where the timing puts a stop on `step`: `estimate` tiles walked +// (Pokemon::LegResult::tiles), give or take STOP_TIMING_SLACK at +// `tile_time` a tile: a tile either side walking, two running. +WalkedTiles walked_near(const RouteStep& step, int estimate, Milliseconds tile_time); +// Anywhere on `step`, for a stop the timing doesn't place. +WalkedTiles whole_leg(const RouteStep& step); + +// A trainer who saw the player and walked up to them: which of `map`'s +// objects, and the tile next to the player they stand on afterwards. +struct TrainerStop{ + size_t object = 0; + MapPosition at; + + bool operator==(const TrainerStop& other) const = default; +}; + +// One way things may stand after a stop: the player's tile, and the +// trainer who stopped them there, if one did. +struct StopCandidate{ + MapPosition player; + std::optional trainer; + + bool operator==(const StopCandidate& other) const = default; +}; +std::string to_string(const StopCandidate& candidate); + +// Where `step`, an open WALK from `start`, may have been stopped: +// - on the tiles `walked` covers, by a wild battle or a message; +// - on any tile up to `walked.max` a trainer may see, by that trainer: +// the player stops as soon as they are seen, and the trainer speaks +// only after walking up, so later than the timing says. +// Off the grass a message is taken to be a trainer's wherever one may see +// the player, so there only the second kind are left, if there are any. +// `start` not known: worked back from the leg's end. Empty if it can't be. +std::vector stop_candidates( + const std::vector& maps, + const std::optional& start, + const RouteStep& step, + WalkedTiles walked +); + struct Reanchor{ - // Legs to walls, in order. Each but the last ends on a tile that - // depends on where the player was; its `end` is one of them. + // Legs to walls, in order, that bring the player to `found` from every + // candidate; none if there was only one. Each but the last ends on a + // tile that depends on the candidate; its `end` is one of them. std::vector holds; - // Where the player is after them. - MapPosition end; + MapPosition found; + // From `found` back to the stopped leg's end, walking the same from + // there whichever candidate it was (a trainer who walked up stays). + std::vector back; }; -// After `step`, an open WALK, stopped part-way. `start` is where it -// started, if known; else it is worked back from its end. Nothing if no -// holds find the player. +// After `step` stopped part-way, from `candidates`. Nothing if no holds +// bring them all to one tile, or no way back walks the same from there. std::optional plan_reanchor( const std::vector& maps, - const std::optional& start, - const RouteStep& step + const std::vector& candidates, + const RouteStep& step, + const RouteCosts& costs = default_route_costs() ); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp index 49b42cbcdb..bbc2cf72f5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp @@ -2,11 +2,13 @@ * * From: https://github.com/PokemonAutomation/ * - * plan_reanchor(): finding the player again at a wall after an open leg - * stopped part-way. + * stop_candidates() and plan_reanchor(): finding the player again at a + * wall after an open leg stopped part-way. * */ +#include +#include #include #include #include @@ -49,9 +51,30 @@ std::string describe(const std::vector& route){ return text; } +std::string describe(const std::optional& anchor){ + if (!anchor){ + return "none"; + } + std::string text = to_string(anchor->found) + " after"; + for (const RouteStep& hold : anchor->holds){ + text += " " + to_string(hold); + } + return text + ", then" + describe(anchor->back); +} + +// How a walking leg may have stopped: anywhere (seen after the hold), and +// at each estimate the timing can give. +std::vector stops_of(const RouteStep& step){ + std::vector stops{whole_leg(step)}; + for (int estimate = 0; estimate <= step.tiles; estimate++){ + stops.push_back(walked_near(step, estimate, std::chrono::milliseconds(267))); + } + return stops; +} + // Plans from `start` to `goal`, then checks that the player can be found -// again after any open leg through tall grass is stopped, and that a route -// on to the goal can be planned from there. +// again however an open leg through tall grass is stopped, and that the +// way back reaches that leg's end. UnitTestResult check_reanchors(const std::vector& maps, const MapPosition& start, const RouteGoal& goal){ const std::optional> route = plan_overworld_route(maps, start, goal); if (!route){ @@ -59,14 +82,21 @@ UnitTestResult check_reanchors(const std::vector& maps, const MapPositi } std::optional at = start; for (const RouteStep& step : *route){ - if (step.action == RouteAction::WALK && step.wild && !step.to_wall){ - const std::optional anchor = plan_reanchor(maps, at, step); + if (step.action != RouteAction::WALK || !step.wild || step.to_wall){ + at = step.end; + continue; + } + for (const WalkedTiles& walked : stops_of(step)){ + const std::vector candidates = stop_candidates(maps, at, step, walked); + const std::optional anchor = plan_reanchor(maps, candidates, step); if (!anchor){ - return "Can't find the player again on " + to_string(step) + ":" + describe(*route); + return std::format( + "Can't find the player again on {}, stopped {} to {} tiles in ({} candidates):{}", + to_string(step), walked.min, walked.max, candidates.size(), describe(*route) + ); } - const std::optional> rest = plan_overworld_route(maps, anchor->end, goal); - if (!rest || !(follow_overworld_route(maps, anchor->end, *rest) == goal.position)){ - return "No route on from " + to_string(anchor->end) + ", after " + to_string(step) + "."; + if (!(follow_overworld_route(maps, anchor->found, anchor->back) == step.end)){ + return "The way back from " + to_string(anchor->found) + " misses the end of " + to_string(step) + "."; } } at = step.end; @@ -90,6 +120,15 @@ UnitTestResult test_pallet_to_viridian_mart(){ {{"ViridianCity_Mart", map_point(maps, "ViridianCity_Mart", "clerk_front")}, std::nullopt} ); } +// All grass, walled in by trees: where the walker was lost twice (console B, +// 2026-11-01, runs 16 and 17), and where the long dungeon walks start. +UnitTestResult test_viridian_forest(){ + const std::vector maps = overworld_maps(); + return check_reanchors( + maps, {"ViridianForest", {29, 61}}, + {{"Route2_ViridianForest_NorthEntrance", {7, 8}}, std::nullopt} + ); +} // A field with a wall at its east end and a row of ledges to the south. const char* const TEST_FIELD_JSON = R"({ @@ -98,42 +137,91 @@ const char* const TEST_FIELD_JSON = R"({ "elevation": ["33333333", "33333333", "33333333", "33333333", "33333333"] })"; -std::string describe(const std::optional& anchor){ - if (!anchor){ - return "none"; - } - std::string text = to_string(anchor->end) + " after"; - for (const RouteStep& hold : anchor->holds){ - text += " " + to_string(hold); - } - return text; -} - // Stopped part-way east, the player holds back west to the wall, the -// shorter way. Stopped part-way south above the ledges, which would be -// hopped, they hold back north. +// shorter way, and walks back. Stopped part-way south above the ledges, +// which would be hopped, they hold back north. UnitTestResult test_rules(){ const std::vector maps{parse_map(TEST_FIELD_JSON, "Field")}; RouteStep east; east.direction = Direction::EAST; east.tiles = 3; east.end = {"Field", {4, 1}}; - std::optional anchor = plan_reanchor(maps, MapPosition{"Field", {1, 1}}, east); - if (!anchor || anchor->holds.size() != 1 || anchor->holds[0].direction != Direction::WEST || !(anchor->end.point == TilePos{1, 1})){ + std::optional anchor = plan_reanchor(maps, stop_candidates(maps, MapPosition{"Field", {1, 1}}, east, whole_leg(east)), east); + if (!anchor || anchor->holds.size() != 1 || anchor->holds[0].direction != Direction::WEST || !(anchor->found.point == TilePos{1, 1})){ return "Expected a hold west to (1, 1), got " + describe(anchor); } + if (anchor->back.size() != 1 || !(anchor->back[0].end == east.end)){ + return "Expected one leg back east to (4, 1), got " + describe(anchor); + } RouteStep south; south.direction = Direction::SOUTH; south.tiles = 1; south.end = {"Field", {3, 2}}; - anchor = plan_reanchor(maps, std::nullopt, south); - if (!anchor || anchor->holds.size() != 1 || anchor->holds[0].direction != Direction::NORTH || !(anchor->end.point == TilePos{3, 1})){ + anchor = plan_reanchor(maps, stop_candidates(maps, std::nullopt, south, whole_leg(south)), south); + if (!anchor || anchor->holds.size() != 1 || anchor->holds[0].direction != Direction::NORTH || !(anchor->found.point == TilePos{3, 1})){ return "Expected a hold north back to (3, 1), got " + describe(anchor); } - // A leg to a wall is walked again instead. - south.to_wall = true; - if (plan_reanchor(maps, std::nullopt, south)){ - return "Expected nothing for a leg to a wall."; + return true; +} + +// The timing narrows a stop to a tile either side of its estimate walking, +// two running, within the leg. +UnitTestResult test_timing(){ + RouteStep leg; + leg.direction = Direction::EAST; + leg.tiles = 10; + struct Case{ + int estimate; + int tile_ms; + int min; + int max; + }; + const Case cases[] = { + {.estimate = 5, .tile_ms = 267, .min = 4, .max = 6}, + {.estimate = 0, .tile_ms = 267, .min = 0, .max = 1}, + {.estimate = 10, .tile_ms = 267, .min = 9, .max = 10}, + {.estimate = 5, .tile_ms = 133, .min = 3, .max = 7}, + {.estimate = 1, .tile_ms = 133, .min = 0, .max = 3}, + }; + for (const Case& c : cases){ + const WalkedTiles walked = walked_near(leg, c.estimate, std::chrono::milliseconds(c.tile_ms)); + if (walked.min != c.min || walked.max != c.max){ + return std::format( + "{} tiles in at {}ms a tile: expected {} to {}, got {} to {}.", + c.estimate, c.tile_ms, c.min, c.max, walked.min, walked.max + ); + } + } + return true; +} + +// A field with someone standing in the middle of its north row. +const char* const TEST_PERSON_JSON = R"({ + "name": "Person", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-02"}, + "tiles": ["#######", "#.....#", "#.....#", "#######"], + "objects": [{"name": "youngster", "kind": "npc", "x": 3, "y": 1}] +})"; + +// Stopped going east along the south row: holding north would meet the +// person from one of the tiles, so the player holds along the row. On the +// north row, past the person, they don't hold west into them. +UnitTestResult test_person_in_the_way(){ + const std::vector maps{parse_map(TEST_PERSON_JSON, "Person")}; + RouteStep east; + east.direction = Direction::EAST; + east.tiles = 4; + east.end = {"Person", {5, 2}}; + const std::optional anchor = plan_reanchor(maps, stop_candidates(maps, MapPosition{"Person", {1, 2}}, east, whole_leg(east)), east); + if (!anchor || anchor->holds.size() != 1 || anchor->holds[0].direction == Direction::NORTH){ + return "Expected one hold along the row, clear of the person, got " + describe(anchor); + } + RouteStep north_row = east; + north_row.tiles = 1; + north_row.end = {"Person", {5, 1}}; + const std::vector beside{{.player = {"Person", {4, 1}}, .trainer = std::nullopt}, {.player = {"Person", {5, 1}}, .trainer = std::nullopt}}; + const std::optional blocked = plan_reanchor(maps, beside, north_row); + if (blocked && blocked->holds[0].direction == Direction::WEST){ + return "Expected no hold west into the person, got " + describe(blocked); } return true; } @@ -144,8 +232,11 @@ UnitTestResult test_rules(){ void add_tests_OverworldReanchor(UnitTestDatabase& database){ database.add("rules", test_rules); + database.add("timing", test_timing); + database.add("a person in the way", test_person_in_the_way); database.add("Viridian Mart to Oak's Lab", test_viridian_mart_to_oaks_lab); database.add("Pallet Town to Viridian Mart", test_pallet_to_viridian_mart); + database.add("Viridian Forest", test_viridian_forest); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index f03c45b34d..1a907f7a06 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -58,16 +58,6 @@ int sight_onto(const std::vector& maps, const MapPosition& tile){ return sight; } -// Where a trainer stopped `step`, if the maps say: the one tile of the leg -// a trainer may see. Not on the grass, where a stop may be a wild -// battle's, nor on a leg to a warp, which is held on to its warp instead. -std::optional trainer_stop(const std::optional& crossing, const RouteStep& step){ - if (!crossing || crossing->seen.size() != 1 || step.wild || step.warps){ - return std::nullopt; - } - return crossing->seen[0]; -} - // The route's lines in the route log the games share, as BDSP's walker // writes them, under the caller's label. class RouteLogLines{ @@ -93,6 +83,9 @@ class RouteLogLines{ void trainer_stopped(const RouteStep& step, const MapPosition& at) const{ m_calls.log(ROUTE_LOG_TAG + Pokemon::route_replan_line(m_label, route_log_leg(step), "stopped by a trainer who saw the player", route_tiles(at))); } + void found(const MapPosition& at, size_t holds) const{ + m_calls.log(ROUTE_LOG_TAG + Pokemon::route_found_line(m_label, route_tiles(at), holds)); + } // Step `number` (from 1) of a plan whose first `holds` steps are holds // to walls, walked to its end. void done(const RouteStep& step, size_t number, size_t holds) const{ @@ -103,7 +96,7 @@ class RouteLogLines{ } end(step, step.end, EndBasis::WALKED, ""); if (number == holds){ - m_calls.log(ROUTE_LOG_TAG + Pokemon::route_found_line(m_label, route_tiles(step.end), holds)); + found(step.end, holds); } } @@ -113,39 +106,36 @@ class RouteLogLines{ }; // Walks each step through `calls`; watches a leg that crosses a trainer's -// sight for the trainer; logs a leg to a wall seen interrupted after its -// hold, which run_route() walks again, and checks a leg to a wall that -// ended did end at its wall. +// sight for the trainer, and logs whether a leg to a wall that ended did +// end at its wall. class CheckedSteps{ public: - CheckedSteps(const RouteCalls& calls, const RouteLogLines& lines, WallCheck wall_check) + CheckedSteps(const RouteCalls& calls, WallCheck wall_check) : m_calls(calls) - , m_lines(lines) , m_wall_check(wall_check) {} - StepEnd walk(const RouteStep& step, std::optional facing, const std::optional& crossing){ + StepOutcome walk(const RouteStep& step, std::optional facing, const std::optional& crossing){ const std::string leg = to_string(step); m_reholds = m_last_late && leg == m_last ? m_reholds + 1 : 0; - StepEnd end = m_calls.walk(step, facing); - if (end == StepEnd::DONE && crossing && m_calls.watch_for_trainer){ - end = m_calls.watch_for_trainer(step, crossing->approach_tiles); + StepOutcome outcome = m_calls.walk(step, facing); + if (outcome.end == StepEnd::DONE && crossing && m_calls.watch_for_trainer){ + outcome = StepOutcome{.end = m_calls.watch_for_trainer(step, crossing->approach_tiles), .walked = std::nullopt}; } m_last = leg; - m_last_late = end == StepEnd::LATE; - if (step.to_wall && end == StepEnd::LATE && !trainer_stop(crossing, step)){ - m_lines.end(step, std::nullopt, EndBasis::UNKNOWN, "something seen after the hold; walking it again"); - } - if (end == StepEnd::DONE){ + m_last_late = outcome.end == StepEnd::LATE; + if (outcome.end == StepEnd::DONE){ check_wall(step); } - return end; + return outcome; } private: // TODO(forest drift, 2026-11-01): diagnostic; remove or turn into a recovery once the cause is known. // Four runs ended the walk to Viridian Forest's north gate off its // column, which 52 legs walked by hand didn't reproduce: one more tap - // toward a leg's wall moves nobody who stands at it. + // toward a leg's wall moves nobody who stands at it. Only logged: the + // screen check can't tell a bump from a step yet, and failing on it + // stopped good runs. void check_wall(const RouteStep& step) const{ if (!step.to_wall || step.warps || step.action != RouteAction::WALK || m_wall_check == WallCheck::SKIP || !m_calls.moved_past_wall @@ -156,58 +146,19 @@ class CheckedSteps{ return; } m_calls.log(std::format( - "Position check: {} moved on past where its wall should be; believed at {}, after {} re-hold(s) " + "Position check (logged only): a tap after {} moved the screen as a step would; believed at {}, after {} re-hold(s) " "for something seen late (the leg's interrupts are the \"Leg:\" lines above).", to_string(step), to_string(step.end), m_reholds )); - m_calls.fail("position check: " + to_string(step) + " did not end at its wall"); } const RouteCalls& m_calls; - const RouteLogLines& m_lines; WallCheck m_wall_check = WallCheck::TAP; std::string m_last; bool m_last_late = false; size_t m_reholds = 0; }; -// A route rejoined after a stop: `holds` holds to walls, then the rest. -struct Rejoined{ - std::vector steps; - size_t holds = 0; -}; - -// The route from `plan[stopped]`, stopped part-way from `start`: holds that -// find the player, a way back to that step's end, then the rest as planned. -// Logs it as a new plan, from a tile not known. -Rejoined rejoin( - const std::vector& plan, size_t stopped, const std::optional& start, - const RoutePlanning* planning, const RouteCalls& calls, const RouteLogLines& lines -){ - const RouteStep& step = plan[stopped]; - if (planning == nullptr || planning->maps == nullptr){ - calls.fail("walk_overworld_route(): stopped part-way on " + to_string(step) + ", with no maps to find the player again."); - return {}; - } - const std::optional anchor = plan_reanchor(*planning->maps, start, step); - if (!anchor){ - calls.fail("walk_overworld_route(): stopped part-way on " + to_string(step) + ", with no walls along it to find the player again."); - return {}; - } - const std::optional> back = plan_overworld_route( - *planning->maps, anchor->end, RouteGoal{step.end, std::nullopt}, planning->costs, anchor->holds.back().direction - ); - if (!back){ - calls.fail("walk_overworld_route(): no route from " + to_string(anchor->end) + " back to " + to_string(step.end) + "."); - return {}; - } - Rejoined ret{.steps = anchor->holds, .holds = anchor->holds.size()}; - ret.steps.insert(ret.steps.end(), back->begin(), back->end()); - ret.steps.insert(ret.steps.end(), plan.begin() + stopped + 1, plan.end()); - lines.plan(std::nullopt, ret.steps); - return ret; -} - // run_route()'s walk: the plan, as rejoined or planned again after a // stop, which step is next, where the player is and which way they face. class RouteRun{ @@ -216,7 +167,7 @@ class RouteRun{ : m_planning(planning) , m_calls(calls) , m_lines(calls, planning) - , m_steps(calls, m_lines, planning != nullptr ? planning->wall_check : WallCheck::TAP) + , m_steps(calls, planning != nullptr ? planning->wall_check : WallCheck::TAP) , m_plan(route) , m_at(planning != nullptr ? planning->start : std::nullopt) , m_facing(facing) @@ -229,82 +180,131 @@ class RouteRun{ m_lines.leg(m_next, m_plan); // Where a hold that finds the player starts isn't known. const std::optional crossing = m_next >= m_holds ? crossing_here(step) : std::nullopt; - const StepEnd end = m_steps.walk(step, m_facing, crossing); - if (end == StepEnd::DONE){ + const StepOutcome outcome = m_steps.walk(step, m_facing, crossing); + if (outcome.end == StepEnd::DONE){ m_at = step.end; m_facing = step.facing_after; m_lines.done(step, ++m_next, m_holds); continue; } + const std::optional start = m_next >= m_holds ? m_at : std::nullopt; m_facing = std::nullopt; // not known after a stop if (++m_stops > MAX_ROUTE_STOPS){ m_calls.fail(std::format("walk_overworld_route(): stopped part-way {} times.", m_stops)); return; } - if (!after_trainer(step, crossing) && !after_stop(step, end)){ + if (!after_stop(step, outcome, start, crossing)){ return; } } } private: + const std::vector* maps() const{ + return m_planning != nullptr ? m_planning->maps : nullptr; + } + std::optional crossing_here(const RouteStep& step) const{ - if (!m_at || m_planning == nullptr || m_planning->maps == nullptr){ + if (!m_at || maps() == nullptr){ return std::nullopt; } - return trainer_crossing(*m_planning->maps, *m_at, step); + return trainer_crossing(*maps(), *m_at, step); } - // A stop the maps place (trainer_stop()): the trainer walks up to the - // player, who keeps facing the leg's way (Camper Liam, console B, - // 2026-11-01), so the route is planned again from there. False if the - // maps don't place it or there is no way on. - bool after_trainer(const RouteStep& step, const std::optional& crossing){ - const std::optional stop = trainer_stop(crossing, step); - if (!stop || m_planning == nullptr || m_planning->maps == nullptr){ - return false; + // After a stop on `step`, from `start` if known. False once the route + // has failed. + bool after_stop( + const RouteStep& step, const StepOutcome& outcome, + const std::optional& start, const std::optional& crossing + ){ + if (step.to_wall){ + return after_wall_stop(step, outcome, start, crossing); + } + if (outcome.end == StepEnd::STOPPED && step.warps && !step.wild){ + m_calls.hold_to_warp(step); + m_at = step.end; + m_facing = step.facing_after; + m_next++; + m_lines.end(step, m_at, EndBasis::ASSUMED, "stopped, then held on to the warp"); + return true; } - std::optional> way = plan_overworld_route( - *m_planning->maps, *stop, RouteGoal{step.end, std::nullopt}, m_planning->costs, step.direction - ); - if (!way){ + m_lines.end(step, std::nullopt, EndBasis::UNKNOWN, "stopped part-way"); + if (maps() == nullptr){ + m_calls.fail("walk_overworld_route(): stopped part-way on " + to_string(step) + ", with no maps to find the player again."); return false; } - way->insert(way->end(), m_plan.begin() + m_next + 1, m_plan.end()); - m_lines.trainer_stopped(step, *stop); - m_plan = std::move(*way); - m_at = stop; - m_facing = step.direction; - m_holds = 0; - m_next = 0; - m_lines.plan(m_at, m_plan); - return true; + const WalkedTiles walked = outcome.walked.value_or(whole_leg(step)); + return rejoin(step, stop_candidates(*maps(), start, step, walked), walked); } - // Any other stop. False once the route has failed. - bool after_stop(const RouteStep& step, StepEnd end){ - if (step.to_wall && end == StepEnd::LATE){ - return true; // walking it again ends at its wall - } - if (step.to_wall){ + // A leg to a wall seen interrupted after its hold is walked again: the + // wall stops the player at its end however far they got. Unless, off + // the grass, the maps place a trainer who saw them on one tile of it: + // they stopped there, and the route goes on from there (Camper Liam, + // console B, 2026-11-01). + bool after_wall_stop( + const RouteStep& step, const StepOutcome& outcome, + const std::optional& start, const std::optional& crossing + ){ + if (outcome.end == StepEnd::STOPPED){ // Interrupted at once, hold after hold: by something on the // way that speaks, a sign, which the plan didn't have there. m_calls.fail("walk_overworld_route(): a hold toward a wall is interrupted right away, again: the player is off the route, on " + to_string(step) + "."); return false; } - if (end == StepEnd::STOPPED && step.warps && !step.wild){ - m_calls.hold_to_warp(step); - m_at = step.end; - m_facing = step.facing_after; - m_next++; - m_lines.end(step, m_at, EndBasis::ASSUMED, "stopped, then held on to the warp"); - return true; + if (crossing && !step.wild && maps() != nullptr){ + const std::vector candidates = stop_candidates(*maps(), start, step, whole_leg(step)); + if (candidates.size() == 1 && candidates[0].trainer){ + return rejoin(step, candidates, whole_leg(step)); + } } - m_lines.end(step, std::nullopt, EndBasis::UNKNOWN, "stopped part-way"); - Rejoined rejoined = rejoin(m_plan, m_next, m_at, m_planning, m_calls, m_lines); - m_plan = std::move(rejoined.steps); - m_holds = rejoined.holds; + m_lines.end(step, std::nullopt, EndBasis::UNKNOWN, "something seen after the hold; walking it again"); + return true; + } + + // Finds the player on one of `candidates`, plans the way back to the + // end of `step` (m_plan[m_next]), and the rest of the route after it. + bool rejoin(const RouteStep& step, const std::vector& candidates, WalkedTiles walked){ + std::string where; + for (const StopCandidate& candidate : candidates){ + where += (where.empty() ? "" : "; ") + to_string(candidate); + } + m_calls.log(std::format( + "Route: {} stopped {} to {} tiles in; the player is on one of: {}.", + to_string(step), walked.min, walked.max, where.empty() ? "nothing (no start to work from)" : where + )); + const std::optional anchor = candidates.empty() + ? std::nullopt + : plan_reanchor(*maps(), candidates, step, m_planning->costs); + if (!anchor){ + m_calls.fail( + "walk_overworld_route(): stopped part-way on " + to_string(step) + + ", and no holds to walls find the player from every tile they may be on." + ); + return false; + } + std::vector plan = anchor->holds; + plan.insert(plan.end(), anchor->back.begin(), anchor->back.end()); + plan.insert(plan.end(), m_plan.begin() + m_next + 1, m_plan.end()); + m_plan = std::move(plan); + m_holds = anchor->holds.size(); m_next = 0; + if (m_holds > 0){ + m_at = std::nullopt; + m_lines.plan(std::nullopt, m_plan); + return true; + } + // Only one tile: the player stands there, still facing the leg's + // way (also after a trainer walked up: Camper Liam, console B, + // 2026-11-01). + if (candidates[0].trainer){ + m_lines.trainer_stopped(step, anchor->found); + }else{ + m_lines.found(anchor->found, 0); + } + m_at = anchor->found; + m_facing = step.direction; + m_lines.plan(m_at, m_plan); return true; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h index 21c6c325fb..3e15603f4d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h @@ -10,13 +10,19 @@ * trainer), is held on toward the warp; * - a leg that crosses a trainer's sight (trainer_crossing()) is watched * after for long enough that the trainer, if they saw the player, has - * walked up and spoken; when they stopped it on the one tile of the leg - * they can see, the route is planned again from that tile; - * - any other open leg stopped part-way is found again with holds to - * walls (plan_reanchor()), and rejoins the route at that leg's end by - * a route planned on the caller's maps and costs. The rest of the + * walked up and spoken; + * - any other open leg stopped part-way, or a leg seen stopped by a + * trainer, has the tiles the player may be on worked out from the + * stop's timing and the trainers' sight (stop_candidates()). One tile: + * the route is planned again from it. More: holds to walls that end on + * one tile from all of them find the player (plan_reanchor()), and a + * way back to the stopped leg's end rejoins the route. The rest of the * route is then walked as planned. * + * Where the player is is never guessed: when no holds find them from every + * tile they may be on, the route fails, and the caller stops without + * saving. + * * The console side is passed in, so this is tested with scripted steps. * */ @@ -29,6 +35,7 @@ #include #include #include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_OverworldReanchor.h" #include "PokemonFRLG_OverworldRoute.h" namespace PokemonAutomation{ @@ -44,8 +51,16 @@ enum class StepEnd{ // stopped the player before the end }; +// How a step ended, and for an open leg STOPPED part-way, how many of its +// tiles the timing of the stop says the player walked. Not set: anywhere +// on the leg. +struct StepOutcome{ + StepEnd end = StepEnd::DONE; + std::optional walked; +}; + // Whether, after each leg to a wall, the walker taps once more toward the -// wall to check the player really stands at it (see RouteCalls). +// wall and logs whether the player moved (see RouteCalls). enum class WallCheck{ TAP, SKIP, @@ -66,13 +81,13 @@ struct RoutePlanning{ // stop); `hold_to_warp` holds on along a stopped leg until its warp; // `fail` throws. struct RouteCalls{ - std::function facing)> walk; + std::function facing)> walk; std::function hold_to_warp; std::function log; std::function fail; // Taps once more toward the wall a leg to a wall ended at, and says - // whether the player moved: if so, the leg didn't end at its wall. Not - // set (or WallCheck::SKIP): not checked. + // whether the screen moved. Only logged: a bump and a step can't be + // told apart reliably yet. Not set (or WallCheck::SKIP): not tapped. std::function moved_past_wall; // After a leg that ended as planned but crosses a trainer's sight: // watches long enough for a trainer who saw the player to walk up @@ -85,8 +100,8 @@ struct RouteCalls{ // Where a WALK leg crosses the sight of its maps' trainers // (trainer_may_see()): the tiles of the leg after its start that one may // see, in order, and the farthest any of them walks up to the player -// (their sight). A trainer who sees the player stops them on that tile. -// Trainers already beaten are counted too: the maps don't know them. +// (their sight), to watch for them that long. Trainers already beaten are +// counted too: the maps don't know them. struct TrainerCrossing{ std::vector seen; int approach_tiles = 0; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 5513426593..9100f08d3f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -25,9 +25,10 @@ using namespace OverworldWalkSteps; namespace{ -// An open leg stops at its first interruption: how far it got isn't -// counted (that went wrong too often on the console), the route finds the -// player again at a wall instead. See walk_overworld_route_with_recovery(). +// An open leg stops at its first interruption: walking on from how far it +// got went wrong too often on the console. The timing only narrows where +// the player is, and the route finds them again at a wall. See +// walk_overworld_route_with_recovery(). Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ Pokemon::LegSpec leg; leg.direction = step.direction; @@ -39,15 +40,15 @@ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ return leg; } -// One step of a route, for a player facing `facing`. Returns false if an -// open leg stopped part-way, so where the player is isn't known. -bool walk_step( +// One step of a route, for a player facing `facing`. If an open leg +// stopped part-way, returns how far the timing puts the player along it. +std::optional walk_step( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, const OverworldInterruptSet& interrupts, MoveSpeed speed ){ switch (step.action){ - case RouteAction::WALK: + case RouteAction::WALK:{ // Turning first, with a tap, makes the hold start from a standstill // facing the right way: holding through a turn lost a tile on the // console, while holds from a standstill were exact over 15 tiles. @@ -57,8 +58,12 @@ bool walk_step( if (const std::string note = speed_note(step, speed); !note.empty()){ console.log(note); } - if (Pokemon::walk_leg(console, context, leg_mover(step, speed), step.wild ? interrupts.wild : interrupts.other, leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG).stopped){ - return false; + const Pokemon::LegResult result = Pokemon::walk_leg( + console, context, leg_mover(step, speed), step.wild ? interrupts.wild : interrupts.other, + leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG + ); + if (result.stopped){ + return walked_near(step, result.tiles, tile_time(step, speed)); } if (step.warps){ wait_out_warp(console, context, step); @@ -66,6 +71,7 @@ bool walk_step( check_warp_was_no_battle(console, context, step); } break; + } case RouteAction::SURF: start_surf(console, context, step.direction, facing == step.direction); break; @@ -73,28 +79,29 @@ bool walk_step( pbf_press_dpad(context, dpad(step.direction), FACE_HOLD, STEP_RELEASE); break; } - return true; + return std::nullopt; } -// walk_step(), then a watch for what its last step set off. Returns false -// if the player may have stopped short of the step's end: an open leg +// walk_step(), then a watch for what its last step set off. Not DONE if +// the player may have stopped short of the step's end: an open leg // stopped, or something seen after the hold, which may have stopped it // before the end (after a warp, it was checked for already). -StepEnd walk_and_watch( +StepOutcome walk_and_watch( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, const OverworldInterruptSet& interrupts, MoveSpeed speed ){ - if (!walk_step(console, context, step, facing, interrupts, speed)){ - return StepEnd::STOPPED; + if (const std::optional walked = walk_step(console, context, step, facing, interrupts, speed)){ + return StepOutcome{.end = StepEnd::STOPPED, .walked = walked}; } if (step.action == RouteAction::FACE){ - return StepEnd::DONE; + return StepOutcome{}; } const Milliseconds watch = step.wild ? AFTER_WILD_LEG_WATCH : AFTER_LEG_WATCH; const size_t late = Pokemon::clear_late_interrupts(console, context, step.wild ? interrupts.wild : interrupts.other, watch, MAX_INTERRUPTIONS_PER_LEG); // After a warp it was checked for already. - return late == 0 || step.warps || step.action != RouteAction::WALK ? StepEnd::DONE : StepEnd::LATE; + const bool done = late == 0 || step.warps || step.action != RouteAction::WALK; + return StepOutcome{.end = done ? StepEnd::DONE : StepEnd::LATE, .walked = std::nullopt}; } // Holds on along `step`, stopped part-way off the grass, until its warp's diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h index 5cd35f24bd..aedadc9332 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h @@ -5,7 +5,8 @@ * The console side of run_route() (PokemonFRLG_OverworldRecovery.h): walks * a route with the walker's steps (PokemonFRLG_OverworldWalk.h), except * that an open leg stops at its first interruption, and the route finds - * the player again instead of counting how far they got. + * the player again around where the timing puts them, instead of walking + * on from a count. * */ @@ -31,23 +32,25 @@ namespace PokemonFRLG{ // given). // // When a battle or a message stops an open leg part-way, where the player -// is isn't guessed: run_route() finds them again with `planning`'s maps -// and costs and walks the rest of the route as planned. +// is isn't guessed: the stop's timing narrows it to a few tiles of the leg +// (walked_near()), from which run_route() finds them again with +// `planning`'s maps and costs, and walks the rest of the route as planned. // // After a leg that crosses a trainer's sight it watches for the trainer, // long enough for them to walk up and speak, and hands what shows to // `interrupts`; where the maps place the stop, the route goes on from // there (run_route()). // -// After each leg to a wall it taps once more toward the wall, and fails if -// the player moves (a diagnostic; RoutePlanning::wall_check SKIP turns it -// off, as the Fast checks level does). +// After each leg to a wall it taps once more toward the wall, and logs +// whether the screen moved (a diagnostic; RoutePlanning::wall_check SKIP +// turns it off, as the Fast checks level does). // // A message open before a step is handed to `interrupts` once // (clear_open_message()). Throws OperationFailedException if a menu or // message is still open before a step, a warp's fade or the Surf prompt // doesn't show, a warp's fade turns out to be a wild battle's, a leg is -// interrupted too many times, or the player can't be found again. A wild battle that starts as the last leg +// interrupted too many times, or the player can't be found again from +// every tile they may be on. A wild battle that starts as the last leg // ends is cleared before it returns. void walk_overworld_route_with_recovery( ConsoleHandle& console, ProControllerContext& context, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp index 4a2fe82c93..453872473e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp @@ -6,6 +6,8 @@ * */ +#include +#include #include #include #include @@ -46,7 +48,7 @@ class Test_OverworldRecovery : public UnitTest{ // Steps that end as scripted, by the order they are walked in; DONE if not. struct ScriptedRoute{ - std::map ends; + std::map ends; std::vector walked; std::vector held_to_warp; std::vector logs; @@ -67,7 +69,7 @@ struct ScriptedRoute{ walked.push_back(step); facings.push_back(facing); auto iter = ends.find(walked.size() - 1); - return iter == ends.end() ? StepEnd::DONE : iter->second; + return iter == ends.end() ? StepOutcome{} : iter->second; }; calls.hold_to_warp = [this](const RouteStep& step){ held_to_warp.push_back(step); }; calls.log = [this](const std::string& message){ logs.push_back(message); }; @@ -122,9 +124,25 @@ bool same(const RouteStep& a, const RouteStep& b){ return to_string(a) == to_string(b); } +UnitTestResult expect_walked(const std::vector& expected, const std::vector& walked){ + bool match = expected.size() == walked.size(); + for (size_t c = 0; match && c < expected.size(); c++){ + match = same(expected[c], walked[c]); + } + if (!match){ + return "Walked:" + describe(walked) + "\nexpected:" + describe(expected); + } + return true; +} + +StepOutcome stop_part_way(std::optional walked = std::nullopt){ + return StepOutcome{.end = StepEnd::STOPPED, .walked = walked}; +} + // Viridian Mart to Oak's Lab; the first open leg through grass is stopped -// by a battle. The player is found again, walked back to that leg's end on -// the same maps, and the rest of the route is walked as planned. +// by a battle halfway, by its timing. The player is found again from the +// tiles around there, walked back to that leg's end, and the rest of the +// route is walked as planned. UnitTestResult test_stop_recover_resume(){ const std::vector maps = overworld_maps(); const MapPosition start{"ViridianCity_Mart", map_point(maps, "ViridianCity_Mart", "clerk_front")}; @@ -141,35 +159,32 @@ UnitTestResult test_stop_recover_resume(){ if (stopped == 0 || stopped == route->size()){ return "Expected an open leg through grass:" + describe(*route); } + const RouteStep& step = (*route)[stopped]; + const WalkedTiles walked = walked_near(step, step.tiles / 2, std::chrono::milliseconds(267)); ScriptedRoute script; - script.ends[stopped] = StepEnd::STOPPED; + script.ends[stopped] = StepOutcome{.end = StepEnd::STOPPED, .walked = walked}; const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = std::nullopt, .label = "Route", .wall_check = WallCheck::TAP}; if (script.run(*route, &planning)){ return "Failed: " + script.failure; } - const RouteStep& step = (*route)[stopped]; - const std::optional anchor = plan_reanchor(maps, (*route)[stopped - 1].end, step); - const std::optional> back = anchor - ? plan_overworld_route(maps, anchor->end, RouteGoal{step.end, std::nullopt}, default_route_costs(), anchor->holds.back().direction) - : std::nullopt; - if (!back || !(follow_overworld_route(maps, anchor->end, *back) == step.end)){ - return "Expected a way back to " + to_string(step.end) + "."; + const std::vector candidates = stop_candidates(maps, (*route)[stopped - 1].end, step, walked); + const std::optional anchor = plan_reanchor(maps, candidates, step); + if (candidates.size() != (size_t)(walked.max - walked.min + 1) || !anchor || anchor->holds.empty()){ + return std::format("Expected holds from the {} tiles around the estimate.", walked.max - walked.min + 1); } std::vector expected(route->begin(), route->begin() + stopped + 1); expected.insert(expected.end(), anchor->holds.begin(), anchor->holds.end()); - expected.insert(expected.end(), back->begin(), back->end()); + expected.insert(expected.end(), anchor->back.begin(), anchor->back.end()); expected.insert(expected.end(), route->begin() + stopped + 1, route->end()); - bool match = expected.size() == script.walked.size(); - for (size_t c = 0; match && c < expected.size(); c++){ - match = same(expected[c], script.walked[c]); - } - if (!match){ - return "Walked:" + describe(script.walked) + "\nexpected:" + describe(expected); + const UnitTestResult walked_as_expected = expect_walked(expected, script.walked); + if (walked_as_expected.result != UnitTestResult::PASSED){ + return walked_as_expected; } return expect_logged(script.logs, { "): ended at an unknown tile (unknown: stopped part-way).", - ": found again at " + anchor->end.map + " " + Pokemon::to_string(anchor->end.point), + std::format("{} stopped {} to {} tiles in; the player is on one of: {};", to_string(step), walked.min, walked.max, to_string(candidates[0])), + ": found again at " + anchor->found.map + " " + Pokemon::to_string(anchor->found.point), }); } @@ -193,20 +208,20 @@ RouteStep walk(Direction direction, int tiles, MapPosition end){ return step; } -// With no maps, or no walls to find the player by, a stop fails the route -// instead of guessing. +// With no maps, or no walls to find the player by from every tile they may +// be on, a stop fails the route instead of guessing. UnitTestResult test_no_recovery(){ const std::vector maps{parse_map(TEST_CORRIDOR_JSON, "Corridor")}; const std::vector route{walk(Direction::EAST, 2, {"Corridor", {4, 1}})}; ScriptedRoute without_maps; - without_maps.ends[0] = StepEnd::STOPPED; + without_maps.ends[0] = stop_part_way(); if (!without_maps.run(route, nullptr) || without_maps.failure.find("no maps") == std::string::npos){ return "Expected a failure without maps, got: " + without_maps.failure; } ScriptedRoute without_walls; - without_walls.ends[0] = StepEnd::STOPPED; + without_walls.ends[0] = stop_part_way(WalkedTiles{.min = 1, .max = 2}); const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = std::nullopt, .label = "Route", .wall_check = WallCheck::TAP}; - if (!without_walls.run(route, &planning) || without_walls.failure.find("no walls") == std::string::npos){ + if (!without_walls.run(route, &planning) || without_walls.failure.find("no holds to walls find the player") == std::string::npos){ return "Expected a failure without walls, got: " + without_walls.failure; } return true; @@ -218,12 +233,12 @@ UnitTestResult test_wall_legs(){ RouteStep wall = walk(Direction::NORTH, 3, {"Corridor", {1, 1}}); wall.to_wall = true; ScriptedRoute late; - late.ends[0] = StepEnd::LATE; + late.ends[0] = StepOutcome{.end = StepEnd::LATE, .walked = std::nullopt}; if (late.run({wall}, nullptr) || late.walked.size() != 2){ return "Expected the leg walked again."; } ScriptedRoute blocked; - blocked.ends[0] = StepEnd::STOPPED; + blocked.ends[0] = stop_part_way(); if (!blocked.run({wall}, nullptr)){ return "Expected a blocked leg to a wall to fail."; } @@ -237,16 +252,16 @@ UnitTestResult test_warp_leg(){ door.warps = true; const RouteStep after = walk(Direction::EAST, 2, {"PewterCity", {18, 18}}); ScriptedRoute script; - script.ends[0] = StepEnd::STOPPED; + script.ends[0] = stop_part_way(); if (script.run({door, after}, nullptr) || script.held_to_warp.size() != 1 || script.walked.size() != 2){ return "Expected a hold to the warp, then the next step."; } return true; } -// After a leg to a wall, a tap toward it that moves the player fails the -// route (the forest drift diagnostic); one that doesn't, or with the check -// off, goes on. Open legs aren't tapped. +// After a leg to a wall, a tap toward it that moves the screen is only +// logged (the forest drift diagnostic): a bump and a step can't be told +// apart yet. With the check off there is no tap. Open legs aren't tapped. UnitTestResult test_wall_check(){ RouteStep wall = walk(Direction::NORTH, 3, {"Corridor", {1, 1}}); wall.to_wall = true; @@ -255,25 +270,25 @@ UnitTestResult test_wall_check(){ const char* name; bool moves; WallCheck check; - bool fails; + bool logged; size_t taps; }; const Case cases[] = { - {.name = "the tap moves", .moves = true, .check = WallCheck::TAP, .fails = true, .taps = 1}, - {.name = "the tap bumps", .moves = false, .check = WallCheck::TAP, .fails = false, .taps = 1}, - {.name = "the check is off", .moves = true, .check = WallCheck::SKIP, .fails = false, .taps = 0}, + {.name = "the tap moves", .moves = true, .check = WallCheck::TAP, .logged = true, .taps = 1}, + {.name = "the tap bumps", .moves = false, .check = WallCheck::TAP, .logged = false, .taps = 1}, + {.name = "the check is off", .moves = true, .check = WallCheck::SKIP, .logged = false, .taps = 0}, }; for (const Case& c : cases){ const RoutePlanning planning{.maps = nullptr, .costs = default_route_costs(), .start = std::nullopt, .label = "Route", .wall_check = c.check}; ScriptedRoute script; script.moves_past_wall = c.moves; - const bool failed = script.run({wall, open}, &planning); - if (failed != c.fails || script.wall_taps != c.taps){ - return std::format("{}: expected {} after {} tap(s), got {} after {}: {}", - c.name, c.fails ? "a failure" : "no failure", c.taps, failed ? "a failure" : "none", script.wall_taps, script.failure); + if (script.run({wall, open}, &planning) || script.walked.size() != 2 || script.wall_taps != c.taps){ + return std::format("{}: expected both legs walked after {} tap(s), got {} after {}: {}", + c.name, c.taps, script.walked.size(), script.wall_taps, script.failure); } - if (c.fails && script.failure != "position check: " + to_string(wall) + " did not end at its wall"){ - return std::string(c.name) + ": wrong failure: " + script.failure; + const UnitTestResult logged = expect_logged(script.logs, {"Position check (logged only): a tap after " + to_string(wall)}); + if ((logged.result == UnitTestResult::PASSED) != c.logged){ + return std::string(c.name) + (c.logged ? ": expected the tap logged." : ": didn't expect the tap logged."); } } return true; @@ -349,6 +364,7 @@ struct TrainerCase{ const char* name; RouteStep leg; StepEnd walk_end; + std::optional walked; StepEnd watch_end; size_t watches; bool seen; @@ -374,7 +390,7 @@ UnitTestResult run_trainer_case(const std::vector& maps, const TrainerC face.action = RouteAction::FACE; const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = robin(0, 1), .label = "Route", .wall_check = WallCheck::SKIP}; ScriptedRoute script; - script.ends[0] = c.walk_end; + script.ends[0] = StepOutcome{.end = c.walk_end, .walked = c.walked}; script.watch_ends = {c.watch_end}; if (script.run({c.leg, face}, &planning)){ return "failed: " + script.failure; @@ -413,9 +429,12 @@ UnitTestResult test_trainer_stops_leg(){ wall.to_wall = true; const RouteStep open = walk(Direction::EAST, 4, robin(4, 1)); const TrainerCase cases[] = { - {.name = "to a wall, seen", .leg = wall, .walk_end = StepEnd::DONE, .watch_end = StepEnd::LATE, .watches = 1, .seen = true}, - {.name = "open, seen", .leg = open, .walk_end = StepEnd::STOPPED, .watch_end = StepEnd::DONE, .watches = 0, .seen = true}, - {.name = "to a wall, not seen", .leg = wall, .walk_end = StepEnd::DONE, .watch_end = StepEnd::DONE, .watches = 1, .seen = false}, + {.name = "to a wall, seen", .leg = wall, .walk_end = StepEnd::DONE, .walked = std::nullopt, .watch_end = StepEnd::LATE, .watches = 1, .seen = true}, + {.name = "open, seen", .leg = open, .walk_end = StepEnd::STOPPED, .walked = std::nullopt, .watch_end = StepEnd::DONE, .watches = 0, .seen = true}, + // She stops the player on (3, 1) and speaks only after walking up, + // so the timing puts the stop past her tile: still hers. + {.name = "open, seen, timed late", .leg = open, .walk_end = StepEnd::STOPPED, .walked = WalkedTiles{.min = 3, .max = 4}, .watch_end = StepEnd::DONE, .watches = 0, .seen = true}, + {.name = "to a wall, not seen", .leg = wall, .walk_end = StepEnd::DONE, .walked = std::nullopt, .watch_end = StepEnd::DONE, .watches = 1, .seen = false}, }; for (const TrainerCase& c : cases){ const UnitTestResult result = run_trainer_case(maps, c); @@ -426,6 +445,42 @@ UnitTestResult test_trainer_stops_leg(){ return true; } + +// On the grass a stop past Robin's column may be a wild battle's anywhere +// on the leg, or hers on (3, 1), after which she stands on (3, 2): the +// player is found again from all of them, then walks back to the leg's end. +UnitTestResult test_grass_in_sight(){ + const std::vector maps{parse_map(TEST_ROBIN_JSON, "Robin")}; + RouteStep grass = walk(Direction::EAST, 4, robin(4, 1)); + grass.wild = true; + RouteStep face = walk(Direction::NORTH, 0, grass.end); + face.action = RouteAction::FACE; + const std::vector candidates = stop_candidates(maps, robin(0, 1), grass, whole_leg(grass)); + const StopCandidate hers{.player = robin(3, 1), .trainer = TrainerStop{.object = 0, .at = robin(3, 2)}}; + if (candidates.size() != 6 || std::find(candidates.begin(), candidates.end(), hers) == candidates.end()){ + return std::format("Expected the leg's 5 tiles and hers, got {} candidates.", candidates.size()); + } + const std::optional anchor = plan_reanchor(maps, candidates, grass); + if (!anchor || anchor->holds.empty()){ + return "Expected holds that find the player."; + } + const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = robin(0, 1), .label = "Route", .wall_check = WallCheck::SKIP}; + ScriptedRoute script; + script.ends[0] = stop_part_way(); + if (script.run({grass, face}, &planning)){ + return "Failed: " + script.failure; + } + std::vector expected{grass}; + expected.insert(expected.end(), anchor->holds.begin(), anchor->holds.end()); + expected.insert(expected.end(), anchor->back.begin(), anchor->back.end()); + expected.push_back(face); + const UnitTestResult walked = expect_walked(expected, script.walked); + if (walked.result != UnitTestResult::PASSED){ + return walked; + } + return expect_logged(script.logs, {"Robin (3, 1) (stopped by a trainer, now at (3, 2))"}); +} + } @@ -438,6 +493,7 @@ void add_tests_OverworldRecovery(UnitTestDatabase& database){ database.add("a leg to a wall ends at it", test_wall_check); database.add("a leg crosses a trainer's sight", test_trainer_crossing); database.add("a trainer stops a leg", test_trainer_stops_leg); + database.add("a stop on the grass in a trainer's sight", test_grass_in_sight); }