diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.cpp index 4c89385dbb..9b8a5c1212 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.cpp @@ -70,6 +70,11 @@ class ItemNameMatcher : public OCR::SmallDictionaryMatcher{ {"parlyz-heal", "Parlyz Heal"}, {"awakening", "Awakening"}, {"poke-ball", "Poke Ball"}, + {"repel", "Repel"}, + {"super-repel", "Super Repel"}, + {"max-repel", "Max Repel"}, + {"escape-rope", "Escape Rope"}, + {"bicycle", "Bicycle"}, {"cancel", "Cancel"}, }){ JsonArray names; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h index 0b36e39959..6565fcbfdd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h @@ -36,7 +36,8 @@ ImageFloatBox item_list_name_box(int row); ImageFloatBox bag_item_count_box(int row); // The item named in row `row`: a slug such as "potion", "antidote", -// "parlyz-heal", "awakening", "poke-ball", or "cancel" for the list's last row, in a +// "parlyz-heal", "awakening", "poke-ball", "repel", +// "super-repel", "max-repel", "escape-rope", "bicycle", or "cancel" for the list's last row, in a // game in `language`; empty if it can't be read, as in a language the // reader has no names for. std::string read_item_list_name(Logger& logger, Language language, const ImageViewRGB32& screen, int row); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp index 995ae469e7..6401dd7cc9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp @@ -76,6 +76,7 @@ class PlaceNameMatcher : public OCR::SmallDictionaryMatcher{ {"route-10", "Route 10"}, {"lavender-town", "Lavender Town"}, {"vermilion-city", "Vermilion City"}, + {"celadon-city", "Celadon City"}, }){ JsonArray names; names.push_back(name); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_BikeSpinMechanics.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_BikeSpinMechanics.h new file mode 100644 index 0000000000..ae9283b104 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_BikeSpinMechanics.h @@ -0,0 +1,68 @@ +/* Bicycle and Spinner Mechanics + * + * From: https://github.com/PokemonAutomation/ + * + * How FireRed and LeafGreen move the player on the Bicycle and on spinner + * tiles, as measured on the console (PokemonFRLG_MovementMechanics.h has + * walking and running). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_BikeSpinMechanics_H +#define PokemonAutomation_PokemonFRLG_BikeSpinMechanics_H + +#include "Common/Cpp/Time.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace MovementMechanics{ + + +// Riding the Bicycle, measured on console B (2026-11-09, Lavender Town, +// east from the rock west of the Center, from rest, counted back with +// one-tile taps): holds of 450, 500, 650, 700, 900, 950, 1100, 1250, +// 1300, 1450 and 1500ms moved 5, 5, 7, 7, 9, 10, 11, 13, 13, 15 and 15 +// tiles, the same each time: a tile per 100ms, so an open leg's hold of +// (n - 1) tiles and a half moves n. 112ms a tile (a wall-to-wall time +// of the motion on screen) held "west 16 (open)" for 17 or 18 tiles on +// Route 8 (00.30 run 2). Not the decompilation's 4 frames a tile (67ms): +// the bike speeds up from rest. +inline constexpr Milliseconds BIKE_TILE_TIME(100); + +// On the Bicycle a tap never only turns the player: from rest, a 40ms tap +// moved a tile facing either way, and holds of 112, 150, 200, 250, 300, +// 336 and 400ms moved 1, 2, 2, 3, 3, 4 and 4 tiles (console B, +// 2026-11-09, Route 12's north pier). So a bike leg has no turn tap: its +// hold turns the player too, and loses no tile for it. +inline constexpr bool BIKE_TAP_ONLY_TURNS = false; + +// Chains of 40ms taps on the Bicycle (console B, 2026-11-09): one tap per +// 100ms (60ms release) moved exactly a tile each. +inline constexpr Milliseconds BIKE_TAP_PERIOD_WORKED(100); + +// At one tap per 60ms, one tap in 12 was dropped. Somewhere between that +// and BIKE_TAP_PERIOD_WORKED the game starts dropping taps; the sim drops +// taps that start sooner than this after the last one. +inline constexpr Milliseconds BIKE_TAP_DROP_PERIOD(80); + +// Of the taps that come sooner than BIKE_TAP_DROP_PERIOD, the sim drops +// one in this many. +inline constexpr int BIKE_FAST_TAP_DROP_EVERY = 12; + +// With the Bicycle registered (Key Items, BICYCLE, REGISTER), SELECT gets +// on and off in about 1s (console B, 2026-11-09). Going through a door +// takes the player off (not measured); a battle leaves them on. +inline constexpr Milliseconds BIKE_SELECT_TIME(1000); + +// A spinner tile (Rocket Hideout, Viridian Gym) slides the player a tile +// per 8 frames (pokefirered PlayerGoSpin(): MOVE_SPEED_FAST_1). Not +// measured on the console. +inline constexpr Milliseconds SPIN_TILE_TIME(133); + + +} // namespace MovementMechanics +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_BikeSpinPresses.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_BikeSpinPresses.h new file mode 100644 index 0000000000..e58d996e90 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_BikeSpinPresses.h @@ -0,0 +1,55 @@ +/* Bicycle and Spinner Presses + * + * From: https://github.com/PokemonAutomation/ + * + * The presses the walker sends on the Bicycle and after a slide, from + * PokemonFRLG_BikeSpinMechanics.h. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_BikeSpinPresses_H +#define PokemonAutomation_PokemonFRLG_BikeSpinPresses_H + +#include "Common/Cpp/Time.h" +#include "PokemonFRLG_BikeSpinMechanics.h" +#include "PokemonFRLG_MovementMechanics.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace MovementMechanics{ + + +// An open leg on the Bicycle is counted out in taps, each a tile: a 40ms +// press each. +inline constexpr Milliseconds BIKE_TAP(40); + +// The taps' period, start to start, slower than the chains that worked, +// so none is dropped. +inline constexpr Milliseconds BIKE_TAP_PERIOD(120); + +// After a tap, how long until the screen shows whether it moved: the +// tile's 100ms and video lag. The Command File Runner's tap check judged +// its first tap too early, as unmoved. Not measured: a guess. +inline constexpr Milliseconds BIKE_TAP_SETTLE(250); + +// SELECT, pressed to get on the registered Bicycle. +inline constexpr Milliseconds BIKE_SELECT_PRESS(160); + +// The wait after SELECT for the player to be up on the Bicycle. +inline constexpr Milliseconds BIKE_SELECT_SETTLE(1200); + +// The wait after a slide's last tile before the next press: the spin's +// last frames and the facing it leaves. Not measured. +inline constexpr Milliseconds SPIN_STOP_SETTLE(300); + +static_assert(BIKE_TAP_PERIOD > BIKE_TAP_PERIOD_WORKED && BIKE_TAP_PERIOD_WORKED > BIKE_TAP_DROP_PERIOD); +static_assert(BIKE_TAP < BIKE_TILE_TIME && BIKE_TAP <= LONGEST_TAP); +static_assert(BIKE_SELECT_SETTLE > BIKE_SELECT_TIME); + + +} // namespace MovementMechanics +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp new file mode 100644 index 0000000000..d9a0957b8c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp @@ -0,0 +1,213 @@ +/* Coded Rooms and Tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "PokemonFRLG_CodedRooms.h" +#include "PokemonFRLG_HideoutLiftRoom.h" +#include "PokemonFRLG_PokemonCenterRoom.h" +#include "PokemonFRLG_OverworldMaps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// A floor's spinner tiles, by the way each slides the player. +struct SpinnerFloor{ + std::string_view map; + std::vector north; + std::vector south; + std::vector west; + std::vector east; + std::vector stop; +}; + +// Read off pokefirered: each floor's layout (data/layouts/RocketHideout_ +// B2F, _B3F map.bin, primary tileset gTileset_Building, secondary +// gTileset_SilphCo) with each metatile's behaviour from the tilesets' +// metatile_attributes.bin: MB_SPIN_UP (0x56), MB_SPIN_DOWN (0x57), +// MB_SPIN_LEFT (0x55), MB_SPIN_RIGHT (0x54), MB_STOP_SPINNING (0x58). +const std::vector& spinner_floors(){ + static const std::vector FLOORS{ + { + "RocketHideout_B2F", + {{9, 5}, {11, 6}, {7, 7}, {11, 8}, {15, 9}, {7, 10}, {9, 10}, {3, 11}, {15, 11}, {13, 12}, {15, 13}, {9, 14}, {5, 17}, {7, 18}, {5, 19}}, + {{12, 5}, {8, 9}, {10, 9}, {14, 11}, {10, 13}, {8, 17}}, + {{3, 4}, {7, 4}, {9, 4}, {11, 4}, {16, 5}, {16, 6}, {12, 13}, {3, 14}, {7, 14}, {12, 14}, {12, 17}, {12, 18}}, + {{3, 6}, {12, 7}, {4, 9}, {3, 10}, {10, 11}, {12, 11}, {9, 12}, {11, 12}, {9, 20}}, + {{1, 4}, {7, 6}, {13, 7}, {15, 8}, {13, 10}, {8, 11}, {14, 13}, {1, 14}, {5, 15}, {10, 15}, {8, 19}, {13, 20}}, + }, + { + "RocketHideout_B3F", + {{8, 11}, {10, 14}, {6, 15}, {6, 17}, {4, 18}}, + {{9, 10}, {7, 16}}, + {{4, 9}}, + {{6, 9}, {2, 11}, {6, 13}, {5, 14}, {7, 14}, {4, 15}, {3, 16}, {2, 17}}, + {{2, 9}, {8, 9}, {6, 11}, {10, 13}, {9, 14}, {7, 20}}, + }, + }; + return FLOORS; +} + +const SpinnerFloor* spinner_floor(std::string_view map){ + for (const SpinnerFloor& floor : spinner_floors()){ + if (floor.map == map){ + return &floor; + } + } + return nullptr; +} + +bool contains(const std::vector& tiles, const TilePos& point){ + return std::any_of(tiles.begin(), tiles.end(), [&](const TilePos& tile){ + return tile.x == point.x && tile.y == point.y; + }); +} + +bool has_object(const MapData& map, std::string_view name){ + return std::any_of(map.objects.begin(), map.objects.end(), [&](const MapObject& object){ + return object.name == name; + }); +} + +MapObject object(std::string name, Pokemon::ObjectKind kind, const TilePos& tile){ + MapObject out; + out.name = std::move(name); + out.kind = kind; + out.x = tile.x; + out.y = tile.y; + return out; +} + +// LAYOUT_POKEMON_CENTER_1F's collision and elevation, as the map lock's +// seven Centers (PokemonSimulation 6c2f5c8, data/games/frlg/maps/runtime/ +// *_PokemonCenter_1F.json) all have them. The mat's warps: (6..8, 8), +// the middle one MB_SOUTH_ARROW_WARP-like ("walk_south"); the stairs to +// 2F at (1, 6), which isn't loaded. +constexpr std::string_view CENTER_ROOM_JSON = R"({ + "name": "PokemonCenter_1F", + "source": {"decomp_file": "data/layouts/PokemonCenter_1F/map.bin", "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-11-09"}, + "allow_running": false, + "tiles": ["#.#############", "###############", "....###..##....", "....#######....", "...............", "...............", "#..........##..", "...........##..", "...............", "###############"], + "elevation": ["030000000000000", "000000000000000", "333300033003333", "333300000003333", "333333333333333", "330333333333333", "044033333330033", "330333333330033", "333333333333333", "000000000000000"], + "points": {"warp_0": [6, 8], "warp_1": [7, 8], "warp_2": [8, 8], "nurse_front": [7, 4]}, + "warps": [ + {"x": 6, "y": 8, "enter": "walk_into"}, + {"x": 7, "y": 8, "enter": "walk_south"}, + {"x": 8, "y": 8, "enter": "walk_into"}, + {"x": 1, "y": 6, "enter": "step_on"} + ], + "objects": [{"name": "nurse", "kind": "npc", "x": 7, "y": 2}] +})"; + +// LAYOUT_ROCKET_HIDEOUT_ELEVATOR's collision (map.bin: rows 0 to 2 and 6 +// walls, 3 to 5 floor), the panel's bg event at (0, 2), and warp 1 at +// (2, 5), the one MB_SOUTH_ARROW_WARP tile (warp 0 at (1, 5) has no +// behaviour, so never fires). +constexpr std::string_view LIFT_ROOM_JSON = R"({ + "name": "RocketHideout_Elevator", + "source": {"decomp_file": "data/layouts/RocketHideout_Elevator/map.bin", "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-11-09"}, + "tiles": ["#####", "#####", "S####", ".....", ".....", ".....", "#####"], + "points": {"warp_0": [1, 5], "warp_1": [2, 5]}, + "warps": [{"x": 2, "y": 5, "enter": "walk_south"}] +})"; + +} // namespace + + +std::optional coded_spin_direction(std::string_view map, const TilePos& point){ + const SpinnerFloor* floor = spinner_floor(map); + if (floor == nullptr){ + return std::nullopt; + } + if (contains(floor->north, point)){ + return Direction::NORTH; + } + if (contains(floor->south, point)){ + return Direction::SOUTH; + } + if (contains(floor->west, point)){ + return Direction::WEST; + } + if (contains(floor->east, point)){ + return Direction::EAST; + } + return std::nullopt; +} + +bool coded_spin_stop(std::string_view map, const TilePos& point){ + const SpinnerFloor* floor = spinner_floor(map); + return floor != nullptr && contains(floor->stop, point); +} + + +void add_coded_objects(MapData& map){ + if (map.name != GAME_CORNER_MAP){ + return; + } + if (!has_object(map, "poster")){ + map.objects.push_back(object("poster", Pokemon::ObjectKind::SIGN, GAME_CORNER_POSTER)); + } + if (!has_object(map, "poster_stairs")){ + for (const TilePos& tile : {TilePos{15, 2}, TilePos{16, 2}, TilePos{17, 2}, TilePos{16, 3}, TilePos{17, 3}}){ + map.objects.push_back(object("poster_stairs", Pokemon::ObjectKind::NPC, tile)); + } + } +} + + +bool is_pokemon_center(std::string_view map){ + return map.ends_with("_PokemonCenter_1F"); +} + +MapData pokemon_center_room(const std::string& name, const Pokemon::WarpDestination& outside){ + MapData room = parse_map(std::string(CENTER_ROOM_JSON), name); + room.name = name; + for (MapWarp& warp : room.warps){ + if (warp.y == CENTER_MAT.y){ + warp.to = outside; + } + } + return room; +} + +std::optional add_pokemon_center_room(std::vector& maps, size_t town){ + for (const MapWarp& warp : maps[town].warps){ + if (!warp.to || !is_pokemon_center(warp.to->map)){ + continue; + } + const std::string center = warp.to->map; + const std::string door = center + "_door"; + maps[town].points[door] = TilePos{warp.x, warp.y}; + Pokemon::WarpDestination outside{maps[town].name, door}; + maps.push_back(pokemon_center_room(center, outside)); + return center; + } + return std::nullopt; +} + + +MapData rocket_hideout_lift_room(){ + return parse_map(std::string(LIFT_ROOM_JSON), std::string(LIFT_MAP)); +} + +const std::vector& rocket_hideout_lift_doors(){ + static const std::vector DOORS{ + {"RocketHideout_B1F", {24, 25}}, + {"RocketHideout_B2F", {28, 16}}, + {"RocketHideout_B4F", {20, 23}}, + }; + return DOORS; +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h new file mode 100644 index 0000000000..8b8b548730 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h @@ -0,0 +1,54 @@ +/* Coded Rooms and Tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Places the story scripts rather than maps (the user's call, 2026-11-09): + * every Pokemon Center's ground floor, which all share one layout, the + * Rocket Hideout's lift, a room of 5 by 7 tiles, and the tiles that maps + * can't say in glyphs: the Rocket Hideout's spinners and the Game + * Corner's poster. All of it is read off pret/pokefirered, cited where it + * is defined, so no map file has to carry it. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_CodedRooms_H +#define PokemonAutomation_PokemonFRLG_CodedRooms_H + +#include +#include +#include +#include +#include "PokemonFRLG_OverworldMap.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The spinner arrow on `point` of the map named `map`, from the +// decompilation's metatile behaviours, if the map is one of the hideout's +// spinner floors (RocketHideout_B2F, RocketHideout_B3F). +std::optional coded_spin_direction(std::string_view map, const TilePos& point); +// Whether `point` of the map named `map` is a coded MB_STOP_SPINNING tile. +bool coded_spin_stop(std::string_view map, const TilePos& point); + + +// The Game Corner, whose poster hides the hideout's switch. +inline constexpr std::string_view GAME_CORNER_MAP = "CeladonCity_GameCorner"; +// The poster (pokefirered CeladonCity_GameCorner map.json, the bg event +// CeladonCity_GameCorner_EventScript_Poster at (11, 1)). +inline constexpr TilePos GAME_CORNER_POSTER{11, 1}; +// Adds what the Game Corner's map can't say to `map`, if it is the Game +// Corner and hasn't them: the poster, an object "poster", and the tiles +// the switch turns into stairs (CeladonCity_GameCorner_EventScript_ +// OpenRocketHideout's setmetatile: (15, 2), (16..17, 2..3)), objects +// "poster_stairs" that block them until it is pushed. +void add_coded_objects(MapData& map); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_HideoutLiftRoom.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_HideoutLiftRoom.h new file mode 100644 index 0000000000..30b8e80671 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_HideoutLiftRoom.h @@ -0,0 +1,43 @@ +/* Hideout Lift Room + * + * From: https://github.com/PokemonAutomation/ + * + * The Rocket Hideout's lift, coded rather than mapped + * (PokemonFRLG_CodedRooms.h). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_HideoutLiftRoom_H +#define PokemonAutomation_PokemonFRLG_HideoutLiftRoom_H + +#include +#include "PokemonFRLG_OverworldMap.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The Rocket Hideout's lift (pokefirered LAYOUT_ROCKET_HIDEOUT_ELEVATOR, +// 5 by 7 tiles, rows 3 to 5 walkable). +inline constexpr std::string_view LIFT_MAP = "RocketHideout_Elevator"; +// Where the floors' lift doors put the player (warp 1, an +// MB_SOUTH_ARROW_WARP tile): south from it leaves the lift. +inline constexpr TilePos LIFT_ARRIVAL{2, 5}; +// Below the panel (a bg event at (0, 2), RocketHideout_Elevator_ +// EventScript_FloorSelect), facing north. +inline constexpr TilePos LIFT_PANEL_FRONT{0, 3}; +// The lift's room. Its door is a dynamic warp: the sim takes it to the +// floor picked (SimScript::lift_floors with rocket_hideout_lift_doors()). +MapData rocket_hideout_lift_room(); +// Where the lift lets out for each floor on its panel, top first +// (setdynamicwarp in RocketHideout_Elevator's scripts): B1F (24, 25), +// B2F (28, 16), B4F (20, 23), each its floor's lift door. +const std::vector& rocket_hideout_lift_doors(); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h index f7062858bc..461c103777 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h @@ -84,6 +84,7 @@ inline constexpr Milliseconds STEP_TAP(66); // 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); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike.h new file mode 100644 index 0000000000..ca42c358a7 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike.h @@ -0,0 +1,76 @@ +/* Overworld Bicycle + * + * From: https://github.com/PokemonAutomation/ + * + * How the overworld walker rides the Bicycle: which legs it taps out, + * and getting on with SELECT. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldBike_H +#define PokemonAutomation_PokemonFRLG_OverworldBike_H + +#include +#include +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "PokemonFRLG_OverworldWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The speed walked at instead of `speed` while off the Bicycle (a door +// took the player off): a run. +MoveSpeed on_foot(MoveSpeed speed); + +namespace OverworldWalkSteps{ + +// Whether a WALK `step` at `speed` starts with a turn tap, for a player +// facing `facing`: on foot facing another way (or an unknown way), so the +// hold starts facing the right way. Never on the Bicycle, where a tap +// takes a step (MovementMechanics::BIKE_TAP_ONLY_TURNS). +bool turns_first(const RouteStep& step, MoveSpeed speed, std::optional facing); + +// Whether `step` at `speed` is walked a tap at a time, each a tile +// (count_bike_taps()): an open leg on the Bicycle, outdoors, not onto a +// warp or a spinner, and not over a map connection, where the game holds +// the player up a tile (a tap there isn't measured). Legs to a wall are +// held: the wall ends them. +bool counts_bike_taps(const RouteStep& step, MoveSpeed speed); + +// Whether, walking a route at `speed` and riding the Bicycle or not +// (`riding`), the player gets on with SELECT before `step`: a WALK where +// the Bicycle may be ridden, after a door took them off. +bool gets_on_bike(const RouteStep& step, MoveSpeed speed, bool riding); + +// The console side of select_onto_bike(): presses SELECT and waits it +// out; reads whether the player rides. +struct BikeSelectCalls{ + std::function press_select; + std::function riding; +}; + +// Gets on the registered Bicycle with SELECT, checks the player rides, +// and presses again once if not. Returns whether they ride. +bool select_onto_bike(const BikeSelectCalls& calls); + +// Whether the player rides the Bicycle, from the screen: the overworld +// is up, and no standing player's sprite shows where the player stands +// (read_player_facing()). False under a message or a menu. +bool screen_shows_riding(const ImageViewRGB32& screen); + +// select_onto_bike() on the console: SELECT is the Switch's MINUS. +// Throws OperationFailedException if the player doesn't ride after two +// presses. +void get_on_bike(ConsoleHandle& console, ProControllerContext& context); + +} // namespace OverworldWalkSteps + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBikeTaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBikeTaps.cpp new file mode 100644 index 0000000000..fa2871e6d7 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBikeTaps.cpp @@ -0,0 +1,41 @@ +/* Overworld Bicycle Taps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "PokemonFRLG_OverworldBikeTaps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +StepOutcome count_bike_taps(const RouteStep& step, const BikeTapCalls& calls){ + int moved = 0; + for (int tap = 1; tap <= step.tiles; tap++){ + const BikeTap result = calls.tap(); + if (result.interrupt || result.cleared_late){ + calls.log(std::format("Bike: tap {} of {}: interrupted after {} tile(s).", tap, step.tiles, moved)); + if (result.interrupt){ + calls.clear(*result.interrupt); + } + const int most = std::min(moved + 1, step.tiles); + return StepOutcome{.end = StepEnd::STOPPED, .walked = WalkedTiles{moved, most}}; + } + if (!result.moved){ + calls.log(std::format("Bike: tap {} of {} didn't move: blocked after {} tile(s).", tap, step.tiles, moved)); + return StepOutcome{.end = StepEnd::STOPPED, .walked = WalkedTiles{moved, moved + BLOCKED_TAP_SLACK}, .blocked = true}; + } + moved++; + } + return StepOutcome{}; +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBikeTaps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBikeTaps.h new file mode 100644 index 0000000000..c1018ce17f --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBikeTaps.h @@ -0,0 +1,61 @@ +/* Overworld Bicycle Taps + * + * From: https://github.com/PokemonAutomation/ + * + * An open leg on the Bicycle counted out a tap at a time, each a tile, + * with the console side passed in (BikeTapCalls), so the walker and the + * sim share it. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldBikeTaps_H +#define PokemonAutomation_PokemonFRLG_OverworldBikeTaps_H + +#include +#include +#include +#include +#include "PokemonFRLG_OverworldRecovery.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// One tap of an open leg on the Bicycle, counted out a tile a tap: whether +// the screen moved, and which of the leg's interrupts showed, if one did. +struct BikeTap{ + bool moved = false; + std::optional interrupt; + // The tap didn't move, and then an interrupt showed, which the tap + // cleared already: a trainer who saw the player, a battle seen late. + bool cleared_late = false; +}; +// The console side of count_bike_taps(): `tap` taps once and judges it; +// `clear` clears interrupt i. +struct BikeTapCalls{ + std::function tap; + std::function clear; + std::function log; +}; +// How far a sure start may be off before a blocked tap finds the player: +// three runs of 00.30 stood 3 tiles off (console B, 2026-11-09). +constexpr int BLOCKED_TAP_SLACK = 3; + +// Walks the open leg `step` a tap at a time: on the Bicycle each tap is +// exactly a tile (MovementMechanics::BIKE_TAP). A tap that doesn't move +// the player ends the leg there, blocked, and one with an interrupt ends +// it once that is cleared. DONE once all of the leg's taps moved; else +// STOPPED, with the tiles walked: blocked, the taps that moved, and up +// to BLOCKED_TAP_SLACK more for a start that was off (run_route() keeps +// the tiles with a blocked one ahead); interrupted, the taps that moved +// and one more, which may have moved before its battle showed. +StepOutcome count_bike_taps(const RouteStep& step, const BikeTapCalls& calls); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike_Tests.cpp new file mode 100644 index 0000000000..eca8adf9da --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike_Tests.cpp @@ -0,0 +1,238 @@ +/* Overworld Bicycle Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The Bicycle on the sim, on small made-up maps: its taps, each a tile + * and some dropped when they come too fast, open legs counted out a tap + * at a time, and SELECT getting on (console B, 2026-11-09). + * + */ + +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_MovementMechanics.h" +#include "PokemonFRLG_BikeSpinPresses.h" +#include "PokemonFRLG_BikeSpinMechanics.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldRecovery.h" +#include "PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_OverworldSimField.h" +#include "PokemonFRLG_OverworldBike.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using namespace MovementMechanics; +using OverworldWalkSteps::BikeSelectCalls; + +struct BikeInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct BikeExpectation{ + std::string description; +}; +struct BikeCase{ + BikeInput input; + BikeExpectation expectation; +}; + +class Test_OverworldBike : public UnitTest{ +public: + explicit Test_OverworldBike(BikeCase test_case) + : UnitTest("PokemonFRLG::OverworldBike - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_case.input.body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + BikeCase m_case; +}; + +// A field 28 tiles wide with a rock at (7, 2). +const std::vector& field(){ + static const std::vector MAPS{parse_map(R"({ + "name": "BikeField", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": [ + "##############################", + "#............................#", + "#......#.....................#", + "#............................#", + "##############################" + ] + })", "BikeField")}; + return MAPS; +} + +MapPosition at(int x, int y){ + return MapPosition{"BikeField", {x, y}}; +} + +std::string log_text(const std::vector& lines){ + std::string text; + for (const std::string& line : lines){ + text += "\n " + line; + } + return text; +} + +// `taps` taps west on the Bicycle, `period` apart, start to start. +SimState tap_chain(int taps, Milliseconds period){ + SimScript script; + script.on_bike = true; + OverworldSim sim(field(), at(28, 1), Direction::WEST, script); + for (int c = 0; c < taps; c++){ + sim.tap(Direction::WEST, BIKE_TAP, period - BIKE_TAP); + } + return sim.state(); +} + +// Console B, 2026-11-09: a chain of 40ms taps one per 100ms moved a tile +// a tap; one per 60ms dropped one tap in 12. The walker taps one per +// BIKE_TAP_PERIOD. +UnitTestResult test_tap_chains(){ + const SimState fast = tap_chain(25, Milliseconds(60)); + if (fast.bike_taps_dropped != 2 || fast.steps != 23){ + return std::format("25 taps 60ms apart: {} dropped, {} tiles; expected 2 and 23.", fast.bike_taps_dropped, fast.steps); + } + for (const Milliseconds period : {BIKE_TAP_PERIOD_WORKED, BIKE_TAP_PERIOD}){ + const SimState state = tap_chain(25, period); + if (state.bike_taps_dropped != 0 || state.steps != 25){ + return std::format("25 taps {}ms apart: {} dropped, {} tiles; expected none and 25.", period.count(), state.bike_taps_dropped, state.steps); + } + } + return true; +} + +// An open leg of `tiles` toward `direction`, ending on `end`. +RouteStep open_leg(Direction direction, int tiles, MapPosition end){ + RouteStep step; + step.direction = direction; + step.tiles = tiles; + step.facing_after = direction; + step.end = std::move(end); + return step; +} + +// An open leg of 11 tiles west from (20, 2), believed, then a tile north. +// The player starts 2 tiles further west, on (18, 2), and the rock at +// (7, 2) stops them a tile past the leg's end. Counted in taps, the +// blocked tap places them against the rock, and the route walks back. +// One hold ends against the rock too, taken for the leg's end, and the +// tile north ends a column off. +SimState walk_off_start(bool count_taps, std::string& failure, std::vector& log){ + const std::vector route{ + open_leg(Direction::WEST, 11, at(9, 2)), + open_leg(Direction::NORTH, 1, at(9, 1)), + }; + SimScript script; + script.on_bike = true; + OverworldSim sim(field(), at(18, 2), Direction::WEST, script); + SimWalkerRules rules; + rules.speed = MoveSpeed::BIKE; + rules.count_bike_taps = count_taps; + OverworldSimWalker walker(sim, rules); + const RoutePlanning planning{.maps = &field(), .costs = default_route_costs(), .start = at(20, 2), .label = "Sim"}; + failure = walker.run(route, planning); + log = walker.log(); + log.insert(log.end(), sim.state().log.begin(), sim.state().log.end()); + return sim.state(); +} + +UnitTestResult test_counted_leg_off_start(){ + std::string failure; + std::vector log; + const SimState held = walk_off_start(false, failure, log); + if (failure.empty() && held.position == at(9, 1)){ + return "One hold should end off: the sim doesn't reproduce the start's error." + log_text(log); + } + const SimState counted = walk_off_start(true, failure, log); + if (!failure.empty()){ + return "Counted in taps: " + failure + log_text(log); + } + if (!(counted.position == at(9, 1))){ + return "Counted in taps, ended on " + to_string(counted.position) + ", not " + to_string(at(9, 1)) + "." + log_text(log); + } + if (counted.bike_taps_dropped != 0 || !counted.violations.empty()){ + return "Counted in taps: a tap dropped or a violation." + log_text(log); + } + return true; +} + +// SELECT gets on the registered Bicycle only: a reload of a save made +// before REGISTER loses it. +UnitTestResult test_select_onto_bike(){ + for (const bool registered : {false, true}){ + SimScript script; + script.bike_registered = registered; + OverworldSim sim(field(), at(10, 1), Direction::WEST, script); + BikeSelectCalls calls; + int presses = 0; + calls.press_select = [&]{ presses++; sim.use_field(SimField::BIKE_SELECT); }; + calls.riding = [&]{ return sim.state().on_bike; }; + const bool riding = OverworldWalkSteps::select_onto_bike(calls); + if (riding != registered || presses != (registered ? 1 : 2)){ + return std::format( + "Registered {}: riding {} after {} press(es).", registered, riding, presses + ); + } + } + return true; +} + +// A door takes the player off the Bicycle: legs after it go on foot, +// until one where the Bicycle may be ridden gets on again. +UnitTestResult test_speed_after_door(){ + RouteStep outdoors = open_leg(Direction::WEST, 3, at(7, 1)); + RouteStep indoors = outdoors; + indoors.walk_only = true; + using OverworldWalkSteps::gets_on_bike; + if (!gets_on_bike(outdoors, MoveSpeed::BIKE, false) || gets_on_bike(outdoors, MoveSpeed::BIKE, true) + || gets_on_bike(indoors, MoveSpeed::BIKE, false) || gets_on_bike(outdoors, MoveSpeed::RUN, false) + ){ + return "Expected to get on again outdoors only, off the Bicycle, on a ride."; + } + if (on_foot(MoveSpeed::BIKE) != MoveSpeed::RUN || on_foot(MoveSpeed::WALK) != MoveSpeed::WALK){ + return "Expected a run off the Bicycle."; + } + if (!OverworldWalkSteps::counts_bike_taps(outdoors, MoveSpeed::BIKE) || OverworldWalkSteps::counts_bike_taps(outdoors, MoveSpeed::RUN)){ + return "Expected open legs counted in taps on the Bicycle only."; + } + return true; +} + +const BikeCase OVERWORLD_BIKE_CASES[] = { + {.input = {"taps a tile each, one in 12 dropped 60ms apart", test_tap_chains}, .expectation = {"23 of 25 tiles at 60ms; 25 at 100 and 120ms"}}, + {.input = {"an open leg of 11 from a start 2 off, counted in taps", test_counted_leg_off_start}, .expectation = {"found against the rock and walked back"}}, + {.input = {"SELECT onto the registered Bicycle", test_select_onto_bike}, .expectation = {"one press registered; two and off unregistered"}}, + {.input = {"on foot after a door, on again outdoors", test_speed_after_door}, .expectation = {"gets_on_bike(), on_foot()"}}, +}; + +} // namespace + + +void add_tests_OverworldBike(UnitTestDatabase& database){ + for (const BikeCase& item : OVERWORLD_BIKE_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp index 03efa4db3f..997c6b4609 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp @@ -6,7 +6,9 @@ #include #include "Common/Cpp/Exceptions.h" +#include "PokemonFRLG_CodedRooms.h" #include "PokemonFRLG_OverworldMap.h" +#include "PokemonFRLG_OverworldSpin.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -230,6 +232,71 @@ StepKind classify_step_onto( return StepKind::WALK; } +namespace{ + +// The regions a map may list spinner tiles in (PokemonFRLG_OverworldSpin.h). +constexpr std::string_view SPIN_NORTH_REGION = "spin_north"; +constexpr std::string_view SPIN_SOUTH_REGION = "spin_south"; +constexpr std::string_view SPIN_WEST_REGION = "spin_west"; +constexpr std::string_view SPIN_EAST_REGION = "spin_east"; +constexpr std::string_view SPIN_STOP_REGION = "spin_stop"; +bool in_region(const MapData& map, std::string_view region, const TilePos& point){ + const auto tiles = map.regions.find(std::string(region)); + return tiles != map.regions.end() && std::find(tiles->second.begin(), tiles->second.end(), point) != tiles->second.end(); +} + +} // namespace + +std::optional spin_direction(const MapData& map, const TilePos& point){ + if (const std::optional coded = coded_spin_direction(map.name, point)){ + return coded; + } + static const std::pair ARROWS[] = { + {SPIN_NORTH_REGION, Direction::NORTH}, + {SPIN_SOUTH_REGION, Direction::SOUTH}, + {SPIN_WEST_REGION, Direction::WEST}, + {SPIN_EAST_REGION, Direction::EAST}, + }; + for (const auto& [region, direction] : ARROWS){ + if (in_region(map, region, point)){ + return direction; + } + } + return std::nullopt; +} +bool stops_spinning(const MapData& map, const TilePos& point){ + return coded_spin_stop(map.name, point) || in_region(map, SPIN_STOP_REGION, point); +} + +std::optional spin_slide(const MapData& map, const PlayerState& state){ + std::optional arrow = spin_direction(map, state.position); + if (!arrow){ + return std::nullopt; + } + SpinSlide slide{state, *arrow, 0}; + // A slide longer than the map has tiles has gone round a loop. + const int max_tiles = map.tiles.width * map.tiles.height; + while (slide.tiles <= max_tiles){ + const StepKind kind = classify_step(map, slide.end, slide.facing); + if (kind != StepKind::WALK){ + return slide; + } + slide.end = after_step(map, slide.end, slide.facing, kind); + slide.tiles++; + if (warp_at(map, slide.end.position) != nullptr){ + return std::nullopt; + } + if (stops_spinning(map, slide.end.position)){ + return slide; + } + arrow = spin_direction(map, slide.end.position); + if (arrow){ + slide.facing = *arrow; + } + } + return std::nullopt; +} + PlayerState after_step(const MapData& map, const PlayerState& state, Direction direction, StepKind kind){ return after_step_onto(map, state, map, state.position.next(direction), kind); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h index 7e1935b643..469f17efa7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h @@ -19,6 +19,7 @@ #include #include #include +#include #include "Pokemon/Navigation/Pokemon_Grid.h" #include "Pokemon/Navigation/Pokemon_MapData.h" @@ -160,7 +161,6 @@ PlayerState after_step_onto( ); - } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp index 4d83c0db45..69ac64c188 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -83,6 +83,7 @@ const char* const MAP_NAMES[] = { "RockTunnel_B1F", "LavenderTown", "LavenderTown_PokemonCenter_1F", + "LavenderTown_Mart", // West from Lavender: Route 8, the Underground Path under Saffron and // Route 7 to Celadon City. "Route8", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index cddb0e2026..8cec6277b7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -7,6 +7,7 @@ #include #include #include "Pokemon/Navigation/Pokemon_RouteLog.h" +#include "PokemonFRLG_OverworldBikeTaps.h" #include "PokemonFRLG_OverworldReanchor.h" #include "PokemonFRLG_OverworldRecovery.h" @@ -313,7 +314,34 @@ class RouteRun{ return false; } const WalkedTiles walked = outcome.walked.value_or(whole_leg(step)); - return rejoin(step, candidates_on(step, start, walked), walked); + if (!outcome.blocked){ + return rejoin(step, candidates_on(step, start, walked), walked); + } + // Past the leg's end too: a start that was off ahead takes the + // taps there. + RouteStep longer = step; + longer.tiles += BLOCKED_TAP_SLACK; + return rejoin(step, blocked_ahead(step, candidates_on(longer, start, walked)), walked); + } + + // Of `candidates`, where a Bicycle tap along `step` stopped the + // player, those the maps block the way ahead of; all of them if none. + std::vector blocked_ahead(const RouteStep& step, std::vector candidates) const{ + std::vector blocked; + for (const StopCandidate& candidate : candidates){ + RouteStep ahead; + ahead.direction = step.direction; + ahead.tiles = 1; + ahead.surfing = step.surfing; + if (!candidate.trainer && leg_tiles(*maps(), candidate.player, ahead).size() < 2){ + blocked.push_back(candidate); + } + } + if (blocked.empty()){ + m_calls.log("Route: a Bicycle tap was blocked where the maps show the way open: finding the player from the tiles the taps put them on."); + return candidates; + } + return blocked; } // Whether `step`, a leg with wild battles stopped part-way, was stopped diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h index 18325a293a..88e18a679b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h @@ -68,8 +68,12 @@ enum class StepEnd{ struct StepOutcome{ StepEnd end = StepEnd::DONE; std::optional walked; + // STOPPED by a Bicycle tap that didn't move the player: the tile + // ahead of where they stand is blocked (count_bike_taps()). + bool blocked = false; }; + // Whether, after each leg to a wall, the walker taps once more toward the // wall and logs whether the player moved (see RouteCalls). enum class WallCheck{ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 9200c0e02b..e7fbd0e42a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -18,8 +18,11 @@ #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" +#include "PokemonFRLG_OverworldBikeTaps.h" #include "PokemonFRLG_OverworldLateBattle.h" #include "PokemonFRLG_OverworldRecoveryWalk.h" +#include "PokemonFRLG_BikeSpinPresses.h" +#include "PokemonFRLG_OverworldBike.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -54,8 +57,80 @@ struct StepWalk{ std::optional stopped; WallClock hold_start; bool dark = false; + // Stopped by a Bicycle tap that didn't move (StepOutcome::blocked). + bool blocked = false; }; +// TODO(forest drift, 2026-11-01): the threshold is a diagnostic's guess. +// How much the game screen's top quarter, clear of the player in the +// middle, may change when a tap toward a wall only bumps the player (the +// player's own animation, an animated tile). A step scrolls the whole map +// by a tile. Not yet measured on the console: a guess on the safe side. +constexpr double WALL_TAP_MOVED_RMSD = 20.0; + +// How much the top quarter of the game screen changed from `before` to +// `after`: a step scrolls it by a tile. +double top_band_change(const ImageViewRGB32& before, const ImageViewRGB32& after){ + const ImageFloatBox game = GameSettings::instance().GAME_BOX; + const ImageFloatBox band(game.x, game.y, game.width, game.height * 0.25); + return ImageMatch::pixel_RMSD(extract_box_reference(before, band), extract_box_reference(after, band)); +} + +// One tap of a leg counted out on the Bicycle (count_bike_taps()): a +// BIKE_TAP press released to a BIKE_TAP_PERIOD period, then +// BIKE_TAP_SETTLE for the screen to show the step, watching `interrupts` +// throughout. A tap that didn't scroll the screen is watched on for a +// late interrupt, which is cleared. +BikeTap console_bike_tap( + ConsoleHandle& console, ProControllerContext& context, + const RouteStep& step, const OverworldInterrupts& interrupts +){ + context.wait_for_all_requests(); + const VideoSnapshot before = console.video().snapshot(); + std::vector> watchers; + std::vector callbacks; + for (const auto& interrupt : interrupts){ + watchers.emplace_back(interrupt->make_watcher()); + callbacks.emplace_back(*watchers.back()); + } + const int ret = run_until( + console, context, + [&](ProControllerContext& context){ + pbf_press_dpad(context, dpad(step.direction), BIKE_TAP, BIKE_TAP_PERIOD - BIKE_TAP); + pbf_wait(context, BIKE_TAP_SETTLE); + context.wait_for_all_requests(); + }, + callbacks + ); + if (ret >= 0){ + console.log("Bike: " + interrupts[ret]->name() + " on a tap of " + to_string(step) + ".", COLOR_ORANGE); + return BikeTap{.moved = false, .interrupt = (size_t)ret}; + } + const VideoSnapshot after = console.video().snapshot(); + if (before && after && top_band_change(before, after) > WALL_TAP_MOVED_RMSD){ + return BikeTap{.moved = true}; + } + const bool late = Pokemon::clear_late_interrupts(console, context, interrupts, AFTER_LEG_WATCH, MAX_INTERRUPTIONS_PER_LEG) > 0; + return BikeTap{.moved = false, .interrupt = std::nullopt, .cleared_late = late}; +} + +// An open leg on the Bicycle, a tap at a time (count_bike_taps()). +StepWalk walk_bike_taps( + ConsoleHandle& console, ProControllerContext& context, + const RouteStep& step, const OverworldInterruptSet& interrupts +){ + StepWalk out{std::nullopt, current_time(), false, false}; + const OverworldInterrupts& watched = step.wild ? interrupts.wild : interrupts.other; + BikeTapCalls calls; + calls.tap = [&]{ return console_bike_tap(console, context, step, watched); }; + calls.clear = [&](size_t index){ watched[index]->clear(console, context); }; + calls.log = [&](const std::string& message){ console.log(message, COLOR_ORANGE); }; + const StepOutcome outcome = count_bike_taps(step, calls); + out.stopped = outcome.walked; + out.blocked = outcome.blocked; + return out; +} + // 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){ @@ -79,13 +154,13 @@ StepWalk walk_step( const RouteStep& step, std::optional facing, const OverworldInterruptSet& interrupts, MoveSpeed speed ){ - StepWalk out{std::nullopt, current_time(), false}; + StepWalk out{std::nullopt, current_time(), false, 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){ + if (turns_first(step, speed, facing)){ turn_to(console, context, step.direction); } if (const std::string note = speed_note(step, speed); !note.empty()){ @@ -189,9 +264,11 @@ StepOutcome walk_and_watch( clear_open_message(console, context, step.wild ? interrupts.wild : interrupts.other, step); check_overworld(console, context, step); const LegRecorder recorder(console, context, interrupts, step); - const StepWalk walked = walk_step(console, context, step, facing, interrupts, speed); + const StepWalk walked = counts_bike_taps(step, speed) + ? walk_bike_taps(console, context, step, interrupts) + : walk_step(console, context, step, facing, interrupts, speed); if (walked.stopped){ - return StepOutcome{.end = StepEnd::STOPPED, .walked = walked.stopped}; + return StepOutcome{.end = StepEnd::STOPPED, .walked = walked.stopped, .blocked = walked.blocked}; } if (step.action == RouteAction::FACE){ return StepOutcome{}; @@ -285,12 +362,6 @@ StepEnd watch_for_trainer( } // TODO(forest drift, 2026-11-01): diagnostic; remove or turn into a recovery once the cause is known. -// How much the game screen's top quarter, clear of the player in the -// middle, may change when a tap toward a wall only bumps the player (the -// player's own animation, an animated tile). A step scrolls the whole map -// by a tile. Not yet measured on the console: a guess on the safe side. -constexpr double WALL_TAP_MOVED_RMSD = 20.0; - // Taps once toward the wall `step` ended at (the player faces it, so a tap // steps if it can) and says whether the map scrolled: whether the player // moved instead of bumping into the wall. @@ -303,9 +374,7 @@ bool moved_past_wall(ConsoleHandle& console, ProControllerContext& context, cons if (!before || !after){ return false; } - const ImageFloatBox game = GameSettings::instance().GAME_BOX; - const ImageFloatBox band(game.x, game.y, game.width, game.height * 0.25); - const double change = ImageMatch::pixel_RMSD(extract_box_reference(before, band), extract_box_reference(after, band)); + const double change = top_band_change(before, after); console.log(std::format("Position check: a tap toward the wall of {} changed the screen by {:.1f}.", to_string(step), change)); return change > WALL_TAP_MOVED_RMSD; } @@ -327,12 +396,26 @@ void walk_overworld_route_with_recovery( if (!facing){ facing = read_facing(console, context); } + // A door takes the player off the Bicycle; the next leg where it may + // be ridden gets on again with SELECT. A battle leaves them on. + bool riding = speed == MoveSpeed::BIKE; RouteCalls calls; calls.walk = [&](const RouteStep& step, std::optional from_facing){ - return walk_and_watch(console, context, step, from_facing, interrupts, speed); + if (gets_on_bike(step, speed, riding)){ + get_on_bike(console, context); + riding = true; + from_facing = std::nullopt; + } + const StepOutcome outcome = walk_and_watch(console, context, step, from_facing, interrupts, riding ? speed : on_foot(speed)); + if (riding && step.warps){ + console.log("Bike: through the warp to " + step.end.map + ", off the Bicycle."); + riding = false; + } + return outcome; }; calls.hold_to_warp = [&](const RouteStep& step){ - hold_to_warp(console, context, step, step.wild ? interrupts.wild : interrupts.other, speed); + hold_to_warp(console, context, step, step.wild ? interrupts.wild : interrupts.other, riding ? speed : on_foot(speed)); + riding = false; }; 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_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 8556442dd0..2e1c573abf 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -14,6 +14,7 @@ #include "Pokemon/Navigation/Pokemon_LegPlanner.h" #include "PokemonFRLG_OverworldMaps.h" #include "PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_OverworldSpin.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -81,6 +82,9 @@ struct Transition{ bool warped = false; bool walked_out = false; // the warp ended with the game walking the player out bool dismounted = false; // stepped off the water onto land + // The step landed on a spinner, which slid the player on: how far, + // and the way they last moved. + std::optional spin; }; // The player taking `warp`, or nothing if it leads off the loaded maps. @@ -182,6 +186,13 @@ std::optional press(const std::vector& maps, const Located& if (transition.action == RouteAction::WALK && warps_on_step(map, landed) && warp_at(map, landed) != nullptr){ return take_warp(maps, warp_at(map, landed)); } + if (transition.action == RouteAction::WALK && spin_direction(map, landed)){ + transition.spin = spin_slide(map, transition.next.state); + if (!transition.spin){ + return std::nullopt; + } + transition.next.state = transition.spin->end; + } return transition; } @@ -268,9 +279,14 @@ class StepModel{ step.action = transition->action == RouteAction::SURF ? ACTION_SURF : 0; // Leaving the water: the player walks at half the speed from here. step.starts_leg = transition->dismounted; - step.ends_leg = transition->warped || transition->dismounted; + // A slide ends the leg: the hold must let go on the spinner. + step.ends_leg = transition->warped || transition->dismounted || transition->spin; step.warped = transition->warped; step.facing = transition->walked_out ? Direction::SOUTH : direction; + if (transition->spin){ + step.facing = transition->spin->facing; + step.extra_cost += transition->spin->tiles * route_cost(m_costs, RouteCost::TILE); + } if (transition->action == RouteAction::SURF){ step.extra_cost += route_cost(m_costs, RouteCost::SURF); } @@ -306,6 +322,24 @@ Pokemon::LegCosts to_leg_costs(const RouteCosts& costs){ return leg_costs; } +// The tiles the slide at the end of WALK `leg` takes, if it ends on a +// spinner: only a leg's last step can slide (StepModel ends the leg). +std::optional last_spin_tiles(const std::vector& maps, const Pokemon::Leg& leg){ + Located player = leg.start; + std::optional transition; + for (int c = 0; c < leg.tiles; c++){ + transition = press(maps, player, leg.direction); + if (!transition){ + return std::nullopt; + } + player = transition->next; + } + if (!transition || !transition->spin){ + return std::nullopt; + } + return transition->spin->tiles; +} + RouteStep to_route_step(const std::vector& maps, const Pokemon::Leg& leg){ RouteStep step; switch (leg.kind){ @@ -333,6 +367,10 @@ RouteStep to_route_step(const std::vector& maps, const Pokemon::Leg tiles = leg_tiles(maps, MapPosition{maps[leg.start.map].name, leg.start.state.position}, step); for (int c : std::views::iota(1, (int)tiles.size()) | std::views::filter([&](int n){ return tiles[n].map != tiles[n - 1].map; })){ step.connection_steps.push_back(c); @@ -361,7 +399,7 @@ std::optional predict_overworld_press( .position = {maps[next->next.map].name, next->next.state.position}, .elevation = next->next.state.elevation, .surfing = next->next.state.surfing, - .facing = next->walked_out ? Direction::SOUTH : direction, + .facing = next->spin ? next->spin->facing : next->walked_out ? Direction::SOUTH : direction, }, .warped = next->warped, }; @@ -424,7 +462,7 @@ std::optional locate(const std::vector& maps, const MapPositio // A plain step: no warp, no Surf, no getting off the water. std::optional plain_step(const std::vector& maps, const Located& player, Direction direction){ const std::optional transition = press(maps, player, direction); - if (!transition || transition->warped || transition->dismounted || transition->action != RouteAction::WALK){ + if (!transition || transition->warped || transition->dismounted || transition->spin || transition->action != RouteAction::WALK){ return std::nullopt; } return transition->next; @@ -473,7 +511,7 @@ std::vector leg_tiles( ){ std::vector tiles{start}; std::optional player = locate(maps, start, step.surfing); - const int along = step.tiles - (step.warps ? 1 : 0); + const int along = step.tiles - (step.warps || step.spin_tiles ? 1 : 0); for (int c = 0; player && c < along; c++){ player = plain_step(maps, *player, step.direction); if (player){ @@ -524,472 +562,6 @@ MapPosition follow_overworld_route( -namespace{ - -class Test_OverworldRoute : public UnitTest{ -public: - Test_OverworldRoute(std::string name, std::function body) - : UnitTest("PokemonFRLG::OverworldRoute - " + std::move(name)) - , m_body(std::move(body)) - {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - try{ - return m_body(); - }catch (Exception& e){ - return std::string(e.name()) + ": " + e.message(); - } - } - -private: - std::function m_body; -}; - -std::string describe(const std::vector& route){ - std::string text; - for (const RouteStep& step : route){ - text += "\n " + to_string(step); - } - return text; -} - -// A 5x4 map for the step rules: a pond, a raised ledge reached by a stair -// tile, and a tile that can't be entered moving south. -const MapData& small_map(){ - static const MapData MAP = parse_map(R"({ - "name": "Small", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["#####", "#.8.#", "#.w.#", "#####"], - "elevation": ["33333", "33403", "33133", "33333"] - })", "Small"); - return MAP; -} - -UnitTestResult test_step_rules(){ - const MapData& map = small_map(); - PlayerState state{{1, 1}, 3, false}; - if (classify_step(map, state, Direction::EAST) != StepKind::BLOCKED){ - return "Walked from elevation 3 onto elevation 4."; - } - if (classify_step(map, PlayerState{{2, 0}, 3, false}, Direction::SOUTH) != StepKind::BLOCKED){ - return "Entered a north-blocked tile moving south."; - } - state = PlayerState{{1, 2}, 3, false}; - if (classify_step(map, state, Direction::EAST) != StepKind::START_SURF){ - return "Can't start surfing onto the pond."; - } - state = after_step(map, state, Direction::EAST, StepKind::START_SURF); - if (!state.surfing || state.elevation != 1){ - return "Surfing didn't put the player on the water."; - } - if (classify_step(map, state, Direction::EAST) != StepKind::STOP_SURFING){ - return "Can't land from the pond."; - } - if (classify_step(map, PlayerState{{3, 2}, 3, false}, Direction::NORTH) != StepKind::WALK){ - return "Can't step from elevation 3 onto the elevation-0 stair."; - } - return true; -} - -// The maps after the Hall of Fame, which clears the way to Cerulean Cave. -std::vector post_game_maps(){ - return overworld_maps(map_flag_set(overworld_maps(), "after_hall_of_fame")); -} - -MapPosition point_on(const std::vector& maps, const std::string& map, const std::string& point){ - return MapPosition{map, map_point(maps, map, point)}; -} - -const MapData* map_named(const std::vector& maps, const std::string& name){ - const int index = find_map(maps, name); - return index < 0 ? nullptr : &maps[index]; -} - -UnitTestResult test_maps(){ - const std::vector maps = post_game_maps(); - const MapData* b1f = map_named(maps, "CeruleanCave_B1F"); - const MapData* city = map_named(maps, "CeruleanCity"); - if (b1f == nullptr || city == nullptr || map_named(maps, "CeruleanCity_PokemonCenter_1F") == nullptr){ - return "Missing a map."; - } - if (b1f->tiles.width != 40 || b1f->tiles.height != 23 || city->tiles.width != 48 || city->tiles.height != 40){ - return "Unexpected map size."; - } - const TilePos mewtwo_tile = map_point(maps, "CeruleanCave_B1F", "mewtwo"); - const MapObject* mewtwo = object_at(*b1f, mewtwo_tile); - if (mewtwo == nullptr || mewtwo->name != "mewtwo"){ - return "Mewtwo is not on B1F at " + to_string(mewtwo_tile) + "."; - } - if (object_at(*city, {1, 13}) != nullptr){ - return "The cave guard is still there after the Hall of Fame."; - } - if (object_at(*map_named(overworld_maps(), "CeruleanCity"), {1, 13}) == nullptr){ - return "The cave guard is missing before the Hall of Fame."; - } - return true; -} - -// Plans `start` to `goal`, replays it with the step rules and checks where it ends. -UnitTestResult check_route( - const std::vector& maps, - const MapPosition& start, const RouteGoal& goal, - std::vector& route -){ - const std::optional> planned = plan_overworld_route(maps, start, goal); - if (!planned){ - return "No route from " + to_string(start) + " to " + to_string(goal.position) + "."; - } - route = *planned; - const MapPosition end = follow_overworld_route(maps, start, route); - if (!(end == goal.position)){ - return "Route ends at " + to_string(end) + ":" + describe(route); - } - if (goal.facing && route.back().direction != *goal.facing){ - return "Doesn't end facing " + std::string(direction_name(*goal.facing)) + ":" + describe(route); - } - return true; -} - -// Below Mewtwo, facing it, to talk to it. -RouteGoal facing_mewtwo(const std::vector& maps){ - const TilePos mewtwo = map_point(maps, "CeruleanCave_B1F", "mewtwo"); - return RouteGoal{{"CeruleanCave_B1F", mewtwo.next(Direction::SOUTH)}, Direction::NORTH}; -} - -UnitTestResult test_entrance_to_mewtwo(){ - const std::vector maps = post_game_maps(); - std::vector route; - UnitTestResult result = check_route(maps, point_on(maps, "CeruleanCave_1F", "entrance"), facing_mewtwo(maps), route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - bool surfs = false; - bool reaches_b1f = false; - for (const RouteStep& step : route){ - surfs |= step.action == RouteAction::SURF; - reaches_b1f |= step.warps && step.end.map == "CeruleanCave_B1F"; - } - if (!surfs || !reaches_b1f){ - return "Expected to take the ladder to B1F and to surf:" + describe(route); - } - return true; -} - -UnitTestResult test_pokemon_center_to_mewtwo(){ - const std::vector maps = post_game_maps(); - std::vector route; - UnitTestResult result = check_route( - maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps), route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - // Out through the exit mat, onto the city's step below the door, and - // into the cave by its entrance. - std::vector maps_in_order; - for (const RouteStep& step : route){ - if (step.warps){ - maps_in_order.push_back(step.end.map); - } - } - if (maps_in_order.size() < 2 || maps_in_order[0] != "CeruleanCity" || maps_in_order[1] != "CeruleanCave_1F"){ - return "Expected to leave by the mat and enter the cave next:" + describe(route); - } - if (!(route[0].end.map == "CeruleanCity_PokemonCenter_1F" || route[0].warps)){ - return "Unexpected first step:" + describe(route); - } - return true; -} - -UnitTestResult test_cave_guard_blocks(){ - // Before the Hall of Fame the guard stands in the cave entrance. - const std::vector maps = overworld_maps(); - if (plan_overworld_route(maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps))){ - return "Planned past the cave guard."; - } - return true; -} - -UnitTestResult test_mewtwo_tile_unreachable(){ - const std::vector maps = post_game_maps(); - const std::optional> route = plan_overworld_route( - maps, - point_on(maps, "CeruleanCave_1F", "entrance"), - {point_on(maps, "CeruleanCave_B1F", "mewtwo"), std::nullopt} - ); - if (route){ - return "Planned a route onto Mewtwo itself:" + describe(*route); - } - return true; -} - -UnitTestResult test_story_maps(){ - const std::vector maps = overworld_maps(); - for (const char* name : { - "PalletTown_PlayersHouse_2F", "PalletTown_ProfessorOaksLab", "Route1", "ViridianCity_Mart", - "Route22", "Route2", "ViridianForest", "PewterCity", "PewterCity_Museum_1F", "PewterCity_Gym", - }){ - if (map_named(maps, name) == nullptr){ - return std::string("Missing map ") + name + "."; - } - } - return true; -} - -// Which maps `route` warps into, in order. -std::vector maps_warped_into(const std::vector& route){ - std::vector maps; - for (const RouteStep& step : route){ - if (step.warps){ - maps.push_back(step.end.map); - } - } - return maps; -} - -UnitTestResult test_bedroom_to_oaks_lab(){ - const std::vector maps = overworld_maps(); - std::vector route; - UnitTestResult result = check_route( - maps, point_on(maps, "PalletTown_PlayersHouse_2F", "start"), - {point_on(maps, "PalletTown", "oaks_lab_front"), Direction::NORTH}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - const std::vector warped = maps_warped_into(route); - if (warped != std::vector{"PalletTown_PlayersHouse_1F", "PalletTown"}){ - return "Expected to take the stairs and leave by the front door:" + describe(route); - } - return true; -} - -UnitTestResult test_viridian_mart_to_pewter_gym(){ - const std::vector maps = overworld_maps(); - std::vector route; - UnitTestResult result = check_route( - maps, point_on(maps, "ViridianCity_Mart", "clerk_front"), - {point_on(maps, "PewterCity", "gym_front"), Direction::NORTH}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - const std::vector warped = maps_warped_into(route); - if (std::find(warped.begin(), warped.end(), "ViridianForest") == warped.end()){ - return "Expected to go through Viridian Forest:" + describe(route); - } - return true; -} - -// A room with a sign on its north wall, above the third column. -const char* const TEST_SIGN_ROOM_JSON = R"({ - "name": "SignRoom", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["##S##", "#...#", "#...#", "#...#", "#####"], - "elevation": ["33333", "33333", "33333", "33333", "33333"] -})"; - -// Walking north under a sign stops by counting tiles, never by holding into -// the sign; under the wall beside it, the wall stops the player. -UnitTestResult test_no_hold_into_sign(){ - const std::vector maps{parse_map(TEST_SIGN_ROOM_JSON, "SignRoom")}; - std::vector route; - UnitTestResult result = check_route(maps, {"SignRoom", {2, 3}}, {{"SignRoom", {2, 1}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.to_wall && step.end.point == TilePos{2, 1}){ - return "Expected no hold into the sign:" + describe(route); - } - } - route.clear(); - result = check_route(maps, {"SignRoom", {1, 3}}, {{"SignRoom", {1, 1}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - if (route.empty() || !route.back().to_wall){ - return "Expected the wall to stop the player beside the sign:" + describe(route); - } - return true; -} - -// A yard with a row of ledges across it. -const char* const TEST_LEDGE_YARD_JSON = R"({ - "name": "LedgeYard", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["#####", "#...#", "#...#", "#vvv#", "#...#", "#####"], - "elevation": ["33333", "33333", "33333", "33333", "33333", "33333"] -})"; - -// A ledge isn't a wall to hold into: moving the way it faces the player -// would hop it, and routes don't hop ledges. Viridian City, -// from the Pokemon Center's door south to the row above the ledge. -UnitTestResult test_no_hold_into_ledge(){ - const std::vector maps{parse_map(TEST_LEDGE_YARD_JSON, "LedgeYard")}; - std::vector route; - UnitTestResult result = check_route(maps, {"LedgeYard", {2, 1}}, {{"LedgeYard", {2, 2}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.to_wall){ - return "Expected no hold into the ledge:" + describe(route); - } - } - route.clear(); - const std::vector world = overworld_maps(); - result = check_route(world, {"ViridianCity", {26, 27}}, {{"ViridianCity", {26, 29}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.to_wall && step.end.point == TilePos{26, 29}){ - return "Expected no hold into the ledge below the Pokemon Center:" + describe(route); - } - } - return true; -} - -// Two small maps that don't need the map files: a room left by the exit mat -// at its bottom, onto the door of a yard, out of which the game walks the -// player. -// No wild battle can start between the bedroom and Oak's Lab: Pallet Town's -// streets have no tall grass. Route 2 and Viridian Forest do. -UnitTestResult test_wild_steps(){ - const std::vector maps = overworld_maps(); - std::vector route; - UnitTestResult result = check_route( - maps, point_on(maps, "PalletTown_PlayersHouse_2F", "start"), - {point_on(maps, "PalletTown", "oaks_lab_front"), Direction::NORTH}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.wild){ - return "Expected no wild step from the bedroom to Oak's Lab, got " + to_string(step) + ":" + describe(route); - } - } - route.clear(); - result = check_route( - maps, point_on(maps, "ViridianCity_Mart", "clerk_front"), - {point_on(maps, "PewterCity", "gym_front"), Direction::NORTH}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - bool forest = false; - for (const RouteStep& step : route){ - forest = forest || (step.wild && step.end.map == "ViridianForest"); - } - if (!forest){ - return "Expected a wild step in Viridian Forest:" + describe(route); - } - // The leg from Pallet Town north onto Route 1 crosses Route 1's grass - // after the map's edge. - route.clear(); - result = check_route( - maps, point_on(maps, "PalletTown", "oaks_lab_front"), - {point_on(maps, "ViridianCity_Mart", "clerk_front"), std::nullopt}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.end.map == "Route1" && step.direction == Direction::NORTH && step.tiles > 14 && !step.wild){ - return "Expected the leg from Pallet Town onto Route 1 to be wild, got " + to_string(step) + ":" + describe(route); - } - } - return true; -} - -const char* const TEST_ROOM_JSON = R"({ - "name": "Room", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["#####", "#...#", "#...#", "##.##"], - "elevation": ["33333", "33333", "33333", "33333"], - "points": {"warp_0": [2, 3]}, - "warps": [{"x": 2, "y": 3, "enter": "walk_south", "to_map": "Yard", "to_point": "warp_0"}] -})"; -const char* const TEST_YARD_JSON = R"({ - "name": "Yard", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["#####", "##D##", "#...#", "#####"], - "elevation": ["33333", "33333", "33333", "33333"], - "points": {"warp_0": [2, 1]}, - "warps": [{"x": 2, "y": 1, "enter": "walk_into", "to_map": "Room", "to_point": "warp_0"}] -})"; - -UnitTestResult test_route_through_warp(){ - const std::vector maps{ - parse_map(TEST_ROOM_JSON, "Room"), - parse_map(TEST_YARD_JSON, "Yard"), - }; - std::vector route; - UnitTestResult result = check_route(maps, {"Room", {2, 1}}, {{"Yard", {1, 2}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.warps && step.end.map == "Yard"){ - return true; - } - } - return "Expected to warp into the yard:" + describe(route); -} - -// `json` with its elevation grid replaced by `rows`, or left out if empty. -std::string with_elevation(std::string json, const std::string& rows){ - const size_t begin = json.find("\"elevation\""); - const size_t end = json.find("\n", begin); - json.erase(begin, end - begin + 1); - if (!rows.empty()){ - json.insert(begin, "\"elevation\": " + rows + ",\n"); - } - return json; -} - -// Room and yard as the decompilation writes them, their walls and the -// door at elevation 0, plan the same as with no elevation grid, which -// frlg_maps.py leaves out when every tile the player can stand on is ground. -UnitTestResult test_ground_only_elevation_left_out(){ - const std::vector written{ - parse_map(with_elevation(TEST_ROOM_JSON, R"(["00000", "03330", "03330", "00300"])"), "Room"), - parse_map(with_elevation(TEST_YARD_JSON, R"(["00000", "00000", "03330", "00000"])"), "Yard"), - }; - const std::vector left_out{ - parse_map(with_elevation(TEST_ROOM_JSON, ""), "Room"), - parse_map(with_elevation(TEST_YARD_JSON, ""), "Yard"), - }; - for (const MapPosition& start : {MapPosition{"Room", {1, 1}}, MapPosition{"Room", {3, 2}}}){ - for (const MapPosition& goal : {MapPosition{"Yard", {1, 2}}, MapPosition{"Yard", {3, 2}}, MapPosition{"Room", {2, 2}}}){ - const std::optional> a = plan_overworld_route(written, start, {goal, std::nullopt}); - const std::optional> b = plan_overworld_route(left_out, start, {goal, std::nullopt}); - if (!a || !b || describe(*a) != describe(*b)){ - return "Plans differ from " + to_string(start) + " to " + to_string(goal) + ":" - + (a ? describe(*a) : " none") + "\nwithout elevation:" + (b ? describe(*b) : " none"); - } - } - } - return true; -} - -} // namespace - -void add_tests_OverworldRoute(UnitTestDatabase& database){ - database.add("route through a warp", test_route_through_warp); - database.add("ground-only elevation left out", test_ground_only_elevation_left_out); - database.add("maps to Brock", test_story_maps); - database.add("bedroom to Oak's Lab", test_bedroom_to_oaks_lab); - database.add("Viridian Mart to Pewter Gym", test_viridian_mart_to_pewter_gym); - database.add("wild steps", test_wild_steps); - database.add("no hold into a sign", test_no_hold_into_sign); - database.add("no hold into a ledge", test_no_hold_into_ledge); - database.add("step rules", test_step_rules); - database.add("maps", test_maps); - database.add("Cerulean Cave entrance to Mewtwo", test_entrance_to_mewtwo); - database.add("Cerulean Pokemon Center to Mewtwo", test_pokemon_center_to_mewtwo); - database.add("cave guard blocks the way", test_cave_guard_blocks); - database.add("Mewtwo's tile is blocked", test_mewtwo_tile_unreachable); -} - - - } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index 82a9d65676..2d5f45f8e4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -76,6 +76,10 @@ struct RouteStep{ // WALK: a wall stops the player at the end, so the leg can be held for // longer than it takes and still ends on its tile. bool to_wall = false; + // WALK: the last tile is a spinner, which slides the player this many + // tiles on, to `end` (spin_slide()). The hold must end on the spinner: + // held on, the player walks on after the slide. + std::optional spin_tiles; // WALK, SURF: a wild Pokemon can appear on the way: the step crosses // tall grass or water. Elsewhere (indoors, streets) no wild battle can // start, so the walker doesn't watch for one. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp index 4c082f2c76..8b7175b066 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp @@ -21,6 +21,9 @@ std::string to_string(const RouteStep& step){ if (step.to_wall){ text += " (to wall)"; } + if (step.spin_tiles){ + text += std::format(" (spins {})", *step.spin_tiles); + } break; case RouteAction::SURF: text = "surf " + std::string(direction_name(step.direction)); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp new file mode 100644 index 0000000000..3058352ad3 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp @@ -0,0 +1,552 @@ +/* Overworld Route Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Moved from PokemonFRLG_OverworldRoute.cpp, the tests registered from a + * table of cases. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct RouteTestInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct RouteTestExpectation{ + std::string description; +}; +struct RouteTestCase{ + RouteTestInput input; + RouteTestExpectation expectation; +}; + +class Test_OverworldRoute : public UnitTest{ +public: + explicit Test_OverworldRoute(RouteTestCase test_case) + : UnitTest("PokemonFRLG::OverworldRoute - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_case.input.body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + RouteTestCase m_case; +}; + +std::string describe(const std::vector& route){ + std::string text; + for (const RouteStep& step : route){ + text += "\n " + to_string(step); + } + return text; +} + +// A 5x4 map for the step rules: a pond, a raised ledge reached by a stair +// tile, and a tile that can't be entered moving south. +const MapData& small_map(){ + static const MapData MAP = parse_map(R"({ + "name": "Small", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#####", "#.8.#", "#.w.#", "#####"], + "elevation": ["33333", "33403", "33133", "33333"] + })", "Small"); + return MAP; +} + +UnitTestResult test_step_rules(){ + const MapData& map = small_map(); + PlayerState state{{1, 1}, 3, false}; + if (classify_step(map, state, Direction::EAST) != StepKind::BLOCKED){ + return "Walked from elevation 3 onto elevation 4."; + } + if (classify_step(map, PlayerState{{2, 0}, 3, false}, Direction::SOUTH) != StepKind::BLOCKED){ + return "Entered a north-blocked tile moving south."; + } + state = PlayerState{{1, 2}, 3, false}; + if (classify_step(map, state, Direction::EAST) != StepKind::START_SURF){ + return "Can't start surfing onto the pond."; + } + state = after_step(map, state, Direction::EAST, StepKind::START_SURF); + if (!state.surfing || state.elevation != 1){ + return "Surfing didn't put the player on the water."; + } + if (classify_step(map, state, Direction::EAST) != StepKind::STOP_SURFING){ + return "Can't land from the pond."; + } + if (classify_step(map, PlayerState{{3, 2}, 3, false}, Direction::NORTH) != StepKind::WALK){ + return "Can't step from elevation 3 onto the elevation-0 stair."; + } + return true; +} + +// The maps after the Hall of Fame, which clears the way to Cerulean Cave. +std::vector post_game_maps(){ + return overworld_maps(map_flag_set(overworld_maps(), "after_hall_of_fame")); +} + +MapPosition point_on(const std::vector& maps, const std::string& map, const std::string& point){ + return MapPosition{map, map_point(maps, map, point)}; +} + +const MapData* map_named(const std::vector& maps, const std::string& name){ + const auto map = std::find_if(maps.begin(), maps.end(), [&](const MapData& item){ return item.name == name; }); + return map == maps.end() ? nullptr : &*map; +} + +UnitTestResult test_maps(){ + const std::vector maps = post_game_maps(); + const MapData* b1f = map_named(maps, "CeruleanCave_B1F"); + const MapData* city = map_named(maps, "CeruleanCity"); + if (b1f == nullptr || city == nullptr || map_named(maps, "CeruleanCity_PokemonCenter_1F") == nullptr){ + return "Missing a map."; + } + if (b1f->tiles.width != 40 || b1f->tiles.height != 23 || city->tiles.width != 48 || city->tiles.height != 40){ + return "Unexpected map size."; + } + const TilePos mewtwo_tile = map_point(maps, "CeruleanCave_B1F", "mewtwo"); + const MapObject* mewtwo = object_at(*b1f, mewtwo_tile); + if (mewtwo == nullptr || mewtwo->name != "mewtwo"){ + return "Mewtwo is not on B1F at " + to_string(mewtwo_tile) + "."; + } + if (object_at(*city, {1, 13}) != nullptr){ + return "The cave guard is still there after the Hall of Fame."; + } + if (object_at(*map_named(overworld_maps(), "CeruleanCity"), {1, 13}) == nullptr){ + return "The cave guard is missing before the Hall of Fame."; + } + return true; +} + +// Plans `start` to `goal`, replays it with the step rules and checks where it ends. +UnitTestResult check_route( + const std::vector& maps, + const MapPosition& start, const RouteGoal& goal, + std::vector& route +){ + const std::optional> planned = plan_overworld_route(maps, start, goal); + if (!planned){ + return "No route from " + to_string(start) + " to " + to_string(goal.position) + "."; + } + route = *planned; + const MapPosition end = follow_overworld_route(maps, start, route); + if (!(end == goal.position)){ + return "Route ends at " + to_string(end) + ":" + describe(route); + } + if (goal.facing && route.back().direction != *goal.facing){ + return "Doesn't end facing " + std::string(direction_name(*goal.facing)) + ":" + describe(route); + } + return true; +} + +// Below Mewtwo, facing it, to talk to it. +RouteGoal facing_mewtwo(const std::vector& maps){ + const TilePos mewtwo = map_point(maps, "CeruleanCave_B1F", "mewtwo"); + return RouteGoal{{"CeruleanCave_B1F", mewtwo.next(Direction::SOUTH)}, Direction::NORTH}; +} + +UnitTestResult test_entrance_to_mewtwo(){ + const std::vector maps = post_game_maps(); + std::vector route; + UnitTestResult result = check_route(maps, point_on(maps, "CeruleanCave_1F", "entrance"), facing_mewtwo(maps), route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + bool surfs = false; + bool reaches_b1f = false; + for (const RouteStep& step : route){ + surfs |= step.action == RouteAction::SURF; + reaches_b1f |= step.warps && step.end.map == "CeruleanCave_B1F"; + } + if (!surfs || !reaches_b1f){ + return "Expected to take the ladder to B1F and to surf:" + describe(route); + } + return true; +} + +UnitTestResult test_pokemon_center_to_mewtwo(){ + const std::vector maps = post_game_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps), route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + // Out through the exit mat, onto the city's step below the door, and + // into the cave by its entrance. + std::vector maps_in_order; + for (const RouteStep& step : route){ + if (step.warps){ + maps_in_order.push_back(step.end.map); + } + } + if (maps_in_order.size() < 2 || maps_in_order[0] != "CeruleanCity" || maps_in_order[1] != "CeruleanCave_1F"){ + return "Expected to leave by the mat and enter the cave next:" + describe(route); + } + if (!(route[0].end.map == "CeruleanCity_PokemonCenter_1F" || route[0].warps)){ + return "Unexpected first step:" + describe(route); + } + return true; +} + +UnitTestResult test_cave_guard_blocks(){ + // Before the Hall of Fame the guard stands in the cave entrance. + const std::vector maps = overworld_maps(); + if (plan_overworld_route(maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps))){ + return "Planned past the cave guard."; + } + return true; +} + +UnitTestResult test_mewtwo_tile_unreachable(){ + const std::vector maps = post_game_maps(); + const std::optional> route = plan_overworld_route( + maps, + point_on(maps, "CeruleanCave_1F", "entrance"), + {point_on(maps, "CeruleanCave_B1F", "mewtwo"), std::nullopt} + ); + if (route){ + return "Planned a route onto Mewtwo itself:" + describe(*route); + } + return true; +} + +UnitTestResult test_story_maps(){ + const std::vector maps = overworld_maps(); + for (const std::string name : { + "PalletTown_PlayersHouse_2F", "PalletTown_ProfessorOaksLab", "Route1", "ViridianCity_Mart", + "Route22", "Route2", "ViridianForest", "PewterCity", "PewterCity_Museum_1F", "PewterCity_Gym", + }){ + if (map_named(maps, name) == nullptr){ + return std::string("Missing map ") + name + "."; + } + } + return true; +} + +// Which maps `route` warps into, in order. +std::vector maps_warped_into(const std::vector& route){ + std::vector maps; + for (const RouteStep& step : route){ + if (step.warps){ + maps.push_back(step.end.map); + } + } + return maps; +} + +UnitTestResult test_bedroom_to_oaks_lab(){ + const std::vector maps = overworld_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "PalletTown_PlayersHouse_2F", "start"), + {point_on(maps, "PalletTown", "oaks_lab_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + const std::vector warped = maps_warped_into(route); + if (warped != std::vector{"PalletTown_PlayersHouse_1F", "PalletTown"}){ + return "Expected to take the stairs and leave by the front door:" + describe(route); + } + return true; +} + +UnitTestResult test_viridian_mart_to_pewter_gym(){ + const std::vector maps = overworld_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "ViridianCity_Mart", "clerk_front"), + {point_on(maps, "PewterCity", "gym_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + const std::vector warped = maps_warped_into(route); + if (std::find(warped.begin(), warped.end(), "ViridianForest") == warped.end()){ + return "Expected to go through Viridian Forest:" + describe(route); + } + return true; +} + +// A room with a sign on its north wall, above the third column. +const std::string TEST_SIGN_ROOM_JSON = R"({ + "name": "SignRoom", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["##S##", "#...#", "#...#", "#...#", "#####"], + "elevation": ["33333", "33333", "33333", "33333", "33333"] +})"; + +// Walking north under a sign stops by counting tiles, never by holding into +// the sign; under the wall beside it, the wall stops the player. +UnitTestResult test_no_hold_into_sign(){ + const std::vector maps{parse_map(TEST_SIGN_ROOM_JSON, "SignRoom")}; + std::vector route; + UnitTestResult result = check_route(maps, {"SignRoom", {2, 3}}, {{"SignRoom", {2, 1}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.to_wall && step.end.point == TilePos{2, 1}){ + return "Expected no hold into the sign:" + describe(route); + } + } + route.clear(); + result = check_route(maps, {"SignRoom", {1, 3}}, {{"SignRoom", {1, 1}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + if (route.empty() || !route.back().to_wall){ + return "Expected the wall to stop the player beside the sign:" + describe(route); + } + return true; +} + +// A yard with a row of ledges across it. +const std::string TEST_LEDGE_YARD_JSON = R"({ + "name": "LedgeYard", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#####", "#...#", "#...#", "#vvv#", "#...#", "#####"], + "elevation": ["33333", "33333", "33333", "33333", "33333", "33333"] +})"; + +// A ledge isn't a wall to hold into: moving the way it faces the player +// would hop it, and routes don't hop ledges. Viridian City, +// from the Pokemon Center's door south to the row above the ledge. +UnitTestResult test_no_hold_into_ledge(){ + const std::vector maps{parse_map(TEST_LEDGE_YARD_JSON, "LedgeYard")}; + std::vector route; + UnitTestResult result = check_route(maps, {"LedgeYard", {2, 1}}, {{"LedgeYard", {2, 2}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.to_wall){ + return "Expected no hold into the ledge:" + describe(route); + } + } + route.clear(); + const std::vector world = overworld_maps(); + result = check_route(world, {"ViridianCity", {26, 27}}, {{"ViridianCity", {26, 29}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.to_wall && step.end.point == TilePos{26, 29}){ + return "Expected no hold into the ledge below the Pokemon Center:" + describe(route); + } + } + return true; +} + +// Two small maps that don't need the map files: a room left by the exit mat +// at its bottom, onto the door of a yard, out of which the game walks the +// player. +// No wild battle can start between the bedroom and Oak's Lab: Pallet Town's +// streets have no tall grass. Route 2 and Viridian Forest do. +UnitTestResult test_wild_steps(){ + const std::vector maps = overworld_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "PalletTown_PlayersHouse_2F", "start"), + {point_on(maps, "PalletTown", "oaks_lab_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.wild){ + return "Expected no wild step from the bedroom to Oak's Lab, got " + to_string(step) + ":" + describe(route); + } + } + route.clear(); + result = check_route( + maps, point_on(maps, "ViridianCity_Mart", "clerk_front"), + {point_on(maps, "PewterCity", "gym_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + bool forest = false; + for (const RouteStep& step : route){ + forest = forest || (step.wild && step.end.map == "ViridianForest"); + } + if (!forest){ + return "Expected a wild step in Viridian Forest:" + describe(route); + } + // The leg from Pallet Town north onto Route 1 crosses Route 1's grass + // after the map's edge. + route.clear(); + result = check_route( + maps, point_on(maps, "PalletTown", "oaks_lab_front"), + {point_on(maps, "ViridianCity_Mart", "clerk_front"), std::nullopt}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.end.map == "Route1" && step.direction == Direction::NORTH && step.tiles > 14 && !step.wild){ + return "Expected the leg from Pallet Town onto Route 1 to be wild, got " + to_string(step) + ":" + describe(route); + } + } + return true; +} + +const std::string TEST_ROOM_JSON = R"({ + "name": "Room", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#####", "#...#", "#...#", "##.##"], + "elevation": ["33333", "33333", "33333", "33333"], + "points": {"warp_0": [2, 3]}, + "warps": [{"x": 2, "y": 3, "enter": "walk_south", "to_map": "Yard", "to_point": "warp_0"}] +})"; +const std::string TEST_YARD_JSON = R"({ + "name": "Yard", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#####", "##D##", "#...#", "#####"], + "elevation": ["33333", "33333", "33333", "33333"], + "points": {"warp_0": [2, 1]}, + "warps": [{"x": 2, "y": 1, "enter": "walk_into", "to_map": "Room", "to_point": "warp_0"}] +})"; + +UnitTestResult test_route_through_warp(){ + const std::vector maps{ + parse_map(TEST_ROOM_JSON, "Room"), + parse_map(TEST_YARD_JSON, "Yard"), + }; + std::vector route; + UnitTestResult result = check_route(maps, {"Room", {2, 1}}, {{"Yard", {1, 2}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.warps && step.end.map == "Yard"){ + return true; + } + } + return "Expected to warp into the yard:" + describe(route); +} + +// `json` with its elevation grid replaced by `rows`, or left out if empty. +std::string with_elevation(std::string json, const std::string& rows){ + const size_t begin = json.find("\"elevation\""); + const size_t end = json.find("\n", begin); + json.erase(begin, end - begin + 1); + if (!rows.empty()){ + json.insert(begin, "\"elevation\": " + rows + ",\n"); + } + return json; +} + +// Room and yard as the decompilation writes them, their walls and the +// door at elevation 0, plan the same as with no elevation grid, which +// frlg_maps.py leaves out when every tile the player can stand on is ground. +UnitTestResult test_ground_only_elevation_left_out(){ + const std::vector written{ + parse_map(with_elevation(TEST_ROOM_JSON, R"(["00000", "03330", "03330", "00300"])"), "Room"), + parse_map(with_elevation(TEST_YARD_JSON, R"(["00000", "00000", "03330", "00000"])"), "Yard"), + }; + const std::vector left_out{ + parse_map(with_elevation(TEST_ROOM_JSON, ""), "Room"), + parse_map(with_elevation(TEST_YARD_JSON, ""), "Yard"), + }; + for (const MapPosition& start : {MapPosition{"Room", {1, 1}}, MapPosition{"Room", {3, 2}}}){ + for (const MapPosition& goal : {MapPosition{"Yard", {1, 2}}, MapPosition{"Yard", {3, 2}}, MapPosition{"Room", {2, 2}}}){ + const std::optional> a = plan_overworld_route(written, start, {goal, std::nullopt}); + const std::optional> b = plan_overworld_route(left_out, start, {goal, std::nullopt}); + if (!a || !b || describe(*a) != describe(*b)){ + return "Plans differ from " + to_string(start) + " to " + to_string(goal) + ":" + + (a ? describe(*a) : " none") + "\nwithout elevation:" + (b ? describe(*b) : " none"); + } + } + } + return true; +} + +const RouteTestCase ROUTE_CASES[] = { + { + .input = {.name = "route through a warp", .body = test_route_through_warp}, + .expectation = {.description = "route through a warp"}, + }, + { + .input = {.name = "ground-only elevation left out", .body = test_ground_only_elevation_left_out}, + .expectation = {.description = "ground-only elevation left out"}, + }, + { + .input = {.name = "maps to Brock", .body = test_story_maps}, + .expectation = {.description = "maps to Brock"}, + }, + { + .input = {.name = "bedroom to Oak's Lab", .body = test_bedroom_to_oaks_lab}, + .expectation = {.description = "bedroom to Oak's Lab"}, + }, + { + .input = {.name = "Viridian Mart to Pewter Gym", .body = test_viridian_mart_to_pewter_gym}, + .expectation = {.description = "Viridian Mart to Pewter Gym"}, + }, + { + .input = {.name = "wild steps", .body = test_wild_steps}, + .expectation = {.description = "wild steps"}, + }, + { + .input = {.name = "no hold into a sign", .body = test_no_hold_into_sign}, + .expectation = {.description = "no hold into a sign"}, + }, + { + .input = {.name = "no hold into a ledge", .body = test_no_hold_into_ledge}, + .expectation = {.description = "no hold into a ledge"}, + }, + { + .input = {.name = "step rules", .body = test_step_rules}, + .expectation = {.description = "step rules"}, + }, + { + .input = {.name = "maps", .body = test_maps}, + .expectation = {.description = "maps"}, + }, + { + .input = {.name = "Cerulean Cave entrance to Mewtwo", .body = test_entrance_to_mewtwo}, + .expectation = {.description = "Cerulean Cave entrance to Mewtwo"}, + }, + { + .input = {.name = "Cerulean Pokemon Center to Mewtwo", .body = test_pokemon_center_to_mewtwo}, + .expectation = {.description = "Cerulean Pokemon Center to Mewtwo"}, + }, + { + .input = {.name = "cave guard blocks the way", .body = test_cave_guard_blocks}, + .expectation = {.description = "cave guard blocks the way"}, + }, + { + .input = {.name = "Mewtwo's tile is blocked", .body = test_mewtwo_tile_unreachable}, + .expectation = {.description = "Mewtwo's tile is blocked"}, + }, +}; + +} // namespace + +void add_tests_OverworldRoute(UnitTestDatabase& database){ + for (const RouteTestCase& item : ROUTE_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index e8625e13b3..14129e9086 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -13,6 +13,7 @@ #include "PokemonFRLG_OverworldLateBattle.h" #include "PokemonFRLG_OverworldReanchor.h" #include "PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_OverworldBike.h" #include "PokemonFRLG_OverworldSim.h" namespace PokemonAutomation{ @@ -96,8 +97,6 @@ namespace{ constexpr Milliseconds NO_BATTLE_WAIT(15000); constexpr Milliseconds BATTLE_TIME(10000); constexpr Milliseconds MESSAGE_CLOSE(1000); -// The questions, " used CUT!" and the tree falling. -constexpr Milliseconds CUT_TIME(4000); // Rows of black below a small room, seen from this near its bottom. constexpr int BLACK_BELOW_ROWS = 5; constexpr int SMALL_ROOM_ROWS = 20; @@ -147,8 +146,10 @@ OverworldSim::OverworldSim(std::vector maps, MapPosition start, Directi if (m_map < 0){ throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Sim: start on a map that isn't loaded: " + start.map); } + add_coded_field(); m_player = PlayerState{start.point, elevation_at(map(), start.point), false}; m_state.facing = facing; + m_state.on_bike = m_script.on_bike; show(m_script.start_menu_open ? SimScreen::START_MENU : SimScreen::OVERWORLD, m_now); update_view(); } @@ -243,7 +244,13 @@ std::optional OverworldSim::watch_until(const std::vector& std::optional OverworldSim::take_warp(int map_index, const TilePos& tile) const{ const MapWarp* warp = warp_at(m_maps[map_index], tile); - if (warp == nullptr || !warp->to){ + if (warp == nullptr){ + return std::nullopt; + } + if (m_lift_floor && m_script.lift_floors.contains(m_maps[map_index].name)){ + return lift_warp(map_index); + } + if (!warp->to){ return std::nullopt; } const int destination = find_map(m_maps, warp->to->map); @@ -309,10 +316,13 @@ std::optional OverworldSim::press(Direction direction) const if (kind != StepKind::WALK){ return std::nullopt; } - const Step step{m_map, after_step_onto(map(), m_player, map(), ahead, kind), false, false, false}; + Step step{m_map, after_step_onto(map(), m_player, map(), ahead, kind), false, false, false}; if (warps_on_step(map(), ahead)){ return take_warp(m_map, ahead); } + if (!slide_on(step)){ + return std::nullopt; + } return step; } @@ -358,6 +368,11 @@ bool OverworldSim::after_step(const Step& step, Milliseconds pressed){ m_maps[index].objects.push_back(tree); } m_cut_trees.clear(); + if (m_state.on_bike){ + // A door takes the player off (the user's; not measured). + m_state.on_bike = false; + note("the warp takes the player off the Bicycle."); + } m_frozen = true; schedule(pressed + timing.tile, SimScreen::FADE, false); schedule(pressed + timing.tile + timing.fade, SimScreen::OVERWORLD, true); @@ -458,13 +473,13 @@ void OverworldSim::bump(Direction direction, Milliseconds pressed){ // The steps a hold of `duration` takes, `short_by` fewer. void OverworldSim::walk_hold(Direction direction, Milliseconds duration, int short_by){ - const Milliseconds tile = m_script.timing.tile; + const Milliseconds tile = tile_time(); const Milliseconds end = m_now + duration; Milliseconds pressed = m_now; if (m_state.facing != direction){ - // Holding through a turn lost a tile on the console. + // Holding through a turn lost a tile on the console, on foot. + pressed += m_state.on_bike ? Milliseconds(0) : tile; m_state.facing = direction; - pressed += tile; } take_steps(direction, pressed, end, (int)((end - pressed + tile - 1ms) / tile) - short_by); } @@ -472,7 +487,7 @@ void OverworldSim::walk_hold(Direction direction, Milliseconds duration, int sho // 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; + const Milliseconds tile = tile_time(); for (; left > 0 && pressed < end; left--){ const std::optional step = press(direction); if (!step){ @@ -491,6 +506,7 @@ void OverworldSim::take_steps(Direction direction, Milliseconds pressed, Millise if (step->walked_out){ m_state.facing = Direction::SOUTH; } + pressed += after_slide(*step); if (after_step(*step, pressed)){ return; } @@ -519,6 +535,8 @@ std::optional OverworldSim::hold( m_last_tap.reset(); if (m_state.screen != SimScreen::OVERWORLD || m_frozen){ stuck_hold(direction); + }else if (m_state.on_bike && duration <= MovementMechanics::LONGEST_TAP && drops_bike_tap(start)){ + // Dropped: no step. }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); @@ -537,9 +555,11 @@ void OverworldSim::tap(Direction direction, Milliseconds duration, Milliseconds 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){ + }else if (m_state.on_bike && drops_bike_tap(start)){ + m_last_tap.reset(); + }else if (m_state.facing != direction && !m_state.on_bike){ // From a standstill a tap only turns the player, even one as long - // as LONGEST_TAP. + // as LONGEST_TAP. Not on the Bicycle: there it steps. m_last_tap = TapPress{direction, start, start + duration, m_state.facing, 0}; if (drops_turn_tap()){ m_state.turn_taps_dropped++; @@ -558,7 +578,7 @@ void OverworldSim::tap(Direction direction, Milliseconds duration, Milliseconds 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){ + if (!m_state.on_bike && 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.", @@ -578,7 +598,7 @@ void OverworldSim::step_tap(Direction direction, Milliseconds duration){ // 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; + const Milliseconds tile = tile_time(); Milliseconds pressed = press.start; if (press.facing_before != direction){ pressed += tile; @@ -651,24 +671,6 @@ bool OverworldSim::close_message(){ return true; } -bool OverworldSim::cut_tree(){ - std::vector& objects = m_maps[m_map].objects; - const TilePos ahead = m_player.position.next(m_state.facing); - for (size_t c = 0; c < objects.size(); c++){ - if (objects[c].name == "cut_tree" && objects[c].x == ahead.x && objects[c].y == ahead.y - && m_state.screen == SimScreen::OVERWORLD && !m_frozen - ){ - m_cut_trees.emplace_back(m_map, objects[c]); - objects.erase(objects.begin() + c); - advance_to(m_now + CUT_TIME); - note("cut the tree on " + to_string(MapPosition{map().name, ahead}) + "."); - return true; - } - } - violation("no tree to cut " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); - return false; -} - // @@ -697,6 +699,7 @@ Milliseconds delay_of(const OverworldInterrupts& interrupts, size_t index){ OverworldSimWalker::OverworldSimWalker(OverworldSim& sim, SimWalkerRules rules, const OverworldInterruptSet& walker) : m_sim(sim) , m_rules(rules) + , m_riding(rules.speed == MoveSpeed::BIKE) { // In the walker's order: battles first. m_wild = { @@ -759,6 +762,12 @@ size_t OverworldSimWalker::clear_late(const std::vector& interrupts, } } +void OverworldSimWalker::wait_out_slide(const RouteStep& step){ + if (step.spin_tiles){ + m_sim.watch({}, SPIN_TILE_TIME * *step.spin_tiles + SPIN_STOP_SETTLE); + } +} + void OverworldSimWalker::check_overworld(const RouteStep& step){ const SimScreen screen = m_sim.state().screen; if (m_rules.check_overworld && (screen == SimScreen::START_MENU || screen == SimScreen::MESSAGE)){ @@ -815,7 +824,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, if (step.action != RouteAction::WALK){ fail("Sim: Surf isn't modelled."); } - if (facing != step.direction){ + if (turns_first(step, m_speed, facing)){ sim_turn_to(m_sim, step.direction, m_rules.confirm_turn); } const int warps_before = m_sim.state().warps; @@ -824,7 +833,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, leg.direction = step.direction; leg.tiles = step.tiles + step.warp_slack; leg.to_wall = step.to_wall; - leg.tile_time = TILE_TIME; + leg.tile_time = tile_time(step, m_speed); leg.stop_on_interrupt = !step.to_wall; leg.stop_if_blocked = step.to_wall && m_rules.stop_if_blocked; if (m_rules.hold_over_connections){ @@ -846,6 +855,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, if (result.stopped){ return dark_stop(step, walked_near(step, result.tiles, TILE_TIME)); } + wait_out_slide(step); 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){ @@ -858,7 +868,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, } // walk_and_watch(). -StepOutcome OverworldSimWalker::walk(const RouteStep& step, std::optional facing){ +StepOutcome OverworldSimWalker::walk_ridden(const RouteStep& step, std::optional facing){ if (m_rules.battle_before_step && battle_on_its_way()){ m_log.push_back("Route: " + std::string(sim_screen_name(m_sim.state().screen)) + " before " + to_string(step) + ": watching on for a wild battle set off before it."); if (clear_late(m_wild, UNSEEN_BATTLE_WATCH, MAX_INTERRUPTIONS_PER_LEG) > 0){ @@ -870,7 +880,12 @@ StepOutcome OverworldSimWalker::walk(const RouteStep& step, std::optional walked = walk_step(step, facing)){ + if (m_rules.count_bike_taps && counts_bike_taps(step, m_speed)){ + const StepOutcome outcome = walk_bike_taps(step); + if (outcome.end != StepEnd::DONE){ + return outcome; + } + }else if (const std::optional walked = walk_step(step, facing)){ return StepOutcome{.end = StepEnd::STOPPED, .walked = walked}; } if (step.action == RouteAction::FACE){ @@ -921,6 +936,8 @@ size_t OverworldSimWalker::clear_unseen_battle(const RouteStep& step){ } void OverworldSimWalker::hold_to_warp(const RouteStep& step){ + // The warp it holds on to takes the player off the Bicycle. + m_riding = false; const std::vector& ints = interrupts(step); std::vector fade_first{SimWatcher::FADE}; for (const SimWatcher watcher : watchers(ints)){ @@ -967,23 +984,6 @@ 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 21038720d4..3b847ecc70 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -20,8 +20,10 @@ * A step onto a connected map waits a tile first, as the console does * while the game loads the map. * - * Not modelled: Surf, bikes, running, HP and status. Wandering people - * stand on their tile. + * Not modelled: Surf, running, HP and status. Wandering people stand on + * their tile. The Bicycle is: its pace, its taps (each a tile, some + * dropped when they come too fast), SELECT and doors taking the player + * off it. * */ @@ -40,9 +42,14 @@ #include "Common/Cpp/TestRunners/UnitTestDatabase.h" #include "Pokemon/AutoStory/Pokemon_ScreenEvents.h" #include "PokemonFRLG_MovementMechanics.h" +#include "PokemonFRLG_BikeSpinPresses.h" +#include "PokemonFRLG_BikeSpinMechanics.h" +#include "PokemonFRLG_OverworldBikeTaps.h" #include "PokemonFRLG_OverworldRecovery.h" #include "PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_OverworldSimField.h" #include "PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_OverworldSpin.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -60,7 +67,7 @@ enum class SimScreen{ BATTLE, // a battle's text box with the HP boxes FADE, // a warp's fade out and in }; -// The human-readable name of a simulated screen. +// The human-readable name of a simulated screen. const char* sim_screen_name(SimScreen screen); // What the walker watches the screen with. @@ -87,6 +94,8 @@ struct SimEncounter{ // at these. struct SimTiming{ Milliseconds tile = MovementMechanics::TILE_TIME; + // A tile on the Bicycle. + Milliseconds bike_tile = MovementMechanics::BIKE_TILE_TIME; Milliseconds flash{300}; Milliseconds battle_screen{3700}; // A wild battle's text box, before the HP boxes (battle_screen). Not @@ -132,11 +141,30 @@ struct SimScript{ // (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; + // The player rides the Bicycle: a tap facing another way takes a step + // too, a hold through a turn loses no tile + // (MovementMechanics::BIKE_TAP_ONLY_TURNS), and no step tap is + // ignored: a 40ms turn tap and a 56ms hold 340ms after it took two + // tiles (console B, 2026-11-09, 00.30). At the start: SELECT gets on + // and off (OverworldSim::press_select()), a warp gets off. Each tap is + // a tile; one in MovementMechanics::BIKE_FAST_TAP_DROP_EVERY of those + // that start sooner than BIKE_TAP_DROP_PERIOD after the last is + // dropped. Its pace is timing.bike_tile. + bool on_bike = false; + // The Bicycle is registered (Key Items, REGISTER): SELECT gets on. A + // reload of a save made before it was registered loses it. + bool bike_registered = false; // 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; + // A lift's map, and where its door lets the player out for each floor + // on its panel's menu, top first (pokefirered setdynamicwarp in + // RocketHideout_Elevator's scripts): the door's own warp is the + // game's placeholder. The hideout's: rocket_hideout_lift_room() under + // LIFT_MAP, with rocket_hideout_lift_doors(). + std::map> lift_floors = {}; SimTiming timing; }; @@ -167,6 +195,10 @@ struct SimState{ Milliseconds in_wild_battles{0}; int signs_read = 0; int turn_taps_dropped = 0; + // On the Bicycle (SimScript::on_bike). + bool on_bike = false; + // Bicycle taps the game dropped, too soon after the last one. + int bike_taps_dropped = 0; // Step taps the game ignored, inside the lockout after the last one // (MovementMechanics::STEP_TAP_LOCKOUT). int step_taps_ignored = 0; @@ -201,14 +233,15 @@ enum class SimBattleChoice{ RUN, FIGHT, }; -// How a simulated battle concluded. +// How a simulated battle concluded. enum class SimBattleEnd{ ENDED, CANT_RUN, // a trainer's battle: "No! There's no running from a TRAINER battle!" NO_BATTLE, // there was none, on screen or about to show }; -// Simulates overworld movement, encounters and dialogs. + +// Simulates overworld movement, encounters and dialogs. class OverworldSim{ public: OverworldSim(std::vector maps, MapPosition start, Direction facing, SimScript script = {}); @@ -250,10 +283,9 @@ class OverworldSim{ // battle follows it. bool close_message(); - // Cuts the tree the player faces (an object named "cut_tree") with - // YES to the game's question: it is gone until the player next warps, - // as its hide flag is a FLAG_TEMP. Returns whether there was one. - bool cut_tree(); + // A on what the player faces, as `field` says (`floor`: the lift's). + // Returns whether it was there. (PokemonFRLG_OverworldSimField.cpp) + bool use_field(SimField field, size_t floor = 0); private: // A screen that shows at `at`; `unfreeze`: the player can move again. @@ -269,6 +301,8 @@ class OverworldSim{ bool warped = false; bool walked_out = false; // the warp ended with the game walking the player a tile south bool ledge = false; + // The step landed on a spinner, which slid the player to `player`. + std::optional spin = {}; }; void advance_to(Milliseconds time); @@ -295,11 +329,32 @@ class OverworldSim{ std::optional press(Direction direction) const; std::optional take_warp(int map_index, const TilePos& tile) const; std::optional cross_edge(Direction direction) const; + // The lift's door to the floor picked, if `map_index` is a lift and + // one was picked. + std::optional lift_warp(int map_index) const; + // A step that landed on a spinner slides on to the slide's end; false + // if the slide never stops. + bool slide_on(Step& step) const; + // The slide's time after `step`, and its facing; 0 for no slide. + Milliseconds after_slide(const Step& step); + // What the maps can't say, from code (add_coded_objects()). + void add_coded_field(); + bool cut_tree(); + bool choose_lift_floor(size_t floor); + bool push_poster_switch(); 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 press_select(); + // Whether the Bicycle tap starting at `start` is dropped, too soon + // after the last one. + bool drops_bike_tap(Milliseconds start); + // How long a tile takes: on the Bicycle or on foot. + Milliseconds tile_time() const{ + return m_state.on_bike ? m_script.timing.bike_tile : m_script.timing.tile; + } bool trainer_sees(Milliseconds pressed); void update_view(); void note(const std::string& line); @@ -323,6 +378,8 @@ class OverworldSim{ std::optional m_trainer; // The trees cut since the last warp, and their maps. std::vector> m_cut_trees; + // The floor picked on the lift's panel, if any. + std::optional m_lift_floor; int m_grass_steps = 0; // The game's wild encounter state (SimScript::encounter_rates). std::mt19937 m_encounter_rng; @@ -332,6 +389,9 @@ class OverworldSim{ int m_turn_taps = 0; // The last tap, until a hold. std::optional m_last_tap; + // When the last Bicycle tap started, and how many came too soon. + std::optional m_last_bike_tap; + int m_fast_bike_taps = 0; // When the last tap that stepped started. std::optional m_last_step_tap; // The end of the lockout after the last battle. @@ -410,6 +470,22 @@ struct SimWalkerRules{ // 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; + // The speed the walker goes at: its turn taps + // (OverworldWalkSteps::turns_first()) and its holds' tile time. + MoveSpeed speed = MoveSpeed::WALK; + // An open leg on the Bicycle is counted out a tap at a time, each a + // tile (count_bike_taps()), and one that doesn't move finds the player + // ahead of a block. Without it the leg is one hold, and a start off + // along the leg ends it off with nothing to show it (console B, + // 2026-11-09, 00.30: three runs 3 tiles east after Route 8's legs). + bool count_bike_taps = true; + // How far apart, start to start, the counted taps are + // (MovementMechanics::BIKE_TAP_PERIOD). + Milliseconds bike_tap_period = MovementMechanics::BIKE_TAP_PERIOD; + // After a warp, which takes the player off the Bicycle, the next leg + // where it may be ridden gets on again with SELECT. Without it the + // walker held on-foot legs at the Bicycle's pace. + bool get_back_on_bike = 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, @@ -473,6 +549,10 @@ class OverworldSimWalker{ bool battle; }; StepOutcome walk(const RouteStep& step, std::optional facing); + StepOutcome walk_ridden(const RouteStep& step, std::optional facing); + // count_bike_taps() on the sim. + StepOutcome walk_bike_taps(const RouteStep& step); + BikeTap bike_tap(const RouteStep& step); 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. @@ -495,6 +575,8 @@ class OverworldSimWalker{ StepEnd watch_for_trainer(const RouteStep& step, int approach_tiles); bool moved_past_wall(const RouteStep& step); void check_overworld(const RouteStep& step); + // After a leg onto a spinner: the slide's time. + void wait_out_slide(const RouteStep& step); const std::vector& interrupts(const RouteStep& step) const; std::vector watchers(const std::vector& interrupts) const; [[noreturn]] void fail(const std::string& message); @@ -508,17 +590,20 @@ class OverworldSimWalker{ // and whether its map was dark then. Milliseconds m_leg_start{0}; bool m_leg_dark = false; + // Whether the walker takes the player to ride the Bicycle, and the + // speed of the step under way. + bool m_riding = false; + MoveSpeed m_speed = MoveSpeed::WALK; }; -// Register overworld simulation unit tests. +// 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); - -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation #endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp new file mode 100644 index 0000000000..dfdc41651b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp @@ -0,0 +1,137 @@ +/* Overworld Sim: the Field + * + * From: https://github.com/PokemonAutomation/ + * + * What A does on the sim's overworld, facing something: a tree to cut, + * the Game Corner's poster, the lift's panel (OverworldSim::use_field()). + * + */ + +#include +#include +#include "PokemonFRLG_CodedRooms.h" +#include "PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_OverworldSimField.h" +#include "PokemonFRLG_BikeSpinMechanics.h" +#include "PokemonFRLG_OverworldSpin.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// The questions, " used CUT!" and the tree falling. +constexpr Milliseconds CUT_TIME(4000); + +} // namespace + + +bool OverworldSim::use_field(SimField field, size_t floor){ + switch (field){ + case SimField::CUT_TREE: + return cut_tree(); + case SimField::POSTER_SWITCH: + return push_poster_switch(); + case SimField::LIFT_FLOOR: + return choose_lift_floor(floor); + case SimField::BIKE_SELECT: + return press_select(); + } + return false; +} + +void OverworldSim::add_coded_field(){ + for (MapData& loaded : m_maps){ + add_coded_objects(loaded); + } +} + +bool OverworldSim::slide_on(Step& step) const{ + if (!spin_direction(m_maps[step.map], step.player.position)){ + return true; + } + step.spin = spin_slide(m_maps[step.map], step.player); + if (!step.spin){ + return false; + } + step.player = step.spin->end; + return true; +} + +Milliseconds OverworldSim::after_slide(const Step& step){ + if (!step.spin){ + return Milliseconds(0); + } + // The game moves the player through the slide; the hold, if it goes + // on, walks on from its end. + m_state.facing = step.spin->facing; + note(std::format("slid {} tile(s) to {}.", step.spin->tiles, to_string(step.spin->end.position))); + return MovementMechanics::SPIN_TILE_TIME * step.spin->tiles; +} + +std::optional OverworldSim::lift_warp(int map_index) const{ + const auto lift = m_script.lift_floors.find(m_maps[map_index].name); + if (lift == m_script.lift_floors.end() || !m_lift_floor){ + return std::nullopt; + } + const MapPosition& out = lift->second[*m_lift_floor]; + const auto to = std::find_if(m_maps.begin(), m_maps.end(), [&](const MapData& map){ return map.name == out.map; }); + if (to == m_maps.end()){ + return std::nullopt; + } + return Step{(int)(to - m_maps.begin()), PlayerState{out.point, elevation_at(*to, out.point), false}, true, false, false}; +} + +bool OverworldSim::cut_tree(){ + std::vector& objects = m_maps[m_map].objects; + const TilePos ahead = m_player.position.next(m_state.facing); + for (size_t c = 0; c < objects.size(); c++){ + if (objects[c].name == "cut_tree" && objects[c].x == ahead.x && objects[c].y == ahead.y + && m_state.screen == SimScreen::OVERWORLD && !m_frozen + ){ + m_cut_trees.emplace_back(m_map, objects[c]); + objects.erase(objects.begin() + c); + advance_to(m_now + CUT_TIME); + note("cut the tree on " + to_string(MapPosition{map().name, ahead}) + "."); + return true; + } + } + violation("no tree to cut " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); + return false; +} + +bool OverworldSim::choose_lift_floor(size_t floor){ + const auto lift = m_script.lift_floors.find(map().name); + if (lift == m_script.lift_floors.end() || floor >= lift->second.size() || m_frozen){ + violation("no lift panel on " + to_string(m_state.position) + " with a floor " + std::to_string(floor) + "."); + return false; + } + m_lift_floor = floor; + advance_to(m_now + m_script.timing.message); + note("the lift goes to " + to_string(lift->second[floor]) + "."); + return true; +} + +bool OverworldSim::push_poster_switch(){ + std::vector& objects = m_maps[m_map].objects; + const TilePos ahead = m_player.position.next(m_state.facing); + const auto poster = std::find_if(objects.begin(), objects.end(), [&](const MapObject& object){ + return object.name == "poster" && object.x == ahead.x && object.y == ahead.y; + }); + if (poster == objects.end() || m_state.screen != SimScreen::OVERWORLD || m_frozen){ + violation("no poster " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); + return false; + } + std::erase_if(objects, [](const MapObject& object){ return object.name == "poster_stairs"; }); + advance_to(m_now + m_script.timing.message); + note("pushed the switch behind the poster: the stairs are open."); + return true; +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.h new file mode 100644 index 0000000000..4dd405a0f6 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.h @@ -0,0 +1,48 @@ +/* Overworld Sim Field Actions + * + * From: https://github.com/PokemonAutomation/ + * + * What the overworld sim does for A (or SELECT) on the field, and the + * tests of the Bicycle's taps and SELECT in the sim. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldSimField_H +#define PokemonAutomation_PokemonFRLG_OverworldSimField_H + +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// What A does on the overworld, facing something (OverworldSim::use_field()). +enum class SimField{ + // Cuts the tree the player faces (an object named "cut_tree") with + // YES to the game's question: it is gone until the player next warps, + // as its hide flag is a FLAG_TEMP. + CUT_TREE, + // The poster the player faces (an object named "poster"): the switch + // behind it opens the stairs, which takes away the objects named + // "poster_stairs" for good (pokefirered + // CeladonCity_GameCorner_EventScript_Poster's setmetatile). The sim + // adds both to the Game Corner from code (add_coded_objects()). + POSTER_SWITCH, + // The panel of the lift the player stands in (SimScript::lift_floors): + // the floor picked, the lift's door now lets out there. + LIFT_FLOOR, + // Not A: SELECT, which with the Bicycle registered gets on it where + // running is allowed, or off it, in about + // MovementMechanics::BIKE_SELECT_TIME. (PokemonFRLG_OverworldSimTaps.cpp) + BIKE_SELECT, +}; + +// Register the tests of the Bicycle's taps and SELECT on a made-up map. +void add_tests_OverworldBike(UnitTestDatabase& database); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimTaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimTaps.cpp new file mode 100644 index 0000000000..197d4d7355 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimTaps.cpp @@ -0,0 +1,123 @@ +/* Overworld Sim: taps and the Bicycle + * + * From: https://github.com/PokemonAutomation/ + * + * The walker's taps on the sim: turn taps, step taps, and on the Bicycle + * taps dropped when they come too fast, SELECT, and legs counted out a + * tap at a time. + * + */ + +#include +#include "PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_OverworldSimField.h" +#include "PokemonFRLG_BikeSpinMechanics.h" +#include "PokemonFRLG_BikeSpinPresses.h" +#include "PokemonFRLG_OverworldBike.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace MovementMechanics; +using namespace OverworldWalkSteps; + + +bool OverworldSim::drops_bike_tap(Milliseconds start){ + const bool fast = m_last_bike_tap && start - *m_last_bike_tap < MovementMechanics::BIKE_TAP_DROP_PERIOD; + m_last_bike_tap = start; + if (!fast || ++m_fast_bike_taps % MovementMechanics::BIKE_FAST_TAP_DROP_EVERY != 0){ + return false; + } + m_state.bike_taps_dropped++; + note(std::format("a Bicycle tap is dropped, {} fast taps in.", m_fast_bike_taps)); + return true; +} + +bool OverworldSim::press_select(){ + const Milliseconds start = m_now; + if (m_state.screen != SimScreen::OVERWORLD || m_frozen || !m_script.bike_registered){ + note(m_script.bike_registered ? "SELECT does nothing off the overworld." : "SELECT does nothing: the Bicycle isn't registered."); + advance_to(start + MovementMechanics::BIKE_SELECT_PRESS); + return false; + } + if (!m_state.on_bike && !map().allow_running){ + note("SELECT: the Bicycle can't be ridden here."); + advance_to(start + MovementMechanics::BIKE_SELECT_TIME); + return false; + } + m_state.on_bike = !m_state.on_bike; + note(m_state.on_bike ? "gets on the Bicycle." : "gets off the Bicycle."); + advance_to(start + MovementMechanics::BIKE_SELECT_TIME); + return true; +} + +BikeTap OverworldSimWalker::bike_tap(const RouteStep& step){ + const MapPosition before = m_sim.state().position; + const std::vector& ints = interrupts(step); + const Milliseconds release = m_rules.bike_tap_period - BIKE_TAP; + const std::optional seen = m_sim.hold(step.direction, BIKE_TAP, release, m_rules.bike_tap_period + BIKE_TAP_SETTLE, watchers(ints)); + if (seen){ + m_log.push_back("Bike: " + ints[seen->watcher].name + " on a tap of " + to_string(step) + "."); + return BikeTap{.moved = false, .interrupt = seen->watcher}; + } + if (!(m_sim.state().position == before)){ + return BikeTap{.moved = true}; + } + return BikeTap{.moved = false, .interrupt = std::nullopt, .cleared_late = clear_late(ints, AFTER_LEG_WATCH, MAX_INTERRUPTIONS_PER_LEG) > 0}; +} + +StepOutcome OverworldSimWalker::walk_bike_taps(const RouteStep& step){ + const std::vector& ints = interrupts(step); + BikeTapCalls calls; + calls.tap = [&]{ return bike_tap(step); }; + calls.clear = [&](size_t index){ clear(ints[index]); }; + calls.log = [&](const std::string& message){ m_log.push_back(message); }; + m_leg_start = m_sim.state().time; + m_leg_dark = m_sim.state().dark; + return count_bike_taps(step, calls); +} + +// walk_and_watch(), getting on the Bicycle first where +// walk_overworld_route_with_recovery() does. +StepOutcome OverworldSimWalker::walk(const RouteStep& step, std::optional facing){ + if (m_rules.get_back_on_bike && gets_on_bike(step, m_rules.speed, m_riding)){ + BikeSelectCalls select; + select.press_select = [&]{ m_sim.use_field(SimField::BIKE_SELECT); }; + select.riding = [&]{ return m_sim.state().on_bike; }; + if (!select_onto_bike(select)){ + fail("get_on_bike(): not on the Bicycle after SELECT twice. Is it registered?"); + } + m_riding = true; + facing = std::nullopt; + } + m_speed = m_riding || !m_rules.get_back_on_bike ? m_rules.speed : on_foot(m_rules.speed); + const StepOutcome outcome = walk_ridden(step, facing); + if (m_riding && step.warps){ + m_log.push_back("Bike: through the warp to " + step.end.map + ", off the Bicycle."); + m_riding = false; + } + return outcome; +} + +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); +} + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index 7b7fb39c46..25d6f4bfc4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -24,6 +24,8 @@ #include "PokemonFRLG_OverworldLateBattle.h" #include "PokemonFRLG_OverworldMaps.h" #include "PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_OverworldSimField.h" +#include "PokemonFRLG_OverworldSpin.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -986,6 +988,8 @@ void add_tests_OverworldSim(UnitTestDatabase& database){ } add_tests_OverworldLateBattle(database); add_tests_OverworldWarpFade(database); + add_tests_OverworldSpin(database); + add_tests_OverworldBike(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin.h new file mode 100644 index 0000000000..6a4a5a9730 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin.h @@ -0,0 +1,62 @@ +/* Overworld Spinner Tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Spinner tiles: which way their arrows slide the player, and where a + * slide ends. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldSpin_H +#define PokemonAutomation_PokemonFRLG_OverworldSpin_H + +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_OverworldMap.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Spinner tiles (Rocket Hideout B2F and B3F, Viridian Gym): pokefirered's +// MB_SPIN_UP, MB_SPIN_DOWN, MB_SPIN_LEFT, MB_SPIN_RIGHT and +// MB_STOP_SPINNING. The tile glyphs can't say so: the hideout's floors +// have them coded (coded_spin_direction(), PokemonFRLG_CodedRooms.h); +// another map may list them in these regions, as it does WILD_REGION. +// "spin_north": MB_SPIN_UP. +// "spin_south": MB_SPIN_DOWN. +// "spin_west": MB_SPIN_LEFT. +// "spin_east": MB_SPIN_RIGHT. +// "spin_stop": MB_STOP_SPINNING. +// +// The way the arrow on this tile slides the player, if it is a spinner. +std::optional spin_direction(const MapData& map, const TilePos& point); +// Whether this tile ends a slide (MB_STOP_SPINNING). +bool stops_spinning(const MapData& map, const TilePos& point); + +// Where a slide leaves the player, the way it last moved them, and how +// many tiles it took. +struct SpinSlide{ + PlayerState end; + Direction facing = Direction::NORTH; + int tiles = 0; +}; +// The slide of a player who has just stepped onto the spinner `state` +// stands on (pokefirered TryUpdatePlayerSpinDirection()): a tile at a +// time the last arrow's way, turned by each arrow on the way, until a stop +// tile, or a step the step rules refuse (a wall, a person: the game's +// collision ends the forced movement there). Nothing if `state` isn't on a +// spinner, if the arrows turn the player round in a loop, or if the slide +// crosses a warp, which isn't modelled. +std::optional spin_slide(const MapData& map, const PlayerState& state); + +// Register the tests of spinner tiles, the lift's panel and the poster +// switch on made-up maps. +void add_tests_OverworldSpin(UnitTestDatabase& database); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp new file mode 100644 index 0000000000..e17a69bda4 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp @@ -0,0 +1,334 @@ +/* Overworld Spinner, Lift and Poster Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The Rocket Hideout's walking on small made-up maps: spinner tiles that + * slide the player (the map functions, the planner, the sim and its + * walker), the lift's floor panel and the Game Corner's poster switch + * on the sim. + * + */ + +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_MovementMechanics.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldRecovery.h" +#include "PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_OverworldSimField.h" +#include "PokemonFRLG_OverworldSpin.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct SpinInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct SpinExpectation{ + std::string description; +}; +struct SpinCase{ + SpinInput input; + SpinExpectation expectation; +}; + +class Test_OverworldSpin : public UnitTest{ +public: + explicit Test_OverworldSpin(SpinCase test_case) + : UnitTest("PokemonFRLG::OverworldSpin - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_case.input.body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + SpinCase m_case; +}; + +// A room with spinners: +// - (3, 3) north, up a dead end to (3, 1) against the wall; (3, 2) is only +// ever slid over; +// - (5, 4) east onto (6, 4), which turns the slide south to (6, 5); +// - (7, 3) west, onto the stop tile (5, 3). +const MapData& spinner_room(){ + static const MapData MAP = parse_map(R"({ + "name": "Spinners", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "allow_running": false, + "tiles": ["#########", "###.#####", "###.#####", "#.......#", "#.......#", "#.......#", "#########"], + "regions": { + "spin_north": [[3, 3]], "spin_east": [[5, 4]], "spin_south": [[6, 4]], + "spin_west": [[7, 3]], "spin_stop": [[5, 3]] + } + })", "Spinners"); + return MAP; +} + +// Two arrows that face each other. +const MapData& spinner_loop(){ + static const MapData MAP = parse_map(R"({ + "name": "SpinLoop", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["####", "#..#", "####"], + "regions": {"spin_east": [[1, 1]], "spin_west": [[2, 1]]} + })", "SpinLoop"); + return MAP; +} + +std::string describe(const std::vector& route){ + std::string text; + for (const RouteStep& step : route){ + text += "\n " + to_string(step); + } + return text; +} + +UnitTestResult check_slide(const TilePos& from, const TilePos& end, Direction facing, int tiles){ + const std::optional slide = spin_slide(spinner_room(), PlayerState{from, ELEVATION_GROUND, false}); + if (!slide){ + return "No slide from " + to_string(from) + "."; + } + if (!(slide->end.position == end) || slide->facing != facing || slide->tiles != tiles){ + return std::format( + "From {}: slid {} tile(s) to {} facing {}; expected {} to {} facing {}.", + to_string(from), slide->tiles, to_string(slide->end.position), direction_name(slide->facing), + tiles, to_string(end), direction_name(facing) + ); + } + return true; +} + +UnitTestResult test_slide_into_wall(){ + return check_slide({3, 3}, {3, 1}, Direction::NORTH, 2); +} +UnitTestResult test_slide_turned_by_arrow(){ + return check_slide({5, 4}, {6, 5}, Direction::SOUTH, 2); +} +UnitTestResult test_slide_to_stop_tile(){ + return check_slide({7, 3}, {5, 3}, Direction::WEST, 2); +} +UnitTestResult test_slide_loop(){ + if (spin_slide(spinner_loop(), PlayerState{{1, 1}, ELEVATION_GROUND, false})){ + return "A slide round two arrows that face each other stopped."; + } + if (spin_slide(spinner_room(), PlayerState{{1, 3}, ELEVATION_GROUND, false})){ + return "A slide from a tile with no arrow."; + } + return true; +} + +const MapPosition SPIN_START{"Spinners", {1, 5}}; +const MapPosition DEAD_END{"Spinners", {3, 1}}; + +UnitTestResult test_route_ends_on_slide(){ + const std::vector maps{spinner_room()}; + const auto route = plan_overworld_route(maps, SPIN_START, RouteGoal{DEAD_END, std::nullopt}); + if (!route || route->empty()){ + return std::string("No route to the dead end, which only a slide reaches."); + } + const RouteStep& last = route->back(); + if (last.spin_tiles != 2 || last.to_wall || !(last.end == DEAD_END)){ + return "Expected a last leg onto (3, 3) that slides 2 tiles:" + describe(*route); + } + const MapPosition end = follow_overworld_route(maps, SPIN_START, *route); + if (!(end == DEAD_END)){ + return "The route replays to " + to_string(end) + ":" + describe(*route); + } + return true; +} + +// Walking back down from the dead end steps onto (3, 3), which slides the +// player north again: there is no way out. +UnitTestResult test_dead_end_traps(){ + const std::vector maps{spinner_room()}; + const auto route = plan_overworld_route(maps, DEAD_END, RouteGoal{SPIN_START, std::nullopt}); + if (route){ + return "Planned a way out past the spinner that slides back in:" + describe(*route); + } + return true; +} + +UnitTestResult test_sim_slides(){ + OverworldSim sim({spinner_room()}, MapPosition{"Spinners", {3, 4}}, Direction::NORTH); + sim.hold(Direction::NORTH, MovementMechanics::TILE_TIME, MovementMechanics::STEP_RELEASE, MovementMechanics::TILE_TIME, {}); + if (!(sim.state().position == DEAD_END)){ + return "One step north onto (3, 3) ended on " + to_string(sim.state().position) + "."; + } + if (sim.state().facing != Direction::NORTH){ + return std::string("The slide left the player facing ") + direction_name(sim.state().facing) + "."; + } + return true; +} + +UnitTestResult test_sim_walker_walks_slide(){ + const std::vector maps{spinner_room()}; + const auto route = plan_overworld_route(maps, SPIN_START, RouteGoal{DEAD_END, std::nullopt}); + if (!route){ + return std::string("No route."); + } + OverworldSim sim(maps, SPIN_START, Direction::NORTH); + OverworldSimWalker walker(sim); + const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = SPIN_START, .label = "Sim"}; + const std::string failure = walker.run(*route, planning); + if (!failure.empty() || !(sim.state().position == DEAD_END)){ + std::string log; + for (const std::string& line : walker.log()){ + log += "\n " + line; + } + return "Walked to " + to_string(sim.state().position) + ", failure \"" + failure + "\":" + describe(*route) + log; + } + return true; +} + + +// A lift whose door, the exit mat (1, 2), lets out on the floor picked. +std::vector lift_maps(){ + const std::string FLOOR = R"({ + "name": "%s", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["###", "#.#", "###"] + })"; + std::vector maps; + maps.push_back(parse_map(R"({ + "name": "Lift", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["###", "#.#", "#.#", "###"], + "warps": [{"x": 1, "y": 2, "enter": "walk_south"}] + })", "Lift")); + for (const std::string name : {"FloorA", "FloorB"}){ + std::string json = FLOOR; + json.replace(json.find("%s"), 2, name); + maps.push_back(parse_map(json, name)); + } + return maps; +} + +UnitTestResult lift_ride(bool pick, const MapPosition& expected){ + SimScript script; + script.lift_floors["Lift"] = {MapPosition{"FloorA", {1, 1}}, MapPosition{"FloorB", {1, 1}}}; + OverworldSim sim(lift_maps(), MapPosition{"Lift", {1, 2}}, Direction::SOUTH, script); + if (pick && !sim.use_field(SimField::LIFT_FLOOR, 1)){ + return std::string("No floor 1 on the lift's panel."); + } + sim.hold(Direction::SOUTH, MovementMechanics::TILE_TIME, MovementMechanics::STEP_RELEASE, std::chrono::milliseconds(3000), {}); + if (!(sim.state().position == expected)){ + return "Expected " + to_string(expected) + ", ended on " + to_string(sim.state().position) + "."; + } + return true; +} +UnitTestResult test_lift_floor(){ + return lift_ride(true, MapPosition{"FloorB", {1, 1}}); +} +UnitTestResult test_lift_no_floor(){ + return lift_ride(false, MapPosition{"Lift", {1, 2}}); +} + + +// The poster (2, 1) north of the player; the stairs behind it, (3, 1), +// closed until its switch is pushed. +const MapData& poster_room(){ + static const MapData MAP = parse_map(R"({ + "name": "Corner", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["#####", "#...#", "#...#", "#####"], + "objects": [ + {"name": "poster", "kind": "sign", "x": 2, "y": 1}, + {"name": "poster_stairs", "kind": "npc", "x": 3, "y": 1} + ] + })", "Corner"); + return MAP; +} + +MapPosition poster_walk(bool push){ + OverworldSim sim({poster_room()}, MapPosition{"Corner", {2, 2}}, Direction::NORTH); + if (push){ + sim.use_field(SimField::POSTER_SWITCH); + } + const Milliseconds tile = MovementMechanics::TILE_TIME; + // Each hold turns the player first, which costs a tile's time. + sim.hold(Direction::EAST, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); + sim.hold(Direction::NORTH, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); + return sim.state().position; +} +UnitTestResult test_poster_opens_stairs(){ + const MapPosition shut = poster_walk(false); + if (!(shut == MapPosition{"Corner", {3, 2}})){ + return "Before the switch, walked onto " + to_string(shut) + "."; + } + const MapPosition open = poster_walk(true); + if (!(open == MapPosition{"Corner", {3, 1}})){ + return "After the switch, walked onto " + to_string(open) + ", not the stairs."; + } + return true; +} + +const SpinCase SPIN_CASES[] = { + { + .input = {.name = "slide up to a wall", .body = test_slide_into_wall}, + .expectation = {.description = "slide up to a wall"}, + }, + { + .input = {.name = "slide turned by an arrow", .body = test_slide_turned_by_arrow}, + .expectation = {.description = "slide turned by an arrow"}, + }, + { + .input = {.name = "slide to a stop tile", .body = test_slide_to_stop_tile}, + .expectation = {.description = "slide to a stop tile"}, + }, + { + .input = {.name = "arrows in a loop never stop", .body = test_slide_loop}, + .expectation = {.description = "arrows in a loop never stop"}, + }, + { + .input = {.name = "route ends with a slide", .body = test_route_ends_on_slide}, + .expectation = {.description = "route ends with a slide"}, + }, + { + .input = {.name = "a spinner back into a dead end traps", .body = test_dead_end_traps}, + .expectation = {.description = "a spinner back into a dead end traps"}, + }, + { + .input = {.name = "sim slides the player", .body = test_sim_slides}, + .expectation = {.description = "sim slides the player"}, + }, + { + .input = {.name = "sim walker walks a slide", .body = test_sim_walker_walks_slide}, + .expectation = {.description = "sim walker walks a slide"}, + }, + { + .input = {.name = "lift lets out on the floor picked", .body = test_lift_floor}, + .expectation = {.description = "lift lets out on the floor picked"}, + }, + { + .input = {.name = "lift with no floor picked stays", .body = test_lift_no_floor}, + .expectation = {.description = "lift with no floor picked stays"}, + }, + { + .input = {.name = "poster switch opens the stairs", .body = test_poster_opens_stairs}, + .expectation = {.description = "poster switch opens the stairs"}, + }, +}; + +} // namespace + + +void add_tests_OverworldSpin(UnitTestDatabase& database){ + for (const SpinCase& item : SPIN_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 80cf4de310..104d07542f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -23,6 +23,9 @@ #include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_OverworldBike.h" +#include "PokemonFRLG_BikeSpinMechanics.h" +#include "PokemonFRLG_BikeSpinPresses.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -191,7 +194,74 @@ Milliseconds tile_time(const RouteStep& step, MoveSpeed speed){ if (step.surfing){ return SURF_TILE_TIME; } - return step_speed(step, speed) == MoveSpeed::RUN ? RUN_TILE_TIME : TILE_TIME; + switch (step_speed(step, speed)){ + case MoveSpeed::RUN: + return RUN_TILE_TIME; + case MoveSpeed::BIKE: + return BIKE_TILE_TIME; + case MoveSpeed::WALK: + break; + } + return TILE_TIME; +} + +bool turns_first(const RouteStep& step, MoveSpeed speed, std::optional facing){ + if (facing == step.direction){ + return false; + } + return step.surfing || step_speed(step, speed) != MoveSpeed::BIKE || BIKE_TAP_ONLY_TURNS; +} + +bool counts_bike_taps(const RouteStep& step, MoveSpeed speed){ + return step.action == RouteAction::WALK && step_speed(step, speed) == MoveSpeed::BIKE + && !step.surfing && !step.to_wall && !step.warps && !step.spin_tiles + && step.connection_steps.empty() && step.tiles > 0; +} + +bool gets_on_bike(const RouteStep& step, MoveSpeed speed, bool riding){ + return speed == MoveSpeed::BIKE && !riding && step.action == RouteAction::WALK + && !step.walk_only && !step.surfing; +} + +bool select_onto_bike(const BikeSelectCalls& calls){ + for (int press = 1; press <= 2; press++){ + calls.press_select(); + if (calls.riding()){ + return true; + } + } + return false; +} + +bool screen_shows_riding(const ImageViewRGB32& screen){ + if (StartMenuDetector(COLOR_RED).detect(screen) || SelectionDialogDetector(COLOR_RED).detect(screen) + || WhiteDialogDetector(COLOR_RED).detect(screen) + ){ + return false; + } + return !read_player_facing(screen).has_value(); +} + +void get_on_bike(ConsoleHandle& console, ProControllerContext& context){ + BikeSelectCalls calls; + calls.press_select = [&]{ + pbf_press_button(context, BUTTON_MINUS, BIKE_SELECT_PRESS, BIKE_SELECT_SETTLE); + }; + calls.riding = [&]{ + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + const bool riding = screen && screen_shows_riding(*screen.frame); + console.log(riding ? "Bike: riding." : "Bike: not riding after SELECT.", riding ? COLOR_BLACK : COLOR_ORANGE); + return riding; + }; + console.log("Bike: SELECT (MINUS) onto the Bicycle."); + if (!select_onto_bike(calls)){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "get_on_bike(): not on the Bicycle after SELECT twice. Is it registered?", + console + ); + } } Pokemon::LegMover leg_mover(const RouteStep& step, MoveSpeed speed){ @@ -469,10 +539,16 @@ const char* move_speed_name(MoveSpeed speed){ return "walk"; case MoveSpeed::RUN: return "run"; + case MoveSpeed::BIKE: + return "bike"; } return "?"; } +MoveSpeed on_foot(MoveSpeed speed){ + return speed == MoveSpeed::BIKE ? MoveSpeed::RUN : speed; +} + OverworldInterruptSet overworld_interrupts_with_repel(int max_repel_slot){ OverworldInterruptSet ret; ret.wild.emplace_back(std::make_unique()); @@ -536,6 +612,10 @@ void walk_step_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.spin_tiles){ + // The hold let go on the spinner; the game slides the player on. + pbf_wait(context, SPIN_TILE_TIME * *step.spin_tiles + SPIN_STOP_SETTLE); + } if (!step.warps){ return; } @@ -566,7 +646,8 @@ void walk_overworld_route( // 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){ + // Not on the Bicycle, where the tap is a step. + if (turns_first(step, speed, facing)){ turn_to(console, context, step.direction); } if (const std::string note = speed_note(step, speed); !note.empty()){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index d2120b8e04..5666f54367 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -77,6 +77,8 @@ void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& contex enum class MoveSpeed{ WALK, RUN, + // On the Bicycle, already mounted (MovementMechanics::BIKE_TILE_TIME). + BIKE, }; // Name of a movement speed. const char* move_speed_name(MoveSpeed speed); @@ -152,6 +154,7 @@ MoveSpeed step_speed(const RouteStep& step, MoveSpeed speed); // pace whatever the speed; where running isn't allowed, the walking pace. Milliseconds tile_time(const RouteStep& step, MoveSpeed speed); + // The mover for `step` at `speed`: hold_dpad_running() for a run on foot // where the map allows it, hold_dpad() otherwise (B does nothing on the // water). @@ -256,7 +259,6 @@ void start_surf(ConsoleHandle& console, ProControllerContext& context, Direction void add_tests_OverworldWalk(UnitTestDatabase& database); - } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp index a488f376ae..a0f8605ca0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp @@ -13,6 +13,7 @@ #include "PokemonFRLG_OverworldMaps.h" #include "PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_BikeSpinMechanics.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -118,6 +119,16 @@ const OverworldWalkTestCase OVERWORLD_WALK_CASES[] = { .input = {.name = "walk, surfing", .speed = MoveSpeed::WALK, .tiles = 3, .to_wall = false, .surfing = true}, .expectation = {.hold = Milliseconds(2 * 133 + 66), .note = ""}, }, + // Console B, 2026-11-09: on the bike, 1450ms moved 15 tiles and 50ms + // (a tap) one; 1736ms, held for "west 16 (open)", went past 16. + { + .input = {.name = "bike, open 16", .speed = MoveSpeed::BIKE, .tiles = 16, .to_wall = false, .surfing = false}, + .expectation = {.hold = Milliseconds(1550), .note = ""}, + }, + { + .input = {.name = "bike, open 1", .speed = MoveSpeed::BIKE, .tiles = 1, .to_wall = false, .surfing = false}, + .expectation = {.hold = Milliseconds(50), .note = ""}, + }, }; @@ -127,6 +138,8 @@ struct TurnTapInput{ std::set dropped_turn_taps; // SimWalkerRules::confirm_turn. bool confirm_turn = true; + // On the Bicycle (SimScript::on_bike, SimWalkerRules::speed). + bool on_bike = false; }; struct TurnTapExpectation{ @@ -162,6 +175,11 @@ class Test_OverworldTurnTap : public UnitTest{ script.dropped_turn_taps = m_case.input.dropped_turn_taps; SimWalkerRules rules; rules.confirm_turn = m_case.input.confirm_turn; + if (m_case.input.on_bike){ + script.on_bike = true; + script.timing.tile = BIKE_TILE_TIME; + rules.speed = MoveSpeed::BIKE; + } 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"}; @@ -184,6 +202,10 @@ const TurnTapCase TURN_TAP_CASES[] = { {.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}}, + // Console B, 2026-11-09 (00.30): on the Bicycle the turn tap took a + // step, each one-tile leg went two, and the walk to Route 8 ended on + // Route 12. + {.input = {.name = "on the bike, no turn tap", .on_bike = true}, .expectation = {.end = TURN_END}}, }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PokemonCenterRoom.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PokemonCenterRoom.h new file mode 100644 index 0000000000..9578c86200 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PokemonCenterRoom.h @@ -0,0 +1,52 @@ +/* Pokemon Center Room + * + * From: https://github.com/PokemonAutomation/ + * + * A Pokemon Center's ground floor, coded rather than mapped + * (PokemonFRLG_CodedRooms.h). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_PokemonCenterRoom_H +#define PokemonAutomation_PokemonFRLG_PokemonCenterRoom_H + +#include +#include +#include +#include +#include "PokemonFRLG_OverworldMap.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// A Pokemon Center's ground floor. pokefirered gives all of Kanto's and +// the Sevii Islands' but the Indigo Plateau's and One Island's the one +// layout, LAYOUT_POKEMON_CENTER_1F; the seven in the map lock have the +// same tiles and differ only in who stands where, none of them on the +// nurse's column. +// The mat inside the door: the player arrives on it and leaves from it, +// walking south. +inline constexpr TilePos CENTER_MAT{7, 8}; +// Below the nurse's counter, facing her (the nurse at (7, 2)). +inline constexpr TilePos CENTER_NURSE_FRONT{7, 4}; +// Whether `map` names a Pokemon Center's ground floor. +bool is_pokemon_center(std::string_view map); +// The ground floor of the Center named `name`, whose mat lets out on +// `outside` (the door tile of the town's map): its tiles, the nurse, and +// the mat's warps. No person but the nurse: who stands where differs, and +// the script keeps to the nurse's column. +MapData pokemon_center_room(const std::string& name, const Pokemon::WarpDestination& outside); +// For a sim: adds the coded room of the Center whose door is on +// `maps[town]` to `maps`, and returns its name; nothing if the town has +// no Center door. The town gets a point "
_door" on its door tile +// for the room's mat to let out on. +std::optional add_pokemon_center_room(std::vector& maps, size_t town); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/AuthoredStory_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/AuthoredStory_Tests.cpp index 5ae6fd9f82..dacd5dc049 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/AuthoredStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/AuthoredStory_Tests.cpp @@ -119,7 +119,7 @@ void unknown_event_field(JsonObject& root){ first_event(root)["speed"] = "fast"; } void unknown_variant(JsonObject& root){ - first_event(root)["variant"] = "bike"; + first_event(root)["variant"] = "skate"; } void unknown_game_action(JsonObject& root){ JsonObject& event = first_event(root); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp index 8f6c63ecaf..efc9dfe566 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp @@ -15,10 +15,13 @@ #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h" #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice.h" #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h" +#include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h" +#include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h" #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.h" #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.h" #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.h" #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h" +#include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h" #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h" #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h" #include "LegacyJsonFormat.h" @@ -225,7 +228,8 @@ bool known_game_step(const StoryStep& step){ } return parse_capture_step(step) || parse_pc_step(step) || parse_mart_step(step) || parse_teach_step(step) || parse_grind_step(step) || parse_grind_check_step(step) - || parse_fossil_step(step) || parse_level_step(step); + || parse_fossil_step(step) || parse_level_step(step) + || (is_center_variant(step.variant) && step.args.empty()) || is_item_use_step(step) || is_hideout_step(step); } // Whether FRLG does `step`'s kind the way its variant names. @@ -233,7 +237,7 @@ bool known_variant(const StoryStep& step){ switch (step.kind){ case StoryStepKind::ROUTE: case StoryStepKind::LEGS: - return step.variant.empty() || step.variant == RUN_VARIANT; + return step.variant.empty() || step.variant == RUN_VARIANT || step.variant == BIKE_VARIANT; case StoryStepKind::STEP: return step.variant.empty() || step.variant == TRAINER_LINE_VARIANT || step.variant == TRAINER_WAIT_VARIANT; case StoryStepKind::GAME: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBag.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBag.h new file mode 100644 index 0000000000..9bd80334fb --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBag.h @@ -0,0 +1,78 @@ +/* AutoStory Bag + * + * From: https://github.com/PokemonAutomation/ + * + * The bag as the story's item steps drive it: its pockets, the rows of + * items, and the actions on it, the console's or a test's. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryBag_H +#define PokemonAutomation_PokemonFRLG_AutoStoryBag_H + +#include +#include +#include +#include +#include +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The bag's pockets, in the order LEFT and RIGHT go through them. +enum class BagPocket{ + ITEMS, + KEY_ITEMS, + POKE_BALLS, +}; + +// The pocket of an item the story uses from the bag: repel, super-repel, +// max-repel, escape-rope (Items) or bicycle (Key Items); nothing for +// another. +std::optional usable_item_pocket(std::string_view item); + +// Whether `item` is a Repel of any kind. +bool is_repel(std::string_view item); + +// The row of `item` in `rows` (read_bag_pocket()'s, from the top), if it +// has one left: a count of -1 (unread) counts as some. +std::optional bag_row_of(const std::vector& rows, std::string_view item); + +// The bag, as use_item_from_bag() drives it: the console's, or a test's. +class ItemUseActions{ +public: + virtual ~ItemUseActions() = default; + // From the overworld, opens the bag on `pocket`, scrolled to its top. + virtual bool open_pocket(BagPocket pocket) = 0; + // The open pocket's rows, from the top. + virtual std::vector read_pocket() = 0; + // Moves to row `row`, USE, and through what the item says. + virtual void use_row(size_t row) = 0; + // Moves to row `row` and REGISTERs it for SELECT. + virtual void register_row(size_t row) = 0; + // Back to the overworld (nothing to do if the item closed the bag). + virtual void close_bag() = 0; +}; + +// How use_item_from_bag() ended. +enum class ItemUseEnd{ + USED, + // The pocket has none left (or the name isn't read). + NONE_LEFT, + // The bag didn't open. + NO_BAG, + // Not an item the story uses from the bag. + UNKNOWN_ITEM, +}; + +// "used", "none_left": the end's name in logs and failures. +std::string item_use_end_name(ItemUseEnd end); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBagUse.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBagUse.h new file mode 100644 index 0000000000..802f5c0724 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBagUse.h @@ -0,0 +1,59 @@ +/* AutoStory Bag Use + * + * From: https://github.com/PokemonAutomation/ + * + * Using and registering items from the bag, and renewing a Repel, on + * the console or a test's bag. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryBagUse_H +#define PokemonAutomation_PokemonFRLG_AutoStoryBagUse_H + +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "CommonFramework/Language.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "PokemonFRLG_AutoStoryBag.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Opens the item's pocket, finds it and uses it, then back to the +// overworld. +ItemUseEnd use_item_from_bag(Logger& logger, ItemUseActions& actions, std::string_view item); + +// The same, REGISTERing it for SELECT (the Bicycle) instead of USE. +ItemUseEnd register_item_in_bag(Logger& logger, ItemUseActions& actions, std::string_view item); + +// After "REPEL's effect wore off..." has closed: FRLG asks nothing, so +// another of the same Repel is used if the bag has one. NONE_LEFT is no +// failure: the walk goes on without. +ItemUseEnd renew_repel(Logger& logger, ItemUseActions& actions, std::string_view item); + +// The console's bag. +class ConsoleItemUseActions : public ItemUseActions{ +public: + ConsoleItemUseActions(ConsoleHandle& console, ProControllerContext& context, Language language); + + virtual bool open_pocket(BagPocket pocket) override; + virtual std::vector read_pocket() override; + virtual void use_row(size_t row) override; + virtual void register_row(size_t row) override; + virtual void close_bag() override; + +private: + ConsoleHandle& m_console; + ProControllerContext& m_context; + Language m_language; +}; + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index 0deab3ef7c..a70c1da706 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -86,6 +86,17 @@ constexpr StoryTrainer STORY_TRAINERS[] = { {.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"}, + {.map = "Route8", .battle = "gamer_rich", .trainer = "TRAINER_GAMER_RICH"}, + // The Rocket Hideout, as PokeGuy's run fights it (the 00.31 and 00.32 + // drafts): the Grunt at the Game Corner's poster (11, 2) + // (CeladonCity_GameCorner_EventScript_RocketGrunt); on B4F, Grunt1 at + // (4, 2), who drops the LIFT KEY, the door guards Grunt2 (16, 14) and + // Grunt3 (19, 14), and Giovanni (19, 4) (RocketHideout_B4F scripts.inc). + {.map = "CeladonCity_GameCorner", .battle = "rocket_grunt", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_7"}, + {.map = "RocketHideout_B4F", .battle = "lift_key_grunt", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_18"}, + {.map = "RocketHideout_B4F", .battle = "left_guard", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_16"}, + {.map = "RocketHideout_B4F", .battle = "right_guard", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_17"}, + {.map = "RocketHideout_B4F", .battle = "giovanni", .trainer = "TRAINER_BOSS_GIOVANNI"}, }; using JsonTable = std::map>; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData_Tests.cpp index d2e1cda312..4b4a442605 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData_Tests.cpp @@ -140,6 +140,38 @@ UnitTestResult test_story_trainers(){ return true; } +// The Rocket Hideout's battles, which no table row fights yet (the +// 00.31 and 00.32 drafts): each names its map's trainer, with pret's +// party. +UnitTestResult test_hideout_trainers(){ + struct Expected{ + std::string map; + std::string battle; + std::vector party; + }; + const Expected EXPECTED[] = { + {"CeladonCity_GameCorner", "rocket_grunt", {"raticate", "zubat"}}, + {"RocketHideout_B4F", "lift_key_grunt", {"koffing", "zubat"}}, + {"RocketHideout_B4F", "left_guard", {"sandshrew", "ekans", "sandslash"}}, + {"RocketHideout_B4F", "right_guard", {"ekans", "sandshrew", "arbok"}}, + {"RocketHideout_B4F", "giovanni", {"onix", "rhyhorn", "kangaskhan"}}, + }; + for (const Expected& expected : EXPECTED){ + const std::optional trainer = story_trainer(expected.map, expected.battle); + if (!trainer){ + return std::format("{} on {}: no trainer.", expected.battle, expected.map); + } + const std::vector on_map = map_trainers(expected.map); + if (std::find(on_map.begin(), on_map.end(), *trainer) == on_map.end()){ + return std::format("{} on {}: {} isn't one of the map's trainers.", expected.battle, expected.map, *trainer); + } + if (trainer_party(*trainer) != expected.party){ + return std::format("{} on {}: {} hasn't the expected party.", expected.battle, expected.map, *trainer); + } + } + return true; +} + // Mt. Moon: Super Nerd Miguel's Grimer (Poison) takes Water Gun, with or // without Bite known: Bite (60, Dark) ties Water Gun (40 x 1.5 STAB), both // special, and the tie goes to the plan's Water Gun. @@ -208,6 +240,10 @@ const AutoStoryBattleDataTestCase AUTO_STORY_BATTLE_DATA_CASES[] = { .input = {.name = "every story battle fights a trainer with a party", .body = test_story_trainers}, .expectation = {.description = "ensures all scripted story battles encounter valid teams"}, }, + { + .input = {.name = "the Rocket Hideout's Grunts and Giovanni", .body = test_hideout_trainers}, + .expectation = {.description = "names each hideout battle's trainer and party"}, + }, { .input = {.name = "Miguel's Grimer: Water Gun", .body = test_miguel_grimer}, .expectation = {.description = "verifies move matchup choice against Miguel Grimer"}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp index 877265def1..5d6582a1b6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp @@ -22,6 +22,7 @@ #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_BattleMenuNavigation.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryCapture.h" +#include "PokemonFRLG_AutoStoryPacing.h" #include "PokemonFRLG_AutoStoryVariantWords.h" namespace PokemonAutomation{ @@ -502,9 +503,8 @@ ConsoleCaptureActions::ConsoleCaptureActions( {} bool ConsoleCaptureActions::find_encounter(Direction pace){ - PaceSpot spot; - ConsolePatchSteps steps(m_console, m_context, pace, spot, m_trainer_battle); - return pace_until_battle(m_console.logger(), steps, PACE_MAX_STEPS); + ConsolePatchSteps steps(m_console, m_context, pace, m_spot, m_trainer_battle); + return find_paced_encounter(m_console.logger(), console_bike_select(m_console, m_context), steps); } EncounterSeen ConsoleCaptureActions::read_encounter(){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h index b273878c59..7b692d4a67 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h @@ -318,6 +318,8 @@ class ConsoleCaptureActions : public CaptureActions{ ProControllerContext& m_context; Language m_language; PaceTrainerBattle m_trainer_battle; + // Where pacing left the player, kept from one battle to the next. + PaceSpot m_spot; }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 2aad5b3368..b089da5379 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -22,6 +22,7 @@ #include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryHideout.h" #include "PokemonFRLG_AutoStoryScreenEvents.h" #include "PokemonFRLG_AutoStorySummary.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -364,10 +365,6 @@ const std::vector& story_maps(){ return maps; } -MoveSpeed move_speed(const StoryStep& step){ - return step.variant == RUN_VARIANT ? MoveSpeed::RUN : MoveSpeed::WALK; -} - Pokemon::Pose to_pose(const StoryPose& pose){ return Pokemon::tile_pose(pose.map, {pose.tile}, pose.facing); } @@ -396,6 +393,7 @@ std::string save_card_place(const std::string& map){ {"VermilionCity", "vermilion-city"}, {"Route10", "route-10"}, {"LavenderTown", "lavender-town"}, + {"CeladonCity", "celadon-city"}, }; for (const auto& [prefix, place] : PLACES){ if (map == prefix || map.rfind(prefix + "_", 0) == 0){ @@ -689,7 +687,10 @@ Outcome other_game_step(StoryEnv& env, RunState& state, const StoryStep& step){ pace_trainer_battle(env, state, step, patch); }); ConsolePcStorageActions pc(env.console, env.context); - std::optional done = run_storage_game_step(env.console.logger(), &env.console, step, capture, pc); + std::optional done = run_field_game_step(env.console, env.context, env.language, step); + if (!done){ + done = run_storage_game_step(env.console.logger(), &env.console, step, capture, pc); + } if (!done){ done = run_abilities(env, state, step); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index 9e089b767c..ee7cb5dced 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -44,11 +44,11 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -// Story leg definition imported from Pokemon AutoStory. +// Story leg definition imported from Pokemon AutoStory. using Pokemon::StoryLeg; -// Story step definition imported from Pokemon AutoStory. +// Story step definition imported from Pokemon AutoStory. using Pokemon::StoryStep; -// Story step kind classification imported from Pokemon AutoStory. +// Story step kind classification imported from Pokemon AutoStory. using Pokemon::StoryStepKind; // Where the player stands and faces: the one-tile poses of FRLG's table @@ -69,7 +69,7 @@ inline const std::string RUN_VARIANT = "run"; // (step_until_seen()). The battle after it starts on the trainer's row // whether the walk before ended on its tile or a tile short. inline const std::string TRAINER_LINE_VARIANT = "trainer_line"; -// Maximum tiles to step into a trainer's line of sight. +// Maximum tiles to step into a trainer's line of sight. inline constexpr int TRAINER_LINE_TILES = 3; // The variant of a STEP into the line of a trainer who looks around @@ -101,7 +101,8 @@ inline const std::string CLOSE_TRAINER_CARD = "close_trainer_card"; // the overworld. inline const std::string LEAVE_GIFT_SUMMARY = "leave_gift_summary"; -// How `step`, a ROUTE or LEGS, moves the player: RUN_VARIANT runs. +// How `step`, a ROUTE or LEGS, moves the player: RUN_VARIANT runs, +// BIKE_VARIANT rides. MoveSpeed move_speed(const StoryStep& step); @@ -176,7 +177,7 @@ const std::vector& story_maps(); // City's harbor). const RouteCosts& story_route_costs(); -// Converts a StoryPose to a general navigation Pose. +// Converts a StoryPose to a general navigation Pose. Pokemon::Pose to_pose(const StoryPose& pose); // `pose` as a StoryPose: nothing unless it is on one tile. std::optional story_pose(const Pokemon::Pose& pose); @@ -344,8 +345,7 @@ Pokemon::Checkpoint build_checkpoint(const StoryCheckpoint& data, StoryExecutor& Pokemon::Segment make_story_segment(const std::vector& checkpoints, StoryExecutor& executor); - -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation #endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp index bd2a0112bb..61a208d570 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp @@ -98,7 +98,7 @@ const std::map KNOWN_FAILING{ // 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}, + {"Route7", 21}, {"Route8", 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}, }; @@ -161,6 +161,19 @@ struct RowRun{ // Where a stubbed step leaves the player instead of its pose_after: by step index. using Landings = std::map; +// Whether `map` is one of sweep_maps(). +bool map_loaded(const std::string& map){ + const std::vector& maps = sweep_maps(); + return std::any_of(maps.begin(), maps.end(), [&](const MapData& data){ return data.name == map; }); +} + +// Whether a step of `row` after its step `c` walks. +bool walks_after(const StoryCheckpoint& row, size_t c){ + return 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; + }); +} + // 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). @@ -180,6 +193,7 @@ RowRun run_row( const std::optional>& scripted = std::nullopt ){ RowRun out; + std::optional off_maps; 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){ @@ -204,6 +218,12 @@ RowRun run_row( 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 (!map_loaded(land.map)){ + // A room with no map (Celadon's Pokemon Center) must end the row. + off_maps = land; + out.failure = walks_after(row, c) ? std::format("{}/{}: ends on {}, which has no map, with a walk after it.", row.id, step.name, land.map) : ""; + break; + } if (!(sim->state().position == land)){ add(sim->state()); sim = std::make_unique(sweep_maps(), land, at.facing.value_or(sim->state().facing), script); @@ -212,7 +232,7 @@ RowRun run_row( if (out.failure.empty() && !sim->state().violations.empty()){ out.failure = "a violation on the way: " + sim->state().violations.back(); } - out.end = sim->state().position; + out.end = off_maps.value_or(sim->state().position); add(sim->state()); return out; } @@ -282,9 +302,7 @@ UnitTestResult sweep_scene_end_pose(Logger& logger, const StoryCheckpoint& row, // 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; - }); + const bool walk_after = walks_after(row, c); if (sim_plays(step) || !walk_after || !after || (from != clean.before.end() && from->second == MapPosition{after->map, after->tile})){ continue; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFieldSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFieldSteps.h new file mode 100644 index 0000000000..7712e128e1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFieldSteps.h @@ -0,0 +1,54 @@ +/* AutoStory Field Steps + * + * From: https://github.com/PokemonAutomation/ + * + * Table constructors for the GAME steps on the field: the hideout's + * switch and lift, Pokemon Centers, and items from the bag. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryFieldSteps_H +#define PokemonAutomation_PokemonFRLG_AutoStoryFieldSteps_H + +#include +#include +#include +#include "PokemonFRLG_AutoStoryTableSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryTable{ + + +// Pushes the switch behind the Game Corner's poster the player faces +// (POSTER_SWITCH_VARIANT). +StoryStep poster_switch(std::string name, std::string why); + +// Rides the Rocket Hideout's lift from the floor it stands at to `to` +// (LIFT_FLOOR_VARIANT): "B1F", "B2F", "B4F". The player starts below +// `from`'s lift door, facing north, and ends on `to`'s, facing south. +StoryStep lift_floor(std::string name, std::string_view from, std::string_view to, std::string why); + +// Into the Pokemon Center `center` (a map name) from below its door, +// facing north, to below the nurse's counter (CENTER_ENTER_VARIANT): the +// same script in every Center, with no map of its rooms. +StoryStep center_enter(std::string name, const std::string& center, std::string why); + +// Out of the Center from below the nurse's counter (CENTER_EXIT_VARIANT) +// to `below_door`, the tile below its door outside, facing south. +StoryStep center_exit(std::string name, Pokemon::Pose below_door, std::string why); + +// Uses `item` from the bag on the overworld (USE_ITEM_VARIANT): a +// Repel, or an Escape Rope, which leaves the player on `pose_after`. +StoryStep use_item(std::string name, std::string_view item, std::string why, std::optional pose_after = std::nullopt); + +// Gets on the Bicycle (RIDE_BIKE_VARIANT). +StoryStep ride_bike(std::string name, std::string why); + + +} // namespace StoryTable +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp index 670a4dbe47..57c103090d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp @@ -10,6 +10,7 @@ #include "CommonFramework/Exceptions/OperationFailedException.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryPacing.h" #include "PokemonFRLG_AutoStoryVariantWords.h" namespace PokemonAutomation{ @@ -349,7 +350,7 @@ bool ConsoleGrindActions::find_encounter(Direction pace){ // As ConsoleCaptureActions::find_encounter(), keeping track of the // tile: a battle starts on the tile just stepped onto. ConsolePatchSteps steps(m_console, m_context, pace, m_spot, m_trainer_battle); - return pace_until_battle(m_console.logger(), steps, PACE_MAX_STEPS); + return find_paced_encounter(m_console.logger(), console_bike_select(m_console, m_context), steps); } EncounterSeen ConsoleGrindActions::read_encounter(){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp new file mode 100644 index 0000000000..7c648a746c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp @@ -0,0 +1,159 @@ +/* AutoStory Rocket Hideout Steps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryHideoutLift.h" +#include "PokemonFRLG_AutoStoryItemUse.h" +#include "PokemonFRLG_AutoStoryBagUse.h" +#include "PokemonFRLG_AutoStoryBag.h" +#include "PokemonFRLG_AutoStoryRooms.h" +#include "PokemonFRLG_AutoStoryRoomLegs.h" +#include "PokemonFRLG_AutoStoryVariantWords.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +using namespace std::chrono_literals; + + +const std::vector& rocket_hideout_lift_menu(){ + static const std::vector MENU{"B1F", "B2F", "B4F", "EXIT"}; + return MENU; +} + +namespace{ + +// The row of `floor` on the panel's menu, if it is a floor (not EXIT). +std::optional floor_row(std::string_view floor){ + const std::vector& menu = rocket_hideout_lift_menu(); + for (size_t row = 0; row + 1 < menu.size(); row++){ + if (menu[row] == floor){ + return row; + } + } + return std::nullopt; +} + +} // namespace + +std::optional parse_lift_floor_step(const Pokemon::StoryStep& step){ + const Pokemon::StoryArgs* args = game_step_args(step, LIFT_FLOOR_VARIANT, LIFT_FLOOR_ARGS); + if (args == nullptr){ + return std::nullopt; + } + const std::optional from = floor_row(*Pokemon::story_arg_text(*args, "from")); + const std::optional to = floor_row(*Pokemon::story_arg_text(*args, "to")); + if (!from || !to){ + return std::nullopt; + } + return LiftRide{*from, *to}; +} + +MenuPresses lift_menu_presses(const LiftRide& ride, size_t entries){ + const size_t down = (ride.to + entries - ride.from) % entries; + const size_t up = (ride.from + entries - ride.to) % entries; + if (entries <= 3){ + // No wrap: the cursor goes straight there. + return ride.to >= ride.from + ? MenuPresses{Pokemon::Direction::SOUTH, ride.to - ride.from} + : MenuPresses{Pokemon::Direction::NORTH, ride.from - ride.to}; + } + return down <= up ? MenuPresses{Pokemon::Direction::SOUTH, down} : MenuPresses{Pokemon::Direction::NORTH, up}; +} + +bool is_hideout_step(const Pokemon::StoryStep& step){ + return (step.variant == POSTER_SWITCH_VARIANT && step.args.empty()) || parse_lift_floor_step(step).has_value(); +} + + +namespace{ + +// How long a message and its menu take to show on fast text, before the +// menu takes the D-pad. Not measured on the console. +constexpr Milliseconds LIFT_MENU_WAIT = 1500ms; +constexpr Milliseconds MENU_TAP = 80ms; +constexpr Milliseconds MENU_TAP_RELEASE = 240ms; +// The lift's shake and the doors, then the overworld, A press to the end. +constexpr Milliseconds HIDEOUT_SCENE_TIMEOUT = 30000ms; + +} // namespace + +void pick_lift_floor(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride){ + const MenuPresses presses = lift_menu_presses(ride, rocket_hideout_lift_menu().size()); + console.log( + "Story: lift from " + rocket_hideout_lift_menu()[ride.from] + " to " + rocket_hideout_lift_menu()[ride.to] + + ": " + std::to_string(presses.presses) + " press(es) " + Pokemon::direction_name(presses.direction) + "." + ); + pbf_press_button(context, BUTTON_A, 160ms, LIFT_MENU_WAIT); + const DpadPosition dpad = presses.direction == Pokemon::Direction::SOUTH ? DPAD_DOWN : DPAD_UP; + for (size_t c = 0; c < presses.presses; c++){ + pbf_press_dpad(context, dpad, MENU_TAP, MENU_TAP_RELEASE); + } + pbf_press_button(context, BUTTON_A, 160ms, 800ms); + // B during the ride does nothing; the menu took the A. + mash_until_overworld(console, context, HIDEOUT_SCENE_TIMEOUT); +} + +void ride_lift(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride){ + walk_room_legs(console, context, lift_in_legs(), Pokemon::Direction::NORTH); + pick_lift_floor(console, context, ride); + walk_room_legs(console, context, lift_out_legs(), Pokemon::Direction::NORTH); +} + +void push_poster_switch(ConsoleHandle& console, ProControllerContext& context){ + console.log("Story: the switch behind the poster."); + pbf_press_button(context, BUTTON_A, 160ms, 800ms); + mash_until_overworld(console, context, HIDEOUT_SCENE_TIMEOUT); +} + +std::optional run_field_game_step( + ConsoleHandle& console, ProControllerContext& context, Language language, const Pokemon::StoryStep& step +){ + const auto done = [&step]{ + return step.pose_after ? Pokemon::outcome_ok(*step.pose_after) : Pokemon::outcome_ok(); + }; + if (const std::optional ride = parse_lift_floor_step(step)){ + ride_lift(console, context, *ride); + return done(); + } + if (is_center_variant(step.variant)){ + // Both start facing north: at the door, or at the nurse. + walk_room_legs(console, context, center_legs(step.variant), Pokemon::Direction::NORTH); + return done(); + } + if (step.variant == POSTER_SWITCH_VARIANT){ + push_poster_switch(console, context); + return Pokemon::outcome_ok(); + } + if (!is_item_use_step(step)){ + return std::nullopt; + } + if (step.variant == RIDE_BIKE_VARIANT){ + OverworldWalkSteps::get_on_bike(console, context); + return done(); + } + ConsoleItemUseActions bag(console, context, language); + const ItemUseEnd end = step.variant == REGISTER_BIKE_VARIANT + ? register_item_in_bag(console.logger(), bag, "bicycle") + : use_item_from_bag(console.logger(), bag, *parse_use_item_step(step)); + if (end != ItemUseEnd::USED){ + return Pokemon::outcome_failed(std::string("use_item_") + item_use_end_name(end), step.variant); + } + return done(); +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h new file mode 100644 index 0000000000..f343157956 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h @@ -0,0 +1,77 @@ +/* AutoStory Rocket Hideout Steps + * + * From: https://github.com/PokemonAutomation/ + * + * The two story steps the Rocket Hideout needs that no other place does: + * the switch behind the Celadon Game Corner's poster, which opens the + * stairs down, and the lift's floor panel. Both are GAME steps named by + * their variant, as the draft plans write them (StoryData/Draft). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryHideout_H +#define PokemonAutomation_PokemonFRLG_AutoStoryHideout_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "CommonFramework/Language.h" +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The GAME step that pushes the switch behind the Game Corner's poster, +// the player facing it (pokefirered CeladonCity_GameCorner_EventScript_ +// Poster): A, "Hey! A switch behind the POSTER!? Let's push it!", and the +// stairs (15..17, 2..3) open for good. The Grunt in front of it must be +// beaten first. +inline const std::string POSTER_SWITCH_VARIANT = "poster_switch"; + +// The GAME step that rides the Rocket Hideout's lift with the LIFT KEY, +// its args `from` and `to`, floors as the panel names them (B1F, B2F, +// B4F). `from` is the floor the lift stands at, where the panel's cursor +// starts. The player starts below `from`'s lift door, facing north, and +// ends on `to`'s lift door (rocket_hideout_lift_doors()): the lift is +// walked by a script (lift_in_legs(), lift_out_legs()), not a map. +inline constexpr std::string_view LIFT_FLOOR_VARIANT = "lift_floor"; +// Its args: the floors it rides from and to. +inline constexpr Pokemon::StoryArgSpec LIFT_FLOOR_ARGS[] = { + {"from", Pokemon::StoryArgType::TEXT, true}, + {"to", Pokemon::StoryArgType::TEXT, true}, +}; + + +// Whether `step` is one of this file's steps. +bool is_hideout_step(const Pokemon::StoryStep& step); + +// On the console: A on the poster, its message closed back to the +// overworld. +void push_poster_switch(ConsoleHandle& console, ProControllerContext& context); + +// On the console, a GAME step of this file's, a Pokemon Center's +// (is_center_variant()) or an item from the bag (is_item_use_step()); +// nothing for another GAME step. The step ends on its pose_after, if it +// has one (an Escape Rope's, a lift ride's, a Center's). +std::optional run_field_game_step( + ConsoleHandle& console, ProControllerContext& context, Language language, const Pokemon::StoryStep& step +); + + +// Registers the hideout steps' tests. +void add_tests_AutoStoryHideout(UnitTestDatabase& database); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideoutLift.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideoutLift.h new file mode 100644 index 0000000000..0f782cae71 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideoutLift.h @@ -0,0 +1,64 @@ +/* AutoStory Hideout Lift + * + * From: https://github.com/PokemonAutomation/ + * + * Riding the Rocket Hideout's lift: its panel's menu, the presses that + * pick a floor, and the ride on the console. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryHideoutLift_H +#define PokemonAutomation_PokemonFRLG_AutoStoryHideoutLift_H + +#include +#include +#include +#include +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The lift panel's menu, top first (pokefirered +// sMultichoiceList_RocketHideoutElevator): B1F, B2F, B4F, EXIT. +const std::vector& rocket_hideout_lift_menu(); + +// A ride: the menu rows the cursor starts on and is moved to. +struct LiftRide{ + size_t from = 0; + size_t to = 0; +}; + +// The ride a lift_floor step names, or nothing if it is not one, or names +// a floor the panel hasn't. +std::optional parse_lift_floor_step(const Pokemon::StoryStep& step); + +// Presses of the D-pad on a menu: which way, how many. +struct MenuPresses{ + Pokemon::Direction direction = Pokemon::Direction::SOUTH; + size_t presses = 0; +}; + +// The fewest presses that move the cursor from `ride.from` to `ride.to` +// on a menu of `entries` rows. A multichoice of more than three rows +// wraps round (pokefirered DrawVerticalMultichoiceMenu()). +MenuPresses lift_menu_presses(const LiftRide& ride, size_t entries); + +// On the console: A on the panel, the floor picked, the ride waited out +// back to the overworld, the player still in the lift. +void pick_lift_floor(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride); + +// On the console, the whole ride: into the lift from below the door, +// the floor picked, out onto the floor's lift door. +void ride_lift(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp new file mode 100644 index 0000000000..e84ba87ea8 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp @@ -0,0 +1,311 @@ +/* AutoStory Rocket Hideout Steps Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_HideoutLiftRoom.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryHideoutLift.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" +#include "PokemonFRLG_AutoStoryFieldSteps.h" +#include "PokemonFRLG_AutoStoryVariantWords.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct LiftInput{ + std::string name; + std::string variant; +}; +struct LiftExpectation{ + // Nothing: the variant isn't a ride. + std::optional presses; +}; +struct LiftCase{ + LiftInput input; + LiftExpectation expectation; +}; + +class Test_AutoStoryHideout : public UnitTest{ +public: + explicit Test_AutoStoryHideout(LiftCase test_case) + : UnitTest("PokemonFRLG::AutoStoryHideout - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::string_view floors[] = {"from", "to"}; + const StoryStep step = legacy_game_step(m_case.input.variant, floors); + const std::optional ride = parse_lift_floor_step(step); + const std::optional& expected = m_case.expectation.presses; + if (!ride || !expected){ + if (ride.has_value() != expected.has_value()){ + return std::format("\"{}\": a ride {}, expected {}.", m_case.input.variant, ride.has_value(), expected.has_value()); + } + return true; + } + const MenuPresses got = lift_menu_presses(*ride, rocket_hideout_lift_menu().size()); + if (got.direction != expected->direction || got.presses != expected->presses){ + return std::format( + "\"{}\": {} press(es) {}, expected {} {}.", m_case.input.variant, + got.presses, Pokemon::direction_name(got.direction), expected->presses, Pokemon::direction_name(expected->direction) + ); + } + if (!is_hideout_step(step)){ + return "A ride that isn't a hideout step."; + } + return true; + } + +private: + LiftCase m_case; +}; + +UnitTestResult test_variants(){ + const auto game_step = [](std::string variant, Pokemon::StoryArgs args){ + StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.variant = std::move(variant); + step.args = std::move(args); + return step; + }; + if (!is_hideout_step(game_step(POSTER_SWITCH_VARIANT, {})) || is_hideout_step(game_step("cut_tree", {})) + || is_hideout_step(game_step("", {})) || is_hideout_step(game_step(POSTER_SWITCH_VARIANT, {{"from", "B1F"}})) + ){ + return "Expected only the poster switch and lift rides as hideout steps."; + } + const StoryStep ride = StoryTable::lift_floor("ride", "B2F", "B4F", ""); + const Pokemon::StoryArgs expected{{"from", "B2F"}, {"to", "B4F"}}; + if (ride.variant != LIFT_FLOOR_VARIANT || ride.args != expected){ + return "Expected lift_floor with {from: B2F, to: B4F}, got \"" + ride.variant + "\"."; + } + // A three-row menu doesn't wrap. + const MenuPresses no_wrap = lift_menu_presses(LiftRide{0, 2}, 3); + if (no_wrap.direction != Pokemon::Direction::SOUTH || no_wrap.presses != 2){ + return "Expected 2 down on a three-row menu."; + } + return true; +} + +// The table's steps, run as a row runs them on the sim: the poster's +// switch opens the stairs north-east of it, and the lift lets out on the +// floor picked. +std::string poster_on_sim(); +std::string lift_on_sim(); + +UnitTestResult test_steps_on_sim(){ + const std::string poster = poster_on_sim(); + if (!poster.empty()){ + return poster; + } + const std::string lift = lift_on_sim(); + if (!lift.empty()){ + return lift; + } + return true; +} + +// The Game Corner with no poster or stairs in its map: the sim adds them +// from code (add_coded_objects()). The room ends at x 16, so the walk +// east ends on (15, 2), the stairs' top, only once the switch opens it. +std::string poster_on_sim(){ + std::string rows; + for (int y = 0; y < 5; y++){ + rows += std::string(y == 0 ? "" : ", ") + (y == 0 || y == 4 ? "\"#################\"" : "\"#...............#\""); + } + const MapData room = parse_map( + "{\"name\": \"CeladonCity_GameCorner\", \"source\": {\"decomp_file\": \"\", \"revision\": \"test\", \"date\": \"2026-11-09\"}," + " \"tiles\": [" + rows + "]}", + "CeladonCity_GameCorner" + ); + const MapPosition below_poster{"CeladonCity_GameCorner", {GAME_CORNER_POSTER.x, GAME_CORNER_POSTER.y + 1}}; + OverworldSim sim({room}, below_poster, Direction::NORTH); + StoryPose at{below_poster.map, below_poster.point, Direction::NORTH}; + const std::string failure = row_step_on_sim(sim, at, StoryTable::poster_switch("poster_switch", "test"), "00.31"); + if (!failure.empty()){ + return failure; + } + const Milliseconds tile = MovementMechanics::TILE_TIME; + sim.hold(Direction::EAST, tile * 6, MovementMechanics::STEP_RELEASE, tile * 6, {}); + if (!(sim.state().position == MapPosition{"CeladonCity_GameCorner", {15, 2}})){ + return "After the poster step, walked onto " + to_string(sim.state().position) + ", not the stairs' (15, 2)."; + } + return ""; +} + +// A hideout floor of 32 by 26 open tiles with its lift door, which lets +// into the lift as pokefirered's floors' warps do (warp 1 of the lift). +MapData hideout_floor(const std::string& name, const TilePos& door){ + std::string rows; + for (int y = 0; y < 26; y++){ + std::string row(32, '.'); + if (y == door.y){ + row[door.x] = 'D'; + } + rows += (y == 0 ? "\"" : ", \"") + row + "\""; + } + return parse_map(std::format( + "{{\"name\": \"{}\", \"source\": {{\"decomp_file\": \"\", \"revision\": \"test\", \"date\": \"2026-11-09\"}}," + " \"tiles\": [{}], \"warps\": [{{\"x\": {}, \"y\": {}, \"enter\": \"walk_into\", \"to_map\": \"{}\", \"to_point\": \"warp_1\"}}]}}", + name, rows, door.x, door.y, LIFT_MAP + ), name); +} + +// The ride from B2F to B4F with the lift's room coded, not mapped: from +// below B2F's lift door, in, the panel, out onto B4F's lift door. +std::string lift_on_sim(){ + const std::vector& doors = rocket_hideout_lift_doors(); + std::vector maps{ + hideout_floor("RocketHideout_B2F", doors[1].point), + hideout_floor("RocketHideout_B4F", doors[2].point), + rocket_hideout_lift_room(), + }; + SimScript script; + script.lift_floors[std::string(LIFT_MAP)] = doors; + const MapPosition below_door{"RocketHideout_B2F", doors[1].point.next(Direction::SOUTH)}; + OverworldSim sim(maps, below_door, Direction::NORTH, script); + StoryPose at{below_door.map, below_door.point, Direction::NORTH}; + const std::string failure = row_step_on_sim(sim, at, StoryTable::lift_floor("lift_to_b4f", "B2F", "B4F", "test"), "00.32"); + if (!failure.empty()){ + return failure; + } + if (!(sim.state().position == doors[2])){ + return "After the lift step, the door let out on " + to_string(sim.state().position) + "."; + } + if (!sim.state().violations.empty()){ + return "The lift step broke the game's rules: " + sim.state().violations.front(); + } + return ""; +} + +// The hideout's spinners are coded: a map named as B2F or B3F slides the +// player with no spin_* regions of its own. +UnitTestResult test_coded_spinners(){ + MapData b3f = parse_map(R"({ + "name": "RocketHideout_B3F", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["##########", "#........#", "#........#", "##########"] + })", "RocketHideout_B3F"); + if (spin_direction(b3f, TilePos{4, 9}) != Direction::WEST || !stops_spinning(b3f, TilePos{2, 9})){ + return "Expected B3F (4, 9) to slide west and (2, 9) to stop a slide."; + } + if (coded_spin_direction("RocketHideout_B2F", TilePos{3, 4}) != Direction::WEST + || coded_spin_direction("RocketHideout_B2F", TilePos{9, 5}) != Direction::NORTH + || !coded_spin_stop("RocketHideout_B2F", TilePos{1, 4}) + || coded_spin_direction("RocketHideout_B1F", TilePos{3, 4}).has_value() + ){ + return "Expected B2F's arrows and stops from pokefirered, and none on B1F."; + } + return true; +} + +struct AutoStoryHideoutBodyInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct AutoStoryHideoutBodyExpectation{ + std::string description; +}; +struct AutoStoryHideoutBodyCase{ + AutoStoryHideoutBodyInput input; + AutoStoryHideoutBodyExpectation expectation; +}; + +class Test_AutoStoryHideoutBody : public UnitTest{ +public: + explicit Test_AutoStoryHideoutBody(AutoStoryHideoutBodyCase test_case) + : UnitTest("PokemonFRLG::AutoStoryHideout - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_case.input.body(); + } + +private: + AutoStoryHideoutBodyCase m_case; +}; + +const AutoStoryHideoutBodyCase HIDEOUT_BODY_CASES[] = { + { + .input = {.name = "the poster and lift variants", .body = test_variants}, + .expectation = {.description = "only the poster switch and lift rides are hideout steps"}, + }, + { + .input = {.name = "table steps on the sim", .body = test_steps_on_sim}, + .expectation = {.description = "the poster opens the stairs; the lift lets out on the floor picked"}, + }, + { + .input = {.name = "coded spinners", .body = test_coded_spinners}, + .expectation = {.description = "B2F's and B3F's arrows come from code, not the map"}, + }, +}; + +const LiftCase LIFT_CASES[] = { + { + .input = {.name = "B2F to B4F: one down", .variant = "lift_floor B2F B4F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::SOUTH, 1}}, + }, + { + // Down past EXIT wraps to B1F: as few presses as up, and down is + // taken. + .input = {.name = "B4F to B1F: two down past EXIT", .variant = "lift_floor B4F B1F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::SOUTH, 2}}, + }, + { + .input = {.name = "B4F to B2F: one up", .variant = "lift_floor B4F B2F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::NORTH, 1}}, + }, + { + // B1F up past the top wraps to EXIT, then B4F: two up equal two + // down; down is taken. + .input = {.name = "B1F to B4F: two down", .variant = "lift_floor B1F B4F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::SOUTH, 2}}, + }, + { + .input = {.name = "the lift's own floor: no press", .variant = "lift_floor B4F B4F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::SOUTH, 0}}, + }, + { + .input = {.name = "no B3F on the panel", .variant = "lift_floor B2F B3F"}, + .expectation = {.presses = std::nullopt}, + }, + { + .input = {.name = "EXIT is no floor", .variant = "lift_floor B2F EXIT"}, + .expectation = {.presses = std::nullopt}, + }, + { + .input = {.name = "one floor only", .variant = "lift_floor B2F"}, + .expectation = {.presses = std::nullopt}, + }, +}; + +} // namespace + + +void add_tests_AutoStoryHideout(UnitTestDatabase& database){ + for (const LiftCase& item : LIFT_CASES){ + database.add(item); + } + for (const AutoStoryHideoutBodyCase& item : HIDEOUT_BODY_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp new file mode 100644 index 0000000000..d1b7ecb16d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp @@ -0,0 +1,213 @@ +/* AutoStory Item Use + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryItemUse.h" +#include "PokemonFRLG_AutoStoryBagUse.h" +#include "PokemonFRLG_AutoStoryBag.h" +#include "PokemonFRLG_AutoStoryVariantWords.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +using namespace std::chrono_literals; + + +MoveSpeed move_speed(const StoryStep& step){ + if (step.variant == BIKE_VARIANT){ + return MoveSpeed::BIKE; + } + return step.variant == RUN_VARIANT ? MoveSpeed::RUN : MoveSpeed::WALK; +} + +std::optional usable_item_pocket(std::string_view item){ + if (is_repel(item) || item == "escape-rope"){ + return BagPocket::ITEMS; + } + if (item == "bicycle"){ + return BagPocket::KEY_ITEMS; + } + return std::nullopt; +} +bool is_repel(std::string_view item){ + return item == "repel" || item == "super-repel" || item == "max-repel"; +} + +std::optional parse_use_item_step(const Pokemon::StoryStep& step){ + const Pokemon::StoryArgs* args = game_step_args(step, USE_ITEM_VARIANT, USE_ITEM_ARGS); + if (args == nullptr || args->contains("slot")){ + return std::nullopt; + } + const std::string_view item = *Pokemon::story_arg_text(*args, "item"); + if (!usable_item_pocket(item)){ + return std::nullopt; + } + return std::string(item); +} +bool is_item_use_step(const Pokemon::StoryStep& step){ + const bool bike = step.variant == RIDE_BIKE_VARIANT || step.variant == REGISTER_BIKE_VARIANT; + return (bike && step.args.empty()) || parse_use_item_step(step).has_value(); +} + +std::optional bag_row_of(const std::vector& rows, std::string_view item){ + for (size_t row = 0; row < rows.size(); row++){ + if (rows[row].item == item && rows[row].count != 0){ + return row; + } + } + return std::nullopt; +} + +std::string item_use_end_name(ItemUseEnd end){ + switch (end){ + case ItemUseEnd::USED: + return "used"; + case ItemUseEnd::NONE_LEFT: + return "none_left"; + case ItemUseEnd::NO_BAG: + return "no_bag"; + case ItemUseEnd::UNKNOWN_ITEM: + return "unknown_item"; + } + return "?"; +} + +namespace{ + +// Opens the item's pocket, finds it and `uses` it, then back to the +// overworld. +ItemUseEnd with_bag_row( + Logger& logger, ItemUseActions& actions, std::string_view item, bool registers +){ + const std::optional pocket = usable_item_pocket(item); + if (!pocket){ + return ItemUseEnd::UNKNOWN_ITEM; + } + if (!actions.open_pocket(*pocket)){ + logger.log("Bag: the bag didn't open."); + return ItemUseEnd::NO_BAG; + } + const std::optional row = bag_row_of(actions.read_pocket(), item); + if (!row){ + logger.log("Bag: no " + std::string(item) + " left."); + actions.close_bag(); + return ItemUseEnd::NONE_LEFT; + } + logger.log("Bag: " + std::string(registers ? "registering" : "using") + " the " + std::string(item) + " on row " + std::to_string(*row + 1) + "."); + if (registers){ + actions.register_row(*row); + }else{ + actions.use_row(*row); + } + actions.close_bag(); + return ItemUseEnd::USED; +} + +} // namespace + +ItemUseEnd use_item_from_bag(Logger& logger, ItemUseActions& actions, std::string_view item){ + return with_bag_row(logger, actions, item, false); +} +ItemUseEnd register_item_in_bag(Logger& logger, ItemUseActions& actions, std::string_view item){ + return with_bag_row(logger, actions, item, true); +} + +ItemUseEnd renew_repel(Logger& logger, ItemUseActions& actions, std::string_view item){ + if (!is_repel(item)){ + return ItemUseEnd::UNKNOWN_ITEM; + } + logger.log("Bag: the " + std::string(item) + " wore off."); + return use_item_from_bag(logger, actions, item); +} + + +namespace{ + +// The waits that worked on console B (2026-11-09, the Bicycle from Key +// Items: TEACHY TV, TM CASE, HELIX FOSSIL, FAME CHECKER, S.S. TICKET, +// BICYCLE; USE the first option, on the bike about 1.5 s after its A). +// The bag slides in, and ignores presses until it is done. +constexpr Milliseconds BAG_SLIDE_IN = 1500ms; +// A pocket switch (left or right), press and wait. +constexpr Milliseconds POCKET_TAP = 100ms; +constexpr Milliseconds POCKET_SWITCH_WAIT = 600ms; +// A cursor step in a pocket, press and wait: 250 ms in all. +constexpr Milliseconds CURSOR_TAP = 100ms; +constexpr Milliseconds CURSOR_WAIT = 150ms; +// Out of the bag and its messages, or the Escape Rope's warp. +constexpr Milliseconds BAG_CLOSE_TIMEOUT = 30000ms; + +} // namespace + +ConsoleItemUseActions::ConsoleItemUseActions(ConsoleHandle& console, ProControllerContext& context, Language language) + : m_console(console) + , m_context(context) + , m_language(language) +{} + +bool ConsoleItemUseActions::open_pocket(BagPocket pocket){ + open_bag_from_overworld(m_console, m_context); + pbf_wait(m_context, BAG_SLIDE_IN); + // Left to the Items pocket, which doesn't wrap, then right to `pocket`. + for (int c = 0; c < 3; c++){ + pbf_press_dpad(m_context, DPAD_LEFT, POCKET_TAP, POCKET_SWITCH_WAIT); + } + for (int c = 0; c < (int)pocket; c++){ + pbf_press_dpad(m_context, DPAD_RIGHT, POCKET_TAP, POCKET_SWITCH_WAIT); + } + for (int c = 0; c < 20; c++){ + pbf_press_dpad(m_context, DPAD_UP, 100ms, 100ms); + } + return true; +} + +std::vector ConsoleItemUseActions::read_pocket(){ + m_context.wait_for_all_requests(); + VideoSnapshot screen = m_console.video().snapshot(); + if (!screen){ + return {}; + } + return read_bag_pocket(m_console.logger(), m_language, screen); +} + +void ConsoleItemUseActions::use_row(size_t row){ + for (size_t c = 0; c < row; c++){ + pbf_press_dpad(m_context, DPAD_DOWN, CURSOR_TAP, CURSOR_WAIT); + } + // The item's menu, USE on top. + pbf_press_button(m_context, BUTTON_A, 160ms, 800ms); + pbf_press_button(m_context, BUTTON_A, 160ms, 1500ms); +} + +void ConsoleItemUseActions::register_row(size_t row){ + for (size_t c = 0; c < row; c++){ + pbf_press_dpad(m_context, DPAD_DOWN, CURSOR_TAP, CURSOR_WAIT); + } + // The item's menu: USE, REGISTER, ... Not timed on the console: the + // USE waits. + pbf_press_button(m_context, BUTTON_A, 160ms, 800ms); + pbf_press_dpad(m_context, DPAD_DOWN, CURSOR_TAP, CURSOR_WAIT); + pbf_press_button(m_context, BUTTON_A, 160ms, 1500ms); +} + +void ConsoleItemUseActions::close_bag(){ + // B until the overworld: past " used the REPEL.", out of the + // bag and the START menu. The Escape Rope and the Bicycle leave the + // bag by themselves. + mash_until_overworld(m_console, m_context, BAG_CLOSE_TIMEOUT); +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h new file mode 100644 index 0000000000..f19b161c35 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h @@ -0,0 +1,73 @@ +/* AutoStory Item Use + * + * From: https://github.com/PokemonAutomation/ + * + * Items the story uses from the bag on the overworld, as PokeGuy's run + * does: a Repel or Super Repel before long walks through grass and caves, + * renewed when it wears off; an Escape Rope out of the Rocket Hideout; + * and the Bicycle. The bag is read with read_bag_pocket(), so the step + * finds its item wherever the pocket keeps it. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryItemUse_H +#define PokemonAutomation_PokemonFRLG_AutoStoryItemUse_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Language.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/AutoStory/Types/Pokemon_StoryArgs.h" +#include "Pokemon/AutoStory/Types/Pokemon_StoryStep.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The GAME step that uses an item from the bag on the overworld, its +// args the item's slug as read_item_list_name() names it, and for an item +// used on a Pokemon the party slot (none the story uses yet). +inline constexpr std::string_view USE_ITEM_VARIANT = "use_item"; +// Its args: the item, and the party slot it is used on. +inline constexpr Pokemon::StoryArgSpec USE_ITEM_ARGS[] = { + {"item", Pokemon::StoryArgType::TEXT, true}, + {"slot", Pokemon::StoryArgType::NUMBER, false}, +}; +// The GAME step that gets on the registered Bicycle with SELECT +// (OverworldWalkSteps::get_on_bike()). The ROUTE steps after it ride +// (BIKE_VARIANT). +inline const std::string RIDE_BIKE_VARIANT = "ride_bike"; +// The variant of a ROUTE or LEGS step ridden on the Bicycle, mounted by a +// RIDE_BIKE_VARIANT step before it. +// Indoors, where the game doesn't let the player ride, it walks. +inline const std::string BIKE_VARIANT = "bike"; +// The GAME step that registers the Bicycle for SELECT: the Key Items +// pocket's BICYCLE, REGISTER, its menu's second option. Once a save: +// the registration is saved with the game, and a save made before it +// doesn't have it. +inline const std::string REGISTER_BIKE_VARIANT = "register_bike"; + + +// The item a use_item step uses, or nothing if it isn't one, the item +// isn't one the story uses from the bag, or it names a slot. +std::optional parse_use_item_step(const Pokemon::StoryStep& step); +// Whether `step` is one of this file's steps. +bool is_item_use_step(const Pokemon::StoryStep& step); + + +// Registers the item use tests. +void add_tests_AutoStoryItemUse(UnitTestDatabase& database); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp new file mode 100644 index 0000000000..82900c7f7a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp @@ -0,0 +1,266 @@ +/* AutoStory Item Use Tests + * + * From: https://github.com/PokemonAutomation/ + * + * use_item_from_bag() and renew_repel() on a made-up bag, and the bike's + * pace. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_BikeSpinMechanics.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryItemUse.h" +#include "PokemonFRLG_AutoStoryBagUse.h" +#include "PokemonFRLG_AutoStoryBag.h" +#include "PokemonFRLG_AutoStoryVariantWords.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// A use_item step's positional key, for legacy_game_step(). +const std::string_view ITEM[] = {"item"}; + +// A bag on the sim: its pockets, each item used takes one, a bag that may +// not open. +class SimBag : public ItemUseActions{ +public: + std::map> pockets; + bool opens = true; + std::vector used; + std::vector registered; + bool open = false; + + virtual bool open_pocket(BagPocket pocket) override{ + m_pocket = pocket; + open = opens; + return opens; + } + virtual std::vector read_pocket() override{ + return pockets[m_pocket]; + } + virtual void use_row(size_t row) override{ + BagRow& item = pockets[m_pocket][row]; + used.push_back(item.item); + if (item.count > 0){ + item.count--; + } + } + virtual void register_row(size_t row) override{ + registered.push_back(pockets[m_pocket][row].item); + } + virtual void close_bag() override{ + open = false; + } + +private: + BagPocket m_pocket = BagPocket::ITEMS; +}; + +struct ItemUseInput{ + std::string name; + std::string variant; + std::vector items; + std::vector key_items; + bool opens = true; + // renew_repel() instead of use_item_from_bag(). + bool renew = false; +}; +struct ItemUseExpectation{ + ItemUseEnd end; + std::vector used; +}; +struct ItemUseCase{ + ItemUseInput input; + ItemUseExpectation expectation; +}; + +class Test_AutoStoryItemUse : public UnitTest{ +public: + explicit Test_AutoStoryItemUse(ItemUseCase test_case) + : UnitTest("PokemonFRLG::AutoStoryItemUse - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + SimBag bag; + bag.pockets[BagPocket::ITEMS] = m_case.input.items; + bag.pockets[BagPocket::KEY_ITEMS] = m_case.input.key_items; + bag.opens = m_case.input.opens; + const std::string variant = m_case.input.variant; + const std::string item = variant == RIDE_BIKE_VARIANT ? "bicycle" : parse_use_item_step(legacy_game_step(variant, ITEM)).value_or(variant); + const ItemUseEnd end = m_case.input.renew ? renew_repel(logger, bag, item) : use_item_from_bag(logger, bag, item); + if (end != m_case.expectation.end || bag.used != m_case.expectation.used || bag.open){ + return std::format( + "\"{}\": {} using {} item(s), bag {}; expected {} using {}.", variant, + item_use_end_name(end), bag.used.size(), bag.open ? "open" : "closed", + item_use_end_name(m_case.expectation.end), m_case.expectation.used.size() + ); + } + return true; + } + +private: + ItemUseCase m_case; +}; + +const ItemUseCase ITEM_USE_CASES[] = { + { + .input = { + .name = "a Repel below the Potions", + .variant = "use_item repel", + .items = {{"potion", 15}, {"repel", 5}, {"escape-rope", 2}}, + }, + .expectation = {.end = ItemUseEnd::USED, .used = {"repel"}}, + }, + { + .input = { + .name = "the Escape Rope with its count unread", + .variant = "use_item escape-rope", + .items = {{"potion", 15}, {"", -1}, {"escape-rope", -1}}, + }, + .expectation = {.end = ItemUseEnd::USED, .used = {"escape-rope"}}, + }, + { + .input = { + .name = "no Super Repel bought", + .variant = "use_item super-repel", + .items = {{"potion", 15}, {"repel", 5}}, + }, + .expectation = {.end = ItemUseEnd::NONE_LEFT, .used = {}}, + }, + { + .input = { + .name = "the bag doesn't open", + .variant = "use_item repel", + .items = {{"repel", 5}}, + .opens = false, + }, + .expectation = {.end = ItemUseEnd::NO_BAG, .used = {}}, + }, + { + .input = { + .name = "the Bicycle from the Key Items", + .variant = "ride_bike", + .items = {{"bicycle", 1}}, + .key_items = {{"", -1}, {"bicycle", -1}}, + }, + .expectation = {.end = ItemUseEnd::USED, .used = {"bicycle"}}, + }, + { + .input = { + .name = "a Repel wore off: another used", + .variant = "use_item repel", + .items = {{"repel", 1}}, + .renew = true, + }, + .expectation = {.end = ItemUseEnd::USED, .used = {"repel"}}, + }, + { + .input = { + .name = "the last Repel wore off: none left", + .variant = "use_item repel", + .items = {{"repel", 0}}, + .renew = true, + }, + .expectation = {.end = ItemUseEnd::NONE_LEFT, .used = {}}, + }, + { + .input = { + .name = "an Escape Rope is no Repel to renew", + .variant = "use_item escape-rope", + .items = {{"escape-rope", 2}}, + .renew = true, + }, + .expectation = {.end = ItemUseEnd::UNKNOWN_ITEM, .used = {}}, + }, +}; + +UnitTestResult test_variants_and_bike(){ + if (parse_use_item_step(legacy_game_step("use_item potion", ITEM)) || parse_use_item_step(legacy_game_step("use_item", ITEM)) + || parse_use_item_step(legacy_game_step("use_item repel slot=1", ITEM)) + || !parse_use_item_step(legacy_game_step("use_item super-repel", ITEM)) + ){ + return "Expected only the story's bag items as use_item steps."; + } + if (!is_item_use_step(legacy_game_step(RIDE_BIKE_VARIANT)) || is_item_use_step(legacy_game_step("poster_switch"))){ + return "Expected ride_bike, not poster_switch, as an item use."; + } + StoryStep ride; + ride.kind = Pokemon::StoryStepKind::ROUTE; + ride.variant = BIKE_VARIANT; + if (move_speed(ride) != MoveSpeed::BIKE){ + return "A bike ROUTE isn't ridden."; + } + RouteStep outdoors; + outdoors.tiles = 10; + RouteStep indoors = outdoors; + indoors.walk_only = true; + if (OverworldWalkSteps::tile_time(outdoors, MoveSpeed::BIKE) != MovementMechanics::BIKE_TILE_TIME + || OverworldWalkSteps::tile_time(indoors, MoveSpeed::BIKE) != MovementMechanics::TILE_TIME + ){ + return "Expected the bike's pace outdoors and a walk indoors."; + } + return true; +} + +struct AutoStoryItemUseBodyInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct AutoStoryItemUseBodyExpectation{ + std::string description; +}; +struct AutoStoryItemUseBodyCase{ + AutoStoryItemUseBodyInput input; + AutoStoryItemUseBodyExpectation expectation; +}; + +class Test_AutoStoryItemUseBody : public UnitTest{ +public: + explicit Test_AutoStoryItemUseBody(AutoStoryItemUseBodyCase test_case) + : UnitTest("PokemonFRLG::AutoStoryItemUse - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_case.input.body(); + } + +private: + AutoStoryItemUseBodyCase m_case; +}; + +const AutoStoryItemUseBodyCase ITEM_USE_BODY_CASES[] = { + { + .input = {.name = "variants and the bike's pace", .body = test_variants_and_bike}, + .expectation = {.description = "the story's bag items, ride_bike, and the bike's pace outdoors only"}, + }, +}; + +} // namespace + + +void add_tests_AutoStoryItemUse(UnitTestDatabase& database){ + for (const ItemUseCase& item : ITEM_USE_CASES){ + database.add(item); + } + for (const AutoStoryItemUseBodyCase& item : ITEM_USE_BODY_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index 28a8a12bb1..3b4c0f3fc6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -4,7 +4,8 @@ * * 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. + * to its south mouth, 00.29 down Route 10 to Lavender Town's Center, and + * 00.30 west over Route 8 and under Saffron to Celadon City. * Each row whole, each open leg a tile short or long, and a wild battle * on every open leg in the tunnel. * @@ -15,10 +16,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTest.h" @@ -572,6 +575,140 @@ UnitTestResult test_save_places(Logger&){ return true; } +// 00.30 on the sim: the Mart, Route 8 with only Rich fought (the walker +// keeps out of the other trainers' lines), the Underground Path, the +// Pidgey's grass and Celadon City, below its Center's door. The Center +// has no map: its way in is stubbed. +UnitTestResult test_celadon_way_row(Logger&){ + return row_on_sim("00.30", SimScript{}, {{"old_man_1"}}, MapPosition{"CeladonCity", {48, 12}}); +} + +// 00.30's walk into Route 7's grass with a wild battle on its one grass +// step (Route 7's land rate is 21): it still ends on the Pidgey's tile. +UnitTestResult test_route7_grass_battles(Logger&){ + const StoryCheckpoint& row = checkpoint("00.30"); + const size_t index = step_index(row, "pidgey_grass"); + const MapPosition end{"Route7", {14, 6}}; + SimScript script; + script.encounters.push_back(SimEncounter{.grass_step = 1}); + StoryPose at{"Route7", {7, 15}, Direction::SOUTH}; + OverworldSim sim(maps_after_route10(), MapPosition{at.map, at.tile}, *at.facing, script); + if (const std::string failure = row_step_on_sim(sim, at, row.steps[index], row.id); !failure.empty()){ + return failure; + } + if (!(sim.state().position == end) || sim.state().wild_battles != 1){ + return std::format("Ended on {} after {} wild battles, not on {} after 1.", to_string(sim.state().position), sim.state().wild_battles, to_string(end)); + } + return true; +} + +// The maps after Gamer Rich's battle: Rich, beaten, stands where he +// walked down to (55, 17). +std::vector maps_after_rich(){ + std::vector maps = maps_after_route10(); + for (MapData& map : maps){ + if (map.name != "Route8"){ + continue; + } + for (MapObject& object : map.objects){ + if (object.name == "old_man_1"){ + object.y = 17; + object.trainer.reset(); + } + } + } + return maps; +} + +// The walker believes the player stands on `believed`, a tile 00.30's +// steps from Rich's battle on pass through, while the player stands on +// `actual`. +struct OffStart{ + TilePos believed; + TilePos actual; +}; + +const OffStart ROUTE8_OFF_STARTS[] = { + {{55, 18}, {58, 18}}, + {{55, 18}, {52, 18}}, + {{48, 7}, {45, 7}}, + {{48, 7}, {50, 7}}, + {{26, 7}, {29, 7}}, + {{26, 7}, {26, 6}}, + {{26, 7}, {26, 9}}, + {{26, 7}, {28, 10}}, +}; + +// 00.30's steps from `first` to `last`, as the table lays them out from +// `at`: the first that starts on `tile`, and where the table says the +// player stands then. Nothing if none does. +std::optional> step_starting_on( + const StoryCheckpoint& row, size_t first, size_t last, StoryPose at, const TilePos& tile +){ + for (size_t c = first; c <= last; c++){ + if (at.map == "Route8" && at.tile == tile){ + return std::make_pair(c, at); + } + const std::optional> route = walked_route(at, row.steps[c]); + if (!route || route->empty()){ + return std::nullopt; + } + at = StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after}; + } + return std::nullopt; +} + +// Console B, 2026-11-09 (00.30, three runs): after Route 8's open legs +// the player stood 3 tiles east of where the walker believed, the hold +// north to the Underground Path's door ran up column 17 to the cliff +// (16, 2), and the step onto the door bumped the house: "no fade in and +// out after walking onto the warp". From a start up to 3 tiles off, the +// steps on to Route 7 meet no trainer and come out of the Underground +// Path where the table says. +UnitTestResult test_route8_west_off_start(Logger&){ + const StoryCheckpoint& row = checkpoint("00.30"); + const size_t first = step_index(row, "west_of_glenn_column"); + const size_t last = step_index(row, "route7_underground_exit"); + const StoryPose after_rich{"Route8", {55, 18}, Direction::NORTH}; + const std::vector maps = maps_after_rich(); + const MapPosition end{"Route7", {7, 15}}; + std::string failures; + for (const OffStart& off : ROUTE8_OFF_STARTS){ + const std::optional> from = step_starting_on(row, first, last, after_rich, off.believed); + std::string failure = from ? "" : "no step starts there."; + StoryPose at = from ? from->second : after_rich; + OverworldSim sim(maps, MapPosition{at.map, off.actual}, at.facing.value_or(Direction::NORTH)); + for (size_t c = from ? from->first : last + 1; c <= last && failure.empty(); c++){ + failure = row_step_on_sim(sim, at, row.steps[c], row.id); + } + if (failure.empty() && !(sim.state().position == end)){ + failure = std::format("ended on {}, not {}.", to_string(sim.state().position), to_string(end)); + } + if (failure.empty() && !sim.state().trainers_beaten.empty()){ + failure = "fought " + sim.state().trainers_beaten.front() + "."; + } + if (failure.empty() && !sim.state().violations.empty()){ + failure = "a violation on the way: " + sim.state().violations.back(); + } + if (!failure.empty()){ + failures += std::format("{}from {}, not {}: {}", failures.empty() ? "" : " | ", to_string(off.actual), to_string(off.believed), failure); + } + } + if (!failures.empty()){ + return failures; + } + return true; +} + +// 00.30 saves in Celadon City's Pokemon Center: CELADON CITY on the save +// card. +UnitTestResult test_celadon_save_place(Logger&){ + if (save_card_place("CeladonCity_PokemonCenter_1F") != "celadon-city"){ + return "Wrong save card place for Celadon City's Pokemon Center."; + } + return true; +} + } // namespace @@ -590,6 +727,10 @@ const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { {.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 = {}}, + {.input = {"00.30 Lavender's Mart, Route 8 past Rich alone, the Underground Path, Route 7 to Celadon City", test_celadon_way_row}, .expectation = {}}, + {.input = {"00.30 a wild battle on the step into Route 7's grass", test_route7_grass_battles}, .expectation = {}}, + {.input = {"00.30 Route 8 west from a start up to 3 tiles off", test_route8_west_off_start}, .expectation = {}}, + {.input = {"00.30 saves in Celadon City's Pokemon Center", test_celadon_save_place}, .expectation = {}}, }; void add_tests_AutoStoryLavender(UnitTestDatabase& database){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp index 6a76612aaa..a796111168 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp @@ -18,6 +18,9 @@ #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryPaceSim.h" +#include "PokemonFRLG_AutoStoryPacing.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -111,6 +114,84 @@ UnitTestResult test_capture_paced_in_trainer_sight(){ } +// A capture on the sim as ConsoleCaptureActions paces it, east and back: +// find_paced_encounter() with one PatchSteps kept from battle to battle, +// `species` read in turn. +class SimConsoleCapture : public CaptureActions{ +public: + SimConsoleCapture(Logger& logger, OverworldSim& sim, std::vector species) + : m_logger(logger), m_sim(sim), m_pacing(sim, Direction::EAST, Direction::WEST), m_species(std::move(species)) + { + m_bike.press_select = [this]{ m_sim.use_field(SimField::BIKE_SELECT); }; + m_bike.riding = [this]{ return m_sim.state().on_bike; }; + } + virtual bool find_encounter(Direction) override{ + return find_paced_encounter(m_logger, m_bike, m_pacing); + } + virtual EncounterSeen read_encounter() override{ + const std::string species = m_read < m_species.size() ? m_species[m_read] : m_species.back(); + m_read++; + return EncounterSeen{.species = species}; + } + virtual bool flee() override{ + const bool ended = m_sim.handle_battle(SimBattleChoice::RUN) == SimBattleEnd::ENDED; + tiles.push_back(m_sim.state().position); + return ended; + } + virtual FoeHp read_foe_hp() override{ return FoeHp::UNKNOWN; } + virtual TurnEnd attack(size_t) override{ return TurnEnd::FAILED; } + virtual ThrowEnd throw_ball() override{ return ThrowEnd::CAUGHT; } + virtual bool finish_catch(CatchDestination) override{ + return m_sim.handle_battle(SimBattleChoice::FIGHT) == SimBattleEnd::ENDED; + } + // Where each battle run from left the player. + std::vector tiles; +private: + Logger& m_logger; + OverworldSim& m_sim; + SimPatch m_pacing; + OverworldWalkSteps::BikeSelectCalls m_bike; + std::vector m_species; + size_t m_read = 0; +}; + +// Console B, 2026-11-09 (00.30 run 5): catch_pidgey started on Route 7's +// (14, 6) on the Bicycle, ran from 4 wild battles and then paced 4 minutes +// on the flowers east of the grass (11..18, 2..6). On the Bicycle a tap +// facing another way steps too, so each turn of the pace took a tile, and +// each search began its pace afresh from where the last battle left the +// player. On the sim, from (14, 6) on the Bicycle, 6 battles, the Pidgey +// last: every battle starts on (14, 6) or (15, 6), on foot. +UnitTestResult test_capture_paced_on_bike_stays_in_grass(){ + const MapPosition patch{"Route7", {14, 6}}; + const CapturePlan plan{.species = "pidgey", .max_encounters = 30, .pace = Direction::EAST}; + SimScript script; + script.on_bike = true; + script.bike_registered = true; + for (int grass_step : {2, 5, 6, 9, 12, 13}){ + script.encounters.push_back(SimEncounter{.grass_step = grass_step}); + } + OverworldSim sim(story_maps(), patch, Direction::EAST, script); + LinesLogger logger; + SimConsoleCapture actions(logger, sim, {"rattata", "spearow", "rattata", "oddish", "meowth", "pidgey"}); + const CaptureResult result = run_capture(logger, actions, plan, 5); + if (result.end != CaptureEnd::CAUGHT || result.encounters != 6){ + return capture_message(plan, result) + joined(logger.lines) + joined(sim.state().log); + } + const MapPosition east{"Route7", {15, 6}}; + for (size_t c = 0; c < actions.tiles.size(); c++){ + if (!(actions.tiles[c] == patch) && !(actions.tiles[c] == east)){ + return std::format("Battle {} left the player on {}, off the pace's tiles.", c + 1, to_string(actions.tiles[c])) + + joined(sim.state().log); + } + } + if (sim.state().on_bike){ + return "Paced on the Bicycle." + joined(sim.state().log); + } + return true; +} + + struct AutoStoryPaceSimInput{ std::string name; std::function body; @@ -153,6 +234,15 @@ const AutoStoryPaceSimTestCase AUTO_STORY_PACE_SIM_CASES[] = { .description = "handles trainer encounter while pacing and resumes capture sequence", }, }, + { + .input = { + .name = "a capture started on the Bicycle paces on foot in the grass", + .body = test_capture_paced_on_bike_stays_in_grass, + }, + .expectation = { + .description = "every battle starts on one of the pace's two tiles, off the Bicycle", + }, + }, }; } // namespace diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPacing.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPacing.cpp new file mode 100644 index 0000000000..fff4f60541 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPacing.cpp @@ -0,0 +1,54 @@ +/* AutoStory Pacing + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_BikeSpinPresses.h" +#include "PokemonFRLG_AutoStoryPacing.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using OverworldWalkSteps::BikeSelectCalls; + + +bool select_off_bike(const BikeSelectCalls& calls){ + for (size_t c = 0; c < 2 && calls.riding(); c++){ + calls.press_select(); + } + return !calls.riding(); +} + +bool find_paced_encounter(Logger& logger, const BikeSelectCalls& bike, PatchSteps& steps){ + if (!select_off_bike(bike)){ + logger.log("Pacing: still on the Bicycle after SELECT twice; not pacing on it.", COLOR_RED); + return false; + } + return pace_until_battle(logger, steps, PACE_MAX_STEPS); +} + +BikeSelectCalls console_bike_select(ConsoleHandle& console, ProControllerContext& context){ + BikeSelectCalls calls; + calls.press_select = [&console, &context]{ + console.log("Pacing: SELECT (MINUS) off the Bicycle."); + pbf_press_button(context, BUTTON_MINUS, MovementMechanics::BIKE_SELECT_PRESS, MovementMechanics::BIKE_SELECT_SETTLE); + }; + calls.riding = [&console, &context]{ + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + return screen && OverworldWalkSteps::screen_shows_riding(*screen.frame); + }; + return calls; +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPacing.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPacing.h new file mode 100644 index 0000000000..7c36a7674a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPacing.h @@ -0,0 +1,47 @@ +/* AutoStory Pacing + * + * From: https://github.com/PokemonAutomation/ + * + * A capture's or grind's search for a wild battle: off the Bicycle, then + * pacing in the patch from where the last battle left the player. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryPacing_H +#define PokemonAutomation_PokemonFRLG_AutoStoryPacing_H + +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldBike.h" +#include "PokemonFRLG_AutoStoryCapture.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +// Before pacing: gets the player off the Bicycle with SELECT, checked, +// and once more if they still ride. On the Bicycle a tap facing another +// way steps as well as turns, so pacing's turns walk it off the patch +// (console B, 2026-11-09, 00.30 run 5: Route 7's grass). Returns whether +// the player is on foot. +bool select_off_bike(const OverworldWalkSteps::BikeSelectCalls& calls); + +// One search for a wild battle, as a capture or a grind paces: off the +// Bicycle (select_off_bike()), then pace_until_battle(). `steps` keeps +// the tile pacing left the player on from one search to the next, so the +// pace never starts a tile out. False if no battle came, or the player +// rides on. +bool find_paced_encounter(Logger& logger, const OverworldWalkSteps::BikeSelectCalls& bike, PatchSteps& steps); + +// select_off_bike()'s calls on the console: SELECT is the Switch's MINUS, +// and the player rides while no standing sprite shows (OverworldWalkSteps::screen_shows_riding()). +OverworldWalkSteps::BikeSelectCalls console_bike_select(ConsoleHandle& console, ProControllerContext& context); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRoomLegs.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRoomLegs.h new file mode 100644 index 0000000000..aa8fc199a9 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRoomLegs.h @@ -0,0 +1,44 @@ +/* AutoStory Room Legs + * + * From: https://github.com/PokemonAutomation/ + * + * The scripted legs through a Pokemon Center and the Rocket Hideout's + * lift. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryRoomLegs_H +#define PokemonAutomation_PokemonFRLG_AutoStoryRoomLegs_H + +#include +#include +#include "PokemonFRLG_AutoStoryRooms.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Into a Center: north through the door, north 4 to the nurse. +const std::vector& center_enter_legs(); + +// Out of a Center: south 4 to the mat, south through the door. +const std::vector& center_exit_legs(); + +// Into the hideout's lift, from below a floor's lift door facing north: +// north through the door onto LIFT_ARRIVAL, north 2, west 2 to below the +// panel (LIFT_PANEL_FRONT), facing it. +const std::vector& lift_in_legs(); + +// Out of the lift, from below the panel: east 2, south 2 onto the arrow +// warp, south through it, onto the floor picked's lift door. +const std::vector& lift_out_legs(); + +// The legs of the Center's step `variant`; empty if it isn't one. +const std::vector& center_legs(std::string_view variant); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp new file mode 100644 index 0000000000..e6dbf9cb22 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp @@ -0,0 +1,92 @@ +/* AutoStory Scripted Rooms + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryRooms.h" +#include "PokemonFRLG_AutoStoryRoomLegs.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +bool is_center_variant(std::string_view variant){ + return variant == CENTER_ENTER_VARIANT || variant == CENTER_EXIT_VARIANT; +} + +const std::vector& center_enter_legs(){ + static const std::vector LEGS{ + {Pokemon::Direction::NORTH, 1, true}, + {Pokemon::Direction::NORTH, 4, false}, + }; + return LEGS; +} +const std::vector& center_exit_legs(){ + static const std::vector LEGS{ + {Pokemon::Direction::SOUTH, 4, false}, + {Pokemon::Direction::SOUTH, 1, true}, + }; + return LEGS; +} +const std::vector& lift_in_legs(){ + static const std::vector LEGS{ + {Pokemon::Direction::NORTH, 1, true}, + {Pokemon::Direction::NORTH, 2, false}, + {Pokemon::Direction::WEST, 2, false}, + {Pokemon::Direction::NORTH, 0, false}, + }; + return LEGS; +} +const std::vector& lift_out_legs(){ + static const std::vector LEGS{ + {Pokemon::Direction::EAST, 2, false}, + {Pokemon::Direction::SOUTH, 2, false}, + {Pokemon::Direction::SOUTH, 1, true}, + }; + return LEGS; +} +const std::vector& center_legs(std::string_view variant){ + static const std::vector NONE; + if (variant == CENTER_ENTER_VARIANT){ + return center_enter_legs(); + } + if (variant == CENTER_EXIT_VARIANT){ + return center_exit_legs(); + } + return NONE; +} + + +void walk_room_legs( + ConsoleHandle& console, ProControllerContext& context, + const std::vector& legs, std::optional facing +){ + for (const RoomLeg& leg : legs){ + if (leg.tiles == 0){ + OverworldWalkSteps::turn_to(console, context, leg.direction); + facing = leg.direction; + continue; + } + for (int tile = 0; tile < leg.tiles; tile++){ + step_once(console, context, leg.direction, facing); + facing = leg.direction; + } + if (leg.warps){ + RouteStep step; + step.direction = leg.direction; + step.tiles = 1; + step.warps = true; + OverworldWalkSteps::wait_out_warp(console, context, step); + } + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h new file mode 100644 index 0000000000..3cb36443b0 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h @@ -0,0 +1,67 @@ +/* AutoStory Scripted Rooms + * + * From: https://github.com/PokemonAutomation/ + * + * Rooms the story walks by a fixed script of taps, not a planned route: + * every Pokemon Center's ground floor (one layout, so one script) and the + * Rocket Hideout's lift. Their tiles are coded (PokemonFRLG_CodedRooms.h), + * not mapped: the user's call, 2026-11-09 ("mostly the same"; "small area, + * high overhead, just script it"). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryRooms_H +#define PokemonAutomation_PokemonFRLG_AutoStoryRooms_H + +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The GAME step into a Pokemon Center: from the tile below its door, +// facing north, through the door onto the mat (CENTER_MAT) and up the +// nurse's column to below her counter (CENTER_NURSE_FRONT), facing her. +// The HEAL step after it talks to her. +inline const std::string CENTER_ENTER_VARIANT = "center_enter"; +// The GAME step out of a Pokemon Center: from below the nurse's counter +// down to the mat and out; the game walks the player a tile out of the +// door, onto the tile below it, facing south. +inline const std::string CENTER_EXIT_VARIANT = "center_exit"; +// Whether `variant` is one of the Center's steps. +bool is_center_variant(std::string_view variant); + +// A straight piece of a room's script: `tiles` steps `direction`, one +// tap a tile; none: a turn to face `direction`. `warps`: the last step +// takes a warp, whose fade is waited out. +struct RoomLeg{ + Pokemon::Direction direction = Pokemon::Direction::NORTH; + int tiles = 0; + bool warps = false; +}; + +// On the console: the legs, a step tap a tile (step_once()), each warp's +// fade waited out. `facing` is the way the player faces at the start, if +// known. +void walk_room_legs( + ConsoleHandle& console, ProControllerContext& context, + const std::vector& legs, std::optional facing +); + + +// Registers the scripted rooms' tests. +void add_tests_AutoStoryRooms(UnitTestDatabase& database); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp new file mode 100644 index 0000000000..a314f95df8 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp @@ -0,0 +1,127 @@ +/* AutoStory Scripted Rooms Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PokemonCenterRoom.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryRooms.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" +#include "PokemonFRLG_AutoStoryFieldSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct CenterInput{ + // The town or route whose Center it is. + std::string town; +}; +struct CenterExpectation{ + std::string center; +}; +struct CenterCase{ + CenterInput input; + CenterExpectation expectation; +}; + +// The Center's coded room has the tiles of its map in the lock. +std::string same_tiles(const std::string& center){ + const MapData mapped = load_map(center); + const MapData coded = pokemon_center_room(center, {}); + if (mapped.tiles.width != coded.tiles.width || mapped.tiles.height != coded.tiles.height){ + return center + ": the coded room is not the map's size."; + } + for (int y = 0; y < coded.tiles.height; y++){ + for (int x = 0; x < coded.tiles.width; x++){ + if (tile_at(mapped, TilePos{x, y}) != tile_at(coded, TilePos{x, y})){ + return center + ": the coded room differs at (" + std::to_string(x) + ", " + std::to_string(y) + ")."; + } + } + } + return ""; +} + +// On the sim, with the town's map and the Center's coded room: in from +// below the door to the nurse, out again to below the door. +std::string in_and_out(const std::string& town, const std::string& center){ + std::vector maps{load_map(town)}; + const std::optional added = add_pokemon_center_room(maps, 0); + if (added != center){ + return town + ": no door to " + center + "."; + } + const TilePos door = maps[0].points.at(center + "_door"); + const MapPosition below_door{town, door.next(Direction::SOUTH)}; + OverworldSim sim(maps, below_door, Direction::NORTH); + StoryPose at{town, below_door.point, Direction::NORTH}; + std::string failure = row_step_on_sim(sim, at, StoryTable::center_enter("to_nurse", center, "test"), "test"); + if (!failure.empty()){ + return failure; + } + const Pokemon::Pose outside = StoryTable::at(town.c_str(), below_door.point.x, below_door.point.y, Direction::SOUTH); + failure = row_step_on_sim(sim, at, StoryTable::center_exit("out", outside, "test"), "test"); + if (!failure.empty()){ + return failure; + } + if (!sim.state().violations.empty()){ + return town + ": " + sim.state().violations.front(); + } + return ""; +} + +class Test_AutoStoryCenter : public UnitTest{ +public: + explicit Test_AutoStoryCenter(CenterCase test_case) + : UnitTest("PokemonFRLG::AutoStoryRooms - Center: " + test_case.expectation.center) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::string& center = m_case.expectation.center; + if (std::string failure = same_tiles(center); !failure.empty()){ + return failure; + } + if (std::string failure = in_and_out(m_case.input.town, center); !failure.empty()){ + return failure; + } + return true; + } + +private: + CenterCase m_case; +}; + +// The seven Centers in the map lock. +const CenterCase CENTER_CASES[] = { + {.input = {.town = "ViridianCity"}, .expectation = {.center = "ViridianCity_PokemonCenter_1F"}}, + {.input = {.town = "PewterCity"}, .expectation = {.center = "PewterCity_PokemonCenter_1F"}}, + {.input = {.town = "Route4"}, .expectation = {.center = "Route4_PokemonCenter_1F"}}, + {.input = {.town = "CeruleanCity"}, .expectation = {.center = "CeruleanCity_PokemonCenter_1F"}}, + {.input = {.town = "VermilionCity"}, .expectation = {.center = "VermilionCity_PokemonCenter_1F"}}, + {.input = {.town = "Route10"}, .expectation = {.center = "Route10_PokemonCenter_1F"}}, + {.input = {.town = "LavenderTown"}, .expectation = {.center = "LavenderTown_PokemonCenter_1F"}}, +}; + +} // namespace + + +void add_tests_AutoStoryRooms(UnitTestDatabase& database){ + for (const CenterCase& item : CENTER_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 ff86b55359..91ce25068e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -15,7 +15,14 @@ #include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryBattleDecisions.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryHideoutLift.h" +#include "PokemonFRLG_AutoStoryRooms.h" +#include "PokemonFRLG_AutoStoryRoomLegs.h" #include "PokemonFRLG_AutoStoryRowSim.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -370,6 +377,8 @@ Pokemon::StoryAction OverworldStepSim::action(StoryActionId id){ case StoryActionId::BATTLE: body = &OverworldStepSim::battle; break; + case StoryActionId::GAME: + return hideout_or_stub(); default: return m_stubs.stub(); } @@ -508,7 +517,7 @@ std::string OverworldStepSim::cut(const StoryStep& step){ if (m_sim.state().facing != step.direction){ sim_turn_to(m_sim, step.direction); } - if (!m_sim.cut_tree()){ + if (!m_sim.use_field(SimField::CUT_TREE)){ return "no tree to cut."; } sim_step_once(m_sim, step.direction); @@ -519,6 +528,97 @@ std::string OverworldStepSim::cut(const StoryStep& step){ return ""; } +namespace{ + +// walk_room_legs() on the sim: a step tap a tile, each warp's fade and +// the walk out of a door waited out. +void sim_walk_room_legs(OverworldSim& sim, const std::vector& legs){ + for (const RoomLeg& leg : legs){ + if (leg.tiles == 0){ + sim_turn_to(sim, leg.direction); + continue; + } + for (int tile = 0; tile < leg.tiles; tile++){ + sim_step_once(sim, leg.direction); + } + if (leg.warps){ + sim.watch({}, OverworldWalkSteps::AFTER_WARP); + } + } +} + +} // namespace + +namespace{ + +// Whether the sim can walk a Pokemon Center's way in or out of `step`: +// the map it ends on is built into the program. Celadon's Center has no +// map yet (00.30), so its way in is stubbed. +bool center_walkable(const StoryStep& step){ + if (!is_center_variant(step.variant)){ + return false; + } + const std::optional after = step.pose_after ? story_pose(*step.pose_after) : std::nullopt; + const std::vector>& maps = overworld_map_json(); + return after && std::any_of(maps.begin(), maps.end(), [&](const auto& map){ return map.first == after->map; }); +} + +} // namespace + +// A scripted GAME step on the sim: the poster's switch, the lift's ride +// (its room coded, SimScript::lift_floors, rows as on the panel), or a +// Pokemon Center's way in or out (center_walkable()); any other GAME step +// is stubbed. +Pokemon::StoryAction OverworldStepSim::hideout_or_stub(){ + Pokemon::StoryAction stub = m_stubs.stub(); + Pokemon::StoryAction sim = played([this](RunState&, const StoryStep& step){ + const std::string failure = hideout(step); + return failure.empty() ? Pokemon::outcome_ok() : Pokemon::outcome_failed("sim_failure", failure); + }); + sim.act = [stub, played_act = sim.act](RunState& state, const StoryStep& step){ + return is_hideout_step(step) || center_walkable(step) + ? played_act(state, step) + : stub.act(state, step); + }; + sim.needs_console = stub.needs_console; + return sim; +} + +std::string OverworldStepSim::hideout(const StoryStep& step){ + if (step.variant == POSTER_SWITCH_VARIANT){ + return m_sim.use_field(SimField::POSTER_SWITCH) ? "" : "no poster ahead."; + } + if (is_center_variant(step.variant)){ + sim_walk_room_legs(m_sim, center_legs(step.variant)); + return ended_on(step); + } + const std::optional ride = parse_lift_floor_step(step); + if (!ride){ + return "no lift ride \"" + step.variant + "\"."; + } + sim_walk_room_legs(m_sim, lift_in_legs()); + if (!m_sim.use_field(SimField::LIFT_FLOOR, ride->to)){ + return "no lift panel for \"" + step.variant + "\"."; + } + sim_walk_room_legs(m_sim, lift_out_legs()); + return ended_on(step); +} + +// After a scripted step: the player's pose, and whether it is the step's +// pose_after, if it has one. +std::string OverworldStepSim::ended_on(const StoryStep& step){ + const SimState& state = m_sim.state(); + m_at = StoryPose{state.position.map, state.position.point, state.facing}; + if (!step.pose_after){ + return ""; + } + const std::optional expected = story_pose(*step.pose_after); + if (!expected || !(MapPosition{expected->map, expected->tile} == state.position)){ + return "expected to end on " + (expected ? to_string(MapPosition{expected->map, expected->tile}) : std::string("a pose")) + "."; + } + return ""; +} + // A battle from the trainer's message, which must be up, ending where the // table says, the battle loop's end played on the overworld after it. std::string OverworldStepSim::battle(const StoryStep& step){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h index 15655a8162..401b5b9b34 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h @@ -183,6 +183,9 @@ class OverworldStepSim : public Pokemon::TableExecutor{ std::string step_onto(const StoryStep& step); std::string cut(const StoryStep& step); std::string battle(const StoryStep& step); + Pokemon::StoryAction hideout_or_stub(); + std::string hideout(const StoryStep& step); + std::string ended_on(const StoryStep& step); OverworldSim& m_sim; StoryPose m_at; 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 c6344b076a..430c754d9e 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 00.28 00.29"; +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 00.30"; 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 00.28 00.29"}, + .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 00.30"}, }, { .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 00.28 00.29"}, + .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 00.30"}, }, { .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 00.28 00.29"}, + .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 00.30"}, }, { .input = { .name = "Starter RNG first, with both on", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp index 5981e1b290..1e6ef673ce 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp @@ -9,6 +9,8 @@ #include "PokemonFRLG_AutoStoryChoice.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryItemUse.h" #include "PokemonFRLG_AutoStoryMart.h" #include "PokemonFRLG_AutoStoryMember.h" #include "PokemonFRLG_AutoStoryPcStorage.h" @@ -73,6 +75,8 @@ const std::vector& story_action_rows(){ {StoryStepKind::GAME, std::string(CAPTURE_VARIANT), StoryActionId::GAME, CAPTURE_ARGS}, {StoryStepKind::GAME, std::string(FOSSIL_VARIANT), StoryActionId::GAME, FOSSIL_ARGS}, {StoryStepKind::GAME, std::string(LEVEL_VARIANT), StoryActionId::GAME, LEVEL_ARGS}, + {StoryStepKind::GAME, std::string(USE_ITEM_VARIANT), StoryActionId::GAME, USE_ITEM_ARGS}, + {StoryStepKind::GAME, std::string(LIFT_FLOOR_VARIANT), StoryActionId::GAME, LIFT_FLOOR_ARGS}, {StoryStepKind::GAME, "", StoryActionId::GAME}, }; return ROWS; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp index 33302b794b..c61f6c10ce 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp @@ -19,6 +19,9 @@ #include "PokemonFRLG_AutoStoryStepInterrupts.h" #include "PokemonFRLG_AutoStoryStepTable.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryRooms.h" +#include "PokemonFRLG_AutoStoryItemUse.h" #include "PokemonFRLG_AutoStoryTable.h" #include "PokemonFRLG_AutoStoryTrashCans.h" @@ -249,6 +252,10 @@ void add_tests_AutoStoryStepTable(UnitTestDatabase& database){ database.add(scenario); } add_tests_AutoStoryStepInterrupts(database); + // The GAME variants with logic of their own. + add_tests_AutoStoryHideout(database); + add_tests_AutoStoryRooms(database); + add_tests_AutoStoryItemUse(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp index acd9a79de8..28daae9c8f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp @@ -4,7 +4,15 @@ * */ +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_HideoutLiftRoom.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PokemonCenterRoom.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryHideoutLift.h" +#include "PokemonFRLG_AutoStoryItemUse.h" +#include "PokemonFRLG_AutoStoryRooms.h" #include "PokemonFRLG_AutoStoryTableSteps.h" +#include "PokemonFRLG_AutoStoryFieldSteps.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -154,6 +162,51 @@ StoryStep cut_tree(std::string name, Pokemon::Pose tree, std::string why){ return step; } +StoryStep poster_switch(std::string name, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = POSTER_SWITCH_VARIANT; + return step; +} + +StoryStep lift_floor(std::string name, std::string_view from, std::string_view to, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = LIFT_FLOOR_VARIANT; + step.args = {{"from", std::string(from)}, {"to", std::string(to)}}; + if (const std::optional ride = parse_lift_floor_step(step)){ + const MapPosition& door = rocket_hideout_lift_doors()[ride->to]; + step.pose_after = to_pose(StoryPose{door.map, door.point, Direction::SOUTH}); + } + return step; +} + +StoryStep center_enter(std::string name, const std::string& center, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = CENTER_ENTER_VARIANT; + step.pose_after = at(center.c_str(), CENTER_NURSE_FRONT.x, CENTER_NURSE_FRONT.y, Direction::NORTH); + return step; +} + +StoryStep center_exit(std::string name, Pokemon::Pose below_door, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = CENTER_EXIT_VARIANT; + step.pose_after = std::move(below_door); + return step; +} + +StoryStep use_item(std::string name, std::string_view item, std::string why, std::optional pose_after){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = USE_ITEM_VARIANT; + step.args = {{"item", std::string(item)}}; + step.pose_after = std::move(pose_after); + return step; +} + +StoryStep ride_bike(std::string name, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = RIDE_BIKE_VARIANT; + return step; +} + } // namespace StoryTable diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h index 53ce8fcaf4..00d2012393 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h @@ -13,6 +13,7 @@ #include #include +#include #include #include "PokemonFRLG_AutoStoryEvents.h" diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp index af918f809d..17a9b016b8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -25,6 +25,7 @@ #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.h" #include "PokemonFRLG_AutoStoryDamage.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryGrind.h" @@ -325,7 +326,7 @@ UnitTestResult test_tree_shuts_the_yard(){ // The sim: no step onto the tree; cut, it's gone; a warp grows it back. OverworldSim sim(story_maps(), MapPosition{StoryTable::VERMILION, {19, 23}}, Direction::SOUTH); sim.tap(Direction::SOUTH); - if (!(sim.state().position == MapPosition{StoryTable::VERMILION, {19, 23}}) || !sim.cut_tree()){ + if (!(sim.state().position == MapPosition{StoryTable::VERMILION, {19, 23}}) || !sim.use_field(SimField::CUT_TREE)){ return "Expected the tree to stop the player, then be cut."; } sim.tap(Direction::SOUTH); 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 2eb4e20f90..174473bdc1 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() != 36 || checkpoints[0].start){ - return "Expected 36 checkpoints, the first from a new game."; + if (checkpoints.size() != 37 || checkpoints[0].start){ + return "Expected 37 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -351,9 +351,11 @@ UnitTestResult test_pewter_aide_walk(){ return true; } -// Every ROUTE and LEGS after the Running Shoes (00.10) runs; none before. +// Every ROUTE and LEGS after the Running Shoes (00.10) runs, or after the +// BICYCLE (00.26) may ride; none before. UnitTestResult test_runs_after_shoes(){ bool has_shoes = false; + bool has_bike = false; size_t runs = 0; for (const StoryCheckpoint& checkpoint : story_checkpoints()){ for (const StoryStep& step : checkpoint.steps){ @@ -361,12 +363,13 @@ UnitTestResult test_runs_after_shoes(){ continue; } const MoveSpeed expected = has_shoes ? MoveSpeed::RUN : MoveSpeed::WALK; - if (move_speed(step) != expected){ + if (move_speed(step) != expected && !(has_bike && move_speed(step) == MoveSpeed::BIKE)){ return checkpoint.id + "/" + step.name + ": expected to " + move_speed_name(expected) + ", it would " + move_speed_name(move_speed(step)) + "."; } runs += has_shoes ? 1 : 0; } has_shoes = has_shoes || checkpoint.id == "00.10"; + has_bike = has_bike || checkpoint.id == "00.26"; } if (!has_shoes || runs == 0){ return "No checkpoint 00.10, where the player gets the Running Shoes, or no route after it."; @@ -600,7 +603,7 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set