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..27b28bb54d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -0,0 +1,374 @@ +/* Overworld Re-anchor + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "PokemonFRLG_OverworldReanchor.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +constexpr size_t MAX_HOLDS = 3; + +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){ + 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; +} + +template +void add_unique(std::vector& items, const Type& item){ + if (std::find(items.begin(), items.end(), item) == items.end()){ + items.push_back(item); + } +} + +// 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; +} + +// 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, 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; + RouteStep hold; + hold.direction = direction; + hold.surfing = surfing; + hold.to_wall = true; + hold.facing_after = direction; + 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, Hypothesis{position.world, 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; +} + +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>& 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(worlds, positions, surfing, direction); + if (!next || next->first == positions || next->second.tiles == 0){ + continue; + } + holds.push_back(next->second); + if (converged(next->first)){ + if (depth == 1){ + found.push_back(Found{holds, next->first[0].at}); + } + }else if (depth > 1){ + 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; +} + +} + + +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, + WalkedTiles walked +){ + 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; + } + + // 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 new file mode 100644 index 0000000000..9037aa83ce --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -0,0 +1,115 @@ +/* Overworld Re-anchor + * + * From: https://github.com/PokemonAutomation/ + * + * Finding the player again after a battle or a message stopped a leg + * 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 + * hold for some tile, and nothing on its grass legs could be found. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldReanchor_H +#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" + +namespace PokemonAutomation{ +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, 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; + 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` 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::vector& candidates, + const RouteStep& step, + const RouteCosts& costs = default_route_costs() +); + + +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 new file mode 100644 index 0000000000..bbc2cf72f5 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp @@ -0,0 +1,246 @@ +/* Overworld Re-anchor Tests + * + * From: https://github.com/PokemonAutomation/ + * + * stop_candidates() and plan_reanchor(): finding the player again at a + * wall after an open leg stopped part-way. + * + */ + +#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" + +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 (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; +} + +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 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){ + 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){ + 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 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) + ); + } + 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; + } + 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} + ); +} +// 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"({ + "name": "Field", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["########", "#......#", "#......#", "#vvvvvv#", "#......#"], + "elevation": ["33333333", "33333333", "33333333", "33333333", "33333333"] +})"; + +// Stopped part-way east, the player holds back west to the wall, the +// 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, 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, 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); + } + 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; +} + + +} + + +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 new file mode 100644 index 0000000000..1a907f7a06 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -0,0 +1,358 @@ +/* Overworld Recovery + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "Pokemon/Navigation/Pokemon_RouteLog.h" +#include "PokemonFRLG_OverworldReanchor.h" +#include "PokemonFRLG_OverworldRecovery.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +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 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; +} + +// 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)); + } + 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{ + 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){ + found(step.end, holds); + } + } + +private: + const RouteCalls& m_calls; + std::string m_label; +}; + +// Walks each step through `calls`; watches a leg that crosses a trainer's +// 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, WallCheck wall_check) + : m_calls(calls) + , m_wall_check(wall_check) + {} + 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; + 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 = outcome.end == StepEnd::LATE; + if (outcome.end == StepEnd::DONE){ + check_wall(step); + } + 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. 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 + ){ + return; + } + if (!m_calls.moved_past_wall(step)){ + return; + } + m_calls.log(std::format( + "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 + )); + } + + const RouteCalls& m_calls; + WallCheck m_wall_check = WallCheck::TAP; + std::string m_last; + bool m_last_late = false; + size_t m_reholds = 0; +}; + +// 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, 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 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_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 || maps() == nullptr){ + return std::nullopt; + } + return trainer_crossing(*maps(), *m_at, step); + } + + // 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; + } + 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; + } + const WalkedTiles walked = outcome.walked.value_or(whole_leg(step)); + return rejoin(step, stop_candidates(*maps(), start, step, walked), walked); + } + + // 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 (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, "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; + } + + 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 new file mode 100644 index 0000000000..3e15603f4d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h @@ -0,0 +1,134 @@ +/* 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; + * - 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; + * - 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. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldRecovery_H +#define PokemonAutomation_PokemonFRLG_OverworldRecovery_H + +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_OverworldReanchor.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 +}; + +// 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 and logs whether the player moved (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. +struct RoutePlanning{ + const std::vector* maps = nullptr; + 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 +// 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; + // Taps once more toward the wall a leg to a wall ended at, and says + // 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 + // `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), 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; +}; +// 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, and no leg is watched for trainers. +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_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp new file mode 100644 index 0000000000..9100f08d3f --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -0,0 +1,246 @@ +/* Overworld Recovery Walk + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageDiff.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" +#include "PokemonFRLG_OverworldRecoveryWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace OverworldWalkSteps; + + +namespace{ + +// 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; + leg.tiles = step.tiles; + leg.to_wall = step.to_wall; + leg.tile_time = tile_time(step, speed); + leg.stop_on_interrupt = !step.to_wall; + leg.stop_if_blocked = step.to_wall; + return leg; +} + +// 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:{ + // 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); + } + if (const std::string note = speed_note(step, speed); !note.empty()){ + console.log(note); + } + 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); + static_assert(AFTER_WARP_BATTLE_WATCH >= AFTER_WARP); + check_warp_was_no_battle(console, context, step); + } + 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; + } + return std::nullopt; +} + +// 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). +StepOutcome walk_and_watch( + ConsoleHandle& console, ProControllerContext& context, + const RouteStep& step, std::optional facing, + const OverworldInterruptSet& interrupts, MoveSpeed speed +){ + 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 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. + 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 +// 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, 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; + 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); + mover(context, step.direction, tile_time(step, speed) * (step.tiles + 2)); + }, + 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 + ); +} + +// 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 +// 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; +} + +} + + +void walk_overworld_route_with_recovery( + ConsoleHandle& console, ProControllerContext& context, + const std::vector& route, + const RoutePlanning& planning, + std::optional facing, + const OverworldInterruptSet& interrupts, + MoveSpeed speed +){ + if (route.empty()){ + return; + } + if (!facing){ + facing = read_facing(console, context); + } + 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); + }; + calls.hold_to_warp = [&](const RouteStep& step){ + 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){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, message, console); + }; + 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 new file mode 100644 index 0000000000..aedadc9332 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h @@ -0,0 +1,69 @@ +/* Overworld Recovery Walk + * + * From: https://github.com/PokemonAutomation/ + * + * 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 around where the timing puts them, instead of walking + * on from a count. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldRecoveryWalk_H +#define PokemonAutomation_PokemonFRLG_OverworldRecoveryWalk_H + +#include +#include +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "PokemonFRLG_OverworldRecovery.h" +#include "PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_OverworldWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// 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: 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 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 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, + const std::vector& route, + const RoutePlanning& planning, + std::optional facing = std::nullopt, + const OverworldInterruptSet& interrupts = default_overworld_interrupts(), + MoveSpeed speed = MoveSpeed::WALK +); + + + +} +} +} +#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..453872473e --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp @@ -0,0 +1,503 @@ +/* Overworld Recovery Tests + * + * From: https://github.com/PokemonAutomation/ + * + * run_route() with scripted steps: walk, interruption, recovery, resume. + * + */ + +#include +#include +#include +#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::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; + // 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 facing){ + walked.push_back(step); + facings.push_back(facing); + auto iter = ends.find(walked.size() - 1); + 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); }; + 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); + }; + 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. + 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; +} + +// 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); +} + +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 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")}; + 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); + } + const RouteStep& step = (*route)[stopped]; + const WalkedTiles walked = walked_near(step, step.tiles / 2, std::chrono::milliseconds(267)); + ScriptedRoute script; + 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 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(), anchor->back.begin(), anchor->back.end()); + expected.insert(expected.end(), route->begin() + stopped + 1, route->end()); + 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).", + 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), + }); +} + +// A corridor with a person at each end: nothing finds the player again. +const char* const TEST_CORRIDOR_JSON = R"({ + "name": "Corridor", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#######", "#.....#", "#######"], + "elevation": ["3333333", "3333333", "3333333"], + "objects": [ + {"name": "youngster", "kind": "npc", "x": 1, "y": 1}, + {"name": "lass", "kind": "npc", "x": 5, "y": 1} + ] +})"; + +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 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] = 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] = 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 holds to walls find the player") == 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] = 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] = stop_part_way(); + 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] = 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 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; + const RouteStep open = walk(Direction::EAST, 2, {"Corridor", {3, 1}}); + struct Case{ + const char* name; + bool moves; + WallCheck check; + bool logged; + size_t taps; + }; + const Case cases[] = { + {.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; + 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); + } + 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; +} + +// 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", .wall_check = WallCheck::TAP}; + ScriptedRoute script; + if (script.run(route, &planning)){ + return "Failed: " + script.failure; + } + 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; +} + +// 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; + std::optional walked; + 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] = 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; + } + 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, .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); + if (result.result != UnitTestResult::PASSED){ + return std::string(c.name) + ": " + result.message; + } + } + 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))"}); +} + +} + + +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); + 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); + database.add("a stop on the grass in a trainer's sight", test_grass_in_sight); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index f144a90940..1ac43e21a6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -28,6 +28,8 @@ #include "Inference/PokemonFRLG_TrainerIdReader.h" #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_OverworldWalk.h" #include "Overworld/PokemonFRLG_PlayerFacingReader.h" @@ -295,6 +297,8 @@ void add_tests(UnitTestDatabase& database){ add_tests_OverworldMaps(database); add_tests_OverworldRoute(database); add_tests_OverworldWalk(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 b072a343c3..8b2cf990a1 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1800,6 +1800,14 @@ 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_OverworldRecovery.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.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