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/Overworld/PokemonFRLG_CodedRooms.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp new file mode 100644 index 0000000000..64a103cf0a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp @@ -0,0 +1,211 @@ +/* Coded Rooms and Tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "PokemonFRLG_CodedRooms.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. +const char 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). +const char 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(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(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..348aba0492 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h @@ -0,0 +1,96 @@ +/* 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); + + +// 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); + + +// 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..de3c682440 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h @@ -48,6 +48,12 @@ inline constexpr Milliseconds TILE_TIME(267); inline constexpr Milliseconds RUN_TILE_TIME(133); // Surfing: a tile per 8 frames. inline constexpr Milliseconds SURF_TILE_TIME(133); +// Riding the Bicycle, measured on console B (2026-11-09, Lavender Town +// row 13, wall to wall, from rest): 16 tiles (1, 13) to (17, 13) in +// 1800 ms of motion, east and back west alike, 112 ms a tile; 5 tiles +// from rest in 600 ms (120 ms a tile, the start up included). Not the +// decompilation's 4 frames a tile (67 ms): the bike speeds up from rest. +inline constexpr Milliseconds BIKE_TILE_TIME(112); // The longest tap measured to only turn a player facing another way, and // to take exactly one step for one facing its way. inline constexpr Milliseconds LONGEST_TAP(133); @@ -62,6 +68,10 @@ inline constexpr Milliseconds STEP_TAP_SPACING_WORKED(666); // the D-pad this long. Not measured: pokefirered's fade from black is 16 // frames, then the field takes input; a tap sent at once was lost. inline constexpr Milliseconds BATTLE_EXIT_LOCKOUT(500); +// 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); // The walker's presses. @@ -87,6 +97,9 @@ 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); +// 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(TURN_TAP <= LONGEST_TAP && FACE_HOLD <= LONGEST_TAP && STEP_TAP <= LONGEST_TAP); static_assert(STEP_TAP + STEP_TAP_RELEASE > STEP_TAP_SPACING_WORKED); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp index 03efa4db3f..41a578fdde 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp @@ -6,6 +6,7 @@ #include #include "Common/Cpp/Exceptions.h" +#include "PokemonFRLG_CodedRooms.h" #include "PokemonFRLG_OverworldMap.h" namespace PokemonAutomation{ @@ -230,6 +231,71 @@ StepKind classify_step_onto( return StepKind::WALK; } +const char SPIN_NORTH_REGION[] = "spin_north"; +const char SPIN_SOUTH_REGION[] = "spin_south"; +const char SPIN_WEST_REGION[] = "spin_west"; +const char SPIN_EAST_REGION[] = "spin_east"; +const char SPIN_STOP_REGION[] = "spin_stop"; + +namespace{ + +bool in_region(const MapData& map, const char* region, const TilePos& point){ + const auto tiles = map.regions.find(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..aecc3af3c0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h @@ -160,6 +160,43 @@ PlayerState after_step_onto( ); +// 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. +extern const char SPIN_NORTH_REGION[]; +// "spin_south": MB_SPIN_DOWN. +extern const char SPIN_SOUTH_REGION[]; +// "spin_west": MB_SPIN_LEFT. +extern const char SPIN_WEST_REGION[]; +// "spin_east": MB_SPIN_RIGHT. +extern const char SPIN_EAST_REGION[]; +// "spin_stop": MB_STOP_SPINNING. +extern const char SPIN_STOP_REGION[]; +// 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); + + } // namespace PokemonFRLG } // namespace NintendoSwitch diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 8556442dd0..d16f0ca592 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -81,6 +81,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 +185,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 +278,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 +321,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 +366,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 +398,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 +461,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 +510,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 +561,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..0f9fcb753a --- /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{ + const char* 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 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; +} + +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..2e9b85afac 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -96,8 +96,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,6 +145,7 @@ 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; show(m_script.start_menu_open ? SimScreen::START_MENU : SimScreen::OVERWORLD, m_now); @@ -243,7 +242,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 +314,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; } @@ -491,6 +499,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; } @@ -651,23 +660,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; -} @@ -759,6 +751,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)){ @@ -846,6 +844,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){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 21038720d4..94fdd1d063 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -60,7 +60,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. @@ -137,6 +137,12 @@ struct SimScript{ // 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; }; @@ -201,14 +207,31 @@ 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. +// 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, +}; + +// Simulates overworld movement, encounters and dialogs. class OverworldSim{ public: OverworldSim(std::vector maps, MapPosition start, Direction facing, SimScript script = {}); @@ -250,10 +273,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 +291,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,6 +319,19 @@ 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(); @@ -323,6 +360,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; @@ -495,6 +534,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); @@ -511,14 +552,17 @@ class OverworldSimWalker{ }; -// 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); +// 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 +} // 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..ac198790ac --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp @@ -0,0 +1,132 @@ +/* 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" + +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); + } + 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_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index 7b7fb39c46..ce0f2054ae 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -986,6 +986,7 @@ void add_tests_OverworldSim(UnitTestDatabase& database){ } add_tests_OverworldLateBattle(database); add_tests_OverworldWarpFade(database); + add_tests_OverworldSpin(database); } 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..afe24a3f8b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp @@ -0,0 +1,332 @@ +/* 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" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct SpinInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct SpinExpectation{ + const char* 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 char* 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 char* 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..35214508be 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -191,7 +191,15 @@ 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; } Pokemon::LegMover leg_mover(const RouteStep& step, MoveSpeed speed){ @@ -469,6 +477,8 @@ const char* move_speed_name(MoveSpeed speed){ return "walk"; case MoveSpeed::RUN: return "run"; + case MoveSpeed::BIKE: + return "bike"; } return "?"; } @@ -536,6 +546,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; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index d2120b8e04..22eb2eea05 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); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index 0deab3ef7c..17281e2e83 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -86,6 +86,16 @@ 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"}, + // 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..f9e53a5779 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{ + const char* map; + const char* 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_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 13a39e6cae..e62adfbf53 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); } @@ -689,7 +686,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..4ecbd72ab7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -63,6 +63,10 @@ struct StoryPose{ // The variant of a ROUTE or LEGS step that runs, with B held: the player // has the Running Shoes (00.10 on). inline const std::string RUN_VARIANT = "run"; +// The variant of a ROUTE or LEGS step ridden on the Bicycle, mounted by a +// RIDE_BIKE_VARIANT step before it (PokemonFRLG_AutoStoryItemUse.h). +// Indoors, where the game doesn't let the player ride, it walks. +inline const std::string BIKE_VARIANT = "bike"; // The variant of a STEP into a trainer's line: a tile at a time, at most // TRAINER_LINE_TILES, until the trainer sees the player @@ -101,7 +105,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); 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..8c11c1fa7d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp @@ -0,0 +1,155 @@ +/* AutoStory Rocket Hideout Steps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryItemUse.h" +#include "PokemonFRLG_AutoStoryRooms.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; +} + +std::string lift_floor_variant(std::string_view from, std::string_view to){ + return std::string(LIFT_FLOOR_PREFIX) + std::string(from) + " " + std::string(to); +} + +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_variant(std::string_view variant){ + if (!variant.starts_with(LIFT_FLOOR_PREFIX)){ + return std::nullopt; + } + const std::string_view floors = variant.substr(LIFT_FLOOR_PREFIX.size()); + const size_t space = floors.find(' '); + if (space == std::string_view::npos){ + return std::nullopt; + } + const std::optional from = floor_row(floors.substr(0, space)); + const std::optional to = floor_row(floors.substr(space + 1)); + 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_variant(std::string_view variant){ + return variant == POSTER_SWITCH_VARIANT || parse_lift_floor_variant(variant).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_variant(step.variant)){ + 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_variant(step.variant)){ + return std::nullopt; + } + const std::string item = parse_use_item_variant(step.variant).value_or("bicycle"); + ConsoleItemUseActions bag(console, context, language); + const ItemUseEnd end = use_item_from_bag(console.logger(), bag, item); + 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..bf8ed55c83 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h @@ -0,0 +1,103 @@ +/* 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: +// "lift_floor ", 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_PREFIX = "lift_floor "; + +// 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 variant for a ride from floor `from` to `to`. +std::string lift_floor_variant(std::string_view from, std::string_view to); +// The ride a "lift_floor " variant names, or nothing if it is +// not one, or names a floor the panel hasn't. +std::optional parse_lift_floor_variant(std::string_view variant); + +// 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); + +// Whether `variant` is one of this file's steps. +bool is_hideout_variant(std::string_view variant); + +// 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); +// 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_variant()); +// 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_AutoStoryHideout_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp new file mode 100644 index 0000000000..ae6b342f3e --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp @@ -0,0 +1,293 @@ +/* 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_MovementMechanics.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTableSteps.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::optional ride = parse_lift_floor_variant(m_case.input.variant); + 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_variant(m_case.input.variant)){ + return "A ride that isn't a hideout variant."; + } + return true; + } + +private: + LiftCase m_case; +}; + +UnitTestResult test_variants(){ + if (!is_hideout_variant(POSTER_SWITCH_VARIANT) || is_hideout_variant("cut_tree") || is_hideout_variant("")){ + return "Expected only the poster switch and lift rides as hideout variants."; + } + if (lift_floor_variant("B2F", "B4F") != "lift_floor B2F B4F"){ + return "Expected \"lift_floor B2F B4F\", got \"" + lift_floor_variant("B2F", "B4F") + "\"."; + } + // 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{ + const char* 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..3bce9b0ae6 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp @@ -0,0 +1,181 @@ +/* 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" + +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::string use_item_variant(std::string_view item){ + return std::string(USE_ITEM_PREFIX) + std::string(item); +} +std::optional parse_use_item_variant(std::string_view variant){ + if (!variant.starts_with(USE_ITEM_PREFIX)){ + return std::nullopt; + } + const std::string_view item = variant.substr(USE_ITEM_PREFIX.size()); + if (!usable_item_pocket(item)){ + return std::nullopt; + } + return std::string(item); +} +bool is_item_use_variant(std::string_view variant){ + return variant == RIDE_BIKE_VARIANT || parse_use_item_variant(variant).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; +} + +const char* 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 "?"; +} + +ItemUseEnd use_item_from_bag(Logger& logger, ItemUseActions& actions, std::string_view item){ + 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: using the " + std::string(item) + " on row " + std::to_string(*row + 1) + "."); + actions.use_row(*row); + actions.close_bag(); + return ItemUseEnd::USED; +} + +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::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..3836b7a78a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h @@ -0,0 +1,129 @@ +/* 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 "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: +// "use_item ", the slug as read_item_list_name() names it. +inline constexpr std::string_view USE_ITEM_PREFIX = "use_item "; +// The GAME step that gets on the Bicycle (the Key Items pocket's BICYCLE, +// USE). The ROUTE steps after it ride (BIKE_VARIANT). +inline const std::string RIDE_BIKE_VARIANT = "ride_bike"; + +// 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 variant that uses `item`. +std::string use_item_variant(std::string_view item); +// The item a "use_item " variant uses, or nothing if it isn't one +// or the item isn't one the story uses from the bag. +std::optional parse_use_item_variant(std::string_view variant); +// Whether `variant` is one of this file's steps. +bool is_item_use_variant(std::string_view variant); + +// 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; + // 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. +const char* item_use_end_name(ItemUseEnd end); + +// 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); + +// 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 close_bag() override; + +private: + ConsoleHandle& m_console; + ProControllerContext& m_context; + Language m_language; +}; + + +// 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..ab33b1a43c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp @@ -0,0 +1,252 @@ +/* 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_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryItemUse.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// 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; + 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 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_variant(variant).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_variant("use_item potion") || parse_use_item_variant("use_item") || !parse_use_item_variant(use_item_variant("super-repel"))){ + return "Expected only the story's bag items as use_item variants."; + } + if (!is_item_use_variant(RIDE_BIKE_VARIANT) || is_item_use_variant("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{ + const char* 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_AutoStoryRooms.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp new file mode 100644 index 0000000000..52de05543b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp @@ -0,0 +1,91 @@ +/* AutoStory Scripted Rooms + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryRooms.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..ee5b3d9f91 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h @@ -0,0 +1,81 @@ +/* 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; +}; +// 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); + +// 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..00884dc0b3 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp @@ -0,0 +1,125 @@ +/* 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_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryRooms.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTableSteps.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.c_str(), "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..089904ef6a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -15,6 +15,9 @@ #include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryBattleDecisions.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryRooms.h" #include "PokemonFRLG_AutoStoryRowSim.h" namespace PokemonAutomation{ @@ -370,6 +373,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 +513,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 +524,80 @@ 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 + +// 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; 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_variant(step.variant) || is_center_variant(step.variant) + ? 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_variant(step.variant); + 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_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..70d3a2b8f0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp @@ -4,6 +4,10 @@ * */ +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryItemUse.h" +#include "PokemonFRLG_AutoStoryRooms.h" #include "PokemonFRLG_AutoStoryTableSteps.h" namespace PokemonAutomation{ @@ -154,6 +158,49 @@ 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(from, to); + if (const std::optional ride = parse_lift_floor_variant(step.variant)){ + 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 char* 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, 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(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 d0a314a2ac..8729198d44 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" @@ -182,6 +183,25 @@ StoryStep options_step(); // Cuts the tree the player faces (CUT_TREE_VARIANT) and steps onto its // tile, `tree`, facing the way the step goes. StoryStep cut_tree(std::string name, Pokemon::Pose tree, std::string why); +// 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 char* 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); // Rows in files of their own (PokemonFRLG_AutoStoryTableVermilionGym.cpp, 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 b59faf6d75..3188bb3189 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -325,7 +325,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/StoryData/Draft/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md new file mode 100644 index 0000000000..0d23b8ff91 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md @@ -0,0 +1,205 @@ +# FRLG story drafts, 00.30 to the Hall of Fame + +**Every file here is an unverified draft.** None has run on a console, and the +story table doesn't load them: `pa_embed_text_files()` embeds only +`StoryData/*.json`, not this folder. The authored format has no status field, +so this README is where they are marked unverified. + +Each file is a `pokesim-authored-checkpoint/1` plan in the same format as +`../frlg-parcel-00.05.plan.json`. The order and the item stops follow PokeGuy's +LeafGreen run (PokemonSimulation `data/runs/pokeguy-leafgreen-15934/events.json` +and `external/pokeguy-notes.md`). 00.28 and 00.29 are table rows on +`frlg/stage8-rocktunnel` (#293); 00.29 ends at Lavender's nurse, where 00.30 +starts. + +| Id | Title | Events | +|---|---|---:| +| 00.30 | Lavender Mart, then west over Route 8 on the Bicycle under a Repel: Gamer Rich, a Pidgey for Fly, the Underground Path to Celadon's Pokemon Center | 14 | +| 00.31 | Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the poster switch, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy | 12 | +| 00.32 | Rocket Hideout: the lift to B4F, the two door guards, two Rare Candies, Giovanni, the SILPH SCOPE, an Escape Rope out | 16 | + +The later drafts (Celadon Department Store to the Hall of Fame) are on +`frlg/draft-story-50-51` (#303) under the ids 00.32 to 00.51; with 00.30 +restored here they each move up by one when that stack takes this one in. + +## Story order after the hideout + +As PokeGuy plays it: the Celadon Department Store 5F (6 X Speed, 21 X Special, +5 X Accuracy, 2 Guard Spec), then Pokemon Tower (a Super Repel on 3F to 7F, +the floors with wild Pokemon), Route 16's Snorlax, Fuchsia and Koga, the +Pokemon Mansion and Blaine, Erika, Silph Co., Sabrina, Viridian's Giovanni, and +the Elite Four. + +Items from the bag, as PokeGuy uses them: a Repel over Route 8 and the +Underground Path (00.30; 5 bought in 00.26), renewed when "REPEL's effect wore +off" shows (`renew_repel()`); Super Repels in Pokemon Tower; an Escape Rope out +of the hideout after Giovanni (00.32), which puts the player at the Game +Corner's door, CeladonCity (34, 22) (pokefirered `UpdateEscapeWarp()`: the last +outdoor door, a tile south). The Rocket Hideout has no wild Pokemon +(`wild_encounters.json` lists none of its floors), so no Repel is used there. +The BICYCLE (00.26) is ridden on Route 8's open legs (`bike`) at +`BIKE_TILE_TIME`, 112 ms a tile, measured on console B (2026-11-09). + +## The maps each draft needs + +The story can't run or simulate these drafts until their maps are in the map +resource lock (PokemonSimulation `6c2f5c8`, `data/maps/runtime-manifest.json`). +Status there, in PokemonSimulation's `data/games/frlg/maps/source-candidate`, +or neither: + +| Map | Draft | Status | +|---|---|---| +| LavenderTown | 00.30 | published | +| LavenderTown_Mart | 00.30 | candidate | +| Route8 | 00.30 | candidate | +| UndergroundPath_EastEntrance | 00.30 | candidate | +| UndergroundPath_EastWestTunnel | 00.30 | candidate | +| UndergroundPath_WestEntrance | 00.30 | candidate | +| Route7 | 00.30 | candidate | +| CeladonCity | 00.30 to 00.32 | candidate | +| CeladonCity_GameCorner | 00.31, 00.32 | candidate | +| RocketHideout_B1F | 00.31, 00.32 | candidate | +| RocketHideout_B2F | 00.31, 00.32 | candidate | +| RocketHideout_B3F | 00.31 | candidate | +| RocketHideout_B4F | 00.31, 00.32 | candidate | + +### Scripted, not mapped + +Pokemon Centers and the hideout's lift have no maps (the user's call, +2026-11-09): every Center's ground floor shares pokefirered's +`LAYOUT_POKEMON_CENTER_1F`, and the lift is 5 by 7 tiles. The seven Centers in +the map lock have the same tiles; they differ only in the people standing in +them, none on the nurse's column (x 7). So the story walks both by a fixed +script (`PokemonFRLG_AutoStoryRooms.h`), and the sim builds their rooms from +code (`PokemonFRLG_CodedRooms.h`): + +- `center_enter`: from below the Center's door, facing north, through the door + onto the mat (7, 8) and north 4 to below the nurse (7, 4); `heal` follows. + `center_exit`: south 4 and out; the game walks the player back onto the tile + below the door. 00.30 and 00.31 use them for Lavender's and Celadon's + Centers; no `CeladonCity_PokemonCenter_1F` map is needed. +- `lift_floor `: from below `from`'s lift door, facing north: in + onto (2, 5), north 2, west 2 to below the panel (0, 2), the floor picked, + east 2, south 2 and south through the arrow warp onto `to`'s lift door + (`setdynamicwarp`: B1F (24, 25), B2F (28, 16), B4F (20, 23)). No + `RocketHideout_Elevator` map is needed. + +The two other things the candidate maps can't say in glyphs are coded too, +from pokefirered, so the maps need no extra regions or objects: + +- B2F's and B3F's spinner tiles (`MB_SPIN_*`, `MB_STOP_SPINNING` from the + floors' `map.bin` and the tilesets' metatile attributes; + `coded_spin_direction()`); +- the Game Corner's poster at (11, 1) and the tiles its switch turns into + stairs, (15, 2) and (16..17, 2..3) (`OpenRocketHideout`'s `setmetatile`), + added to the sim's Game Corner by `add_coded_objects()`. + +Beyond those, the drafts need a PokemonSimulation publish and a lock bump for +the candidate maps above, which need the user's approval. + +`STORY_TRAINERS` names the battles these drafts fight: CeladonCity_GameCorner +`rocket_grunt` (GRUNT_7), and on RocketHideout_B4F `lift_key_grunt` (GRUNT_18), +`left_guard` (GRUNT_16), `right_guard` (GRUNT_17) and `giovanni`. When the +drafts become table rows, their battle steps take those names. + +## Conventions + +- A `battle` event's `id` is the battle's id in PokemonSimulation + `docs/battles/pokeguy-trainers.json`, e.g. `route8-rich`, `koga`. Its strategy + belongs to the battle agent, not to these files. Two battles have no entry + there: `tower-marowak` (an `events.json` scripted fight) and + `dojo-karate-master` (not in PokeGuy's run). +- Shopping is a `mart ...` `game_action` with the quantities from + PokemonSimulation `docs/battles/pokeguy-conditions.md` bags and PokeGuy's + receipts: Lavender (4 Escape Ropes, 7 Super Repels), Celadon 5F (6 X Speed, + 21 X Special, 5 X Accuracy, 2 Guard Spec), Indigo Plateau (12 Full Restores). +- A pickup faces the ball and talks (A). Hidden items (Cycling Road's Rare Candy + and Max Elixir, the Mansion's Elixir) have no tile in the candidate maps. +- The story's shiny gifts are placeholders: a `party_room free=1` check, then a + `gift_rng ` handoff, for the Eevee (00.32b), the Omanyte from the Helix + Fossil (00.38b), the Hitmonchan (00.41) and the Lapras (00.42). The authored + format can't yet say `handoff`/`live_start` as 00.12/00.12b do in C++. +- `game_action` variants the C++ executor now has (no console run yet): + `poster_switch`, `lift_floor ` (PokemonFRLG_AutoStoryHideout.h), + `center_enter`, `center_exit` (PokemonFRLG_AutoStoryRooms.h), + `use_item ` and `ride_bike` + (PokemonFRLG_AutoStoryItemUse.h), and the `bike` route variant. Variants + that no executor has yet: `party_room`, `gift_rng`, `use_item rare-candy + slot=N` (an item used on a party member), `fly`, `flee_wild`, `surf`, + `strength_puzzle`, `reenter_map`. The `mart` variants name Marts the Mart table doesn't list + yet (Lavender, Celadon 5F, Indigo Plateau). +- HMs and key items come in story order: LIFT KEY, SILPH SCOPE, HM02 Fly, TEA, + POKE FLUTE, HM03 Surf, GOLD TEETH, HM04 Strength, SECRET KEY, CARD KEY. + +## Decisions for the user + +- **A Pidgey for Fly.** PokeGuy flies everywhere from 1:03; the story party has + no flier. 00.30 catches a Pidgey in Route 7's grass (Route 8's grass is + behind two Cut trees). Without Fly, Cinnabar needs a long walk round to Pallet. +- **TM14 Blizzard over Mega Kick** (00.40) is a one-shot TM: it needs the team + plan first. +- **Strength over Water Pulse** (00.45) and **Surf over Water Gun** (00.37) + follow PokeGuy. +- **The Hitmonchan** needs the Dojo's Karate Master beaten, a fight PokeGuy + skipped. + +## Leg check + +Every `route` event was planned on PokemonSimulation `6c2f5c8` +(`data/games/frlg/maps/source-candidate`, plus `runtime` for the maps the +candidate set lacks) with `RuntimeWorld` and the shared `shortest_path()`, from +the previous event's pose. 74 legs plan; 36 are skipped, none fails. The +planner ignores trainer sight, Surf, Strength, spinner and quiz tiles, and holds +NPC wander boxes as walls. No map was edited. Where a leg crosses a map edge on +an open tile (Lavender to Route 8, Route 7 to Celadon), the executor should hold +it to a wall, because of the known off-by-one on unheld crossings. + +Two candidate maps look wrong; these are map questions for the user, not edits: +the Elite Four rooms' arrival points (e.g. Lorelei's `warp_0` (6, 12)) are +wall tiles, and Indigo Plateau's door glyph is at (11, 5) with its warp at +(11, 6). + +The `*_nurse` legs below (drafts on #303) need no Center maps once they become +`center_enter` steps, as 00.30's has. + +| Skipped leg | Why | +|---|---| +| 00.32/b2f_lift | B2F's lift corner is reached over arrow tiles: the planner solves them now, but the candidate map has no `spin_*` regions | +| 00.32/b4f_below_guards | planned before the lift was scripted; it now starts on B4F's lift door (20, 23) | +| 00.32/x_item_clerk | CeladonCity_DepartmentStore_Elevator map not in source-candidate | +| 00.32/route16_below_tree | CeladonCity_DepartmentStore_Elevator map not in source-candidate | +| 00.32/celadon_nurse | map not in source-candidate | +| 00.32b/eevee_ball | Condominiums 2F/3F/RoofRoom map not in source-candidate | +| 00.35/cycling_road_rare_candy | Route17 map not in source-candidate | +| 00.35/cycling_road_max_elixir | Route17 map not in source-candidate | +| 00.35/fuchsia_below_center | Route17 map not in source-candidate | +| 00.35/fuchsia_nurse | map not in source-candidate | +| 00.37/fuchsia_nurse | map not in source-candidate | +| 00.38/mansion_b1f | the statue-switch gates and the floor drop are not modeled | +| 00.38/hidden_elixir_spot | hidden item tiles are not in the candidate maps | +| 00.38/below_tm14 | the statue-switch gates are not modeled | +| 00.38/below_secret_key | the statue-switch gates are not modeled | +| 00.38/cinnabar_nurse | map not in source-candidate | +| 00.38b/fossil_scientist | CinnabarIsland_PokemonLab_* map not in source-candidate | +| 00.39/below_blaine | the quiz doors are not modeled | +| 00.40/celadon_nurse | map not in source-candidate | +| 00.41/karate_master | SaffronCity_Dojo map not in source-candidate | +| 00.41/hitmonchan_ball | SaffronCity_Dojo map not in source-candidate | +| 00.41/saffron_center_door | SaffronCity_Dojo map not in source-candidate | +| 00.41/saffron_nurse | map not in source-candidate | +| 00.43/below_sabrina | warp-tile room order not planned: check SaffronCity_Gym warps | +| 00.44/takashi_line | arrow tiles and trainer lines not planned | +| 00.44/warren_line | arrow tiles and trainer lines not planned | +| 00.44/below_gym_giovanni | start unknown | +| 00.45/victory_road_door | Surf legs between the gates are not planned | +| 00.46/indigo_door | Victory Road boulder states are not modeled | +| 00.46/indigo_clerk | the Plateau door (11, 6) does not lead in on the candidate maps (no door glyph under the credits NPCs) | +| 00.47/below_lorelei | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | +| 00.48/below_bruno | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | +| 00.49/below_agatha | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | +| 00.50/below_lance | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | +| 00.51/below_champion | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | + +The native codec (`Pokemon::read_authored_checkpoint()`, built alone from the +story-data-tests sources) and PokemonSimulation's `read_authored_checkpoint()` +both read all 25 files. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json new file mode 100644 index 0000000000..6ccbd8d610 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json @@ -0,0 +1,110 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "frlg", + "checkpoint": { + "id": "00.30", + "title": "Lavender Mart, then west over Route 8: Gamer Rich, a Pidgey for Fly, the Underground Path to Celadon", + "start": {"certainty": "tile", "map": "LavenderTown_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, + "events": [ + { + "id": "out_of_lavender_center", + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "LavenderTown", "where": {"facing": "south", "kind": "tiles", "tiles": [[6, 6]]}}, + "variant": "center_exit", + "why": "the Center script out: south to the mat and out of the door (6, 5)" + }, + { + "id": "lavender_mart_clerk", + "kind": "route", + "target": {"certainty": "tile", "map": "LavenderTown_Mart", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]}}, + "variant": "run", + "why": "east along row 6 and down to the Mart (20, 15), to its clerk" + }, + { + "id": "buy_ropes_and_super_repels", + "kind": "game_action", + "variant": "mart LavenderTown_Mart budget=5700 floor=9000 escape-rope=4 super-repel=7", + "why": "pokeguy 54:18: 4 Escape Ropes and 7 Super Repels" + }, + { + "id": "repel_route8", + "kind": "game_action", + "variant": "use_item repel", + "why": "a Repel (5 bought in 00.26) for Route 8's grass and the Underground Path; renewed when it wears off" + }, + { + "id": "ride_bike", + "kind": "game_action", + "variant": "ride_bike", + "why": "on the BICYCLE (00.26) for Route 8; the Underground Path's gate, indoors, gets the player off" + }, + { + "id": "east_of_rich_line", + "kind": "route", + "target": {"certainty": "tile", "map": "Route8", "where": {"facing": "west", "kind": "tiles", "tiles": [[56, 18]]}}, + "variant": "bike", + "why": "west out of Lavender onto Route 8, down column 64 east of Julia (62, 14) and west along row 18" + }, + { + "id": "rich_line", + "kind": "step", + "direction": "west", + "variant": "trainer_line", + "why": "onto (55, 18), the end of Rich's line (55, 16..18): he walks down to (55, 17)" + }, + { + "id": "route8-rich", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "Route8", "where": {"facing": "north", "kind": "tiles", "tiles": [[55, 18]]}}, + "why": "Gamer Rich (TRAINER_GAMER_RICH): Growlithe, Vulpix Lv24" + }, + { + "id": "below_underground_door", + "kind": "route", + "target": {"certainty": "tile", "map": "Route8", "where": {"facing": "north", "kind": "tiles", "tiles": [[13, 5]]}}, + "variant": "bike", + "why": "west along rows 14..18, up column 14 east of Aidan's line (10..13, 6), to below the Underground Path door (13, 4)" + }, + { + "id": "route7_underground_exit", + "kind": "route", + "target": {"certainty": "tile", "map": "Route7", "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 15]]}}, + "variant": "run", + "why": "through the door, the east entrance, the east-west tunnel and the west entrance, out onto Route 7" + }, + { + "id": "pidgey_grass", + "kind": "route", + "target": {"certainty": "tile", "map": "Route7", "where": {"facing": "east", "kind": "tiles", "tiles": [[14, 5]]}}, + "variant": "run", + "why": "north round the ledges into Route 7's grass (11..16, 2..5), a grass tile east of it free; Route 8's grass is behind two Cut trees" + }, + { + "id": "catch_pidgey", + "kind": "game_action", + "variant": "capture pidgey catches=1 encounters=30 balls=5 pace=east", + "why": "a Pidgey to carry HM02 Fly (pokeguy flies with a Pidgey from 1:03 on); the party has no other flier" + }, + { + "id": "celadon_below_center", + "kind": "route", + "target": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "north", "kind": "tiles", "tiles": [[48, 12]]}}, + "variant": "run", + "why": "north round the grass, west onto Celadon (row 12..15) and along row 12 to below the Center door (48, 11)" + }, + { + "id": "celadon_nurse", + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, + "variant": "center_enter", + "why": "the Center script in: through the door and up to the nurse; no map of the Center" + }, + { + "id": "heal_celadon", + "kind": "heal", + "why": "the nurse heals the party" + } + ] + }, + "execution": {"menu": "full"} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json new file mode 100644 index 0000000000..39dd599fd8 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json @@ -0,0 +1,94 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "frlg", + "checkpoint": { + "id": "00.31", + "title": "Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy", + "start": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, + "events": [ + { + "id": "out_of_celadon_center", + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "south", "kind": "tiles", "tiles": [[48, 12]]}}, + "variant": "center_exit", + "why": "the Center script out: south to the mat and out of the door (48, 11)" + }, + { + "id": "celadon_below_game_corner", + "kind": "route", + "target": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "north", "kind": "tiles", "tiles": [[34, 22]]}}, + "variant": "run", + "why": "west along row 12, down column 41..43 to row 22, west to below the Game Corner (34, 21)" + }, + { + "id": "below_poster_guard", + "kind": "route", + "target": {"certainty": "tile", "map": "CeladonCity_GameCorner", "where": {"facing": "north", "kind": "tiles", "tiles": [[11, 3]]}}, + "variant": "run", + "why": "into the Game Corner, up the slot rows to below the Grunt (11, 2) in front of the poster" + }, + { + "id": "talk_poster_guard", + "kind": "talk", + "why": "the Grunt guarding the poster" + }, + { + "id": "hideout-poster-guard", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "CeladonCity_GameCorner", "where": {"facing": "north", "kind": "tiles", "tiles": [[11, 3]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_7): Raticate, Zubat Lv20; he runs off" + }, + { + "id": "poster_switch", + "kind": "game_action", + "variant": "poster_switch", + "why": "the poster (11, 1): the switch opens the stairs (15, 2)" + }, + { + "id": "hideout_b4f_west", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[5, 3]]}}, + "variant": "run", + "why": "the stairs (15, 2) down to B1F, B1F to B2F (17, 2), B2F's arrow tiles to B3F (21, 2), B3F to B4F (15, 18), round to (5, 3), east of the tile below the Grunt (4, 2)" + }, + { + "id": "key_grunt_line", + "kind": "step", + "direction": "west", + "variant": "trainer_line", + "why": "onto (4, 3), in the Grunt's line (sight 1)" + }, + { + "id": "hideout-key-grunt", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_18): Koffing, Zubat Lv21; he drops the LIFT KEY" + }, + { + "id": "below_lift_key", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[3, 3]]}}, + "variant": "run", + "why": "a tile west, below the LIFT KEY (3, 2)" + }, + { + "id": "lift_key", + "kind": "talk", + "why": "pick up the LIFT KEY (pokeguy 57:33)" + }, + { + "id": "beside_b3f_rare_candy", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B3F", "where": {"facing": "north", "kind": "tiles", "tiles": [[12, 13]]}}, + "variant": "run", + "why": "back up the stairs to B3F, below the Rare Candy (12, 12) beside the arrow tiles" + }, + { + "id": "b3f_rare_candy", + "kind": "talk", + "why": "pick up the Rare Candy: two, with Route 9's" + } + ] + }, + "execution": {"menu": "full"} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json new file mode 100644 index 0000000000..d5170089d4 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json @@ -0,0 +1,107 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "frlg", + "checkpoint": { + "id": "00.32", + "title": "Rocket Hideout: the lift to B4F, the two door guards, two Rare Candies, Blastoise, Giovanni and the SILPH SCOPE", + "start": {"certainty": "tile", "map": "RocketHideout_B3F", "where": {"facing": "north", "kind": "tiles", "tiles": [[12, 13]]}}, + "events": [ + { + "id": "b2f_lift", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B2F", "where": {"facing": "north", "kind": "tiles", "tiles": [[28, 17]]}}, + "variant": "run", + "why": "up to B2F, along the arrow tiles to the lift (28..29, 16)" + }, + { + "id": "lift_to_b4f", + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "south", "kind": "tiles", "tiles": [[20, 23]]}}, + "variant": "lift_floor B2F B4F", + "why": "the lift script: in through the door (28, 16), to the panel, B4F with the LIFT KEY, out onto B4F's lift door; no map of the lift" + }, + { + "id": "b4f_below_guards", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "variant": "run", + "why": "from the lift door to below the two Grunts (16, 14) and (19, 14)" + }, + { + "id": "talk_left_guard", + "kind": "talk", + "why": "the left door guard" + }, + { + "id": "hideout-left-guard", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_16): Sandshrew, Sandslash, Ekans Lv23" + }, + { + "id": "talk_right_guard", + "kind": "talk", + "why": "the right door guard" + }, + { + "id": "hideout-right-guard", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_17): Ekans, Sandshrew, Arbok Lv23; the door opens" + }, + { + "id": "rare_candy_1", + "kind": "game_action", + "variant": "use_item rare-candy slot=1", + "why": "Rare Candy: Lv35 (pokeguy 1:00:25)" + }, + { + "id": "rare_candy_2", + "kind": "game_action", + "variant": "use_item rare-candy slot=1", + "why": "Rare Candy: Lv36, Wartortle evolves" + }, + { + "id": "evolve_blastoise", + "kind": "scene", + "why": "Wartortle evolves into Blastoise; do not press B" + }, + { + "id": "below_giovanni", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[19, 5]]}}, + "why": "through the door to below Giovanni (19, 4)" + }, + { + "id": "talk_giovanni", + "kind": "talk", + "why": "Giovanni" + }, + { + "id": "hideout-giovanni", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[19, 5]]}}, + "why": "Giovanni (TRAINER_BOSS_GIOVANNI): Onix, Rhyhorn, Kangaskhan" + }, + { + "id": "beside_silph_scope", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[21, 5]]}}, + "why": "round to east of the SILPH SCOPE (20, 5)" + }, + { + "id": "silph_scope", + "kind": "talk", + "why": "pick up the SILPH SCOPE" + }, + { + "id": "escape_rope_hideout", + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "south", "kind": "tiles", "tiles": [[34, 22]]}}, + "variant": "use_item escape-rope", + "why": "out to the Game Corner's door (pokeguy 1:02:10)" + } + ] + }, + "execution": {"menu": "full"} +} diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 976060be1f..35375a29ec 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1846,6 +1846,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_CatchFanfareDetector.h Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_ShinySoundDetector.cpp Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_ShinySoundDetector.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldLateBattle.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldLateBattle.h @@ -1866,9 +1868,12 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp @@ -1916,6 +1921,12 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h @@ -1942,6 +1953,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp