diff --git a/.github/workflows/cpp-ci-serial-programs-base.yml b/.github/workflows/cpp-ci-serial-programs-base.yml index 1601ddaa9a..48e45d1d1c 100644 --- a/.github/workflows/cpp-ci-serial-programs-base.yml +++ b/.github/workflows/cpp-ci-serial-programs-base.yml @@ -106,7 +106,7 @@ jobs: uses: actions/checkout@v7 with: repository: dayfine/PokemonSimulation - ref: 9cb12c52f5f055299e32c2a7cf43e7103c101e3e + ref: bdca47c5045797be1d2a1f0931ad6a8f15ab3225 path: StoryReplaySource - name: Verify replay preview bytes and independently regenerate its route input diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.cpp index 087680cb09..1e0b488e6a 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.cpp @@ -36,6 +36,13 @@ constexpr std::pair GLYPHS[] = { {'2', TileKind::CLOSED_SOUTH}, {'4', TileKind::CLOSED_WEST}, {'6', TileKind::CLOSED_EAST}, + {'u', TileKind::SPIN_NORTH}, + {'d', TileKind::SPIN_SOUTH}, + {'l', TileKind::SPIN_WEST}, + {'r', TileKind::SPIN_EAST}, + {'o', TileKind::STOP_SPIN}, + {'H', TileKind::FALL_HOLE}, + {'B', TileKind::STRENGTH_BUTTON}, }; constexpr std::pair WARP_ENTRIES[] = { diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.h index a5adcea1b9..bb860467c0 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.h @@ -69,6 +69,13 @@ enum class TileKind : uint8_t{ CLOSED_SOUTH, // '2' not entered from it, nor left through it (the CLOSED_WEST, // '4' number pad's arrows) CLOSED_EAST, // '6' + SPIN_NORTH, // 'u' open; stepping on it slides the player north + SPIN_SOUTH, // 'd' (Rocket Hideout and Viridian Gym spin tiles) + SPIN_WEST, // 'l' + SPIN_EAST, // 'r' + STOP_SPIN, // 'o' open; where a spin's slide stops + FALL_HOLE, // 'H' open; a hole that drops the player a floor + STRENGTH_BUTTON,// 'B' open; Victory Road's switch for a boulder }; // The tile a glyph stands for, or nothing if it isn't one. std::optional tile_from_glyph(char glyph); diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData_Tests.cpp index 554ec84c3e..e745d28bd8 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData_Tests.cpp @@ -181,7 +181,7 @@ UnitTestResult test_lists_may_be_left_out(){ } UnitTestResult test_glyphs_round_trip(){ - for (char glyph : std::string("#.~wv^<>DS 8246")){ + for (char glyph : std::string("#.~wv^<>DS 8246udlroHB")){ const std::optional tile = tile_from_glyph(glyph); if (!tile || glyph_of(*tile) != glyph){ return std::string("Glyph '") + glyph + "' does not round-trip."; @@ -193,6 +193,27 @@ UnitTestResult test_glyphs_round_trip(){ return true; } +// PokemonSimulation's tile-legend.json glyphs for forced movement, holes and +// boulder switches, in a map's rows. +UnitTestResult test_spin_hole_and_button_glyphs_parse(){ + const std::string json = R"({ + "name": "SpinRoom", + "source": {"decomp_file": "f", "revision": "r", "date": "d"}, + "tiles": ["udlroHB"] +})"; + const MapData map = parse_map(parse_json(json), "SpinRoom.json"); + constexpr TileKind EXPECTED[] = { + TileKind::SPIN_NORTH, TileKind::SPIN_SOUTH, TileKind::SPIN_WEST, TileKind::SPIN_EAST, + TileKind::STOP_SPIN, TileKind::FALL_HOLE, TileKind::STRENGTH_BUTTON, + }; + for (int x = 0; x < (int)std::size(EXPECTED); x++){ + if (map.tiles.at({x, 0}) != EXPECTED[x]){ + return std::format("Tile ({}, 0) read '{}'.", x, glyph_of(map.tiles.at({x, 0}))); + } + } + return true; +} + struct BadMap{ const char* name; std::string json; @@ -484,6 +505,10 @@ const MapDataTestCase MAP_DATA_CASES[] = { .input = {.name = "glyphs round-trip", .test = test_glyphs_round_trip}, .expectation = {.description = "converts between tile characters and enum kinds"}, }, + { + .input = {.name = "spin, hole and button glyphs", .test = test_spin_hole_and_button_glyphs_parse}, + .expectation = {.description = "parses u d l r o H B as the tile legend's spin, stop, hole and switch tiles"}, + }, { .input = {.name = "each error class", .test = test_each_error_class}, .expectation = {.description = "rejects invalid JSON syntax and malformed map schemas"}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp index af82fde0ea..995ae469e7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp @@ -74,6 +74,7 @@ class PlaceNameMatcher : public OCR::SmallDictionaryMatcher{ {"route-24", "Route 24"}, {"route-25", "Route 25"}, {"route-10", "Route 10"}, + {"lavender-town", "Lavender Town"}, {"vermilion-city", "Vermilion City"}, }){ JsonArray names; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h index f693d02977..68e82bec51 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h @@ -33,7 +33,8 @@ ImageFloatBox save_card_title_box(); // The place on the save card: "pallet-town", "viridian-city", // "viridian-forest", "pewter-city", "route-1" to "route-4", "cerulean-city", -// "route-10", "route-24", "route-25", "vermilion-city"; empty if it can't be read, as +// "route-10", "route-24", "route-25", "vermilion-city", +// "lavender-town"; empty if it can't be read, as // in a language the reader has no names for. std::string read_save_card_location(Logger& logger, Language language, const ImageViewRGB32& screen); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h new file mode 100644 index 0000000000..f7062858bc --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h @@ -0,0 +1,101 @@ +/* Movement Mechanics + * + * From: https://github.com/PokemonAutomation/ + * + * How FireRed and LeafGreen move the player for a press of the D-pad, as + * measured on the console, and the presses the walker sends because of + * it. The overworld walker (OverworldWalkSteps, AutoStory's step_once()) + * presses by this table, and OverworldSim moves its player by the same + * rules. + * + * Taps measured on console B (2026-11-09, Route 10's Pokemon Center 1F, + * D-pad presses with a 700ms release, tiles counted by exact frame match + * against reference shots), 140 of 140 alike: + * - From a standstill, a tap of 16 to 133ms while the player faces + * another way only turns them, never steps (pokefirered turns in 8 + * frames). + * - The same tap while they face its way takes exactly one step. + * - No tap was dropped. + * Repeated step taps (66ms each, after a turn tap and a 1000ms wait): + * - A step tap that starts within STEP_TAP_LOCKOUT of the previous step + * tap's start is ignored. + * - Taps with no release between them are one hold: after a turn, 3 of + * them took 0 or 1 steps. + * After a battle (console B, 2026-11-09 15:50, 00.28 winston_line): a + * step tap sent as flee_battle() returned, on the black's end, was lost; + * one more tap by hand stepped. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_MovementMechanics_H +#define PokemonAutomation_PokemonFRLG_MovementMechanics_H + +#include +#include "Common/Cpp/Time.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace MovementMechanics{ + + +// The game. + +// Walking: a tile per 16 frames at 60 fps (console B, 2026-11-09). +inline constexpr Milliseconds TILE_TIME(267); +// Running with B held: a tile per 8 frames (console B, 2026-11-02, video +// of run 00.11). +inline constexpr Milliseconds RUN_TILE_TIME(133); +// Surfing: a tile per 8 frames. +inline constexpr Milliseconds SURF_TILE_TIME(133); +// The longest tap measured to only turn a player facing another way, and +// to take exactly one step for one facing its way. +inline constexpr Milliseconds LONGEST_TAP(133); +// A step tap that starts sooner than this after the previous step tap +// started is ignored. Measured between 566ms (66ms taps 500ms apart: the +// 2nd of 3 ignored, 3 of 3 times) and 666ms (600 to 900ms apart: 8 of 8 +// worked). +inline constexpr Milliseconds STEP_TAP_LOCKOUT(620); +// The shortest step tap spacing, start to start, measured to work. +inline constexpr Milliseconds STEP_TAP_SPACING_WORKED(666); +// After a battle's black ends, the overworld fades in and the game ignores +// the D-pad this long. Not measured: pokefirered's fade from black is 16 +// frames, then the field takes input; a tap sent at once was lost. +inline constexpr Milliseconds BATTLE_EXIT_LOCKOUT(500); + + +// The walker's presses. + +// Turns a player facing another way, without a step. +inline constexpr Milliseconds TURN_TAP(40); +// How long to wait after a turn tap for the turn to show. +inline constexpr Milliseconds TURN_SETTLE(300); +// How many taps turn_to() tries before it takes the player as turned. +inline constexpr size_t MAX_TURN_TAPS = 3; +// Turns the player to face something, short enough not to step. +inline constexpr Milliseconds FACE_HOLD(40); +// The turn animation, before an A press reaches what is ahead. +inline constexpr Milliseconds FACE_SETTLE(300); +// The release between a leg's hold and what comes next. +inline constexpr Milliseconds STEP_RELEASE(100); +// A one-step tap (step_once()). +inline constexpr Milliseconds STEP_TAP(66); +// The release after a step tap: the next one starts STEP_TAP + +// STEP_TAP_RELEASE after it, past the lockout. +inline constexpr Milliseconds STEP_TAP_RELEASE(700); + +// How long the overworld must show nothing (no box, fade or battle) after +// a battle before the next tap: twice the lockout. +inline constexpr Milliseconds AFTER_BATTLE_QUIET(1000); + +static_assert(TURN_TAP <= LONGEST_TAP && FACE_HOLD <= LONGEST_TAP && STEP_TAP <= LONGEST_TAP); +static_assert(STEP_TAP + STEP_TAP_RELEASE > STEP_TAP_SPACING_WORKED); +static_assert(STEP_TAP_SPACING_WORKED > STEP_TAP_LOCKOUT); +static_assert(AFTER_BATTLE_QUIET > BATTLE_EXIT_LOCKOUT); + + +} // namespace MovementMechanics +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp index 2735ec7ae4..4d83c0db45 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -78,6 +78,19 @@ const char* const MAP_NAMES[] = { "Route9", "Route10", "Route10_PokemonCenter_1F", + // Rock Tunnel to Lavender Town's Pokemon Center. + "RockTunnel_1F", + "RockTunnel_B1F", + "LavenderTown", + "LavenderTown_PokemonCenter_1F", + // West from Lavender: Route 8, the Underground Path under Saffron and + // Route 7 to Celadon City. + "Route8", + "UndergroundPath_EastEntrance", + "UndergroundPath_EastWestTunnel", + "UndergroundPath_WestEntrance", + "Route7", + "CeladonCity", "CeruleanCave_1F", "CeruleanCave_2F", "CeruleanCave_B1F", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index 588b63c357..cca489347c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -17,6 +17,9 @@ namespace PokemonFRLG{ namespace{ constexpr size_t MAX_HOLDS = 3; +// How many of the route's steps after a leg walked on may go by before +// they find the player. +constexpr size_t MAX_WALK_ON_STEPS = 4; Direction opposite(Direction direction){ return (Direction)(((int)direction + 2) % 4); @@ -236,14 +239,46 @@ bool walks_same_everywhere( 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. +int held_tiles(const std::vector& holds){ + int tiles = 0; + for (const RouteStep& hold : holds){ + tiles += hold.tiles; + } + return tiles; +} + +// 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. Not +// only the fewest holds: for each first hold, the fewest after it, so a +// caller can pick a way that keeps what a stopped leg walked. On Rock +// Tunnel B1F row 37 the one single hold went back east to the wall +// behind the leg's start, while a hold south and one west, ahead, were +// found too (console B, 2026-11-09). std::vector find_player( const std::vector>& worlds, const std::vector& positions, bool surfing ){ - return Pokemon::find_wall_anchors( - positions, ReanchorContext{.worlds = worlds, .surfing = surfing}, {.max_holds = MAX_HOLDS} - ); + const ReanchorContext context{.worlds = worlds, .surfing = surfing}; + std::vector found = Pokemon::find_wall_anchors(positions, context, {.max_holds = 0}); + if (!found.empty()){ + return found; + } + for (Direction direction : Pokemon::ALL_DIRECTIONS){ + const auto first = ReanchorPolicy::hold(context, positions, direction); + if (!first || first->positions == positions || first->hold.tiles == 0){ + continue; + } + for (Found& rest : Pokemon::find_wall_anchors(first->positions, context, {.max_holds = MAX_HOLDS - 1})){ + rest.holds.insert(rest.holds.begin(), first->hold); + found.push_back(std::move(rest)); + } + } + std::stable_sort(found.begin(), found.end(), [](const Found& a, const Found& b){ + if (a.holds.size() != b.holds.size()){ + return a.holds.size() < b.holds.size(); + } + return held_tiles(a.holds) < held_tiles(b.holds); + }); + return found; } } // namespace @@ -288,6 +323,10 @@ WalkedTiles whole_leg(const RouteStep& step){ return WalkedTiles{.min = 0, .max = step.tiles}; } +WalkedTiles stop_tiles(const RouteStep& step, WalkedTiles timed, bool dark){ + return dark ? whole_leg(step) : timed; +} + std::string to_string(const StopCandidate& candidate){ std::string text = to_string(candidate.player); if (candidate.trainer){ @@ -326,32 +365,108 @@ std::vector stop_candidates( return candidates; } -std::optional plan_reanchor( - const std::vector& maps, - const std::vector& candidates, - const RouteStep& step, - const RouteCosts& costs +int reanchor_tiles(const Reanchor& anchor){ + return held_tiles(anchor.holds) + held_tiles(anchor.back); +} + +namespace{ + +// How much more a tile of an open leg costs than one held to a wall: a +// wild battle on a hold costs only the battle, as the hold goes on to its +// wall, while one on an open leg stops it and needs finding the player +// again. Not measured: weighted so that on Rock Tunnel B1F row 38 the way +// west to the wall (1, 38) and 13 tiles back beats 10 held east to the +// wall (30, 38) and 17 back. +constexpr int OPEN_TILE_WEIGHT = 4; + +// How costly `anchor` is to walk: its tiles, with open legs' weighted. +int reanchor_cost(const Reanchor& anchor){ + int open = 0; + for (const RouteStep& step : anchor.back){ + if (step.action == RouteAction::WALK && !step.to_wall && !step.warps){ + open += step.tiles; + } + } + return reanchor_tiles(anchor) + (OPEN_TILE_WEIGHT - 1) * open; +} + +// Whether `holds`, from any of `positions`, pass a tile a trainer may see. +// A trainer the route keeps clear of would stop the player and fight +// (the sim, 2026-11-09: Hiker Lucas, on a hold down Rock Tunnel 1F's +// column 16 after a wild battle below the ladder (20, 13)). +bool holds_in_sight( + const std::vector>& worlds, const std::vector& positions, const std::vector& holds ){ - if (candidates.empty() || step.action != RouteAction::WALK){ - return std::nullopt; + for (const Hypothesis& position : positions){ + const std::vector& world = worlds[position.world]; + MapPosition at = position.at; + for (const RouteStep& hold : holds){ + const std::optional wall = hold_to_wall(world, at, hold.surfing, hold.direction); + if (!wall){ + break; + } + RouteStep held = hold; + held.tiles = wall->tiles; + for (const MapPosition& tile : leg_tiles(world, at, held)){ + if (!trainers_seeing(world, tile).empty()){ + return true; + } + } + at = wall->end; + } } + return false; +} - // The maps as they may stand: as they are, and with each trainer who - // may have walked up. - std::vector> worlds{maps}; +// Ways whose holds pass a trainer's sight come after every other way. +constexpr int IN_SIGHT_COST = 1000; + +// The maps as they may stand after a stop on one of `candidates`: as they +// are, and with each trainer who may have walked up; where those trainers +// stand, and where the player may be on which of the maps. +struct StopWorlds{ + std::vector> worlds; std::vector trainers; std::vector positions; +}; +StopWorlds stop_worlds(const std::vector& maps, const std::vector& candidates){ + StopWorlds out{.worlds = {maps}, .trainers = {}, .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); + out.worlds.push_back(with_trainers(maps, {candidate.trainer->at})); + world = out.worlds.size() - 1; + add_unique(out.trainers, candidate.trainer->at); } - add_unique(positions, Hypothesis{world, candidate.player}); + add_unique(out.positions, Hypothesis{world, candidate.player}); + } + return out; +} + +} // namespace + +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; } + const StopWorlds stop = stop_worlds(maps, candidates); + const std::vector>& worlds = stop.worlds; + const std::vector& trainers = stop.trainers; + const std::vector& positions = stop.positions; + + // Of the ways found, the cheapest (reanchor_cost()): it keeps the most + // of what the leg walked. A wild battle every 10 to 14 tiles stopped + // every try of a leg whose way back started behind it (console B, + // 2026-11-09, Rock Tunnel B1F row 37). const std::vector clear_of_trainers = with_trainers(maps, trainers); + std::optional best; + int best_cost = 0; 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 @@ -360,8 +475,137 @@ std::optional plan_reanchor( 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)}; + if (!back || (!trainers.empty() && !walks_same_everywhere(worlds, positions, at.at, *back))){ + continue; + } + Reanchor anchor{.holds = at.holds, .found = at.at, .back = std::move(*back)}; + const int cost = reanchor_cost(anchor) + (holds_in_sight(worlds, positions, anchor.holds) ? IN_SIGHT_COST : 0); + if (!best || cost < best_cost){ + best = std::move(anchor); + best_cost = cost; + } + } + return best; +} + +namespace{ + +// How many tiles `from` is before `to`, walking `direction` on one map; +// nothing if `to` isn't ahead on that line within `max_tiles`. +std::optional tiles_ahead(const MapPosition& from, const MapPosition& to, Direction direction, int max_tiles){ + if (from.map != to.map){ + return std::nullopt; + } + TilePos at = from.point; + for (int c = 0; c <= max_tiles; c++){ + if (at == to.point){ + return c; + } + at = at.next(direction); + } + return std::nullopt; +} + +// One candidate's way on: the tiles it walks, and where it stands. +struct WayOn{ + std::vector tiles; + MapPosition at; +}; + +// `step`, a hold to a wall or an open leg onto no warp, from where each of +// `ways` stands. False if a hold isn't stopped by a wall, an open leg is +// stopped short, or either may be stopped short by a person. +bool walk_ways(const std::vector& maps, const RouteStep& step, std::vector& ways){ + if (step.action != RouteAction::WALK || step.warps || !step.connection_steps.empty()){ + return false; + } + for (WayOn& way : ways){ + std::optional walked = step.to_wall + ? hold_to_wall(maps, way.at, step.surfing, step.direction) + : std::optional(WallHold{.end = way.at, .tiles = step.tiles}); + if (!walked || person_on_the_way(maps, way.at, step.surfing, step.direction, *walked)){ + return false; + } + RouteStep held = step; + held.tiles = walked->tiles; + const std::vector tiles = leg_tiles(maps, way.at, held); + if (tiles.size() != (size_t)held.tiles + 1){ + return false; + } + way.tiles.insert(way.tiles.end(), tiles.begin() + 1, tiles.end()); + way.at = tiles.back(); + } + return true; +} + +// Whether any of `ways` walks onto a tile off `planned` that a trainer may +// see: walking on a few tiles off the planned way may cross a trainer's +// line the route keeps clear of. +bool off_way_in_sight(const std::vector& maps, const std::vector& ways, const std::vector& planned){ + for (const WayOn& way : ways){ + for (const MapPosition& tile : way.tiles){ + const bool off_way = std::find(planned.begin(), planned.end(), tile) == planned.end(); + if (off_way && !trainers_seeing(maps, tile).empty()){ + return true; + } + } + } + return false; +} + +// Each of `candidates` walking `rest`; nothing if any is stopped short. +std::optional> walk_rest( + const std::vector& maps, const std::vector& candidates, const RouteStep& rest +){ + std::vector ways; + for (const StopCandidate& candidate : candidates){ + std::vector tiles = leg_tiles(maps, candidate.player, rest); + if (tiles.size() != (size_t)rest.tiles + 1){ + return std::nullopt; + } + const MapPosition at = tiles.back(); + ways.push_back(WayOn{std::move(tiles), at}); + } + return ways; +} + +} // namespace + +std::optional plan_resume( + const std::vector& maps, + const std::vector& candidates, + const RouteStep& step, + const std::vector& after +){ + const bool open_leg = step.action == RouteAction::WALK && !step.to_wall && !step.warps && step.connection_steps.empty(); + const bool by_trainer = std::any_of(candidates.begin(), candidates.end(), [](const StopCandidate& c){ return c.trainer.has_value(); }); + if (candidates.size() < 2 || !open_leg || by_trainer){ + return std::nullopt; + } + const std::optional remaining = tiles_ahead(candidates[candidates.size() / 2].player, step.end, step.direction, step.tiles); + if (!remaining){ + return std::nullopt; + } + Resume resume{.rest = step, .steps = 0, .starts = {{}}}; + resume.rest.tiles = *remaining; + for (const StopCandidate& candidate : candidates){ + resume.starts[0].push_back(candidate.player); + } + std::optional> ways = walk_rest(maps, candidates, resume.rest); + // The way as planned, from the leg's end. + std::vector planned{WayOn{{step.end}, step.end}}; + for (size_t c = 0; ways && c < after.size() && c < MAX_WALK_ON_STEPS; c++){ + resume.starts.emplace_back(); + for (const WayOn& way : *ways){ + resume.starts.back().push_back(way.at); + } + if (!walk_ways(maps, after[c], *ways) || !walk_ways(maps, after[c], planned)){ + return std::nullopt; + } + const bool found = after[c].to_wall && std::all_of(ways->begin(), ways->end(), [&](const WayOn& way){ return way.at == after[c].end; }); + if (found){ + resume.steps = c + 1; + return off_way_in_sight(maps, *ways, planned[0].tiles) ? std::nullopt : std::optional(std::move(resume)); } } return std::nullopt; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index 157917283e..ae962e95b7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -58,6 +58,14 @@ WalkedTiles walked_near(const RouteStep& step, int estimate, Milliseconds tile_t std::vector with_warp_slack(std::vector route); // Anywhere on `step`, for a stop the timing doesn't place. WalkedTiles whole_leg(const RouteStep& step); +// Where a stop on `step` leaves the player, from `timed`, the tiles its +// timing gives: anywhere on the leg (whole_leg()) if it started on a dark +// cave's screen (OverworldWalkSteps::dark_cave_screen()). There the timing +// has no basis: the screen is black before any battle's wipe, and a +// battle at the end of "east 12" on Rock Tunnel B1F was placed 0 to 3 +// tiles in (console B, 2026-11-09, 00.28). The player, "found again" at +// a wall from those, was 5 tiles off. +WalkedTiles stop_tiles(const RouteStep& step, WalkedTiles timed, bool dark); // 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. @@ -106,8 +114,14 @@ struct Reanchor{ 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. +// How many tiles `anchor`'s holds and way back walk, as far as planned. +int reanchor_tiles(const Reanchor& anchor); + +// After `step` stopped part-way, from `candidates`: of the ways that find +// the player, the one that walks the fewest tiles, holds and way back +// together, with tiles of open legs weighted over held ones. 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, @@ -115,6 +129,36 @@ std::optional plan_reanchor( const RouteCosts& costs = default_route_costs() ); +// Walking on along a stopped open leg instead of finding the player at +// walls first: the rest of the leg from the middle of the tiles they may +// be on, after which the route's next steps, as planned, end with a hold +// that brings them to one tile from every one of them. Walking on from +// how far a leg got, trusting the timing, went wrong on the console; here +// a hold finds the player whichever tile they were on. +struct Resume{ + // From the middle candidate to the stopped leg's end; no tiles if the + // middle candidate is its end. + RouteStep rest; + // How many of the steps after the stopped leg go by until one, a hold + // to a wall, brings every candidate to one tile. + size_t steps = 0; + // Where each of those steps may start, `rest` first. + std::vector> starts; +}; + +// After `step`, an open leg, stopped part-way on one of `candidates` +// (none stopped by a trainer), with `after` the route's steps after it: +// the way to walk on. Nothing if, from some candidate, a step of `after` +// before a hold that brings them all to one tile would walk otherwise +// than planned, or a way would cross a tile off the planned way that a +// trainer may see. +std::optional plan_resume( + const std::vector& maps, + const std::vector& candidates, + const RouteStep& step, + const std::vector& after +); + // Holds to walls that bring the player to one tile from every one of // `tiles`, found as plan_reanchor() finds them, with no trainer about: for // a player known to be on one of a few tiles without a stop (a wild diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp index 8e91a54445..650317683e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp @@ -152,20 +152,29 @@ const char* const TEST_FIELD_JSON = R"({ })"; // 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. +// shorter way, and walks back. Stopped a tile further from that wall, they +// hold on east to the far wall: two more tiles held, one fewer to walk +// back on an open leg, which a wild battle would stop again. 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}}; + east.tiles = 2; + east.end = {"Field", {3, 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); + return "Expected one leg back east to (3, 1), got " + describe(anchor); + } + east.tiles = 3; + east.end = {"Field", {4, 1}}; + 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::EAST || !(anchor->found.point == TilePos{6, 1})){ + return "Expected a hold east to (6, 1), got " + describe(anchor); } RouteStep south; south.direction = Direction::SOUTH; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index 9053f963ea..cddb0e2026 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -86,18 +86,6 @@ bool hold_finds(const std::vector& maps, const std::vector& start, const StepOutcome& outcome) const{ + if (maps() == nullptr){ + return false; + } + const std::vector candidates = candidates_on(step, start, outcome.walked.value_or(whole_leg(step))); + return !candidates.empty() && std::none_of(candidates.begin(), candidates.end(), [](const StopCandidate& candidate){ + return candidate.trainer.has_value(); + }); + } + + // Where `step`, m_plan[m_next] from `start`, may have stopped. The rest + // of a leg walked on after a stop (walk_on()) starts on any of the + // tiles that stop left the player on. + std::vector candidates_on(const RouteStep& step, const std::optional& start, WalkedTiles walked) const{ + if (m_next >= m_walk_on_starts.size()){ + return stop_candidates(*maps(), start, step, walked); + } + std::vector candidates; + for (const MapPosition& from : m_walk_on_starts[m_next]){ + for (const StopCandidate& candidate : stop_candidates(*maps(), from, step, walked)){ + if (std::find(candidates.begin(), candidates.end(), candidate) == candidates.end()){ + candidates.push_back(candidate); + } + } + } + return candidates; } // A leg to a wall seen interrupted after its hold is walked again: the @@ -337,6 +358,15 @@ class RouteRun{ const RouteStep& step, const StepOutcome& outcome, const std::optional& start, const std::optional& crossing ){ + if (outcome.end == StepEnd::STOPPED && step.wild){ + // Where wild battles start, one on the first step held again + // after another interrupt stops the hold the same way: Cooper's + // battle, then a wild one, on Rock Tunnel B1F's column 1 (the + // sim, 2026-11-09). Walked again, as after any stop; a sign + // that interrupts every hold ends the route at MAX_ROUTE_STOPS. + m_lines.end(step, std::nullopt, EndBasis::UNKNOWN, "interrupted again right away where wild battles start; walking it again"); + return true; + } 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. @@ -376,7 +406,7 @@ class RouteRun{ return; } const std::optional anchor = plan_reanchor(*maps(), candidates, step, m_planning->costs); - if (!anchor || anchor->holds.empty() || anchor_tiles(*anchor) > 2 * step.tiles){ + if (!anchor || anchor->holds.empty() || reanchor_tiles(*anchor) > 2 * step.tiles){ m_calls.log("Route: no holds to walls within " + std::to_string(2 * step.tiles) + " tiles find the player if " + to_string(step) + " ended short; taken as walked."); return; } @@ -398,11 +428,35 @@ class RouteRun{ plan.insert(plan.end(), m_plan.begin() + rest, m_plan.end()); m_plan = std::move(plan); m_walked.reset(); + m_walk_on_starts.clear(); m_holds = anchor.holds.size(); m_unchecked = m_holds + anchor.back.size(); m_next = 0; } + // The plan from here: the rest of `step`, the stopped leg m_plan[m_next], + // then the old plan after it, whose first `resume.steps` steps find the + // player. The player still faces the leg's way: a turn tap first would + // step a tile. + void walk_on(const RouteStep& step, const Resume& resume){ + std::vector plan; + m_walk_on_starts = resume.starts; + if (resume.rest.tiles > 0){ + plan.push_back(resume.rest); + }else{ + m_walk_on_starts.erase(m_walk_on_starts.begin()); + } + plan.insert(plan.end(), m_plan.begin() + m_next + 1, m_plan.end()); + m_plan = std::move(plan); + m_walked.reset(); + m_holds = (resume.rest.tiles > 0 ? 1 : 0) + resume.steps; + m_unchecked = m_holds; + m_next = 0; + m_at = std::nullopt; + m_facing = step.direction; + m_lines.plan(std::nullopt, m_plan); + } + // 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){ @@ -414,6 +468,15 @@ class RouteRun{ "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::vector after(m_plan.begin() + m_next + 1, m_plan.end()); + if (const std::optional resume = plan_resume(*maps(), candidates, step, after)){ + m_calls.log(std::format( + "Route: walking on {} tiles along {}; the next {} step(s) find the player from every one of those tiles.", + resume->rest.tiles, to_string(step), resume->steps + )); + walk_on(step, *resume); + return true; + } const std::optional anchor = candidates.empty() ? std::nullopt : plan_reanchor(*maps(), candidates, step, m_planning->costs); @@ -456,6 +519,7 @@ class RouteRun{ size_t m_holds = 0; // of the plan, holds to walls that find the player size_t m_unchecked = 0; // of the plan, the holds and the way back after them std::optional m_walked; // the step walked last, if it may have stopped part-way + std::vector> m_walk_on_starts; // after walking on: where each of the plan's first steps may start }; } // namespace diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 8aac65097e..9200c0e02b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -16,6 +16,7 @@ #include "CommonTools/Async/InferenceSession.h" #include "CommonTools/VisualDetectors/BlackScreenDetector.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_OverworldLateBattle.h" #include "PokemonFRLG_OverworldRecoveryWalk.h" @@ -24,6 +25,7 @@ namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ +using namespace MovementMechanics; using namespace OverworldWalkSteps; @@ -46,42 +48,64 @@ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ } // How one step's walk went: if an open leg stopped part-way, how far the -// timing puts the player along it; and when its hold started. +// timing puts the player along it; when its hold started; and whether the +// screen was a dark cave's then (dark_cave_screen()). struct StepWalk{ std::optional stopped; WallClock hold_start; + bool dark = false; }; +// stop_tiles(), logged when the leg started in a dark cave. +WalkedTiles logged_stop_tiles(ConsoleHandle& console, const RouteStep& step, bool dark, WalkedTiles timed){ + if (dark){ + console.log(std::format( + "Route: {} started in a dark cave: the stop's timing ({} to {} tiles in) isn't trusted.", + to_string(step), timed.min, timed.max + ), COLOR_ORANGE); + } + return stop_tiles(step, timed, dark); +} + +// Whether the screen shows a dark cave's overworld (dark_cave_screen()). +bool on_dark_cave(ConsoleHandle& console){ + const VideoSnapshot screen = console.video().snapshot(); + return screen && dark_cave_screen(*screen.frame); +} + // One step of a route, for a player facing `facing`. StepWalk walk_step( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, const OverworldInterruptSet& interrupts, MoveSpeed speed ){ - StepWalk out{std::nullopt, current_time()}; + StepWalk out{std::nullopt, current_time(), false}; 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); + turn_to(console, context, step.direction); } if (const std::string note = speed_note(step, speed); !note.empty()){ console.log(note); } context.wait_for_all_requests(); + out.dark = on_dark_cave(console); out.hold_start = current_time(); + BlackScreenOverWatcher arrival(COLOR_RED); + Pokemon::WatchAlong fade(arrival); 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 + leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG, warp_hold_watchers(step, fade) ); if (result.stopped){ - out.stopped = walked_near(step, result.tiles, tile_time(step, speed)); + out.stopped = logged_stop_tiles(console, step, out.dark, walked_near(step, result.tiles, tile_time(step, speed))); return out; } if (step.warps){ - wait_out_warp(console, context, step); + wait_out_warp(console, context, step, fade); static_assert(AFTER_WARP_BATTLE_WATCH >= AFTER_WARP); if (run_from_battle_after_warp(console, context)){ console.log("Route: a wild battle, not the warp to " + step.end.map + "; the player is on " + to_string(step) + " before the warp.", COLOR_ORANGE); @@ -183,15 +207,20 @@ StepOutcome walk_and_watch( if (done){ return StepOutcome{}; } - return StepOutcome{.end = StepEnd::LATE, .walked = recorder.place(console, step, tile_time(step, speed), walked.hold_start)}; + std::optional placed = recorder.place(console, step, tile_time(step, speed), walked.hold_start); + if (walked.dark && step.action == RouteAction::WALK && !step.to_wall){ + placed = logged_stop_tiles(console, step, true, placed.value_or(whole_leg(step))); + } + return StepOutcome{.end = StepEnd::LATE, .walked = placed}; } -// 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. +// Holds on along `step`, stopped part-way off the grass or by a wild +// battle where no trainer may see the player, 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, as +// after a wild battle, 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 @@ -303,7 +332,7 @@ void walk_overworld_route_with_recovery( 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); + hold_to_warp(console, context, step, step.wild ? interrupts.wild : 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_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 3395eda537..e8625e13b3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -91,8 +91,6 @@ Pokemon::ScreenEventLog screen_event_log(const SimState& state, Milliseconds fro namespace{ -// A short press: TURN_TAP and TURN_SETTLE. -constexpr Milliseconds TAP_TIME(340); // The story's battle loop takes a battle that doesn't show in this long // for none. constexpr Milliseconds NO_BATTLE_WAIT(15000); @@ -144,6 +142,7 @@ OverworldSim::OverworldSim(std::vector maps, MapPosition start, Directi : m_maps(std::move(maps)) , m_script(std::move(script)) , m_map(find_map(m_maps, start.map)) + , m_encounter_rng(m_script.encounter_seed) { if (m_map < 0){ throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Sim: start on a map that isn't loaded: " + start.map); @@ -167,6 +166,7 @@ void OverworldSim::update_view(){ m_state.position = MapPosition{map().name, m_player.position}; m_state.black_below = map().connections.empty() && map().tiles.height <= SMALL_ROOM_ROWS && m_player.position.y >= map().tiles.height - BLACK_BELOW_ROWS; + m_state.dark = m_script.dark_maps.contains(map().name); } void OverworldSim::schedule(Milliseconds at, SimScreen screen, bool unfreeze){ @@ -345,7 +345,7 @@ bool OverworldSim::trainer_sees(Milliseconds pressed){ } // The player has taken `step`, pressed at `pressed`. Returns whether the -// game stopped them there: a warp, a wild battle, a trainer. +// game stopped them there: a warp, a trainer, a wild battle. bool OverworldSim::after_step(const Step& step, Milliseconds pressed){ const SimTiming& timing = m_script.timing; m_map = step.map; @@ -364,7 +364,11 @@ bool OverworldSim::after_step(const Step& step, Milliseconds pressed){ note("warped to " + to_string(m_state.position) + "."); return true; } - return wild_battle(pressed) || trainer_sees(pressed); + // The game looks for trainers who see the player before it rolls for a + // wild battle, and doesn't roll once one does (pokefirered + // ProcessPlayerFieldInput(): CheckForTrainersWantingBattle() first, + // CheckStandardWildEncounter() last). + return trainer_sees(pressed) || wild_battle(pressed); } // A wild battle the script starts on this step onto tall grass, or onto a @@ -374,13 +378,12 @@ bool OverworldSim::wild_battle(Milliseconds pressed){ return false; } m_grass_steps++; - for (const SimEncounter& encounter : m_script.encounters){ - if (encounter.grass_step != m_grass_steps){ - continue; - } + if (const std::optional encounter = next_encounter()){ m_frozen = true; + m_state.wild_battles++; + m_wild_battle_from = pressed; m_battle_pending = true; - if (!encounter.seen_late){ + if (!encounter->seen_late){ schedule(pressed + m_script.timing.flash, SimScreen::BATTLE_FLASH, false); } if (m_script.timing.battle_wipe){ @@ -390,12 +393,54 @@ bool OverworldSim::wild_battle(Milliseconds pressed){ schedule(pressed + *m_script.timing.battle_opening, SimScreen::BATTLE_OPENING, false); } schedule(pressed + m_script.timing.battle_screen, SimScreen::BATTLE, false); - note(std::format("a wild battle on {}{}.", to_string(m_state.position), encounter.seen_late ? " (flashes missed)" : "")); + note(std::format("a wild battle on {}{}.", to_string(m_state.position), encounter->seen_late ? " (flashes missed)" : "")); return true; } return false; } +// The encounter this step onto a wild tile starts, if any: the script's, +// or the game's roll on a map with an encounter rate. +std::optional OverworldSim::next_encounter(){ + const auto rate = m_script.encounter_rates.find(map().name); + if (rate != m_script.encounter_rates.end()){ + return rolls_encounter(rate->second) ? std::optional(SimEncounter{.grass_step = m_grass_steps}) : std::nullopt; + } + for (const SimEncounter& encounter : m_script.encounters){ + if (encounter.grass_step == m_grass_steps){ + return encounter; + } + } + return std::nullopt; +} + +// pokefirered TryStandardWildEncounter() on land, for a land encounter +// `rate`, with no Repel, flute, Cleanse Tag, lead ability or bike: +// - HandleWildEncounterCooldown(): until `min_steps` steps since the last +// battle (8 - rate / 10, 8 below rate 10, 0 from 80), a step goes on to +// the roll only 5 times in 100; +// - DoWildEncounterRateTest(): a battle if rand % 1600 < rate * 16 plus +// a buff, which grows by `rate` with each roll lost and is cleared by a +// battle, as the step count is. +// StandardWildEncounter()'s 60% roll on a step onto a tile of another +// behavior is left out: a cave's floor is one behavior. +bool OverworldSim::rolls_encounter(int rate){ + const int min_steps = rate >= 80 ? 0 : rate < 10 ? 8 : 8 - rate / 10; + if (m_steps_since_encounter < min_steps){ + m_steps_since_encounter++; + if (m_encounter_rng() % 100 >= 5){ + return false; + } + } + if ((int)(m_encounter_rng() % 1600) >= rate * 16 + m_encounter_rate_buff * 16 / 200){ + m_encounter_rate_buff += rate; + return false; + } + m_encounter_rate_buff = 0; + m_steps_since_encounter = 0; + return true; +} + // Blocked holding `direction` at `pressed`: a sign walked into moving north // or south shows its message. void OverworldSim::bump(Direction direction, Milliseconds pressed){ @@ -421,7 +466,13 @@ void OverworldSim::walk_hold(Direction direction, Milliseconds duration, int sho m_state.facing = direction; pressed += tile; } - int left = (int)((end - pressed + tile - 1ms) / tile) - short_by; + take_steps(direction, pressed, end, (int)((end - pressed + tile - 1ms) / tile) - short_by); +} + +// Up to `left` steps of a hold of `direction` until `end`, the first +// pressed at `pressed`. +void OverworldSim::take_steps(Direction direction, Milliseconds pressed, Milliseconds end, int left){ + const Milliseconds tile = m_script.timing.tile; for (; left > 0 && pressed < end; left--){ const std::optional step = press(direction); if (!step){ @@ -454,7 +505,7 @@ void OverworldSim::stuck_hold(Direction direction){ }else if (m_battle_pending){ violation(std::string("the D-pad held ") + direction_name(direction) + " into a battle."); }else{ - note(std::string("the D-pad held ") + direction_name(direction) + " under " + sim_screen_name(m_state.screen) + "."); + note(std::string("the D-pad held ") + direction_name(direction) + " under " + sim_screen_name(m_state.screen) + (m_frozen ? "." : ", in the lockout after a battle.")); } } @@ -465,8 +516,13 @@ std::optional OverworldSim::hold( ){ const Milliseconds start = m_now; m_holds++; + m_last_tap.reset(); if (m_state.screen != SimScreen::OVERWORLD || m_frozen){ stuck_hold(direction); + }else if (duration <= MovementMechanics::LONGEST_TAP && m_state.facing == direction){ + // A hold as short as a tap is one (a one-tile leg). + step_tap(direction, duration); + m_last_tap.reset(); }else{ const auto short_hold = m_script.short_holds.find(m_holds); walk_hold(direction, duration, short_hold == m_script.short_holds.end() ? 0 : short_hold->second); @@ -474,15 +530,72 @@ std::optional OverworldSim::hold( return watch_until(watchers, start, start + std::max(duration + release, watch)); } -void OverworldSim::tap(Direction direction){ - if (m_state.screen != SimScreen::OVERWORLD || m_frozen){ +void OverworldSim::tap(Direction direction, Milliseconds duration, Milliseconds release){ + const Milliseconds start = m_now; + if (m_state.screen != SimScreen::OVERWORLD || m_frozen || start < m_battle_lockout_end){ stuck_hold(direction); + m_last_tap.reset(); + }else if (m_last_tap && m_last_tap->direction == direction && m_last_tap->end == start){ + hold_on(direction, start + duration); }else if (m_state.facing != direction){ - m_state.facing = direction; + // From a standstill a tap only turns the player, even one as long + // as LONGEST_TAP. + m_last_tap = TapPress{direction, start, start + duration, m_state.facing, 0}; + if (drops_turn_tap()){ + m_state.turn_taps_dropped++; + note(std::string("the turn tap ") + direction_name(direction) + " is dropped."); + }else{ + m_state.facing = direction; + } }else{ - walk_hold(direction, m_script.timing.tile / 2, 0); + step_tap(direction, duration); + } + advance_to(start + duration + release); +} + +// A tap of `direction` by a player facing that way: one step, unless it +// starts inside the lockout after the last step tap. +void OverworldSim::step_tap(Direction direction, Milliseconds duration){ + const Milliseconds start = m_now; + m_last_tap = TapPress{direction, start, start + duration, direction, 0}; + if (m_last_step_tap && start < *m_last_step_tap + MovementMechanics::STEP_TAP_LOCKOUT){ + m_state.step_taps_ignored++; + note(std::format( + "the step tap {} is ignored, {} ms after the last.", + direction_name(direction), (start - *m_last_step_tap).count() + )); + return; + } + const int before = m_state.steps; + walk_hold(direction, duration, 0); + m_last_tap->steps = m_state.steps - before; + if (m_last_tap->steps > 0){ + m_last_step_tap = start; + } +} + +// A tap of the last tap's way with no release before it: the two are one +// hold, from the last one's start until `end`. +void OverworldSim::hold_on(Direction direction, Milliseconds end){ + TapPress& press = *m_last_tap; + const Milliseconds tile = m_script.timing.tile; + Milliseconds pressed = press.start; + if (press.facing_before != direction){ + pressed += tile; } - advance_to(m_now + TAP_TIME); + m_state.facing = direction; + const int steps = (int)((end - pressed + tile - 1ms) / tile); + if (steps > press.steps){ + const int before = m_state.steps; + take_steps(direction, pressed + tile * press.steps, end, steps - press.steps); + press.steps += m_state.steps - before; + } + press.end = end; +} + +bool OverworldSim::drops_turn_tap(){ + m_turn_taps++; + return m_script.dropped_turn_taps.contains(m_turn_taps); } std::optional OverworldSim::watch(const std::vector& watchers, Milliseconds duration){ @@ -507,11 +620,14 @@ SimBattleEnd OverworldSim::handle_battle(SimBattleChoice choice){ show(SimScreen::OVERWORLD, m_now); m_frozen = false; m_battle_pending = false; + m_battle_lockout_end = m_now + m_script.timing.battle_exit_lockout; if (m_trainer){ MapObject& trainer = m_maps[m_map].objects[*m_trainer]; trainer.trainer.reset(); m_state.trainers_beaten.push_back(trainer.name); m_trainer.reset(); + }else{ + m_state.in_wild_battles += m_now - m_wild_battle_from; } note(std::string(choice == SimBattleChoice::RUN ? "ran from" : "won") + " the battle."); return SimBattleEnd::ENDED; @@ -561,6 +677,7 @@ bool OverworldSim::cut_tree(){ namespace{ +using namespace MovementMechanics; using namespace OverworldWalkSteps; // As the console's watch for a trainer (PokemonFRLG_OverworldRecoveryWalk.cpp). @@ -621,6 +738,9 @@ void OverworldSimWalker::clear(const Interrupt& interrupt){ } if (m_sim.close_message() && m_rules.fight_trainer_after_message){ m_sim.handle_battle(SimBattleChoice::FIGHT); + if (const std::string failure = m_rules.trainer_battle_end(m_sim); !failure.empty()){ + fail(failure); + } } } @@ -650,17 +770,30 @@ std::optional OverworldSimWalker::hold_watching(const RouteStep& step, return m_sim.hold(step.direction, hold, STEP_RELEASE, hold + STEP_RELEASE + m_rules.watch_past_hold, watchers(interrupts)); } +// Whether a warp's fade showed and was over between `from` and now. +bool OverworldSimWalker::fade_over_since(Milliseconds from) const{ + const SimState& state = m_sim.state(); + if (state.screen == SimScreen::FADE){ + return false; + } + return std::any_of( + state.shown.begin(), state.shown.end(), + [&](const SimShown& shown){ return shown.screen == SimScreen::FADE && shown.at >= from; } + ); +} + // wait_out_warp(), then run_from_battle_after_warp(): false if the fade // was a wild battle's, run from. wait_out_warp()'s watcher takes a wild // battle's wipe for the fade (run 7; console B, 2026-11-03, Mt. Moon 1F): -// the battle screen stands in for the wipe. -bool OverworldSimWalker::after_warp(const RouteStep& step, int warps_before){ +// the battle screen stands in for the wipe. The fade is watched for from +// `watched_from`: a fade over by then is missed. +bool OverworldSimWalker::after_warp(const RouteStep& step, int warps_before, Milliseconds watched_from){ if (m_sim.state().warps == warps_before){ if (!m_sim.watch({SimWatcher::BATTLE_SCREEN}, WARP_TIMEOUT)){ fail("walk_overworld_route(): no fade in and out after walking onto the warp to " + step.end.map + "."); } - }else{ - m_sim.watch({SimWatcher::FADE}, WARP_TIMEOUT); + }else if (!fade_over_since(watched_from) && !m_sim.watch({SimWatcher::FADE}, WARP_TIMEOUT)){ + fail("walk_overworld_route(): no fade in and out after walking onto the warp to " + step.end.map + "."); } m_sim.watch({}, AFTER_WARP); if (!m_sim.watch(watchers({m_wild[1]}), AFTER_WARP_BATTLE_WATCH - AFTER_WARP)){ @@ -683,7 +816,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, fail("Sim: Surf isn't modelled."); } if (facing != step.direction){ - m_sim.tap(step.direction); + sim_turn_to(m_sim, step.direction, m_rules.confirm_turn); } const int warps_before = m_sim.state().warps; const std::vector& ints = interrupts(step); @@ -708,11 +841,13 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, calls.log = [&](const std::string& message){ m_log.push_back("Leg: " + message); }; calls.fail = [&](const std::string& message){ fail(message); }; m_leg_start = m_sim.state().time; + m_leg_dark = m_sim.state().dark; const Pokemon::LegResult result = Pokemon::walk_leg_with(leg, calls, MAX_INTERRUPTIONS_PER_LEG); if (result.stopped){ - return walked_near(step, result.tiles, TILE_TIME); + return dark_stop(step, walked_near(step, result.tiles, TILE_TIME)); } - if (step.warps && !after_warp(step, warps_before)){ + const Milliseconds fade_watched_from = m_rules.watch_fade_along_hold ? m_leg_start : m_sim.state().time; + if (step.warps && !after_warp(step, warps_before, fade_watched_from)){ if (!m_rules.find_player_after_warp_battle){ fail_off_route(step); } @@ -755,7 +890,7 @@ StepOutcome OverworldSimWalker::walk(const RouteStep& step, std::optional OverworldSimWalker::late_stop(const RouteStep& step){ - if (step.to_wall || !m_rules.place_late_battle){ + if (step.to_wall || (!m_leg_dark && !m_rules.place_late_battle)){ return std::nullopt; } const WallClock start = sim_wall_clock(m_leg_start); @@ -763,14 +898,15 @@ std::optional OverworldSimWalker::late_stop(const RouteStep& step){ step, TILE_TIME, screen_event_log(m_sim.state(), m_leg_start), start, sim_wall_clock(m_sim.state().time) ); m_log.push_back(late_stop_line(step, stop, start)); - return stop.walked; + return m_leg_dark ? std::optional(dark_stop(step, whole_leg(step))) : stop.walked; } // Whether the screen shows a battle on its way: its wipe's black, or its -// text box before the HP boxes. +// text box before the HP boxes; without dark_cave_not_battle, a dark cave. bool OverworldSimWalker::battle_on_its_way() const{ const SimScreen screen = m_sim.state().screen; - return screen == SimScreen::BATTLE_WIPE || screen == SimScreen::BATTLE_OPENING; + const bool dark_misread = !m_rules.dark_cave_not_battle && screen == SimScreen::OVERWORLD && m_sim.state().dark; + return dark_misread || screen == SimScreen::BATTLE_WIPE || screen == SimScreen::BATTLE_OPENING; } // clear_unseen_battle(): the battle's text box or wipe seen after the @@ -785,8 +921,9 @@ size_t OverworldSimWalker::clear_unseen_battle(const RouteStep& step){ } void OverworldSimWalker::hold_to_warp(const RouteStep& step){ + const std::vector& ints = interrupts(step); std::vector fade_first{SimWatcher::FADE}; - for (const SimWatcher watcher : watchers(m_other)){ + for (const SimWatcher watcher : watchers(ints)){ fade_first.push_back(watcher); } const int warps_before = m_sim.state().warps; @@ -795,7 +932,7 @@ void OverworldSimWalker::hold_to_warp(const RouteStep& step){ const Milliseconds hold = TILE_TIME * (step.tiles + 2); const std::optional seen = m_sim.hold(step.direction, hold, STEP_RELEASE, hold + STEP_RELEASE, fade_first); if (seen && seen->watcher == 0){ - if (!after_warp(step, warps_before)){ + if (!after_warp(step, warps_before, m_sim.state().time)){ fail_off_route(step); } return; @@ -803,7 +940,7 @@ void OverworldSimWalker::hold_to_warp(const RouteStep& step){ if (!seen){ break; } - clear(m_other[seen->watcher - 1]); + clear(ints[seen->watcher - 1]); } fail("walk_overworld_route(): no fade holding on toward the warp to " + step.end.map + "."); } @@ -815,7 +952,7 @@ StepEnd OverworldSimWalker::watch_for_trainer(const RouteStep& step, int approac bool OverworldSimWalker::moved_past_wall(const RouteStep& step){ const MapPosition before = m_sim.state().position; - m_sim.tap(step.direction); + m_sim.tap(step.direction, TURN_TAP, TILE_TIME + TURN_SETTLE); return !(m_sim.state().position == before); } @@ -830,6 +967,23 @@ RouteCalls OverworldSimWalker::calls(){ return calls; } +void sim_turn_to(OverworldSim& sim, Direction direction, bool confirm){ + for (size_t tap = 1; tap <= MAX_TURN_TAPS; tap++){ + sim.tap(direction); + const SimState& state = sim.state(); + if (!confirm || state.screen != SimScreen::OVERWORLD || state.facing == direction){ + return; + } + } +} + +void sim_step_once(OverworldSim& sim, Direction direction){ + if (sim.state().facing != direction){ + sim_turn_to(sim, direction); + } + sim.tap(direction, STEP_TAP, STEP_TAP_RELEASE); +} + std::string OverworldSimWalker::run(const std::vector& route, const RoutePlanning& planning){ try{ run_route(route, m_sim.state().facing, &planning, calls()); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 9483c2e289..21038720d4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -28,8 +28,10 @@ #ifndef PokemonAutomation_PokemonFRLG_OverworldSim_H #define PokemonAutomation_PokemonFRLG_OverworldSim_H +#include #include #include +#include #include #include #include @@ -37,6 +39,7 @@ #include "Common/Cpp/Time.h" #include "Common/Cpp/TestRunners/UnitTestDatabase.h" #include "Pokemon/AutoStory/Pokemon_ScreenEvents.h" +#include "PokemonFRLG_MovementMechanics.h" #include "PokemonFRLG_OverworldRecovery.h" #include "PokemonFRLG_OverworldRoute.h" #include "PokemonFRLG_OverworldWalk.h" @@ -83,7 +86,7 @@ struct SimEncounter{ // detection delays (OverworldInterrupt::detection_delay()) are its guesses // at these. struct SimTiming{ - Milliseconds tile{267}; + Milliseconds tile = MovementMechanics::TILE_TIME; Milliseconds flash{300}; Milliseconds battle_screen{3700}; // A wild battle's text box, before the HP boxes (battle_screen). Not @@ -96,6 +99,8 @@ struct SimTiming{ Milliseconds fade{1000}; // from the step's end to the new map Milliseconds trainer_notice{1000}; // "!" over the trainer, before they walk up Milliseconds trainer_battle{2000}; // after the trainer's message closes + // From a battle's end on, the game ignores taps this long. + Milliseconds battle_exit_lockout = MovementMechanics::BATTLE_EXIT_LOCKOUT; }; // What a test sets up beyond the maps. @@ -115,6 +120,23 @@ struct SimScript{ // 2026-11-03, rows 00.16 and 00.20), not south from Route 6 into // Vermilion City (row 00.24). bool connections_stall = true; + // Wild battles at the game's own odds instead of `encounters`, on the + // maps named here, with their land encounter rate (pokefirered + // src/data/wild_encounters.json), rolled from `encounter_seed`. + std::map encounter_rates; + uint32_t encounter_seed = 0; + // Fault injection: turn taps (taps while the player faces another way) + // the game misses, by number from 1; the player goes on facing the old + // way. The game drops none from a standstill (MovementMechanics: 70 of + // 70 turned); these test the walker's check of the turn + // (SimWalkerRules::confirm_turn) after 00.28 run 2 (console B, + // 2026-11-09), where a one-tile leg only turned the player. + std::set dropped_turn_taps; + // Caves dark without Flash (Rock Tunnel): the overworld is black but + // for a lit circle on the player, which the walker's battle text box + // check and screen observer read as a battle on its way (console B, + // 2026-11-09, 00.28), unless SimWalkerRules::dark_cave_not_battle. + std::set dark_maps; SimTiming timing; }; @@ -136,9 +158,18 @@ struct SimState{ // Whether black shows below the map: the player stands near the bottom // of a map with nothing joined to it (a small room or a gate). bool black_below = false; + // Whether the player's map is one of SimScript::dark_maps. + bool dark = false; int steps = 0; int warps = 0; + int wild_battles = 0; + // From each wild battle's step to the overworld after it, together. + Milliseconds in_wild_battles{0}; int signs_read = 0; + int turn_taps_dropped = 0; + // Step taps the game ignored, inside the lockout after the last one + // (MovementMechanics::STEP_TAP_LOCKOUT). + int step_taps_ignored = 0; std::vector trainers_beaten; // Things the walker did that the game wouldn't let pass: D-pad holds // under a battle, a trainer's battle fled, a battle fought that wasn't @@ -192,9 +223,20 @@ class OverworldSim{ const std::vector& watchers ); - // A short press of `direction`: turns the player, or steps if they - // already face that way. - void tap(Direction direction); + // A press of `direction` for `duration` (at most MovementMechanics:: + // LONGEST_TAP), then `release`, as the console measured it: a player + // facing another way only turns; one facing that way steps once, + // unless the tap starts inside the lockout after the last step tap + // (STEP_TAP_LOCKOUT), which the game ignores, as it does any tap + // inside the lockout after a battle (BATTLE_EXIT_LOCKOUT); holds + // aren't held up by that lockout yet. A tap that starts as + // the last one ends, with no release between, holds it on. A turn may + // be dropped by fault injection (SimScript::dropped_turn_taps). + void tap( + Direction direction, + Milliseconds duration = MovementMechanics::TURN_TAP, + Milliseconds release = MovementMechanics::TURN_SETTLE + ); // Watches `watchers` for `duration`, as wait_until(). std::optional watch(const std::vector& watchers, Milliseconds duration); @@ -235,7 +277,19 @@ class OverworldSim{ bool sees(SimWatcher watcher, SimScreen screen) const; std::optional first_seen(SimWatcher watcher, Milliseconds from, Milliseconds to) const; std::optional watch_until(const std::vector& watchers, Milliseconds from, Milliseconds to); + // A tap, for the next one's lockout and to hold on: when it started + // and ended, the player's facing before it and the steps it took. + struct TapPress{ + Direction direction = Direction::NORTH; + Milliseconds start{0}; + Milliseconds end{0}; + Direction facing_before = Direction::NORTH; + int steps = 0; + }; void walk_hold(Direction direction, Milliseconds duration, int short_by); + void take_steps(Direction direction, Milliseconds pressed, Milliseconds end, int left); + void step_tap(Direction direction, Milliseconds duration); + void hold_on(Direction direction, Milliseconds end); void stuck_hold(Direction direction); void bump(Direction direction, Milliseconds pressed); std::optional press(Direction direction) const; @@ -243,6 +297,9 @@ class OverworldSim{ std::optional cross_edge(Direction direction) const; bool after_step(const Step& step, Milliseconds pressed); bool wild_battle(Milliseconds pressed); + std::optional next_encounter(); + bool rolls_encounter(int rate); + bool drops_turn_tap(); bool trainer_sees(Milliseconds pressed); void update_view(); void note(const std::string& line); @@ -267,6 +324,19 @@ class OverworldSim{ // The trees cut since the last warp, and their maps. std::vector> m_cut_trees; int m_grass_steps = 0; + // The game's wild encounter state (SimScript::encounter_rates). + std::mt19937 m_encounter_rng; + int m_steps_since_encounter = 0; + int m_encounter_rate_buff = 0; + // The turn taps so far, for SimScript::dropped_turn_taps. + int m_turn_taps = 0; + // The last tap, until a hold. + std::optional m_last_tap; + // When the last tap that stepped started. + std::optional m_last_step_tap; + // The end of the lockout after the last battle. + Milliseconds m_battle_lockout_end{0}; + Milliseconds m_wild_battle_from{0}; int m_holds = 0; SimState m_state; }; @@ -328,8 +398,46 @@ struct SimWalkerRules{ // player may be anywhere on the leg: on Route 10, "east 11" was found // again from all 12 of its tiles (console B, 2026-11-09, 00.27 run 2). bool place_late_battle = true; + // The watch for a warp's fade starts with the hold onto the warp + // (Pokemon::WatchAlong), not when it ends. Without it a fade over + // before the hold ended was missed, and the walk stopped on "no fade in + // and out after walking onto the warp" though it had warped (console B, + // 2026-11-09 01:41, 00.27, the warp to CeruleanCity: the watch started + // about 1.3 s into the hold). + bool watch_fade_along_hold = true; + // A turn tap is checked by the facing on screen, and tapped again + // while the player faces another way (OverworldWalkSteps::turn_to()). + // Without it a dropped tap left a one-tile leg turning the player + // only, taken as walked (console B, 2026-11-09, 00.28 run 2). + bool confirm_turn = true; + // A dark cave's overworld isn't a battle on its way + // (OverworldWalkSteps::dark_cave_screen()). Without it the check + // before each step watched for a battle on it (console B, 2026-11-09, + // 00.28: "running from a wild Pokemon", then "not a battle"). + bool dark_cave_not_battle = true; + // A stop on an open leg that started in a dark cave may be anywhere on + // the leg (whole_leg()). Without it the stop is placed by the dark + // screen's black, read as the battle at the hold's start: a battle at + // the end of "east 12" on Rock Tunnel B1F was placed 0 to 3 tiles in, + // and the player was found again at a wall 5 tiles off (console B, + // 2026-11-09, 00.28). + bool whole_leg_in_dark = true; + // The end of a trainer's battle the walker fought, as the story's + // battle loop takes it on the screen the sim shows after it: its + // failure, or empty. By default the battle just ends. + std::function trainer_battle_end = [](const OverworldSim&){ + return std::string(); + }; }; +// OverworldWalkSteps::turn_to() on `sim`, which reads the facing on the +// overworld only; with `confirm` false, the one tap before it. +void sim_turn_to(OverworldSim& sim, Direction direction, bool confirm = SimWalkerRules{}.confirm_turn); + +// AutoStory's step_once() on `sim`: turns the player if they face another +// way, then one step tap. +void sim_step_once(OverworldSim& sim, Direction direction); + // The console side of run_route() on an OverworldSim, as // walk_overworld_route_with_recovery() does it, with the story's // interrupts: a wild battle (flashes, or seen late) is fought or run from, @@ -366,8 +474,17 @@ class OverworldSimWalker{ }; StepOutcome walk(const RouteStep& step, std::optional facing); std::optional late_stop(const RouteStep& step); + // stop_tiles(); without SimWalkerRules::whole_leg_in_dark, the dark + // screen places the stop at the hold's start. + WalkedTiles dark_stop(const RouteStep& step, WalkedTiles timed) const{ + if (m_leg_dark && !m_rules.whole_leg_in_dark){ + return walked_near(step, 0, MovementMechanics::TILE_TIME); + } + return stop_tiles(step, timed, m_leg_dark); + } std::optional walk_step(const RouteStep& step, std::optional facing); - bool after_warp(const RouteStep& step, int warps_before); + bool after_warp(const RouteStep& step, int warps_before, Milliseconds watched_from); + bool fade_over_since(Milliseconds from) const; [[noreturn]] void fail_off_route(const RouteStep& step); std::optional hold_watching(const RouteStep& step, Milliseconds hold, const std::vector& interrupts); size_t clear_late(const std::vector& interrupts, Milliseconds watch, size_t max); @@ -387,13 +504,17 @@ class OverworldSimWalker{ std::vector m_wild; std::vector m_other; std::vector m_log; - // When the last WALK leg's first hold started, on the sim's clock. + // When the last WALK leg's first hold started, on the sim's clock, + // and whether its map was dark then. Milliseconds m_leg_start{0}; + bool m_leg_dark = false; }; // Register overworld simulation unit tests. void add_tests_OverworldSim(UnitTestDatabase& database); +// Register the tests of a warp's fade over before the hold onto it ends. +void add_tests_OverworldWarpFade(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index f8a2c4ca80..7b7fb39c46 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -296,10 +296,10 @@ UnitTestResult test_forest_north_gate(){ // on the leg a tile short of the ladder. Here on the leg through the // forest's grass onto its north gate: a battle on the leg's first or // second step is run from, the player found again on the leg, and the -// gate reached through its warp. If the walk back onto the warp then -// ends short of it (hold 4: the leg, the hold that finds the player and -// its re-hold after the Bug Catcher), the route still stops on no fade. -// Without the rule it stops as off the route, as on the console. +// gate reached through its warp: the player is on the leg's line, and +// holds on to it. If that hold then ends well short of the warp (hold 2: +// the leg, then the hold on), the route still stops on no fade. Without the +// rule it stops as off the route, as on the console. UnitTestResult test_battle_on_warp_leg(){ const SimCase c{.start = at("ViridianForest", 5, 20), .facing = Direction::NORTH, .goal = at("Route2_ViridianForest_NorthEntrance", 7, 9)}; for (size_t tile : {1, 2}){ @@ -312,9 +312,9 @@ UnitTestResult test_battle_on_warp_leg(){ } } SimCase no_warp = c; - no_warp.script.short_holds[4] = 2; + no_warp.script.short_holds[2] = 6; const std::optional short_back = run_with_battle_on_warp_leg(no_warp, 1); - if (!short_back || !stopped_with(*short_back, "no fade in and out") || short_back->state.warps != 0){ + if (!short_back || !stopped_with(*short_back, "no fade holding on toward the warp") || short_back->state.warps != 0){ return "Expected a stop on no fade when the walk back ends short of the warp:" + (short_back ? describe(*short_back) : ""); } SimCase off = c; @@ -658,7 +658,9 @@ UnitTestResult test_mt_moon_battle_on_ladder_leg(){ // timing. The text box after the watch is now watched on for, the leg // walked again to its wall, and the route reaches B2F's ladder. Without // the rule the leg is taken as walked, and the run stops on no fade onto -// the ladder down to B2F, as it did. +// the ladder down to B2F, as it did (the console's walker stopped on the +// leg; this one, which holds on toward a warp after a wild battle, stops +// holding on). UnitTestResult test_mt_moon_battle_seen_after_wall_leg(){ SimCase c{.start = at("MtMoon_B1F", 3, 3), .facing = Direction::SOUTH, .goal = at("MtMoon_B2F", 26, 21)}; c.script.encounters = {{.grass_step = 13, .seen_late = true}}; @@ -679,7 +681,7 @@ UnitTestResult test_mt_moon_battle_seen_after_wall_leg(){ const bool taken_as_walked = std::any_of(old.log.begin(), old.log.end(), [](const std::string& line){ return line.find("south 15 (to wall): ended at MtMoon_B1F (3, 18) (walked)") != std::string::npos; }); - if (!taken_as_walked || !stopped_with(old, "no fade in and out after walking onto the warp to MtMoon_B2F.")){ + if (!taken_as_walked || !stopped_with(old, "no fade holding on toward the warp to MtMoon_B2F.")){ return "Expected the leg taken as walked and the run stopped on no fade, as on the console, without the rule:" + describe(old); } return true; @@ -983,6 +985,7 @@ void add_tests_OverworldSim(UnitTestDatabase& database){ database.add(item); } add_tests_OverworldLateBattle(database); + add_tests_OverworldWarpFade(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 8e57b1cc73..80cf4de310 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/ImageStats.h" #include "CommonTools/Async/InferenceRoutines.h" #include "CommonTools/VisualDetectors/BlackScreenDetector.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" @@ -18,6 +20,7 @@ #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_OverworldWalk.h" @@ -26,12 +29,12 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ using namespace std::chrono_literals; +using namespace MovementMechanics; using namespace OverworldWalkSteps; namespace{ -constexpr Milliseconds FACE_SETTLE = 300ms; // the turn animation, before A reaches what is ahead // From the end of the hold, through the fade out and in. constexpr Milliseconds WARP_TIMEOUT = 8000ms; constexpr Milliseconds SURF_PROMPT_TIMEOUT = 5000ms; @@ -213,12 +216,22 @@ std::string speed_note(const RouteStep& step, MoveSpeed speed){ ); } +std::vector warp_hold_watchers(const RouteStep& step, Pokemon::WatchAlong& fade){ + if (!step.warps){ + return {}; + } + return {&fade}; +} + // One watcher for the whole fade: a ladder's black is short enough to be // over before a second watcher, started once the fade is seen, looks. -void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ - BlackScreenOverWatcher arrival(COLOR_RED); +void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, Pokemon::WatchAlong& fade){ + if (fade.fired()){ + console.log("Route: the fade to " + step.end.map + " was over before the hold ended."); + return; + } context.wait_for_all_requests(); - if (wait_until(console, context, WARP_TIMEOUT, {arrival}) < 0){ + if (wait_until(console, context, WARP_TIMEOUT, {fade.watcher()}) < 0){ OperationFailedExceptionWithScreenshot::fire( ErrorReportMode::SEND_ERROR_REPORT, "walk_overworld_route(): no fade in and out after walking onto the warp to " + step.end.map + ".", @@ -227,6 +240,12 @@ void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const } } +void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ + BlackScreenOverWatcher arrival(COLOR_RED); + Pokemon::WatchAlong fade(arrival); + wait_out_warp(console, context, step, fade); +} + // 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, @@ -276,11 +295,12 @@ void clear_open_message( namespace{ // What on screen shows a wild battle on its way: its text box, or the -// black of its wipe. Nothing if neither shows. +// black of its wipe. Nothing if neither shows, or on a dark cave's +// overworld (dark_cave_screen()). const char* battle_on_its_way(ConsoleHandle& console, ProControllerContext& context){ context.wait_for_all_requests(); VideoSnapshot screen = console.video().snapshot(); - if (!screen){ + if (!screen || dark_cave_screen(*screen.frame)){ return nullptr; } if (BattleTextBoxDetector(COLOR_RED).detect(screen)){ @@ -292,8 +312,40 @@ const char* battle_on_its_way(ConsoleHandle& console, ProControllerContext& cont return nullptr; } +// The parts of the game screen a dark cave shows black (its corners) and +// lit (the player's tile and the ones next to it), and how bright each +// may be, as the sum of the average's channels. Not measured on the +// console's frames yet: the corners are a GBA screen's 2 by 2 tiles, the +// middle its player's tile. +constexpr double DARK_CORNER_MAX = 45.0; +constexpr double LIT_MIDDLE_MIN = 120.0; + +bool box_brightness_below(const ImageViewRGB32& screen, const ImageFloatBox& box, double limit){ + return image_stats(extract_box_reference(screen, box)).average.sum() < limit; +} + } // namespace +bool dark_cave_screen(const ImageViewRGB32& screen){ + if (screen.width() == 0 || screen.height() == 0){ + return false; + } + const ImageFloatBox game = GameSettings::instance().GAME_BOX; + auto part = [&](double x, double y, double width, double height){ + return ImageFloatBox(game.x + game.width * x, game.y + game.height * y, game.width * width, game.height * height); + }; + const ImageFloatBox corners[] = { + part(0.00, 0.00, 0.13, 0.20), part(0.87, 0.00, 0.13, 0.20), + part(0.00, 0.80, 0.13, 0.20), part(0.87, 0.80, 0.13, 0.20), + }; + for (const ImageFloatBox& corner : corners){ + if (!box_brightness_below(screen, corner, DARK_CORNER_MAX)){ + return false; + } + } + return !box_brightness_below(screen, part(0.45, 0.42, 0.10, 0.16), LIT_MIDDLE_MIN); +} + size_t clear_unseen_battle( ConsoleHandle& console, ProControllerContext& context, const OverworldInterrupts& wild, const RouteStep& step @@ -361,6 +413,22 @@ std::optional read_facing(ConsoleHandle& console, ProControllerContex return facing; } +void turn_to(ConsoleHandle& console, ProControllerContext& context, Direction direction){ + for (size_t tap = 1; tap <= MAX_TURN_TAPS; tap++){ + pbf_press_dpad(context, dpad(direction), TURN_TAP, TURN_SETTLE); + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + const std::optional facing = screen ? read_player_facing(screen) : std::nullopt; + if (!facing || *facing == direction){ + return; + } + console.log(std::format( + "Route: after turn tap {} {}, the player faces {}.", + tap, Pokemon::direction_name(direction), Pokemon::direction_name(*facing) + ), COLOR_ORANGE); + } +} + 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. @@ -454,6 +522,31 @@ const OverworldInterruptSet& default_overworld_interrupts(){ } +namespace{ + +// A WALK step's leg, and the fade of the warp it walks onto, watched from +// the start of the hold. +void walk_step_leg( + ConsoleHandle& console, ProControllerContext& context, + const RouteStep& step, const OverworldInterruptSet& interrupts, MoveSpeed speed +){ + BlackScreenOverWatcher arrival(COLOR_RED); + Pokemon::WatchAlong fade(arrival); + Pokemon::walk_leg( + console, context, leg_mover(step, speed), step.wild ? interrupts.wild : interrupts.other, + leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG, warp_hold_watchers(step, fade) + ); + if (!step.warps){ + return; + } + wait_out_warp(console, context, step, fade); + static_assert(AFTER_WARP_BATTLE_WATCH >= AFTER_WARP); + check_warp_was_no_battle(console, context, step); +} + +} // namespace + + void walk_overworld_route( ConsoleHandle& console, ProControllerContext& context, const std::vector& route, @@ -474,17 +567,12 @@ void walk_overworld_route( // 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); + turn_to(console, context, step.direction); } if (const std::string note = speed_note(step, speed); !note.empty()){ console.log(note); } - Pokemon::walk_leg(console, context, leg_mover(step, speed), step.wild ? interrupts.wild : interrupts.other, leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG); - 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); - } + walk_step_leg(console, context, step, interrupts, speed); break; case RouteAction::SURF: start_surf(console, context, step.direction, facing == step.direction); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index e166617c2a..d2120b8e04 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -23,10 +23,12 @@ #include #include #include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" #include "Pokemon/AutoStory/Pokemon_ScreenEvents.h" #include "Pokemon/Navigation/Pokemon_LegWalker.h" #include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" #include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "PokemonFRLG_MovementMechanics.h" #include "PokemonFRLG_OverworldRoute.h" namespace PokemonAutomation{ @@ -99,21 +101,9 @@ void walk_overworld_route( // walk_overworld_route() describes. namespace OverworldWalkSteps{ -// Walking duration for a single tile (16 frames at 60 fps). -inline constexpr Milliseconds TILE_TIME(267); -// Surfing duration for a single tile. -inline constexpr Milliseconds SURF_TILE_TIME(133); -// 8 frames: running is 2 pixels a frame, twice walking. Not measured on the -// console yet; the walker logs each leg to a wall (speed_note()) for that. -inline constexpr Milliseconds RUN_TILE_TIME(133); -// turns a player facing another way, without a step. -inline constexpr Milliseconds TURN_TAP(40); -// duration to wait after turning to allow state to settle. -inline constexpr Milliseconds TURN_SETTLE(300); -// short enough to turn without stepping. -inline constexpr Milliseconds FACE_HOLD(40); -// duration of button release between movement steps. -inline constexpr Milliseconds STEP_RELEASE(100); +// The pace, tap and release times are the game's movement mechanics +// (PokemonFRLG_MovementMechanics.h). + // After a warp's fade: leaving by a door the game walks the player a tile out // on its own, and a leg started before that ends loses tiles (on the console a // 600ms wait cost 2 tiles). @@ -176,7 +166,16 @@ Milliseconds leg_hold_time(const RouteStep& step, MoveSpeed speed); // the tiles at the assumed pace and the hold. Empty for other steps. std::string speed_note(const RouteStep& step, MoveSpeed speed); -// After a hold onto `step`'s warp: waits out its fade. +// The hold watchers (Pokemon::walk_leg()) of a leg onto `step`: `fade`, +// which watches for the warp's fade from the start of the hold, if the +// step warps. A fade can be over before the hold ends. +std::vector warp_hold_watchers(const RouteStep& step, Pokemon::WatchAlong& fade); + +// After a hold onto `step`'s warp: waits out its fade, unless `fade`, a +// BlackScreenOverWatcher watched along the hold, saw it over already; +// else waits on with that watcher, which may have seen the black. +void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, Pokemon::WatchAlong& fade); +// The same, watching only from now: for a caller that saw the black itself. void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step); // Before `step`: fails unless the overworld is up. @@ -215,6 +214,16 @@ size_t clear_battle_before( const OverworldInterrupts& wild, const RouteStep& step ); +// Whether `screen` is the overworld of a cave dark without Flash (Rock +// Tunnel): black around a lit circle on the player in the middle. The +// battle text box detector and the black screen check read its black as +// a battle on its way (console B, 2026-11-09, 00.28: the check before +// each step ran a 15 s battle loop on it, "not a battle", and an open +// leg's stop was placed by it 0 to 3 tiles in, where the battle came at +// the leg's end). A battle's wipe is black in the middle too; its text +// box and HP boxes aren't black in the corners. +bool dark_cave_screen(const ImageViewRGB32& screen); + // After a warp's fade: if it was a wild battle's, runs from the battle and // returns true. bool run_from_battle_after_warp(ConsoleHandle& console, ProControllerContext& context); @@ -226,6 +235,17 @@ void check_warp_was_no_battle(ConsoleHandle& console, ProControllerContext& cont // The way the player faces, read from the screen; nothing if it can't be read. std::optional read_facing(ConsoleHandle& console, ProControllerContext& context); +// Turns the player to `direction` with a tap (TURN_TAP), then reads the +// facing from the screen and taps again while it shows another way, up to +// MAX_TURN_TAPS taps. A tap the game misses leaves the player facing the +// old way, and a leg's hold then spends its first 8 frames turning +// (pokefirered PlayerTurnInPlace(), a fast walk in place): a one-tile +// hold only turns the player, with nothing to show it (console B, +// 2026-11-09, 00.28 run 2: the step north onto Rock Tunnel B1F row 34). +// A facing that can't be read is taken as turned: a tap while facing the +// way already would take a step. +void turn_to(ConsoleHandle& console, ProControllerContext& context, Direction direction); + // Facing `direction` (turning first unless `facing_water`), starts Surf. void start_surf(ConsoleHandle& console, ProControllerContext& context, Direction direction, bool facing_water); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp index 60568958df..a488f376ae 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp @@ -6,15 +6,19 @@ #include #include +#include #include #include #include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_OverworldWalk.h" namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ +using namespace MovementMechanics; using namespace OverworldWalkSteps; @@ -116,6 +120,162 @@ const OverworldWalkTestCase OVERWORLD_WALK_CASES[] = { }, }; + +struct TurnTapInput{ + std::string name; + // SimScript::dropped_turn_taps. + std::set dropped_turn_taps; + // SimWalkerRules::confirm_turn. + bool confirm_turn = true; +}; + +struct TurnTapExpectation{ + // Where the leg ends. + MapPosition end; +}; + +struct TurnTapCase{ + TurnTapInput input; + TurnTapExpectation expectation; +}; + +const MapPosition TURN_START{"ViridianForest", {13, 28}}; +const MapPosition TURN_END{"ViridianForest", {12, 28}}; + +// Console B, 2026-11-09 (00.28 run 2): the game missed the turn tap before +// a one-tile open leg, the leg's hold only turned the player, and the leg +// was taken as walked. On the sim, a leg west 1 for a player facing north. +class Test_OverworldTurnTap : public UnitTest{ +public: + explicit Test_OverworldTurnTap(TurnTapCase test_case) + : UnitTest("PokemonFRLG::OverworldWalk - turn tap: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + static const std::vector MAPS = overworld_maps(); + RouteStep west; + west.direction = Direction::WEST; + west.tiles = 1; + west.facing_after = Direction::WEST; + west.end = TURN_END; + SimScript script; + script.dropped_turn_taps = m_case.input.dropped_turn_taps; + SimWalkerRules rules; + rules.confirm_turn = m_case.input.confirm_turn; + OverworldSim sim(MAPS, TURN_START, Direction::NORTH, script); + OverworldSimWalker walker(sim, rules); + const RoutePlanning planning{.maps = &MAPS, .costs = default_route_costs(), .start = TURN_START, .label = "Sim"}; + const std::string failure = walker.run({west}, planning); + if (failure.empty() && sim.state().position == m_case.expectation.end){ + return true; + } + return std::format( + "Expected the leg to end on {}; ended on {}, failure \"{}\".", + to_string(m_case.expectation.end), to_string(sim.state().position), failure + ); + } + +private: + TurnTapCase m_case; +}; + +const TurnTapCase TURN_TAP_CASES[] = { + {.input = {.name = "taken"}, .expectation = {.end = TURN_END}}, + {.input = {.name = "dropped, tapped again", .dropped_turn_taps = {1}}, .expectation = {.end = TURN_END}}, + {.input = {.name = "dropped twice, tapped again", .dropped_turn_taps = {1, 2}}, .expectation = {.end = TURN_END}}, + {.input = {.name = "dropped, not checked: the leg only turns", .dropped_turn_taps = {1}, .confirm_turn = false}, .expectation = {.end = TURN_START}}, +}; + + +struct TapMechanicsInput{ + std::string name; + // The player's facing at the start. + Direction facing = Direction::NORTH; + // The presses, on the sim. + std::function presses; +}; + +struct TapMechanicsExpectation{ + // Tiles stepped east. + int steps = 0; + Direction facing = Direction::EAST; + // Step taps the game ignored. + int ignored = 0; +}; + +struct TapMechanicsCase{ + TapMechanicsInput input; + TapMechanicsExpectation expectation; +}; + +// Open floor east of the start: where the taps were measured (console B, +// 2026-11-09, MovementMechanics). +const MapPosition TAP_START{"Route10_PokemonCenter_1F", {3, 4}}; + +// The sim's taps east, as the console moved the player for them. +class Test_OverworldTapMechanics : public UnitTest{ +public: + explicit Test_OverworldTapMechanics(TapMechanicsCase test_case) + : UnitTest("PokemonFRLG::OverworldWalk - tap: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + static const std::vector MAPS = overworld_maps(); + OverworldSim sim(MAPS, TAP_START, m_case.input.facing); + m_case.input.presses(sim); + const SimState& state = sim.state(); + const TapMechanicsExpectation& expected = m_case.expectation; + const MapPosition end{TAP_START.map, {TAP_START.point.x + expected.steps, TAP_START.point.y}}; + if (state.position == end && state.facing == expected.facing && state.step_taps_ignored == expected.ignored){ + return true; + } + return std::format( + "Expected {} step(s) east to {} facing {}, {} tap(s) ignored; on {} facing {}, {} ignored.", + expected.steps, to_string(end), Pokemon::direction_name(expected.facing), expected.ignored, + to_string(state.position), Pokemon::direction_name(state.facing), state.step_taps_ignored + ); + } + +private: + TapMechanicsCase m_case; +}; + +// `count` taps east of `duration`, each released for `release`. +std::function taps_east(int count, Milliseconds duration, Milliseconds release){ + return [=](OverworldSim& sim){ + for (int c = 0; c < count; c++){ + sim.tap(Direction::EAST, duration, release); + } + }; +} + +// A turn tap east and a 1000ms wait, then `count` taps east of 66ms, +// each released for `release`. +std::function turn_then_taps(int count, Milliseconds release){ + return [=](OverworldSim& sim){ + sim.tap(Direction::EAST, TURN_TAP, Milliseconds(1000)); + taps_east(count, Milliseconds(66), release)(sim); + }; +} + +// AutoStory's step_once() east `count` times. +std::function step_once_east(int count){ + return [=](OverworldSim& sim){ + for (int c = 0; c < count; c++){ + sim_step_once(sim, Direction::EAST); + } + }; +} + +const TapMechanicsCase TAP_MECHANICS_CASES[] = { + {.input = {.name = "133ms facing another way only turns", .presses = taps_east(1, LONGEST_TAP, Milliseconds(700))}, .expectation = {}}, + {.input = {.name = "16ms facing that way steps once", .facing = Direction::EAST, .presses = taps_east(1, Milliseconds(16), Milliseconds(700))}, .expectation = {.steps = 1}}, + {.input = {.name = "3 step taps 566ms apart take 2 steps", .presses = turn_then_taps(3, Milliseconds(500))}, .expectation = {.steps = 2, .ignored = 1}}, + {.input = {.name = "3 step taps 666ms apart take 3 steps", .presses = turn_then_taps(3, Milliseconds(600))}, .expectation = {.steps = 3}}, + {.input = {.name = "3 step taps with no release are one hold", .presses = turn_then_taps(3, Milliseconds(0))}, .expectation = {.steps = 1}}, + {.input = {.name = "step_once 3 times takes 3 steps", .presses = step_once_east(3)}, .expectation = {.steps = 3}}, +}; + } // namespace @@ -123,6 +283,12 @@ void add_tests_OverworldWalk(UnitTestDatabase& database){ for (const auto& scenario : OVERWORLD_WALK_CASES){ database.add(scenario); } + for (const TurnTapCase& item : TURN_TAP_CASES){ + database.add(item); + } + for (const TapMechanicsCase& item : TAP_MECHANICS_CASES){ + database.add(item); + } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWarpFade_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWarpFade_Tests.cpp new file mode 100644 index 0000000000..de344c2dc1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWarpFade_Tests.cpp @@ -0,0 +1,129 @@ +/* Overworld Warp Fade Tests + * + * From: https://github.com/PokemonAutomation/ + * + * A warp whose fade is over before the hold onto it ends, on the sim: the + * walker must watch for the fade from the start of the hold. + * + */ + +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_OverworldWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using namespace std::chrono_literals; + +struct WarpFadeInput{ + std::string name; + // How long the fade shows, from the end of the step onto the warp. + Milliseconds fade{0}; + // SimWalkerRules::watch_fade_along_hold. + bool watch_along_hold = true; +}; + +struct WarpFadeExpectation{ + // The failure the walk stops on; empty: the route is walked. + std::string failure; +}; + +struct WarpFadeCase{ + WarpFadeInput input; + WarpFadeExpectation expectation; +}; + +const std::vector& maps(){ + static const std::vector MAPS = overworld_maps(); + return MAPS; +} + +const MapPosition MART{"ViridianCity_Mart", {4, 3}}; +// Where the Mart's exit mat, (4, 7), puts the player. +const MapPosition OUTSIDE{"ViridianCity", {36, 20}}; + +// 00.27 (console B, 2026-11-09 01:41), the warp to CeruleanCity: "no fade +// in and out after walking onto the warp", the watch for the fade having +// started about 1.3 s into the hold, after the fade was over. Here the +// walk out of Viridian Mart, south 5 onto its mat, is held as to a wall, +// two tiles past the mat; a 200 ms fade is over before the hold ends. +class Test_OverworldWarpFade : public UnitTest{ +public: + explicit Test_OverworldWarpFade(WarpFadeCase test_case) + : UnitTest("PokemonFRLG::OverworldWarpFade - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + RouteStep out; + out.direction = Direction::SOUTH; + out.tiles = 5; + out.warps = true; + out.to_wall = true; + out.facing_after = Direction::SOUTH; + out.end = OUTSIDE; + SimScript script; + script.timing.fade = m_case.input.fade; + SimWalkerRules rules; + rules.watch_fade_along_hold = m_case.input.watch_along_hold; + OverworldSim sim(maps(), MART, Direction::SOUTH, script); + OverworldSimWalker walker(sim, rules); + const RoutePlanning planning{.maps = &maps(), .costs = default_route_costs(), .start = MART, .label = "Sim"}; + const std::string failure = walker.run({out}, planning); + const std::string& expected = m_case.expectation.failure; + const bool as_expected = expected.empty() ? failure.empty() : failure.find(expected) != std::string::npos; + if (as_expected && sim.state().warps == 1){ + return true; + } + std::string log; + for (const std::string& line : walker.log()){ + log += "\n " + line; + } + return std::format( + "Expected failure \"{}\" after 1 warp; got \"{}\" after {}:{}", + expected, failure, sim.state().warps, log + ); + } + +private: + WarpFadeCase m_case; +}; + +} // namespace + + +const WarpFadeCase WARP_FADE_CASES[] = { + { + .input = {.name = "a fade over before the hold ends, watched from its start", .fade = 200ms}, + .expectation = {.failure = ""}, + }, + { + .input = {.name = "a fade over before the hold ends, watched from its end", .fade = 200ms, .watch_along_hold = false}, + .expectation = {.failure = "no fade in and out after walking onto the warp to ViridianCity"}, + }, + { + .input = {.name = "a fade still on when the hold ends, watched from its end", .fade = 1000ms, .watch_along_hold = false}, + .expectation = {.failure = ""}, + }, +}; + +void add_tests_OverworldWarpFade(UnitTestDatabase& database){ + for (const WarpFadeCase& item : WARP_FADE_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp index 0f4ef4de91..94a86456c2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp @@ -27,10 +27,13 @@ #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_StartMenuDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_SelectionArrowDetector.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_NamingScreen.h" #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_StartMenuNavigation.h" #include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryStepInterrupts.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -56,9 +59,6 @@ constexpr Milliseconds INTRO_TIMEOUT = 600000ms; // "Wait about 8 s" after YES at the nurse (console B, 2026-11-01): 1.5 s // between presses did not heal. constexpr Milliseconds HEAL_TIME = 8000ms; -// FRLG takes a tap this short as a turn, without a step. -constexpr Milliseconds TURN_TAP = 40ms; -constexpr Milliseconds TURN_SETTLE = 300ms; DpadPosition dpad(Direction direction){ switch (direction){ @@ -579,12 +579,13 @@ std::optional talk_and_count_pages(ConsoleHandle& console, ProController return pages; } -void step_once(ProControllerContext& context, Direction direction, std::optional facing){ +void step_once(ConsoleHandle& console, ProControllerContext& context, Direction direction, std::optional facing){ if (facing != direction){ - pbf_press_dpad(context, dpad(direction), TURN_TAP, TURN_SETTLE); + OverworldWalkSteps::turn_to(console, context, direction); } - // A step, once started, finishes on its own. - pbf_press_dpad(context, dpad(direction), 120ms, 500ms); + // A step, once started, finishes on its own. The release waits out the + // lockout before a next step tap (MovementMechanics::STEP_TAP_LOCKOUT). + pbf_press_dpad(context, dpad(direction), MovementMechanics::STEP_TAP, MovementMechanics::STEP_TAP_RELEASE); context.wait_for_all_requests(); } @@ -598,6 +599,17 @@ std::optional step_until_seen(int max_tiles, const std::function& s return std::nullopt; } +void wait_out_battle_exit(ConsoleHandle& console, ProControllerContext& context){ + context.wait_for_all_requests(); + ConsoleScreenLook eyes(console, context); + const Pokemon::ScreenSettle settle = wait_for_quiet_overworld( + [&eyes](bool fresh){ return eyes.look(fresh); }, + MovementMechanics::AFTER_BATTLE_QUIET, BATTLE_EXIT_TIMEOUT + ); + const bool quiet = quiet_overworld(settle, MovementMechanics::AFTER_BATTLE_QUIET); + console.log("Story: after the battle, " + Pokemon::describe(settle), quiet ? COLOR_BLACK : COLOR_RED); +} + std::optional step_into_line( ConsoleHandle& console, ProControllerContext& context, Direction direction, std::optional facing, int max_tiles, Milliseconds watch @@ -605,20 +617,26 @@ std::optional step_into_line( return step_until_seen( max_tiles, [&]{ - step_once(context, direction, facing); + step_once(console, context, direction, facing); facing = direction; }, [&]{ WhiteDialogWatcher message(COLOR_RED); BattleScreenWatcher battle(COLOR_RED); - return wait_until(console, context, watch, {message, battle}) >= 0; + const int seen = wait_until(console, context, std::max(watch, WILD_BATTLE_IN_LINE_WATCH), {message, battle}); + if (seen == 1){ + console.log("Story: a wild battle on the step into a trainer's line; running from it, then stepping on.", COLOR_ORANGE); + flee_battle(console, context); + wait_out_battle_exit(console, context); + return false; + } + return seen == 0; } ); } -void face_wall(ProControllerContext& context, Direction direction){ - pbf_press_dpad(context, dpad(direction), TURN_TAP, TURN_SETTLE); - context.wait_for_all_requests(); +void face_wall(ConsoleHandle& console, ProControllerContext& context, Direction direction){ + OverworldWalkSteps::turn_to(console, context, direction); } SaveAction SaveDialog::next(SaveScreen screen){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h index e08122242b..07e37e35c9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h @@ -121,8 +121,11 @@ std::optional talk_and_count_pages(ConsoleHandle& console, ProController // From the overworld: one step `direction`, onto a trigger or into a door. // `facing` is the way the player faces, if known; a player facing another -// way is turned first. -void step_once(ProControllerContext& context, Pokemon::Direction direction, std::optional facing); +// way is turned first (OverworldWalkSteps::turn_to()). +void step_once( + ConsoleHandle& console, ProControllerContext& context, + Pokemon::Direction direction, std::optional facing +); // Steps with `step` a tile at a time, at most `max_tiles`, until `seen` // says a trainer saw the player. Returns the tiles stepped, or nothing @@ -136,6 +139,24 @@ std::optional step_until_seen(int max_tiles, const std::function& s // "!", then their words, as the route walker watches for a trainer // with no tiles to walk up (PokemonFRLG_OverworldRecoveryWalk.cpp). inline constexpr Milliseconds TRAINER_LINE_WATCH(2500); +// How long a step into a trainer's line is watched for a wild battle it +// set off: its HP boxes, with no message before them, came over 5 s after +// the step in Mt. Moon (UNSEEN_BATTLE_WATCH). The trainer didn't see the +// player on that step (the game looks for trainers before it rolls for a +// wild battle), so it is run from and the stepping goes on: in Rock +// Tunnel the battle step after it found no trainer's message (the sim, +// 2026-11-09). +inline constexpr Milliseconds WILD_BATTLE_IN_LINE_WATCH(5000); + +// The longest wait_out_battle_exit() waits for the overworld. +inline constexpr Milliseconds BATTLE_EXIT_TIMEOUT(10000); + +// After a battle run from or won: waits until the overworld has shown +// nothing (wait_for_quiet_overworld()) for MovementMechanics:: +// AFTER_BATTLE_QUIET, at most BATTLE_EXIT_TIMEOUT, and logs how the screen +// ended. A tap sent as the battle's black ends is lost (console B, +// 2026-11-09, 00.28 winston_line). +void wait_out_battle_exit(ConsoleHandle& console, ProControllerContext& context); // How long a step into the line of a trainer who looks around waits for // them to turn to the player (TRAINER_WAIT_VARIANT). @@ -143,7 +164,8 @@ inline constexpr Milliseconds TRAINER_WAIT_WATCH(30000); // From the overworld: step_until_seen() `direction`, turning first // unless `facing` is `direction`, each step watched `watch` for a -// trainer's message or battle. +// trainer's message, and at least WILD_BATTLE_IN_LINE_WATCH for a wild +// battle, which is run from. std::optional step_into_line( ConsoleHandle& console, ProControllerContext& context, Pokemon::Direction direction, std::optional facing, int max_tiles, @@ -151,8 +173,8 @@ std::optional step_into_line( ); // From the overworld, turns to face `direction` without stepping: the -// player must have a wall that way. -void face_wall(ProControllerContext& context, Pokemon::Direction direction); +// player must have a wall that way (OverworldWalkSteps::turn_to()). +void face_wall(ConsoleHandle& console, ProControllerContext& context, Pokemon::Direction direction); // The save dialog after SAVE, as pokefirered's start_menu.c runs it: // "Would you like to save the game?" YES/NO (YES first). Then, over a save diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 9d5e93b652..e01867804f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -22,6 +22,7 @@ #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_PartyMenuDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_AutoStoryActions.h" @@ -62,9 +63,6 @@ constexpr Milliseconds OVERWORLD_CHECK = 3000ms; // No battle menu or HP box this long after a battle was seen: there was // none (console B, 2026-11-01, Viridian Forest's north gate mat). constexpr Milliseconds NOT_A_BATTLE = 15000ms; -// An evolution's animation, with no presses: as long as exit_wild_battle() -// (PokemonFRLG_Navigation.cpp) waits for one. -constexpr Milliseconds EVOLUTION = 20000ms; // Text and the move learn prompts after a battle, on the overworld. constexpr Milliseconds AFTER_BATTLE_TEXT = 60000ms; // The move list to fade in after "Delete a move?", and its cursor to move. @@ -102,7 +100,8 @@ const char* screen_name(BattleScreen screen){ } // Until `confirmed`, the battle text box alone counts for nothing: it also -// reads on the black below a small indoor map. +// reads on the black below a small indoor map. Nor ever on a dark cave's +// overworld after the battle (console B, 2026-11-09, Rock Tunnel). BattleScreen read_battle_screen(const ImageViewRGB32& screen, bool confirmed){ if (BattleMenuDetector(COLOR_RED).detect(screen)){ return BattleScreen::MENU; @@ -110,7 +109,7 @@ BattleScreen read_battle_screen(const ImageViewRGB32& screen, bool confirmed){ if (battle_yes_no_shows(screen)){ return BattleScreen::LEARN; } - if (!confirmed && !BattleScreenDetector(COLOR_RED).detect(screen)){ + if ((!confirmed && !BattleScreenDetector(COLOR_RED).detect(screen)) || OverworldWalkSteps::dark_cave_screen(screen)){ return BattleScreen::NONE; } if (AdvanceBattleDialogDetector(COLOR_RED).detect(screen)){ @@ -128,6 +127,7 @@ BattleTextScreen read_text_screen(const ImageViewRGB32& screen){ .hp_box = BattleScreenDetector(COLOR_RED).detect(screen), .arrow = AdvanceBattleDialogDetector(COLOR_RED).detect(screen), .yes_no = battle_yes_no_shows(screen) || yes_no_cursor(screen).has_value(), + .dark_cave = OverworldWalkSteps::dark_cave_screen(screen), }; } @@ -427,11 +427,11 @@ class BattleLoop{ m_last_press = now; } - // Nothing pressed while the screen may be an evolution, up to EVOLUTION. + // Nothing pressed while the screen may be an evolution, up to MAX_HOLD. void wait_out_evolution(){ const WallClock start = current_time(); bool holds = true; - while (holds && current_time() - start < EVOLUTION){ + while (holds && current_time() - start < EvolutionHold::MAX_HOLD){ pbf_wait(m_context, POLL); m_context.wait_for_all_requests(); VideoSnapshot screen = m_console.video().snapshot(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index 5442216c83..0deab3ef7c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -75,6 +75,17 @@ constexpr StoryTrainer STORY_TRAINERS[] = { {.map = "Route9", .battle = "picnicker_alicia", .trainer = "TRAINER_PICNICKER_ALICIA"}, {.map = "Route9", .battle = "hiker_jeremy", .trainer = "TRAINER_HIKER_JEREMY"}, {.map = "Route9", .battle = "bug_catcher_conner", .trainer = "TRAINER_BUG_CATCHER_CONNER"}, + // Rock Tunnel's nine the way can't walk around (RockTunnel_1F, + // RockTunnel_B1F scripts.inc). + {.map = "RockTunnel_1F", .battle = "pokemaniac_ashton", .trainer = "TRAINER_POKEMANIAC_ASHTON"}, + {.map = "RockTunnel_B1F", .battle = "pokemaniac_winston", .trainer = "TRAINER_POKEMANIAC_WINSTON"}, + {.map = "RockTunnel_B1F", .battle = "picnicker_martha", .trainer = "TRAINER_PICNICKER_MARTHA"}, + {.map = "RockTunnel_B1F", .battle = "hiker_allen", .trainer = "TRAINER_HIKER_ALLEN"}, + {.map = "RockTunnel_1F", .battle = "hiker_lenny", .trainer = "TRAINER_HIKER_LENNY"}, + {.map = "RockTunnel_B1F", .battle = "picnicker_sofia", .trainer = "TRAINER_PICNICKER_SOFIA"}, + {.map = "RockTunnel_B1F", .battle = "hiker_dudley", .trainer = "TRAINER_HIKER_DUDLEY"}, + {.map = "RockTunnel_B1F", .battle = "pokemaniac_cooper", .trainer = "TRAINER_POKEMANIAC_COOPER"}, + {.map = "RockTunnel_1F", .battle = "picnicker_dana", .trainer = "TRAINER_PICNICKER_DANA"}, }; using JsonTable = std::map>; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp index 57fc597e03..d8d8ed4fe3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp @@ -509,7 +509,7 @@ void EvolutionHold::turn_taken(TurnChoice choice){ } bool EvolutionHold::holds(const BattleTextScreen& screen) const{ - return m_attacked && m_held < EVOLUTION_HOLDS && screen.text_box && !screen.hp_box && !screen.arrow && !screen.yes_no; + return m_attacked && m_held < EVOLUTION_HOLDS && screen.text_box && !screen.hp_box && !screen.arrow && !screen.yes_no && !screen.dark_cave; } void EvolutionHold::held(Milliseconds held, bool lead_fainted){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h index 5fb5f5a6cc..6d63a5ecf6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h @@ -87,6 +87,9 @@ struct BattleTextScreen{ bool arrow = false; // A YES/NO box. bool yes_no = false; + // The overworld of a cave dark without Flash (dark_cave_screen()), + // whose black reads as the text box. + bool dark_cave = false; }; // Answer to an in-battle prompt dialogue. @@ -193,6 +196,10 @@ class BattleDecisions{ // battle loop's B presses). class EvolutionHold{ public: + // The longest one hold lasts: as long as exit_wild_battle() + // (PokemonFRLG_Navigation.cpp) waits for an evolution. + static constexpr Milliseconds MAX_HOLD = std::chrono::milliseconds(20000); + explicit EvolutionHold(PartyMember& lead); // The loop took a turn: only one that attacked can end the battle in a @@ -201,7 +208,9 @@ class EvolutionHold{ // Whether `screen` may be an evolution: after a turn that attacked (not // after RUN: console B, 2026-11-03, a minute without B after "Got away // safely!"), the text box with no HP box and no arrow or YES/NO, as - // "What? is evolving!" shows through the animation. Nothing + // "What? is evolving!" shows through the animation; not a + // dark cave's overworld, whose black reads as the text box (console B, + // 2026-11-09, 00.28 hiker_lenny: three holds, then a stop). Nothing // is pressed while it shows, for at most EVOLUTION_HOLDS in all in a // battle, in case it is something else. bool holds(const BattleTextScreen& screen) const; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp index 860a78a259..f292e12fd2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp @@ -487,7 +487,7 @@ bool ConsolePatchSteps::step(){ const int ret = run_until( m_console, m_context, [&](ProControllerContext& context){ - step_once(context, way, m_spot.facing); + step_once(m_console, context, way, m_spot.facing); }, {battle} ); @@ -557,6 +557,9 @@ EncounterSeen ConsoleCaptureActions::read_encounter(){ bool ConsoleCaptureActions::flee(){ try{ flee_battle(m_console, m_context); + // The pacing counts its taps: one lost to the battle's exit + // would put it a tile off. + wait_out_battle_exit(m_console, m_context); return true; }catch (OperationFailedException& e){ m_console.log("Capture: " + e.message(), COLOR_RED); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCeladon_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCeladon_Tests.cpp index 853b4e4af7..922b9c0e18 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCeladon_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCeladon_Tests.cpp @@ -434,6 +434,7 @@ void add_tests_AutoStoryCeladon(UnitTestDatabase& database){ for (const auto& item : AUTO_STORY_CELADON_CASES){ database.add(item); } + add_tests_AutoStoryLavender(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index d06a03851a..13a39e6cae 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -77,7 +77,7 @@ std::optional face_a_wall(StoryEnv& env, const MapPosition& position) const std::optional wall = wall_beside(*map, position.point); if (wall){ env.console.log(std::string("Story: facing unknown, turning to the wall ") + direction_name(*wall) + "."); - face_wall(env.context, *wall); + face_wall(env.console, env.context, *wall); } return wall; } @@ -192,7 +192,7 @@ Outcome take_step(StoryEnv& env, RunState& state, const StoryStep& step){ return Pokemon::outcome_failed("no_trainer", std::format("{} s on the tile {}", TRAINER_WAIT_WATCH.count() / 1000, direction_name(step.direction))); } }else{ - step_once(env.context, step.direction, facing_of(state.pose)); + step_once(env.console, env.context, step.direction, facing_of(state.pose)); } const std::vector tiles = Pokemon::tiles_of(state.pose); if (tiles.size() != 1){ @@ -395,6 +395,7 @@ std::string save_card_place(const std::string& map){ {"Route25", "route-25"}, {"VermilionCity", "vermilion-city"}, {"Route10", "route-10"}, + {"LavenderTown", "lavender-town"}, }; for (const auto& [prefix, place] : PLACES){ if (map == prefix || map.rfind(prefix + "_", 0) == 0){ @@ -617,12 +618,12 @@ Outcome cut_tree(StoryEnv& env, RunState& state, const StoryStep& step){ } if (here->facing != step.direction){ // The tree is in the way: a tap only turns the player to it. - face_wall(env.context, step.direction); + face_wall(env.console, env.context, step.direction); } pbf_press_button(env.context, BUTTON_A, 160ms, 800ms); answer_yes_no(env.console, env.context, true, BUTTON_B, YES_NO_TIMEOUT); mash_until_overworld(env.console, env.context, SCENE_TIMEOUT); - step_once(env.context, step.direction, step.direction); + step_once(env.console, env.context, step.direction, step.direction); return Pokemon::outcome_ok(to_pose(StoryPose{here->map, here->tile.next(step.direction), step.direction})); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp new file mode 100644 index 0000000000..bd2a0112bb --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp @@ -0,0 +1,545 @@ +/* AutoStory Fault Sweep Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Each class of failure console B showed, injected at every row of the + * story on the overworld sim, instead of only where it was seen: + * + * - SCENE_END_POSE: a step the sim does not play (a scene, a talk, an + * event) that moved the player leaves them on another walkable tile + * within one of the table's pose, while the program goes on from the + * table's (BDSP 00.03, lab-B 2026-11-09). + * - WILD_BATTLES: wild battles at the game's own odds on every map with + * land encounters, over GAME_ODDS_SEEDS seeds (00.28's Rock Tunnel). + * - LATE_FADE: every warp's fade ends LATE_FADE later than the sim's + * usual (00.27's warp to Cerulean City). + * - DARK_CAVE: the caves without Flash dark (DARK_MAPS), so each + * battle there ends on the dark cave's overworld, with no wild + * battles and with them at the game's odds (00.28 hiker_lenny, lab-B + * 2026-11-09). + * - LOST_TAP: a wild battle on each tile of each step into a trainer's + * line before its last, so the next tap comes as the overworld comes + * back, inside the lockout after a battle (00.28 winston_line, console + * B, 2026-11-09 15:50). The step alone, from where the clean run took + * it, on its map made a wild floor. + * + * Not swept here: a missed fade, which needs a knob in OverworldSim; a + * battle's opening at a step's start, which needs one in OverworldStepSim; + * and the step tap lockout between two taps, which step_once() waits out. + * + * The sim plays walks, steps, cuts and trainers' battles; after a step it + * does not play, the player is put on the table's pose. Each row runs + * clean first; a row that fails clean, or has no start, is not swept. A + * variant fails when the row then fails or ends elsewhere. A sweep the + * program fails today is on KNOWN_FAILING with its likely fix: its test + * passes while it still fails, and fails once it passes. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryFaultSweep_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using namespace std::chrono_literals; + +enum class FaultClass{ + SCENE_END_POSE, + WILD_BATTLES, + LATE_FADE, + DARK_CAVE, + LOST_TAP, +}; +const char* fault_class_name(FaultClass fault){ + switch (fault){ + case FaultClass::SCENE_END_POSE: + return "scene end pose"; + case FaultClass::WILD_BATTLES: + return "wild battles at the game's odds"; + case FaultClass::LATE_FADE: + return "late warp fade"; + case FaultClass::DARK_CAVE: + return "dark cave without Flash"; + case FaultClass::LOST_TAP: + return "lost tap after a battle in a trainer's line"; + } + return "?"; +} + +// Sweeps the program fails today, by test name, with the likely fix. +const std::map KNOWN_FAILING{ + {"PokemonFRLG::AutoStory - Fault sweep: 00.06, wild battles at the game's odds", + "below_oak on Route 1 gives up after 9 stops part-way (seeds 16, 30): count stops that make no progress, not every battle"}, +}; + +// The land encounter rates of the maps the story walks (pokefirered +// src/data/wild_encounters.json, FireRed). Mt. Moon's floor is wild +// on every tile (WILD_FLOOR_MAPS). +const std::map STORY_ENCOUNTER_RATES{ + {"Route1", 21}, {"Route2", 21}, {"Route3", 21}, {"Route4", 21}, {"Route5", 21}, {"Route6", 21}, + {"Route9", 21}, {"Route10", 21}, {"Route11", 21}, {"Route22", 21}, {"Route24", 21}, {"Route25", 21}, + {"ViridianForest", 14}, {"MtMoon_1F", 7}, {"MtMoon_B1F", 5}, {"MtMoon_B2F", 7}, + {"RockTunnel_1F", 7}, {"RockTunnel_B1F", 7}, +}; +// Caves wild on every floor tile, also where the maps mark no wild tiles +// (Mt. Moon B2F, console B, 2026-11-03). +const std::set WILD_FLOOR_MAPS{"MtMoon_1F", "MtMoon_B1F", "MtMoon_B2F"}; +constexpr unsigned GAME_ODDS_SEEDS = 30; +// The caves the story walks dark: no Flash before Lavender Town. +const std::set DARK_MAPS{"RockTunnel_1F", "RockTunnel_B1F"}; +// Seeds of wild battles at the game's odds in the dark, after a run +// with none. +constexpr unsigned DARK_CAVE_SEEDS = 10; +// How much later than SimTiming's a late fade ends: the new map shows +// this long after the usual. +constexpr Milliseconds LATE_FADE = 1500ms; + +// The maps as the story after the Hall of Fame and a new game flag them: +// the same set for every row, as each stage's tests use. +const std::vector& sweep_maps(){ + static const std::vector maps = []{ + const std::vector all = overworld_maps(); + std::set flags = map_flag_set(all, "after_hall_of_fame"); + flags.merge(map_flag_set(all, "new_game")); + return overworld_maps(flags); + }(); + return maps; +} + +// Whether the overworld sim plays `step` (OverworldStepSim): the rest is stubbed. +bool sim_plays(const StoryStep& step){ + switch (step.kind){ + case StoryStepKind::ROUTE: + case StoryStepKind::LEGS: + case StoryStepKind::STEP: + case StoryStepKind::BATTLE: + return true; + case StoryStepKind::GAME: + return step.variant == CUT_TREE_VARIANT; + default: + return false; + } +} + +// A row run on the sim. +struct RowRun{ + // The first failure, "" if none. + std::string failure; + // The steps whose failure was let pass, by index (run_row()). + std::set scripted; + // Where the player stood before each step, by index. + std::map before; + std::map facing_before; + MapPosition end; + Milliseconds time{0}; + int steps = 0; + int wild_battles = 0; + Milliseconds in_wild_battles{0}; +}; + +// Where a stubbed step leaves the player instead of its pose_after: by step index. +using Landings = std::map; + +// Whether `failure` is a script the sim does not have: a scene's trigger +// stepped onto (no trainer sees the player there) or a leader's battle +// after a talk (no trainer's message up). +bool unscripted(const std::string& failure){ + return failure.find(": no trainer saw the player.") != std::string::npos + || failure.find(": no_battle.") != std::string::npos; +} + +// Row `row` on the sim with `script`, from its start. After a step the sim +// does not play, the player is put on its pose_after, or on `landings`' +// tile for it, the program going on from the pose_after either way. A +// step that fails as unscripted() is let pass as one the sim does not +// play: in a clean run (`scripted` unset) at any step, else only at the +// steps in `scripted`, where the clean run let it pass. +RowRun run_row( + const StoryCheckpoint& row, const SimScript& script, const Landings& landings = {}, + const std::optional>& scripted = std::nullopt +){ + RowRun out; + StoryPose at = *story_pose(*row.start); + auto sim = std::make_unique(sweep_maps(), MapPosition{at.map, at.tile}, at.facing.value_or(Direction::SOUTH), script); + const auto add = [&out](const SimState& state){ + out.time += state.time; + out.steps += state.steps; + out.wild_battles += state.wild_battles; + out.in_wild_battles += state.in_wild_battles; + }; + for (size_t c = 0; c < row.steps.size() && out.failure.empty(); c++){ + const StoryStep& step = row.steps[c]; + out.before.emplace(c, sim->state().position); + out.facing_before.emplace(c, sim->state().facing); + out.failure = row_step_on_sim(*sim, at, step, row.id); + const bool let_pass = unscripted(out.failure) && (!scripted || scripted->contains(c)); + if (let_pass){ + out.failure.clear(); + out.scripted.insert(c); + } + if (!out.failure.empty() || (sim_plays(step) && !let_pass) || !step.pose_after){ + continue; + } + at = *story_pose(*step.pose_after); + const auto landing = landings.find(c); + const MapPosition land{at.map, landing == landings.end() ? at.tile : landing->second}; + if (!(sim->state().position == land)){ + add(sim->state()); + sim = std::make_unique(sweep_maps(), land, at.facing.value_or(sim->state().facing), script); + } + } + if (out.failure.empty() && !sim->state().violations.empty()){ + out.failure = "a violation on the way: " + sim->state().violations.back(); + } + out.end = sim->state().position; + add(sim->state()); + return out; +} + +// Why `run` differs from the clean run `clean`, or "". +std::string differs(const RowRun& run, const RowRun& clean){ + if (!run.failure.empty()){ + return run.failure; + } + if (!(run.end == clean.end)){ + return std::format("ended on {}, not {}.", to_string(run.end), to_string(clean.end)); + } + return ""; +} + +// One injected variant that failed its row. +struct SweepFailure{ + std::string step; + std::string variant; + std::string stopped; +}; + +UnitTestResult sweep_result(Logger& logger, FaultClass fault, const std::string& row, size_t variants, const std::vector& failures){ + logger.log(std::format("Fault sweep {} {}: {} variants, {} fail.", row, fault_class_name(fault), variants, failures.size())); + if (failures.empty()){ + return true; + } + std::string out = std::format("{} of {} variants fail {}:", failures.size(), variants, row); + for (const SweepFailure& failure : failures){ + logger.log(std::format("| {} | {} | {} | {} | {} |", row, failure.step, fault_class_name(fault), failure.variant, failure.stopped)); + out += std::format("\n {} {}: {}", failure.step, failure.variant, failure.stopped); + } + return out; +} + +// The open tiles within one of `pose`, diagonals too: where a scene can +// leave the player instead. +std::vector open_neighbors(const StoryPose& pose){ + const std::vector& maps = sweep_maps(); + const auto map = std::find_if(maps.begin(), maps.end(), [&](const MapData& data){ return data.name == pose.map; }); + std::vector out; + if (map == maps.end()){ + return out; + } + for (int dy = -1; dy <= 1; dy++){ + for (int dx = -1; dx <= 1; dx++){ + const TilePos tile{pose.tile.x + dx, pose.tile.y + dy}; + const bool open = (dx != 0 || dy != 0) && in_bounds(*map, tile) && object_at(*map, tile) == nullptr + && (tile_at(*map, tile) == TileKind::OPEN || tile_at(*map, tile) == TileKind::TALL_GRASS); + if (open){ + out.push_back(tile); + } + } + } + return out; +} + +// Row `row`'s stubbed steps that moved the player and have a walk after +// them, each ending on every open tile next to the table's pose in turn. +UnitTestResult sweep_scene_end_pose(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + std::vector failures; + size_t variants = 0; + for (size_t c = 0; c < row.steps.size(); c++){ + const StoryStep& step = row.steps[c]; + const std::optional after = step.pose_after ? story_pose(*step.pose_after) : std::nullopt; + // A scene that only turns the player (the S.S. Ticket's, the aide's) + // ends where our walk did: not the game's end pose. + const auto from = clean.before.find(c); + // With no walk after it, nothing in the row goes on from the pose. + const bool walk_after = std::any_of(row.steps.begin() + c + 1, row.steps.end(), [](const StoryStep& next){ + return next.kind == StoryStepKind::ROUTE || next.kind == StoryStepKind::LEGS || next.kind == StoryStepKind::STEP; + }); + if (sim_plays(step) || !walk_after || !after || (from != clean.before.end() && from->second == MapPosition{after->map, after->tile})){ + continue; + } + for (const TilePos& tile : open_neighbors(*after)){ + variants++; + const std::string stopped = differs(run_row(row, SimScript{}, {{c, tile}}, clean.scripted), clean); + if (!stopped.empty()){ + failures.push_back({step.name, std::format("ends on {}", to_string(tile)), stopped}); + } + } + } + return sweep_result(logger, FaultClass::SCENE_END_POSE, row.id, variants, failures); +} + +// The mean of `values`, "-" if none. +std::string mean_of(const std::vector& values){ + if (values.empty()){ + return "-"; + } + return std::format("{:.1f}", std::accumulate(values.begin(), values.end(), 0.) / values.size()); +} + +// Row `row` with wild battles at the game's odds, from each seed. Logs +// passed, time, tiles walked again and time lost per battle. +UnitTestResult sweep_wild_battles(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + std::vector failures; + std::vector seconds, again, lost; + int battles = 0; + for (unsigned seed = 1; seed <= GAME_ODDS_SEEDS; seed++){ + SimScript script; + script.encounter_rates = STORY_ENCOUNTER_RATES; + script.encounter_seed = seed; + script.wild_floor_maps = WILD_FLOOR_MAPS; + const RowRun run = run_row(row, script, {}, clean.scripted); + battles += run.wild_battles; + const std::string stopped = differs(run, clean); + if (!stopped.empty()){ + failures.push_back({"", std::format("seed {}", seed), stopped}); + continue; + } + seconds.push_back(run.time.count() / 1000.); + again.push_back(run.steps - clean.steps); + if (run.wild_battles > 0){ + lost.push_back((run.time - clean.time - run.in_wild_battles).count() / 1000. / run.wild_battles); + } + } + logger.log(std::format( + "Fault sweep {} wild battles: passed {}/{}, {} battles; of those passed: time (s) mean {}, clean {:.1f}; tiles walked again mean {}; s lost per battle mean {}", + row.id, GAME_ODDS_SEEDS - failures.size(), GAME_ODDS_SEEDS, battles, mean_of(seconds), clean.time.count() / 1000., mean_of(again), mean_of(lost) + )); + return sweep_result(logger, FaultClass::WILD_BATTLES, row.id, GAME_ODDS_SEEDS, failures); +} + +// Row `row` with every warp's fade ending LATE_FADE late. +UnitTestResult sweep_late_fade(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + SimScript script; + script.timing.fade += LATE_FADE; + const std::string stopped = differs(run_row(row, script, {}, clean.scripted), clean); + std::vector failures; + if (!stopped.empty()){ + failures.push_back({"", std::format("every fade {} ms late", LATE_FADE.count()), stopped}); + } + return sweep_result(logger, FaultClass::LATE_FADE, row.id, 1, failures); +} + +// Row `row` with DARK_MAPS dark: once with no wild battles, then with +// them at the game's odds from DARK_CAVE_SEEDS seeds. Each trainer's +// battle there ends on the dark overworld (battle_end_on_sim() in +// PokemonFRLG_AutoStoryRowSim.cpp); wild ones are run from, after which +// nothing is held for an evolution. A row that walks no dark cave is +// not swept. +UnitTestResult sweep_dark_cave(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + const bool walks_dark = DARK_MAPS.contains(clean.end.map) || std::any_of(clean.before.begin(), clean.before.end(), [](const auto& before){ + return DARK_MAPS.contains(before.second.map); + }); + if (!walks_dark){ + logger.log(std::format("Fault sweep {}: not swept, the row walks no dark cave.", row.id)); + return true; + } + std::vector failures; + for (unsigned seed = 0; seed <= DARK_CAVE_SEEDS; seed++){ + SimScript script; + script.dark_maps = DARK_MAPS; + if (seed > 0){ + script.encounter_rates = STORY_ENCOUNTER_RATES; + script.encounter_seed = seed; + script.wild_floor_maps = WILD_FLOOR_MAPS; + } + const std::string stopped = differs(run_row(row, script, {}, clean.scripted), clean); + if (!stopped.empty()){ + failures.push_back({"", seed == 0 ? std::string("no wild battles") : std::format("seed {}", seed), stopped}); + } + } + return sweep_result(logger, FaultClass::DARK_CAVE, row.id, DARK_CAVE_SEEDS + 1, failures); +} + +// How many tiles row `row`'s step `c`, into a trainer's line, steps onto +// before its last, in the clean run `clean`. +int line_tiles_before_last(const StoryCheckpoint& row, const RowRun& clean, size_t c){ + const auto from = clean.before.find(c); + const auto next = clean.before.find(c + 1); + if (from == clean.before.end()){ + return 0; + } + const MapPosition last = next == clean.before.end() ? clean.end : next->second; + TilePos tile = from->second.point.next(row.steps[c].direction); + int out = 0; + for (; out + 1 < TRAINER_LINE_TILES && last.map == from->second.map && !(tile == last.point); out++){ + tile = tile.next(row.steps[c].direction); + } + return out; +} + +// Row `row`'s step `c`, into a trainer's line, alone from where the clean +// run `clean` took it, with a wild battle on its `battle_step`th step: why +// it fails or ends off the clean run's tile, or "". +std::string line_with_battle(const StoryCheckpoint& row, const RowRun& clean, size_t c, int battle_step){ + const MapPosition from = clean.before.at(c); + const auto next = clean.before.find(c + 1); + const MapPosition last = next == clean.before.end() ? clean.end : next->second; + SimScript script; + script.wild_floor_maps = {from.map}; + script.encounters = {SimEncounter{.grass_step = battle_step}}; + OverworldSim sim(sweep_maps(), from, clean.facing_before.at(c), script); + StoryPose at{from.map, from.point, clean.facing_before.at(c)}; + const std::string failure = row_step_on_sim(sim, at, row.steps[c], row.id); + if (!failure.empty()){ + return failure; + } + if (!(sim.state().position == last)){ + return std::format("ended on {}, not {}.", to_string(sim.state().position), to_string(last)); + } + return ""; +} + +// Row `row` with a wild battle on each tile of each step into a trainer's +// line before its last, one at a time: the tap after it must not be lost. +UnitTestResult sweep_lost_tap(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + std::vector failures; + size_t variants = 0; + for (size_t c = 0; c < row.steps.size(); c++){ + const StoryStep& step = row.steps[c]; + if (step.kind != StoryStepKind::STEP || step.variant != TRAINER_LINE_VARIANT || clean.scripted.contains(c)){ + continue; + } + const int tiles = line_tiles_before_last(row, clean, c); + for (int battle_step = 1; battle_step <= tiles; battle_step++){ + variants++; + if (const std::string stopped = line_with_battle(row, clean, c, battle_step); !stopped.empty()){ + failures.push_back({step.name, std::format("a wild battle on step {}", battle_step), stopped}); + } + } + } + return sweep_result(logger, FaultClass::LOST_TAP, row.id, variants, failures); +} + +// A fault class swept over row `row` (empty in FAULT_SWEEP_CASES: each +// row with a start). +struct FaultSweepInput{ + FaultClass fault = FaultClass::SCENE_END_POSE; + std::string row = {}; +}; +// Whether a sweep is expected to fail is KNOWN_FAILING's, by name. +struct FaultSweepExpectation{ +}; +struct FaultSweepCase{ + FaultSweepInput input; + FaultSweepExpectation expectation; +}; + +// The table's row `id`. +const StoryCheckpoint& table_row(const std::string& id){ + for (const StoryCheckpoint& row : story_checkpoints()){ + if (row.id == id){ + return row; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No row " + id); +} + +class Test_AutoStoryFaultSweep : public UnitTest{ +public: + explicit Test_AutoStoryFaultSweep(FaultSweepCase test_case) + : UnitTest(std::format("PokemonFRLG::AutoStory - Fault sweep: {}, {}", test_case.input.row, fault_class_name(test_case.input.fault))) + , m_fault(test_case.input.fault) + , m_row(table_row(test_case.input.row)) + {} + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + UnitTestResult result; + try{ + result = sweep(logger); + }catch (Exception& e){ + result = std::string(e.name()) + ": " + e.message(); + } + const auto known = KNOWN_FAILING.find(name()); + if (known == KNOWN_FAILING.end()){ + return result; + } + if (result.result == UnitTestResult::PASSED){ + return "on KNOWN_FAILING (" + known->second + ") but passes now: take it off the list."; + } + logger.log("Known failing until " + known->second + ": " + result.message); + return true; + } + +private: + UnitTestResult sweep(Logger& logger) const{ + const RowRun clean = run_row(m_row, SimScript{}); + if (!clean.failure.empty()){ + logger.log(std::format("Fault sweep {}: not swept, the row fails clean: {}", m_row.id, clean.failure)); + return true; + } + switch (m_fault){ + case FaultClass::SCENE_END_POSE: + return sweep_scene_end_pose(logger, m_row, clean); + case FaultClass::WILD_BATTLES: + return sweep_wild_battles(logger, m_row, clean); + case FaultClass::LATE_FADE: + return sweep_late_fade(logger, m_row, clean); + case FaultClass::DARK_CAVE: + return sweep_dark_cave(logger, m_row, clean); + case FaultClass::LOST_TAP: + return sweep_lost_tap(logger, m_row, clean); + } + return true; + } + + FaultClass m_fault; + const StoryCheckpoint& m_row; +}; + +} // namespace + + +const FaultSweepCase FAULT_SWEEP_CASES[] = { + {.input = {.fault = FaultClass::SCENE_END_POSE}, .expectation = {}}, + {.input = {.fault = FaultClass::WILD_BATTLES}, .expectation = {}}, + {.input = {.fault = FaultClass::LATE_FADE}, .expectation = {}}, + {.input = {.fault = FaultClass::DARK_CAVE}, .expectation = {}}, + {.input = {.fault = FaultClass::LOST_TAP}, .expectation = {}}, +}; + +void add_tests_AutoStoryFaultSweep(UnitTestDatabase& database){ + for (const FaultSweepCase& item : FAULT_SWEEP_CASES){ + for (const StoryCheckpoint& row : story_checkpoints()){ + if (!row.start || !story_pose(*row.start)){ + continue; + } + database.add(FaultSweepCase{.input = {.fault = item.input.fault, .row = row.id}, .expectation = item.expectation}); + } + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h new file mode 100644 index 0000000000..cda979a2f3 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h @@ -0,0 +1,26 @@ +/* AutoStory Fault Sweep Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryFaultSweep_Tests_H +#define PokemonAutomation_PokemonFRLG_AutoStoryFaultSweep_Tests_H + +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Each class of console failure injected at every row of the story on the +// overworld sim; the rows the program still fails are known failing +// (PokemonFRLG_AutoStoryFaultSweep_Tests.cpp). +void add_tests_AutoStoryFaultSweep(UnitTestDatabase& database); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp new file mode 100644 index 0000000000..28a8a12bb1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -0,0 +1,607 @@ +/* AutoStory Lavender Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Rows from 00.28 on (PokemonFRLG_AutoStoryTableLavender.cpp) on the + * overworld sim: 00.28 from Route 10's Pokemon Center through Rock Tunnel + * to its south mouth, and 00.29 down Route 10 to Lavender Town's Center. + * Each row whole, each open leg a tile short or long, and a wild battle + * on every open leg in the tunnel. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryFaultSweep_Tests.h" +#include "PokemonFRLG_AutoStoryResources.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct AutoStoryLavenderInput{ + std::string name; + std::function body; +}; + +struct AutoStoryLavenderExpectation{ +}; + +struct AutoStoryLavenderCase{ + AutoStoryLavenderInput input; + AutoStoryLavenderExpectation expectation; +}; + +class Test_AutoStoryLavender : public UnitTest{ +public: + explicit Test_AutoStoryLavender(AutoStoryLavenderCase test_case) + : UnitTest("PokemonFRLG::AutoStory - Simulation: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + try{ + return m_case.input.body(logger); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + AutoStoryLavenderCase m_case; +}; + +const StoryCheckpoint& checkpoint(std::string_view id){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [&](const StoryCheckpoint& data){ return data.id == id; }); + if (row == rows.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::format("No checkpoint {}.", id)); + } + return *row; +} + +// The maps as 00.27 leaves them: the story's flags, Rock Tunnel's and +// Route 10's trainers unfought. +std::vector maps_after_route10(){ + const std::vector all = overworld_maps(); + std::set flags = map_flag_set(all, "after_hall_of_fame"); + flags.merge(map_flag_set(all, "new_game")); + flags.erase("FLAG_HIDE_VERMILION_CITY_OAKS_AIDE"); + return overworld_maps(flags); +} + +// Row `id`'s steps from `first` on, on `sim` from `at`: the failure, or +// empty once on the row's last route's target. +std::string row_rest(OverworldSim& sim, StoryPose at, const StoryCheckpoint& row, size_t first){ + std::string failure; + for (size_t c = first; c < row.steps.size() && failure.empty(); c++){ + failure = row_step_on_sim(sim, at, row.steps[c], row.id); + } + if (!failure.empty()){ + return std::format("ended on {}; {}", to_string(sim.state().position), failure); + } + return ""; +} + +UnitTestResult row_checked( + OverworldSim& sim, std::string_view id, const std::vector>& fought, const MapPosition& end +); + +// Row `id` on the sim with `script`: it ends on `end`, with no violation +// on the way, having fought the trainers of one of `fought`, in order. +UnitTestResult row_on_sim( + std::string_view id, const SimScript& script, const std::vector>& fought, const MapPosition& end +){ + const StoryCheckpoint& row = checkpoint(id); + const StoryPose at = *story_pose(*row.start); + OverworldSim sim(maps_after_route10(), MapPosition{at.map, at.tile}, *at.facing, script); + return row_checked(sim, id, fought, end); +} + +// Row `id` on `sim`, from its start, checked as row_on_sim() does. +UnitTestResult row_checked( + OverworldSim& sim, std::string_view id, const std::vector>& fought, const MapPosition& end +){ + const StoryCheckpoint& row = checkpoint(id); + const StoryPose at = *story_pose(*row.start); + if (const std::string failure = row_rest(sim, at, row, 0); !failure.empty()){ + return std::string(id) + ": " + failure; + } + if (std::find(fought.begin(), fought.end(), sim.state().trainers_beaten) == fought.end()){ + std::string fought; + for (const std::string& name : sim.state().trainers_beaten){ + fought += " " + name; + } + return std::format("{}: fought{}.", id, fought); + } + if (!sim.state().violations.empty()){ + return std::string(id) + ": a violation on the way: " + sim.state().violations.back(); + } + if (!(sim.state().position == end)){ + return std::format("{}: ended on {}, not {}.", id, to_string(sim.state().position), to_string(end)); + } + return true; +} + +const std::vector ROCK_TUNNEL_TRAINERS{ + "poke_maniac", "poke_maniac", "picnicker", "hiker", "hiker", "picnicker", "hiker", "poke_maniac", "picnicker", +}; + +// 00.28 on the sim: the tree cut, the nine trainers fought where the +// table steps into their lines (Cooper by the walker, on the leg past +// him), no one else met, and the walk out of the south mouth. +UnitTestResult test_rock_tunnel_row(Logger&){ + return row_on_sim("00.28", SimScript{}, {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); +} + +// 00.28 with a wild battle on the 3rd step on the tunnel's floor, mid-way +// down the leg to the wall (17, 8): the walker runs, holds the leg to its +// wall again, and nothing else changes. +UnitTestResult test_rock_tunnel_wild_battles(Logger&){ + SimScript script; + script.encounters.push_back(SimEncounter{.grass_step = 3}); + return row_on_sim("00.28", script, {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); +} + +// Wild battles on the tunnel's floor, `gaps` steps apart in turn, from the +// first step: as many as a row walks. +SimScript battles_every(const std::vector& gaps){ + SimScript script; + int step = 0; + for (size_t c = 0; step < 3000; c++){ + step += gaps[c % gaps.size()]; + script.encounters.push_back(SimEncounter{.grass_step = step}); + } + return script; +} + +// 00.28's step `name`: where it is in the row. +size_t step_index(const StoryCheckpoint& row, std::string_view name){ + for (size_t c = 0; c < row.steps.size(); c++){ + if (row.steps[c].name == name){ + return c; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::format("No step {} in {}.", name, row.id)); +} + +// Console B, 2026-11-09: after Winston, the walk west along B1F row 37 to +// (13, 37) met a wild battle 10 to 14 tiles into every try. Each time the +// walker found the player again at the east wall (45, 37), behind where +// the leg started, and walked the whole 32 tiles again, until it gave up +// ("stopped part-way 9 times"). From Winston's tile, with a battle every +// 10 to 14 steps, the steps from below_martha_line on reach Martha's line. +UnitTestResult test_b1f_row37_battles_every_12(Logger&){ + const StoryCheckpoint& row = checkpoint("00.28"); + const size_t first = step_index(row, "below_martha_line"); + StoryPose at{StoryTable::ROCK_TUNNEL_B1F, {29, 37}, Direction::NORTH}; + OverworldSim sim(maps_after_route10(), MapPosition{at.map, at.tile}, *at.facing, battles_every({12, 10, 14, 11, 13})); + const std::string failure = row_step_on_sim(sim, at, row.steps[first], row.id); + if (!failure.empty()){ + return std::format("below_martha_line: ended on {}; {}", to_string(sim.state().position), failure); + } + if (!(sim.state().position == MapPosition{StoryTable::ROCK_TUNNEL_B1F, {16, 35}})){ + return std::format("below_martha_line: ended on {}, not (16, 35).", to_string(sim.state().position)); + } + return true; +} + +// From Winston's tile, below_martha_line, martha_line and Martha's battle +// on `sim`: the failure, or empty once Martha alone is fought. +std::string winston_to_martha(OverworldSim& sim){ + const StoryCheckpoint& row = checkpoint("00.28"); + StoryPose at{StoryTable::ROCK_TUNNEL_B1F, {29, 37}, Direction::NORTH}; + for (size_t c = step_index(row, "below_martha_line"); c <= step_index(row, "picnicker_martha"); c++){ + if (const std::string failure = row_step_on_sim(sim, at, row.steps[c], row.id); !failure.empty()){ + return std::format("ended on {}; {}", to_string(sim.state().position), failure); + } + } + if (sim.state().trainers_beaten != std::vector{"picnicker"}){ + return std::format("ended on {}, Martha not fought.", to_string(sim.state().position)); + } + return ""; +} + +// Console B, 2026-11-09 (run 2): after Winston, the walk west along row +// 37 was found again at the wall (1, 38); a wild battle 0 to 2 tiles into +// the walk east along row 38 left the player on one of (1..3, 38), and +// the walk went on from there to the wall (19, 35). The one-tile step +// north onto row 34 then ended short with nothing seen, the walk east +// "to the wall (17, 34)" ran along row 35 to the rock (20, 35) instead +// (the end frame shows the wall (19, 34) north, the rock east), and +// martha_line found no trainer. Here the same battle, and the 10th hold +// (that step, before the route went up column 16 into her line) a tile +// short: Martha still sees the player. +UnitTestResult test_b1f_martha_after_row38_battle(Logger&){ + SimScript script; + script.encounters = {SimEncounter{.grass_step = 31}}; + script.short_holds = {{1, 2}, {10, 1}}; + OverworldSim sim(maps_after_route10(), MapPosition{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}, Direction::NORTH, script); + if (const std::string failure = winston_to_martha(sim); !failure.empty()){ + return failure; + } + return true; +} + +// Regression: console B, 2026-11-09 15:50, 00.28 winston_line, dark: from +// (32, 37) west into Winston's line, a wild battle on the 2nd tap's tile +// (30, 37), run from; the 3rd tap, sent as the battle's black ended, was +// lost, and the step failed no_trainer. After the battle the next tap +// waits for the overworld (wait_out_battle_exit()): Winston sees the +// player on (29, 37). +UnitTestResult test_winston_line_battle_on_tap_2(Logger&){ + const StoryCheckpoint& row = checkpoint("00.28"); + SimScript script; + script.dark_maps = {StoryTable::ROCK_TUNNEL_1F, StoryTable::ROCK_TUNNEL_B1F}; + script.wild_floor_maps = {StoryTable::ROCK_TUNNEL_B1F}; + script.encounters = {SimEncounter{.grass_step = 2}}; + StoryPose at{StoryTable::ROCK_TUNNEL_B1F, {32, 37}, Direction::WEST}; + OverworldSim sim(maps_after_route10(), MapPosition{at.map, at.tile}, Direction::WEST, script); + const std::string failure = row_step_on_sim(sim, at, row.steps[step_index(row, "winston_line")], row.id); + const auto lost = std::find_if(sim.state().log.begin(), sim.state().log.end(), [](const std::string& line){ + return line.find("in the lockout after a battle") != std::string::npos; + }); + if (!failure.empty() || lost != sim.state().log.end() || sim.state().wild_battles != 1){ + return std::format( + "winston_line: ended on {}, {} wild battle(s), {}; {}", to_string(sim.state().position), + sim.state().wild_battles, lost == sim.state().log.end() ? "no tap lost" : *lost, failure + ); + } + return true; +} + +// Rock Tunnel has no Flash: both floors are dark. +const std::set ROCK_TUNNEL_DARK{StoryTable::ROCK_TUNNEL_1F, StoryTable::ROCK_TUNNEL_B1F}; + +// Regression: console B, 2026-11-09, 00.28 hiker_lenny: won, and the dark +// overworld after it was taken for an evolution until fight_battle() gave +// up. 00.28 dark: every trainer's battle ends on the dark cave's overworld. +UnitTestResult test_rock_tunnel_dark_battle_ends(Logger&){ + SimScript script; + script.dark_maps = ROCK_TUNNEL_DARK; + return row_on_sim("00.28", script, {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); +} + +// below_martha_line as `legs` from Winston's tile (29, 37) on `sim`, by the +// walker with `rules`: the failure, or empty if it ended on (16, 35). +std::string walk_below_martha(OverworldSim& sim, const std::vector& legs, const SimWalkerRules& rules){ + const MapPosition start{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}; + const std::vector& maps = story_maps(); + const std::optional> route = fixed_route(maps, start, legs); + if (!route){ + return "the legs don't fit the map"; + } + const RoutePlanning planning{.maps = &maps, .costs = story_route_costs(), .start = start, .label = "below_martha_line"}; + std::string failure = OverworldSimWalker(sim, rules).run(*route, planning); + if (failure.empty() && !(sim.state().position == MapPosition{StoryTable::ROCK_TUNNEL_B1F, {16, 35}})){ + failure = "ended on " + to_string(sim.state().position) + ", not (16, 35)"; + } + return failure; +} + +// Console B, 2026-11-09 13:35, below_martha_line as it was then: west 16, +// north 2, east 6 to the wall (19, 35), west 3. A battle 6 tiles into +// west 16; the player found again at (1, 38); a battle seen late at the +// end of the walk back east 12 along row 38, placed 0 to 3 tiles in by +// the dark screen; "found again" at the rock (20, 35) while against the +// wall (25, 35); ended on (21, 35), where martha_line found no trainer. +// Here the second battle on each step of that walk back: with the dark +// cave's stops placed anywhere on their leg, every one ends on (16, 35); +// without, some don't. +UnitTestResult test_b1f_dark_row38_battle_old_legs(Logger& logger){ + const std::vector old_legs{{Direction::WEST, 16}, {Direction::NORTH, 2}, {Direction::EAST, 6}, {Direction::WEST, 3}}; + std::string failures; + int old_wrong = 0; + for (int second = 8; second <= 60; second++){ + SimScript script; + script.dark_maps = ROCK_TUNNEL_DARK; + script.encounters = {SimEncounter{.grass_step = 6}, SimEncounter{.grass_step = second, .seen_late = true}}; + OverworldSim fixed(maps_after_route10(), MapPosition{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}, Direction::NORTH, script); + if (const std::string failure = walk_below_martha(fixed, old_legs, SimWalkerRules{}); !failure.empty()){ + failures += std::format("{}battle on step {}: {}", failures.empty() ? "" : " | ", second, failure); + } + SimWalkerRules old_rules; + old_rules.whole_leg_in_dark = false; + old_rules.dark_cave_not_battle = false; + OverworldSim old(maps_after_route10(), MapPosition{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}, Direction::NORTH, script); + if (const std::string failure = walk_below_martha(old, old_legs, old_rules); !failure.empty()){ + old_wrong++; + logger.log(std::format("Without the dark cave rules, battle on step {}: {}", second, failure)); + } + } + if (!failures.empty()){ + return failures; + } + if (old_wrong == 0){ + return "Without the dark cave rules every walk still ended on (16, 35): the test doesn't show the failure."; + } + return true; +} + +// Rock Tunnel dark, from Winston's tile: below_martha_line, martha_line +// and Martha's battle with one wild battle on each step the walk takes, +// on rows 37 and 38 and on row 35 between the corner (11, 35) and +// (16, 35), seen as it starts and seen late: Martha alone is fought. +UnitTestResult test_b1f_dark_martha_battle_anywhere(Logger&){ + std::string failures; + for (int step = 1; step <= 40; step++){ + for (const bool late : {false, true}){ + SimScript script; + script.dark_maps = ROCK_TUNNEL_DARK; + script.encounters = {SimEncounter{.grass_step = step, .seen_late = late}}; + OverworldSim sim(maps_after_route10(), MapPosition{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}, Direction::NORTH, script); + if (const std::string failure = winston_to_martha(sim); !failure.empty()){ + failures += std::format("{}step {}{}: {}", failures.empty() ? "" : " | ", step, late ? " late" : "", failure); + } + } + } + if (!failures.empty()){ + return failures; + } + return true; +} + +// 00.28 whole with a wild battle every 7 to 25 steps on the tunnel's floor, +// the gaps drawn from each of these seeds: the row ends where it should. +UnitTestResult test_rock_tunnel_frequent_battles(Logger&){ + std::string failures; + for (unsigned seed = 1; seed <= 12; seed++){ + std::mt19937 random(seed); + std::uniform_int_distribution gap(7, 25); + std::vector gaps; + for (int c = 0; c < 400; c++){ + gaps.push_back(gap(random)); + } + const UnitTestResult result = row_on_sim( + "00.28", battles_every(gaps), {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}} + ); + if (result.result != UnitTestResult::PASSED){ + failures += std::format("{}Seed {}: {}", failures.empty() ? "" : " | ", seed, result.message); + } + } + if (!failures.empty()){ + return failures; + } + return true; +} + +// The sim at row `id`'s start, with `script`. +OverworldSim row_start_sim(std::string_view id, SimScript script){ + const StoryPose at = *story_pose(*checkpoint(id).start); + return OverworldSim(maps_after_route10(), MapPosition{at.map, at.tile}, *at.facing, std::move(script)); +} + +// One run of a row on the sim, against the same row with no wild battle. +struct RowRunNumbers{ + bool passed = false; + double seconds = 0; + int tiles = 0; + // Tiles walked beyond the run with no wild battle: walked again after + // a battle, finding the player and the way back. + int tiles_again = 0; + int battles = 0; + // Per wild battle, the time beyond the run with no wild battle, less + // the battles' own: from each battle's end until the walk is back + // where it was cut off. + double seconds_lost_per_battle = 0; +}; + +RowRunNumbers row_numbers(const SimState& run, bool passed, const SimState& clean){ + RowRunNumbers out; + out.passed = passed; + out.seconds = run.time.count() / 1000.; + out.tiles = run.steps; + out.tiles_again = run.steps - clean.steps; + out.battles = run.wild_battles; + const Milliseconds lost = run.time - clean.time - run.in_wild_battles; + out.seconds_lost_per_battle = run.wild_battles > 0 ? lost.count() / 1000. / run.wild_battles : 0; + return out; +} + +// The mean and 90th percentile of `values`. +std::string mean_p90(std::vector values){ + if (values.empty()){ + return "-"; + } + std::sort(values.begin(), values.end()); + const double mean = std::accumulate(values.begin(), values.end(), 0.) / values.size(); + const double p90 = values[std::min(values.size() - 1, (size_t)(0.9 * values.size()))]; + return std::format("mean {:.1f}, p90 {:.1f}", mean, p90); +} + +// How many of `runs` passed, and over those, the mean and 90th percentile +// of each number. +std::string numbers_summary(const std::vector& runs){ + std::vector seconds, tiles, again, battles, lost; + size_t passed = 0; + for (const RowRunNumbers& run : runs){ + passed += run.passed ? 1 : 0; + if (!run.passed){ + continue; + } + seconds.push_back(run.seconds); + tiles.push_back(run.tiles); + again.push_back(run.tiles_again); + battles.push_back(run.battles); + if (run.battles > 0){ + lost.push_back(run.seconds_lost_per_battle); + } + } + return std::format( + "passed {}/{}; of those passed: time (s) {}; tiles {}; tiles walked again {}; wild battles {}; s lost per battle {}", + passed, runs.size(), mean_p90(seconds), mean_p90(tiles), mean_p90(again), mean_p90(battles), mean_p90(lost) + ); +} + +// The land encounter rates of the maps 00.28 and 00.29 walk (pokefirered +// src/data/wild_encounters.json, FireRed). +const std::map ROCK_TUNNEL_ENCOUNTER_RATES{ + {StoryTable::ROCK_TUNNEL_1F, 7}, + {StoryTable::ROCK_TUNNEL_B1F, 7}, + {StoryTable::ROUTE10, 21}, +}; + +constexpr unsigned GAME_ODDS_SEEDS = 30; + +// 00.28 whole, its wild battles at the game's own odds (OverworldSim:: +// rolls_encounter()), from each of these seeds: every run ends where it +// should. Logs the runs' numbers and the step taps the game ignored. +UnitTestResult test_rock_tunnel_game_odds(Logger& logger){ + OverworldSim clean_sim = row_start_sim("00.28", SimScript{}); + if (const UnitTestResult clean = row_checked(clean_sim, "00.28", {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); clean.result != UnitTestResult::PASSED){ + return clean; + } + std::vector runs; + std::string failures; + int ignored = 0; + for (unsigned seed = 1; seed <= GAME_ODDS_SEEDS; seed++){ + SimScript script; + script.encounter_rates = ROCK_TUNNEL_ENCOUNTER_RATES; + script.encounter_seed = seed; + script.dark_maps = ROCK_TUNNEL_DARK; + OverworldSim sim = row_start_sim("00.28", script); + const UnitTestResult result = row_checked(sim, "00.28", {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); + const bool passed = result.result == UnitTestResult::PASSED; + runs.push_back(row_numbers(sim.state(), passed, clean_sim.state())); + ignored += sim.state().step_taps_ignored; + if (!passed){ + failures += std::format("{}Seed {}: {}", failures.empty() ? "" : " | ", seed, result.message); + } + } + logger.log(std::format( + "00.28 at the game's encounter odds, {} step taps ignored: {}", ignored, numbers_summary(runs) + )); + if (!failures.empty()){ + return failures; + } + return true; +} + + +// 00.29 on the sim: no trainer met, the ledge hopped, Lavender's nurse. +UnitTestResult test_lavender_row(Logger&){ + return row_on_sim("00.29", SimScript{}, {{}}, MapPosition{StoryTable::LAVENDER_CENTER, {7, 4}}); +} + +// Where an open leg of two tiles or more ends a tile short or long, the +// next leg starts a tile off. From each such start (an open tile), the +// rest of the row still ends where the table says. A leg to a wall or +// onto a warp ends on its tile, and a one-tile leg is one step. +UnitTestResult tile_off_after_open_legs(std::string_view id){ + const StoryCheckpoint& row = checkpoint(id); + const std::vector& maps = story_maps(); + std::optional before; + StoryPose at = *story_pose(*row.start); + for (size_t s = 0; s < row.steps.size(); s++){ + const StoryStep& step = row.steps[s]; + const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS; + if (!walks){ + // A step that isn't a walk starts where the table says. + before.reset(); + if (step.pose_after){ + at = *story_pose(*step.pose_after); + }else if (step.kind == StoryStepKind::STEP && step.variant == CUT_TREE_VARIANT){ + at = StoryPose{at.map, at.tile.next(step.direction), step.direction}; + } + continue; + } + const std::vector route = walked_route(at, step).value_or(std::vector{}); + MapPosition from{at.map, at.tile}; + for (size_t c = 0; c < route.size(); c++){ + const bool off = before && before->action == RouteAction::WALK && !before->to_wall && !before->warps && before->tiles > 1; + const TilePos on = from.point.next(before ? before->direction : Direction::NORTH); + const TilePos short_of{2 * from.point.x - on.x, 2 * from.point.y - on.y}; + const auto map = std::find_if(maps.begin(), maps.end(), [&](const MapData& data){ return data.name == from.map; }); + for (const TilePos& start : off ? std::vector{short_of, on} : std::vector{}){ + const bool open = map != maps.end() && in_bounds(*map, start) && object_at(*map, start) == nullptr + && (tile_at(*map, start) == TileKind::OPEN || tile_at(*map, start) == TileKind::TALL_GRASS); + if (!open){ + continue; + } + OverworldSim sim(maps_after_route10(), MapPosition{from.map, start}, before->direction); + const std::vector rest(route.begin() + c, route.end()); + const RoutePlanning planning{.maps = &maps, .costs = story_route_costs(), .start = from, .label = step.name}; + std::string failure = OverworldSimWalker(sim).run(rest, planning); + const StoryPose next{route.back().end.map, route.back().end.point, route.back().facing_after}; + failure = failure.empty() ? row_rest(sim, next, row, s + 1) : failure; + if (!failure.empty()){ + return std::format("{} {} leg {} ({}) from {}, not {}: {}", id, step.name, c + 1, to_string(route[c]), to_string(start), to_string(from.point), failure); + } + } + before = route[c]; + from = route[c].end; + } + at = route.empty() ? at : StoryPose{route.back().end.map, route.back().end.point, route.back().facing_after}; + } + return true; +} + +UnitTestResult test_rock_tunnel_tile_off(Logger&){ + return tile_off_after_open_legs("00.28"); +} + +UnitTestResult test_lavender_tile_off(Logger&){ + return tile_off_after_open_legs("00.29"); +} + +// 00.29 saves in Lavender Town's Pokemon Center, LAVENDER TOWN on the +// save card; 00.28 on Route 10. +UnitTestResult test_save_places(Logger&){ + if (save_card_place(StoryTable::LAVENDER_CENTER) != "lavender-town" || save_card_place(StoryTable::ROUTE10) != "route-10"){ + return "Wrong save card places for Lavender Town's Center or Route 10."; + } + return true; +} + +} // namespace + + +const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { + {.input = {"00.28 the tree, Rock Tunnel's nine trainers, out of its south mouth", test_rock_tunnel_row}, .expectation = {}}, + {.input = {"00.28 a wild battle mid-leg in the tunnel", test_rock_tunnel_wild_battles}, .expectation = {}}, + {.input = {"00.28 dark: each trainer's battle ends on the dark cave's overworld", test_rock_tunnel_dark_battle_ends}, .expectation = {}}, + {.input = {"00.28 a tile off after each open leg", test_rock_tunnel_tile_off}, .expectation = {}}, + {.input = {"00.28 B1F row 37 with a wild battle every 10 to 14 steps", test_b1f_row37_battles_every_12}, .expectation = {}}, + {.input = {"00.28 a wild battle every 7 to 25 steps, 12 seeds", test_rock_tunnel_frequent_battles}, .expectation = {}}, + {.input = {"00.28 wild battles at the game's odds, 30 seeds", test_rock_tunnel_game_odds}, .expectation = {}}, + {.input = {"00.28 B1F a wild battle into row 38, then a step short: Martha still sees the player", test_b1f_martha_after_row38_battle}, .expectation = {}}, + {.input = {"00.28 B1F dark, the 13:35 legs: a battle at the end of the walk back along row 38", test_b1f_dark_row38_battle_old_legs}, .expectation = {}}, + {.input = {"00.28 B1F dark, a wild battle on any step to Martha's line", test_b1f_dark_martha_battle_anywhere}, .expectation = {}}, + {.input = {"00.28 B1F dark, winston_line: a wild battle on the 2nd tap, the 3rd after it", test_winston_line_battle_on_tap_2}, .expectation = {}}, + {.input = {"00.29 Route 10 south, the ledge, Lavender Town's Pokemon Center", test_lavender_row}, .expectation = {}}, + {.input = {"00.29 a tile off after each open leg", test_lavender_tile_off}, .expectation = {}}, + {.input = {"00.28 and 00.29 save on Route 10 and in Lavender Town", test_save_places}, .expectation = {}}, +}; + +void add_tests_AutoStoryLavender(UnitTestDatabase& database){ + for (const auto& item : AUTO_STORY_LAVENDER_CASES){ + database.add(item); + } + add_tests_AutoStoryResources(database); + add_tests_AutoStoryFaultSweep(database); +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp index 19cb975a72..6e16d20bc2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp @@ -24,10 +24,7 @@ SimPatch::SimPatch(OverworldSim& sim, Direction pace, Direction back, std::funct bool SimPatch::step(){ const Direction way = m_displaced ? m_back : m_pace; - if (m_sim.state().facing != way){ - m_sim.tap(way); - } - m_sim.tap(way); + sim_step_once(m_sim, way); m_displaced = !m_displaced; return m_sim.watch({SimWatcher::FLASH}, std::chrono::milliseconds(1000)).has_value(); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp index 0219b7b703..8694afd9e4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp @@ -616,6 +616,45 @@ UnitTestResult test_run_then_overworld(Logger&){ return "START never looked for the overworld in the dark cave."; } +// Regression: console B, 2026-11-09, 00.28 hiker_lenny in Rock Tunnel +// without Flash: the battle won with a move, then the dark cave's +// overworld, black but for the lit circle on the player, read as the +// text box with no HP box. The loop held B for an evolution three times +// for 20 s, and gave up: "no battle screen and no START menu for 60 s +// after the battle". A dark cave's overworld is no evolution: START looks +// for the overworld within seconds of the last HP box. +UnitTestResult test_trainer_win_then_dark_cave(Logger&){ + const BattleTextScreen defeated{.text_box = true, .hp_box = true, .arrow = true, .yes_no = false}; + const BattleTextScreen dark_cave{.text_box = true, .hp_box = false, .arrow = false, .yes_no = false, .dark_cave = true}; + std::vector polls{{.ms = 0, .screen = defeated}}; + for (int ms = 300; ms <= 1500; ms += 300){ + polls.push_back({.ms = ms, .screen = defeated}); + } + for (int ms = 1800; ms <= 60000; ms += 300){ + polls.push_back({.ms = ms, .screen = dark_cave}); + } + + Supplies supplies; + EvolutionHold hold(supplies.lead); + BattleEndWatch watch; + const WallClock start = current_time(); + hold.turn_taken(TurnChoice::MOVE); + for (const LoopPoll& poll : polls){ + const WallClock now = start + std::chrono::milliseconds(poll.ms); + if (hold.holds(poll.screen)){ + return std::format("Held B for an evolution at {} ms, on the dark cave's overworld.", poll.ms); + } + watch.seen(poll.screen.hp_box || poll.screen.yes_no ? BattleSeen::BATTLE : BattleSeen::NONE, now); + if (watch.check_overworld(now)){ + if (poll.ms > 5000){ + return std::format("START first looked for the overworld at {} ms.", poll.ms); + } + return true; + } + } + return "START never looked for the overworld in the dark cave."; +} + // START only once the battle is gone for a while, and then not at every // poll; A and B blind for at most 30 s since the last battle menu. UnitTestResult test_end_watch_timing(Logger&){ @@ -923,6 +962,7 @@ const AutoStoryPartyTestCase AUTO_STORY_PARTY_CASES[] = { {.input = {.name = "summary members: fewer moves, unread PP, strongest attack", .body = test_summary_members}, .expectation = {.description = "handles party members with partial move data"},}, {.input = {.name = "no B during an evolution", .body = test_no_b_during_evolution}, .expectation = {.description = "prevents cancelling evolution sequence"},}, {.input = {.name = "RUN, then a dark cave: no evolution wait, START soon", .body = test_run_then_overworld}, .expectation = {.description = "handles post-battle overworld transition in dark cave"},}, + {.input = {.name = "a trainer beaten, then a dark cave without Flash: no evolution wait, START soon", .body = test_trainer_win_then_dark_cave}, .expectation = {.description = "takes a dark cave's overworld after a win for no evolution"},}, {.input = {.name = "battle end: START after the battle is gone, A and B for 30 s at most", .body = test_end_watch_timing}, .expectation = {.description = "verifies timing constraints on battle end loop"},}, {.input = {.name = "attacks by matchup: Bite on the rival's Bulbasaur and Misty", .body = test_attack_by_matchup}, .expectation = {.description = "selects optimal attack move based on type matchup"},}, {.input = {.name = "attacks against an unknown foe: the plan's order", .body = test_attack_unknown_foe}, .expectation = {.description = "falls back to default move preference order against unknown foe"},}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.cpp new file mode 100644 index 0000000000..f0004cf491 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.cpp @@ -0,0 +1,87 @@ +/* AutoStory Resources + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "PokemonFRLG_AutoStoryResources.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +const std::string_view PATHS[] = { + // Overworld/PokemonFRLG_PlayerFacingReader.cpp + "PokemonFRLG/PlayerSprites/red_normal.png", + + // Inference/PokemonFRLG_SelectionArrowDetector.cpp + "PokemonFRLG/SelectionArrow.png", + // Inference/PokemonFRLG_BattleSelectionArrowDetector.cpp + "PokemonFRLG/BattleSelectionArrow.png", + // Inference/PokemonFRLG_PokedexRegisteredDetector.cpp + "PokemonFRLG/PokedexButtonA.png", + // Inference/Menus/PokemonFRLG_PartyHeldItemDetector.cpp + "PokemonFRLG/PartyHeldItem.png", + // Inference/PokemonFRLG_StatsReader.cpp + "PokemonFRLG/NatureCheckerOCR.json", + + // Inference/Box/PokemonFRLG_BoxSelectionModeDetector.cpp + "PokemonFRLG/Box/SelectionHand_White.png", + "PokemonFRLG/Box/SelectionHand_White2.png", + "PokemonFRLG/Box/SelectionHand_White_Inverted.png", + "PokemonFRLG/Box/SelectionHand_Gold.png", + "PokemonFRLG/Box/SelectionHand_Gold2.png", + "PokemonFRLG/Box/SelectionHand_Gold_Inverted.png", + "PokemonFRLG/Box/SelectionHand_Closed_White.png", + "PokemonFRLG/Box/SelectionHand_Closed_Gold.png", + + // Inference/PokemonFRLG_DigitReader.cpp, digits 0 to 9 of each box. + "PokemonFRLG/Digits/0.png", "PokemonFRLG/Digits/1.png", "PokemonFRLG/Digits/2.png", + "PokemonFRLG/Digits/3.png", "PokemonFRLG/Digits/4.png", "PokemonFRLG/Digits/5.png", + "PokemonFRLG/Digits/6.png", "PokemonFRLG/Digits/7.png", "PokemonFRLG/Digits/8.png", + "PokemonFRLG/Digits/9.png", + "PokemonFRLG/LevelDigits/0.png", "PokemonFRLG/LevelDigits/1.png", "PokemonFRLG/LevelDigits/2.png", + "PokemonFRLG/LevelDigits/3.png", "PokemonFRLG/LevelDigits/4.png", "PokemonFRLG/LevelDigits/5.png", + "PokemonFRLG/LevelDigits/6.png", "PokemonFRLG/LevelDigits/7.png", "PokemonFRLG/LevelDigits/8.png", + "PokemonFRLG/LevelDigits/9.png", + "PokemonFRLG/DialogDigits/0.png", "PokemonFRLG/DialogDigits/1.png", "PokemonFRLG/DialogDigits/2.png", + "PokemonFRLG/DialogDigits/3.png", "PokemonFRLG/DialogDigits/4.png", "PokemonFRLG/DialogDigits/5.png", + "PokemonFRLG/DialogDigits/6.png", "PokemonFRLG/DialogDigits/7.png", "PokemonFRLG/DialogDigits/8.png", + "PokemonFRLG/DialogDigits/9.png", + + // Inference/Sounds/PokemonFRLG_ShinySoundDetector.cpp and + // PokemonFRLG_CatchFanfareDetector.cpp, one per sample rate. + "PokemonFRLG/ShinySound-44100.wav", + "PokemonFRLG/ShinySound-48000.wav", + "PokemonFRLG/CatchFanfare-44100.wav", + "PokemonFRLG/CatchFanfare-48000.wav", + + // Resources/PokemonFRLG_PokemonSprites.cpp + "PokemonFRLG/PokemonSprites.png", + "PokemonFRLG/PokemonSpritesFlipped.png", + "PokemonFRLG/PokemonSprites.json", + + // Programs/RngManipulation/PokemonFRLG_SeedsDatabase.cpp, for the + // story's RNG gifts. + "PokemonFRLG/RngSeeds/FR_Eng.json", + "PokemonFRLG/RngSeeds/FR_Jpn.json", + "PokemonFRLG/RngSeeds/LG_Eng.json", + "PokemonFRLG/RngSeeds/LG_Jpn.json", +}; + +} // namespace + + +const std::vector& autostory_resource_paths(){ + static const std::vector LIST(std::begin(PATHS), std::end(PATHS)); + return LIST; +} + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.h new file mode 100644 index 0000000000..818fa41e34 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.h @@ -0,0 +1,35 @@ +/* AutoStory Resources + * + * From: https://github.com/PokemonAutomation/ + * + * The files under the Resources folder that AutoStory, and the readers + * and detectors it calls, load while a program runs. A deployment whose + * Packages lack one of them stops mid-story with a FileException, so the + * test checks every one of them exists. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryResources_H +#define PokemonAutomation_PokemonFRLG_AutoStoryResources_H + +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Paths relative to RESOURCE_PATH(). When a reader or detector that +// AutoStory uses starts loading a new file, add it here. +const std::vector& autostory_resource_paths(); + +// Adds the test that every path above is under RESOURCE_PATH(). +void add_tests_AutoStoryResources(UnitTestDatabase& database); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources_Tests.cpp new file mode 100644 index 0000000000..e49c7efce9 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources_Tests.cpp @@ -0,0 +1,78 @@ +/* AutoStory Resources Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Checks that every file in autostory_resource_paths() is in the + * Resources folder the program runs with. + * + */ + +#include +#include +#include +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "PokemonFRLG_AutoStoryResources.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +namespace{ + +struct ResourcesInput{ + const char* name; + const std::vector& (*paths)(); +}; +struct ResourcesExpectation{ + // The paths that may be missing; none, for a list a program loads. + size_t missing; +}; +struct ResourcesCase{ + ResourcesInput input; + ResourcesExpectation expectation; +}; + +class Test_AutoStoryResources : public UnitTest{ +public: + explicit Test_AutoStoryResources(ResourcesCase test_case) + : UnitTest(std::string("PokemonFRLG::AutoStoryResources - ") + test_case.input.name) + , m_case(test_case) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + std::string missing; + size_t count = 0; + for (std::string_view path : m_case.input.paths()){ + if (!Filesystem::exists(Filesystem::Path(RESOURCE_PATH() + std::string(path)))){ + missing += "\n " + std::string(path); + count++; + } + } + if (count == m_case.expectation.missing){ + return true; + } + return std::to_string(count) + " missing under " + RESOURCE_PATH() + ":" + missing; + } + +private: + ResourcesCase m_case; +}; + +const ResourcesCase RESOURCES_CASES[] = { + {.input = {"every file AutoStory loads is in Resources", autostory_resource_paths}, .expectation = {.missing = 0}}, +}; + +} // namespace + + +void add_tests_AutoStoryResources(UnitTestDatabase& database){ + for (const ResourcesCase& item : RESOURCES_CASES){ + database.add(item); + } +} + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index 791fe18e85..ff86b55359 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -14,6 +14,7 @@ #include "Common/Cpp/Exceptions.h" #include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryBattleDecisions.h" #include "PokemonFRLG_AutoStoryRowSim.h" namespace PokemonAutomation{ @@ -378,6 +379,79 @@ Pokemon::StoryAction OverworldStepSim::action(StoryActionId id){ }); } +namespace{ + +// fight_battle()'s end on the overworld the sim shows after a battle won +// with a move: the battle loop's polls replayed through its EvolutionHold +// and BattleEndWatch, as BattleLoop::step() takes them. The overworld of a +// dark cave reads as the battle's text box with no HP box (console B, +// 2026-11-09, 00.28 hiker_lenny: three 20 s holds for an evolution, then +// "no battle screen and no START menu for 60 s after the battle"). Empty +// once START would look for the overworld, else the loop's failure. +std::string battle_end_on_sim(const OverworldSim& sim){ + constexpr Milliseconds POLL(300); + // The battle as the loop saw it: a Potion turn's bag, where START + // looked for the overworld and found none (as hiker_lenny's turns 2, + // 4 and 5), then the last turn's text up to the win. + constexpr Milliseconds BAG(4000); + constexpr Milliseconds LAST_TEXT(10000); + // The loop gives up on the overworld after this (BattleEndWatch's + // NO_OVERWORLD); a little longer, so its failure shows. + constexpr Milliseconds GIVE_UP(65000); + const WallClock start{}; + Supplies supplies; + EvolutionHold hold(supplies.lead); + BattleEndWatch watch; + watch.seen(BattleSeen::MENU, start); + Milliseconds at(0); + for (; at <= BAG; at += POLL){ + watch.seen(BattleSeen::NONE, start + at); + if (watch.check_overworld(start + at)){ + break; + } + } + hold.turn_taken(TurnChoice::MOVE); + for (; at <= LAST_TEXT; at += POLL){ + watch.seen(BattleSeen::BATTLE, start + at); + } + const bool dark = sim.state().dark; + const BattleTextScreen overworld{.text_box = dark, .hp_box = false, .arrow = false, .yes_no = false, .dark_cave = dark}; + for (; at <= LAST_TEXT + GIVE_UP; at += POLL){ + if (watch.overworld_lost(start + at)){ + return "fight_battle(): no battle screen and no START menu for 60 s after the battle."; + } + if (hold.holds(overworld)){ + hold.held(EvolutionHold::MAX_HOLD, false); + at += EvolutionHold::MAX_HOLD; + continue; + } + watch.seen(BattleSeen::NONE, start + at); + if (watch.check_overworld(start + at)){ + return ""; + } + } + return "fight_battle(): START never looked for the overworld."; +} + +// wait_out_battle_exit() on `sim`: each look shows the sim's screens for +// BETWEEN_STEPS_LOOK. +void sim_wait_out_battle_exit(OverworldSim& sim){ + Milliseconds from = sim.state().time; + const auto look = [&sim, &from](bool fresh){ + if (fresh){ + from = sim.state().time; + } + sim.watch({}, BETWEEN_STEPS_LOOK); + return Pokemon::ScreenLook{ + .log = screen_event_log(sim.state(), from), + .window = {.start = sim_wall_clock(from), .end = sim_wall_clock(sim.state().time)}, + }; + }; + wait_for_quiet_overworld(look, MovementMechanics::AFTER_BATTLE_QUIET, BATTLE_EXIT_TIMEOUT); +} + +} // namespace + // A ROUTE or LEGS by the sim's walker, fighting a trainer who sees the // player on the way. std::string OverworldStepSim::walk(const StoryStep& step){ @@ -387,7 +461,9 @@ std::string OverworldStepSim::walk(const StoryStep& step){ return ""; } const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = MapPosition{m_at.map, m_at.tile}, .label = step.name}; - const std::string failure = route ? OverworldSimWalker(m_sim).run(*route, planning) : "no legs."; + SimWalkerRules rules; + rules.trainer_battle_end = battle_end_on_sim; + const std::string failure = route ? OverworldSimWalker(m_sim, rules).run(*route, planning) : "no legs."; m_at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : m_at; return failure; } @@ -400,13 +476,20 @@ std::string OverworldStepSim::step_ahead(const StoryStep& step){ return step_onto(step); } const auto tap = [this, &step]{ - if (m_sim.state().facing != step.direction){ - m_sim.tap(step.direction); - } - m_sim.tap(step.direction); + sim_step_once(m_sim, step.direction); }; + // A wild battle, with no message before it, is run from, and the + // stepping goes on, as step_into_line() does. const auto seen = [this]{ - return m_sim.watch({SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, TRAINER_LINE_WATCH).has_value(); + const std::optional shown = m_sim.watch( + {SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, std::max(TRAINER_LINE_WATCH, WILD_BATTLE_IN_LINE_WATCH) + ); + if (shown && shown->watcher == 1){ + m_sim.handle_battle(SimBattleChoice::RUN); + sim_wait_out_battle_exit(m_sim); + return false; + } + return shown.has_value(); }; const int tiles = step.variant == TRAINER_LINE_VARIANT ? TRAINER_LINE_TILES : 1; return step_until_seen(tiles, tap, seen) ? "" : "no trainer saw the player."; @@ -414,10 +497,7 @@ std::string OverworldStepSim::step_ahead(const StoryStep& step){ // A hop() or step_onto(): one step, ending where the table says. std::string OverworldStepSim::step_onto(const StoryStep& step){ - if (m_sim.state().facing != step.direction){ - m_sim.tap(step.direction); - } - m_sim.tap(step.direction); + sim_step_once(m_sim, step.direction); m_at = *story_pose(*step.pose_after); return m_sim.state().position == MapPosition{m_at.map, m_at.tile} ? "" : "expected to end on " + to_string(MapPosition{m_at.map, m_at.tile}) + "."; } @@ -426,12 +506,12 @@ std::string OverworldStepSim::step_onto(const StoryStep& step){ // tile. std::string OverworldStepSim::cut(const StoryStep& step){ if (m_sim.state().facing != step.direction){ - m_sim.tap(step.direction); + sim_turn_to(m_sim, step.direction); } if (!m_sim.cut_tree()){ return "no tree to cut."; } - m_sim.tap(step.direction); + sim_step_once(m_sim, step.direction); m_at = StoryPose{m_at.map, m_at.tile.next(step.direction), step.direction}; if (!(m_sim.state().position == MapPosition{m_at.map, m_at.tile})){ return "expected to step onto the tree's tile " + to_string(MapPosition{m_at.map, m_at.tile}) + "."; @@ -440,11 +520,14 @@ std::string OverworldStepSim::cut(const StoryStep& step){ } // A battle from the trainer's message, which must be up, ending where the -// table says. +// table says, the battle loop's end played on the overworld after it. std::string OverworldStepSim::battle(const StoryStep& step){ if (!m_sim.close_message() || m_sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ return "no_battle."; } + if (const std::string failure = battle_end_on_sim(m_sim); !failure.empty()){ + return failure; + } m_at = *story_pose(*step.pose_after); if (!(m_sim.state().position == MapPosition{m_at.map, m_at.tile})){ return "expected " + to_string(MapPosition{m_at.map, m_at.tile}) + "."; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp index 39230fefbc..e595c875e0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -138,7 +138,7 @@ class Test_RunPlan : public UnitTest{ const RunPlanScenario m_scenario; }; -const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27"; +const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27 00.28 00.29"; const RunPlanScenario RUN_PLAN_CASES[] = { { @@ -229,11 +229,11 @@ const RunPlanScenario RUN_PLAN_CASES[] = { }, { .input = {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11"}, - .expectation = {.expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27"}, + .expectation = {.expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27 00.28 00.29"}, }, { .input = {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b"}, - .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27"}, + .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27 00.28 00.29"}, }, { .input = { .name = "Gift RNG: started at 00.12b, from the save beside the salesman", @@ -258,7 +258,7 @@ const RunPlanScenario RUN_PLAN_CASES[] = { }, { .input = {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}}, - .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27"}, + .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27 00.28 00.29"}, }, { .input = { .name = "Starter RNG first, with both on", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.cpp index 85edb74b36..de1f0072c7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.cpp @@ -4,6 +4,7 @@ * */ +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" #include "PokemonFRLG_AutoStoryScreenEvents.h" namespace PokemonAutomation{ @@ -26,6 +27,10 @@ ScreenEvents FrlgScreenObserver::observe(const VideoSnapshot& frame, WallClock){ return {}; } const ImageViewRGB32& screen = *frame.frame; + if (OverworldWalkSteps::dark_cave_screen(screen)){ + // Its black below passes for a battle's text box. + return {}; + } ScreenEvents out = Pokemon::fade_events(Pokemon::fade_of_screen(screen)); if (Pokemon::fade_in(out) != Pokemon::WarpFade::NONE){ // A uniform screen has no box or menu to read. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.h index d6403a765f..63cfa22307 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.h @@ -25,6 +25,8 @@ namespace PokemonFRLG{ // - CHOICE_BOX: a Yes/No or answer box beside it (SelectionDialogDetector); // - BATTLE_STARTED: the battle menu or a battle's text box; // - FADE_BLACK / FADE_WHITE: fade_of_screen(). +// Nothing on a dark cave's overworld (dark_cave_screen()), whose black +// the battle text box detector reads as a box. // Not reported yet, as FRLG has no detector for them here: the map-name // popup (LOCATION_BANNER), PLAYER_CAN_MOVE, SCREEN_STILL, the interact // prompt (FRLG has none) and a battle's end. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents_Tests.cpp index 7f8182bccc..4ed0f3d1a5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents_Tests.cpp @@ -29,6 +29,9 @@ struct ScreenEventsInput{ std::string name; // The frame's colour; 0 for no frame. uint32_t color; + // The colour of a square in the middle of the frame, around the + // player; 0 for none. + uint32_t middle = 0; }; struct ScreenEventsExpectation{ @@ -52,6 +55,13 @@ class Test_AutoStoryScreenEvents : public UnitTest{ if (m_case.input.color != 0){ ImageRGB32 image(1920, 1080); image.fill(m_case.input.color); + if (m_case.input.middle != 0){ + for (size_t y = 380; y < 700; y++){ + for (size_t x = 800; x < 1120; x++){ + image.pixel(x, y) = m_case.input.middle; + } + } + } frame = VideoSnapshot(std::move(image), WallClock()); } FrlgScreenObserver observer; @@ -82,6 +92,12 @@ const ScreenEventsCase SCREEN_EVENTS_CASES[] = { .input = {"a white screen is a white fade only", 0xffffffff}, .expectation = {{ScreenEvent::FADE_WHITE}}, }, + { + // Rock Tunnel without Flash (console B, 2026-11-09, 00.28): its + // black read as a battle's text box before each step. + .input = {"a dark cave, black but for a lit middle, shows nothing", 0xff000000, 0xff8c7c64}, + .expectation = {{}}, + }, }; void add_tests_AutoStoryScreenEvents(UnitTestDatabase& database){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp index 6685da4ef9..a874f2e22b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -349,10 +349,7 @@ std::string walk_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step) open_end = last.action == RouteAction::WALK && !last.to_wall && !last.warps; at = StoryPose{last.end.map, last.end.point, last.facing_after}; }else if (step.kind == StoryStepKind::STEP){ - if (sim.state().facing != step.direction){ - sim.tap(step.direction); - } - sim.tap(step.direction); + sim_step_once(sim, step.direction); sim.watch({SimWatcher::MESSAGE}, std::chrono::milliseconds(2000)); at = *story_pose(*step.pose_after); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.cpp index e8774ec4af..129905cbbd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.cpp @@ -98,6 +98,29 @@ Pokemon::ScreenLook ConsoleScreenLook::look(bool fresh){ } +bool quiet_overworld(const Pokemon::ScreenSettle& settle, Milliseconds quiet){ + return settle.seen && settle.showing == Pokemon::ScreenEvents{} && settle.quiet >= quiet; +} + +Pokemon::ScreenSettle wait_for_quiet_overworld( + const Pokemon::ScreenLookFunction& look, Milliseconds quiet, Milliseconds timeout +){ + Pokemon::ScreenLook seen = look(true); + Pokemon::ScreenSettle settle = Pokemon::judge_settle(seen); + // Each look takes about BETWEEN_STEPS_LOOK; the cap also ends a look + // that takes no time. + const size_t max_looks = (size_t)(timeout / BETWEEN_STEPS_LOOK) + 1; + for (size_t looks = 1; looks < max_looks && !quiet_overworld(settle, quiet); looks++){ + if (seen.window.end - seen.window.start >= timeout){ + break; + } + seen = look(false); + settle = Pokemon::judge_settle(seen); + } + return settle; +} + + void InterruptLogGuard::on_interrupt(const Pokemon::StoryStep& step, const Pokemon::InterruptCheck& check){ const bool cleared = check.outcome.succeeded(); m_logger.log(std::format( diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.h index d1d3e5d32a..454ad88000 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.h @@ -94,6 +94,20 @@ class ConsoleScreenLook{ WallClock m_start; }; +// Whether a look ended on FRLG's overworld at rest: nothing showing (no +// box, fade or battle; FrlgScreenObserver reports no PLAYER_CAN_MOVE, so +// Pokemon::is_settled() never holds) for at least `quiet`. +bool quiet_overworld(const Pokemon::ScreenSettle& settle, Milliseconds quiet); + +// Looks with `look`, fresh and then on from it, until it ends on +// quiet_overworld() for `quiet`, or has looked for `timeout`. Returns how +// the last look ended. After a battle, the next tap waits for this +// (MovementMechanics::AFTER_BATTLE_QUIET): one sent at its black's end +// is lost (BATTLE_EXIT_LOCKOUT). +Pokemon::ScreenSettle wait_for_quiet_overworld( + const Pokemon::ScreenLookFunction& look, Milliseconds quiet, Milliseconds timeout +); + // The console's guard: a failed step fails it, as before; each interrupt // found between steps is logged, cleared or not, and how the screen looked // before every step. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 3f88fe03e1..94437d8c5c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -791,7 +791,7 @@ const std::vector& story_checkpoints(){ running_shoes(), route3(), magikarp_salesman(), after_gift_rng(), magikarp_to_box(), mt_moon(), route4_to_cerulean(), cerulean_gym(), misty(), cerulean_grind(), cerulean_rival(), nugget_bridge(), route25(), bill(), back_to_cerulean(), oddish(), to_vermilion(), ss_anne(), - vermilion_gym(), to_lt_surge(), lt_surge(), bike_voucher(), route10_center(), + vermilion_gym(), to_lt_surge(), lt_surge(), bike_voucher(), route10_center(), rock_tunnel(), lavender_center(), }; return checkpoints; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h index 2778f00eb5..0e97ce995e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h @@ -44,8 +44,11 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database); void add_tests_AutoStoryVermilion(UnitTestDatabase& database); // Rows 00.23 to 00.25 on the overworld sim (PokemonFRLG_AutoStoryVermilionGym_Tests.cpp). void add_tests_AutoStoryVermilionGym(UnitTestDatabase& database); -// Rows from 00.26 on on the overworld sim (PokemonFRLG_AutoStoryCeladon_Tests.cpp). +// Rows from 00.26 on on the overworld sim (PokemonFRLG_AutoStoryCeladon_Tests.cpp), +// with add_tests_AutoStoryLavender(). void add_tests_AutoStoryCeladon(UnitTestDatabase& database); +// Rows from 00.28 on on the overworld sim (PokemonFRLG_AutoStoryLavender_Tests.cpp). +void add_tests_AutoStoryLavender(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp new file mode 100644 index 0000000000..9ba3bf882d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp @@ -0,0 +1,134 @@ +/* AutoStory Table: to Lavender Town + * + * From: https://github.com/PokemonAutomation/ + * + * Rows from 00.28 on: through Rock Tunnel, without Flash, and down Route + * 10 to Lavender Town's Pokemon Center. + * + */ + +#include "PokemonFRLG_AutoStoryTableSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryTable{ + + +// From Route 10's Pokemon Center through Rock Tunnel (pokefirered +// RockTunnel_1F, RockTunnel_B1F map.json and scripts.inc) to Route 10 +// south of it. The save is on Route 10 by the tunnel's south mouth: the +// save card's places don't include Rock Tunnel. +// - The tunnel's north mouth (8, 19) is reached from the Center through +// the tree (10, 20), cut from below; Heidi (9, 27) sees rows 28..29 +// below the trees and is left. Two single steps west then put the +// player below the mouth on (8, 20). +// - Every floor tile of the tunnel has wild encounters, which are run +// from (the table has no step that uses a Repel), so the legs end on +// walls, ladders or someone standing still, which the walker holds +// again after a battle. An open leg (two tiles or more, not to a wall) +// is followed only by legs a tile of error in it can't send astray. +// - Nine trainers stand where the way can't go round them. The others +// (1F Lucas, Oliver, Ariana, Leah; B1F Eric, Steve) are walked round. +// - 1F Ashton (27, 10) looks west over (24..26, 10), the only way south +// from the mouth's room: stepped into from (24, 9). +// - B1F Winston (29, 35) looks south over the south passage (29, +// 36..38): stepped into from (32, 37), after the leg down column 33 +// to the wall and a tile back. +// - B1F Martha (15, 33) looks east over (16..17, 33), the corridor +// north: stepped into from (17, 34), the wall east of row 34. +// - B1F Allen (36, 11) looks south over (36, 12..14), the way west to +// the ladder (33, 3): stepped into from (38, 12), below the wall. +// - 1F Lenny (7, 5) looks south over (7, 6..9), the way east from the +// ladder (4, 2): stepped into from (6, 9), the end of column 6. +// The way on to the ladder (20, 13) keeps west of Lucas's line (16..19, +// 17): down column 11, up column 14 to row 16. +// - B1F Sofia (11, 15) looks south over (11, 16..19), the way west: +// stepped into along row 16 from (12..14, 16). +// - B1F Dudley (5, 11) looks south over (5, 12..15): stepped into from +// (8, 15) along row 15. +// - B1F Cooper (2, 6) looks around (sight 2) by the ladder (2, 3): the +// leg up column 1 to the wall (1, 2) passes him on (1, 6); if he +// looks west, story_interrupts() fights him and the leg is held on +// to its wall, as Mt. Moon's Grunt in 00.13. +// - 1F Dana (26, 28) looks south over (26, 29..31), the only way west +// to the south mouth: stepped into along row 31 from (27..29, 31). +// - Unverified on the console: Rock Tunnel is dark without Flash, and +// the overworld, fade, dialog and battle detectors haven't been +// measured under its darkness (~/FRLGLab/notes/2026-11-09-0028-dark- +// measure.md). Every step from rock_tunnel_1f to route10_south depends +// on them. +StoryCheckpoint rock_tunnel(){ + return row("00.28", "Through Rock Tunnel: Ashton, Winston, Martha, Allen, Lenny, Sofia, Dudley, Cooper and Dana", at(ROUTE10_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("below_route10_tree", at(ROUTE10, 10, 21, NORTH), "out of the Center (13, 20), west along row 21 to the wall (9, 21), below the tree (10, 20)")), + cut_tree("cut_route10_tree", at(ROUTE10, 10, 20, NORTH), "the tree; a step onto its tile"), + step_onto("west_of_tree", WEST, at(ROUTE10, 9, 20, WEST), "a step west along row 20"), + step_onto("below_rock_tunnel", WEST, at(ROUTE10, 8, 20, WEST), "a step west, below the mouth (8, 19)"), + running(route("rock_tunnel_1f", at(ROCK_TUNNEL_1F, 17, 2, NORTH), "north into Rock Tunnel")), + running(legs("above_ashton_line", {{SOUTH, 6}, {EAST, 8}, {SOUTH, 1}, {WEST, 1}}, "down column 17 to the wall (17, 8), east along row 8 to (25, 8), onto row 9 and west to the wall (24, 9)")), + into_line("ashton_line", SOUTH, "onto (24, 10), in Ashton's line: he walks up to (25, 10)"), + battle("pokemaniac_ashton", at(ROCK_TUNNEL_1F, 24, 10, EAST), false, "Slowpoke, Slowpoke and Slowpoke"), + running(route("b1f_south_ladder", at(ROCK_TUNNEL_B1F, 38, 28, NORTH), "east along row 12 and up column 45 to the ladder (45, 2), down to B1F")), + running(legs("winston_line_end", {{WEST, 5}, {SOUTH, 10}, {NORTH, 1}, {WEST, 1}}, "west to the wall (33, 28), down column 33 to the wall (33, 38), up to (33, 37) and west to (32, 37)")), + into_line("winston_line", WEST, "along row 37 onto (29, 37), the end of Winston's line: he walks down to (29, 36)"), + battle("pokemaniac_winston", at(ROCK_TUNNEL_B1F, 29, 37, NORTH), false, "Slowpoke and Slowpoke"), + // The corner (11, 35), wall north and rock west, finds the player + // from any of (11..14, 35) after a battle; east to the wall (19, + // 35) did not (console B, 2026-11-09 13:35: found 5 tiles off). + running(legs("below_martha_line", {{WEST, 16}, {NORTH, 2}, {WEST, 2}, {EAST, 5}}, "west along row 37 to (13, 37), up to the wall (13, 35), west to the corner (11, 35), east to (16, 35)")), + into_line("martha_line", NORTH, "up column 16 onto (16, 33), beside Martha; from (17, 35), a tile short, up column 17 onto (17, 33), the end of her line"), + battle("picnicker_martha", at(ROCK_TUNNEL_B1F, 16, 33, WEST), false, "Oddish and Bulbasaur"), + running(legs("below_allen_line", {{EAST, 1}, {NORTH, 5}, {EAST, 12}, {NORTH, 9}, {EAST, 11}, {NORTH, 2}, {WEST, 2}, {NORTH, 5}}, "east to the wall (17, 33), up column 17 to the wall (17, 28), east to the wall (29, 28), up column 29 past Steve's line to the wall (29, 19), east to (40, 19), up to row 17, west to the wall (38, 17) and up to the wall (38, 12)")), + into_line("allen_line", WEST, "along row 12 onto (36, 12), below Allen"), + battle("hiker_allen", at(ROCK_TUNNEL_B1F, 36, 12, NORTH), false, "Geodude, Geodude and Graveler"), + running(legs("west_of_b1f_ladder", {{WEST, 4}, {NORTH, 10}}, "west along row 12 to the wall (32, 12), up column 32 to the wall (32, 2)")), + running(route("1f_west_ladder", at(ROCK_TUNNEL_1F, 4, 2, NORTH), "onto the ladder (33, 3), up to 1F")), + running(legs("above_lenny_line", {{EAST, 2}, {SOUTH, 7}}, "east to the wall (6, 2), down column 6 to the wall (6, 9)")), + into_line("lenny_line", EAST, "onto (7, 9), the end of Lenny's line: he walks down to (7, 8)"), + battle("hiker_lenny", at(ROCK_TUNNEL_1F, 7, 9, NORTH), false, "Machop and Onix"), + running(legs("west_of_lucas_line", {{EAST, 4}, {SOUTH, 15}, {EAST, 7}, {WEST, 4}, {NORTH, 8}, {EAST, 6}}, "east to the wall (11, 9), down column 11 to the wall (11, 24), east to the wall (18, 24), west to (14, 24), up column 14 to the wall (14, 16), east to the wall (20, 16)")), + running(route("b1f_middle_ladder", at(ROCK_TUNNEL_B1F, 27, 12, NORTH), "up column 20 onto the ladder (20, 13), down to B1F")), + running(legs("east_of_sofia_line", {{SOUTH, 3}, {WEST, 12}, {SOUTH, 1}, {WEST, 2}}, "down to the wall (27, 15), west along row 15 to the wall (15, 15), down to row 16 and west to (13, 16)")), + into_line("sofia_line", WEST, "along row 16 onto (11, 16), below Sofia"), + battle("picnicker_sofia", at(ROCK_TUNNEL_B1F, 11, 16, NORTH), false, "Pidgey, Pidgey and Pidgeotto"), + running(legs("east_of_dudley_line", {{WEST, 3}, {NORTH, 1}}, "west to the wall (8, 16), up to (8, 15)")), + into_line("dudley_line", WEST, "along row 15 onto (5, 15), the end of Dudley's line: he walks down to (5, 14)"), + battle("hiker_dudley", at(ROCK_TUNNEL_B1F, 5, 15, NORTH), false, "Geodude, Geodude and Graveler"), + running(legs("past_cooper", {{WEST, 4}, {NORTH, 13}, {SOUTH, 1}}, "west to the wall (1, 15), up column 1 past Cooper (2, 6) to the wall (1, 2), back to (1, 3)")), + running(route("1f_east_ladder", at(ROCK_TUNNEL_1F, 45, 21, NORTH), "east onto the ladder (2, 3), up to 1F")), + running(legs("above_dana_line", { + {WEST, 6}, {SOUTH, 5}, {EAST, 6}, {WEST, 1}, {SOUTH, 4}, {WEST, 5}, {SOUTH, 5}, + {WEST, 2}, {NORTH, 3}, {WEST, 6}, {NORTH, 3}, {WEST, 3}, {SOUTH, 2}, + }, "west to the wall (39, 21), down column 39 to Ariana (39, 27), east to the wall (45, 26), down column 44 to the wall (44, 30), west to the wall (39, 30), down to the wall (39, 35), west to (37, 35), up to the wall (37, 32), west to the wall (31, 32), up to (31, 29), west to (28, 29) and down to the wall (28, 31)")), + into_line("dana_line", WEST, "along row 31 onto (26, 31), the end of Dana's line: she walks down to (26, 30)"), + battle("picnicker_dana", at(ROCK_TUNNEL_1F, 26, 31, NORTH), false, "Meowth, Oddish and Pidgey"), + running(legs("above_south_mouth", {{WEST, 6}, {SOUTH, 1}, {WEST, 8}, {SOUTH, 5}, {EAST, 2}, {NORTH, 1}, {EAST, 5}, {WEST, 1}}, "west along row 31 to (20, 31), onto row 32 and west to the wall (12, 32), down column 12 to the wall (12, 37), east to the wall (14, 37), up to (14, 36), east to the wall (19, 36), back to (18, 36)")), + running(route("route10_south", at(ROUTE10, 8, 58, SOUTH), "onto the mouth (18, 37), out onto Route 10")), + }); +} + +// Route 10 south of Rock Tunnel (Route10 map.json) to Lavender Town's +// Pokemon Center. It has no grass. Carol (7, 60) and Clark (4, 62) look +// around, Trent (4, 68) looks west and Herman wanders row 70 (13..17), +// looking west and east (sight 2, to x 19): the way keeps to the east +// side, columns 17..21, and crosses row 70 on column 21. The ledges of +// row 62 leave a gap at (18, 62); row 66's leaves (21, 66). Row 74's +// ledge (11, 74) is hopped, and the hold south goes on over the +// connection into Lavender Town to the wall (11, 9). West of the +// Center's building, the wall (8, 9) sets the door's column. +StoryCheckpoint lavender_center(){ + return row("00.29", "Route 10 south to Lavender Town's Pokemon Center", at(ROUTE10, 8, 58, SOUTH), after_brock(), StartMenu::FULL, { + running(legs("route10_east_side", {{EAST, 10}, {NORTH, 2}, {WEST, 1}, {EAST, 1}, {SOUTH, 8}, {EAST, 3}, {SOUTH, 9}, {WEST, 10}}, "east along row 58 to (18, 58), up to the wall (18, 56), west to the wall (17, 56), back to (18, 56), down through the ledges' gap to (18, 64), east to the wall (21, 64), down column 21 to the wall (21, 73), west along row 73 to the wall (11, 73)")), + hop("route10_ledge_hop", SOUTH, at(ROUTE10, 11, 75, SOUTH), "down the ledge (11, 74)"), + running(route("lavender_north_wall", at(LAVENDER, 11, 8, SOUTH), "down column 11 over the connection into Lavender Town, to the wall (11, 9)")), + running(legs("below_center_door", {{WEST, 6}, {SOUTH, 1}, {EAST, 2}, {WEST, 1}}, "west along row 8 to (5, 8), onto row 9, east to the wall (7, 9), west to (6, 9)")), + running(route("lavender_nurse", at(LAVENDER_CENTER, 7, 4, NORTH), "up column 6 onto the Center's door (6, 5), to the nurse")), + heal("heal_lavender"), + }); +} + + +} // namespace StoryTable +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h index 8775379b84..d0a314a2ac 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h @@ -101,6 +101,14 @@ inline constexpr const char* ROUTE9 = "Route9"; inline constexpr const char* ROUTE10 = "Route10"; // Route 10 Pokemon Center map. inline constexpr const char* ROUTE10_CENTER = "Route10_PokemonCenter_1F"; +// Rock Tunnel 1F map. +inline constexpr const char* ROCK_TUNNEL_1F = "RockTunnel_1F"; +// Rock Tunnel B1F map. +inline constexpr const char* ROCK_TUNNEL_B1F = "RockTunnel_B1F"; +// Lavender Town overworld map. +inline constexpr const char* LAVENDER = "LavenderTown"; +// Lavender Town Pokemon Center map. +inline constexpr const char* LAVENDER_CENTER = "LavenderTown_PokemonCenter_1F"; // Constructs pose at map coordinates. Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt); @@ -187,6 +195,10 @@ StoryCheckpoint lt_surge(); StoryCheckpoint bike_voucher(); // Route 10 Pokemon Center navigation checkpoint. StoryCheckpoint route10_center(); +// Through Rock Tunnel to Route 10 south of it (PokemonFRLG_AutoStoryTableLavender.cpp). +StoryCheckpoint rock_tunnel(); +// Route 10 south to Lavender Town's Pokemon Center. +StoryCheckpoint lavender_center(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp index 4512a610ac..da54867cd9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -230,8 +230,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 34 || checkpoints[0].start){ - return "Expected 34 checkpoints, the first from a new game."; + if (checkpoints.size() != 36 || checkpoints[0].start){ + return "Expected 36 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -600,7 +600,7 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set