diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp index 0793744ff9..9ec73b734a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp @@ -70,6 +70,7 @@ class PlaceNameMatcher : public OCR::SmallDictionaryMatcher{ {"route-2", "Route 2"}, {"route-3", "Route 3"}, {"route-4", "Route 4"}, + {"cerulean-city", "Cerulean City"}, }){ JsonArray names; names.push_back(name); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h index e7f81aa521..94514f94cf 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h @@ -4,9 +4,9 @@ * * Reads where the game would be saved: the place name in blue at the top * of the card the START menu's SAVE shows ("PALLET TOWN" over PLAYER, - * BADGES and TIME). It knows the places from a new game to Route 4, in - * English only so far: in another language nothing is read. Route 3 and - * Route 4 have no test capture yet. + * BADGES and TIME). It knows the places from a new game to Route 4, and + * Cerulean City, in English only so far: in another language nothing is + * read. Route 3, Route 4 and Cerulean City have no test capture yet. * */ @@ -31,7 +31,7 @@ namespace PokemonFRLG{ ImageFloatBox save_card_title_box(); // The place on the save card: "pallet-town", "viridian-city", -// "viridian-forest", "pewter-city", "route-1" to "route-4"; empty if it +// "viridian-forest", "pewter-city", "route-1" to "route-4", "cerulean-city"; empty if it // can't be read, as in a language the reader has no names for. std::string read_save_card_location(Logger& logger, Language language, const ImageViewRGB32& screen); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json index 10e47a673a..51f0256ec9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json @@ -91,6 +91,8 @@ ], "points": { "warp_3": [22, 19], + "warp_4": [31, 21], + "warp_6": [29, 28], "warp_7": [1, 12] }, "warps": [ @@ -119,7 +121,9 @@ { "x": 31, "y": 21, - "enter": "walk_into" + "enter": "walk_into", + "to_map": "CeruleanCity_Gym", + "to_point": "warp_1" }, { "x": 13, @@ -129,7 +133,9 @@ { "x": 29, "y": 28, - "enter": "walk_into" + "enter": "walk_into", + "to_map": "CeruleanCity_Mart", + "to_point": "warp_1" }, { "x": 1, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_Gym.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_Gym.json new file mode 100644 index 0000000000..519944eecb --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_Gym.json @@ -0,0 +1,116 @@ +{ + "name": "CeruleanCity_Gym", + "source": { + "decomp_file": "data/maps/CeruleanCity_Gym/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "#################", + "#####w#w.w#w#####", + "#################", + "#...............#", + "#......www......#", + "#.wwwwwwwwwwwww.#", + "#.wwwww...wwwww.#", + "#.ww.........ww.#", + "#.wwwww...ww.ww.#", + "#.wwwwwwwwww.ww.#", + "#.ww.........ww.#", + "#.ww.wwww.wwwww.#", + "#.ww.wwww.wwwww.#", + "#.ww......wwwww.#", + "#.wwwwwww.wwwww.#", + "#.wwwwwww.wwwww.#", + "#.wwww.....wwww.#", + "#.wwwwS...Swwww.#", + "#...............#", + "#################" + ], + "elevation": [ + "00000000000000000", + "00000000000000000", + "00000000000000000", + "03333333333333330", + "03333331113333330", + "03111111111111130", + "03111113331111130", + "03113333333331130", + "03111113331131130", + "03111111111131130", + "03113333333331130", + "03113111131111130", + "03113111130111130", + "03113333331111130", + "03111111131111130", + "03111111131111130", + "03111133333111130", + "03111103330111130", + "03333333333333330", + "00000000000000000" + ], + "points": { + "misty_front": [8, 7], + "warp_1": [8, 18] + }, + "warps": [ + { + "x": 7, + "y": 18, + "enter": "walk_into", + "to_map": "CeruleanCity", + "to_point": "warp_4" + }, + { + "x": 8, + "y": 18, + "enter": "walk_south", + "to_map": "CeruleanCity", + "to_point": "warp_4" + }, + { + "x": 9, + "y": 18, + "enter": "walk_into", + "to_map": "CeruleanCity", + "to_point": "warp_4" + } + ], + "objects": [ + { + "name": "swimmer_m_water", + "kind": "npc", + "x": 10, + "y": 12, + "facing": "west", + "trainer": { + "sight": 1 + } + }, + { + "name": "picnicker", + "kind": "npc", + "x": 4, + "y": 7, + "facing": "east", + "trainer": { + "sight": 4 + } + }, + { + "name": "misty", + "kind": "npc", + "x": 8, + "y": 6 + }, + { + "name": "gym_guy", + "kind": "npc", + "x": 7, + "y": 16 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_Mart.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_Mart.json new file mode 100644 index 0000000000..db4c9defee --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_Mart.json @@ -0,0 +1,86 @@ +{ + "name": "CeruleanCity_Mart", + "source": { + "decomp_file": "data/maps/CeruleanCity_Mart/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "###########", + "###########", + "##.#......#", + "...#.......", + "####...##.#", + ".......##.#", + ".##....##.#", + "...........", + "###########" + ], + "elevation": [ + "00000000000", + "00000000000", + "00303333330", + "33003333333", + "00003330030", + "33333330030", + "30033330030", + "33333333333", + "00000000000" + ], + "points": { + "clerk_front": [4, 3], + "warp_1": [4, 7] + }, + "warps": [ + { + "x": 3, + "y": 7, + "enter": "walk_into", + "to_map": "CeruleanCity", + "to_point": "warp_6" + }, + { + "x": 4, + "y": 7, + "enter": "walk_south", + "to_map": "CeruleanCity", + "to_point": "warp_6" + }, + { + "x": 5, + "y": 7, + "enter": "walk_into", + "to_map": "CeruleanCity", + "to_point": "warp_6" + } + ], + "objects": [ + { + "name": "clerk", + "kind": "npc", + "x": 2, + "y": 3 + }, + { + "name": "woman_1", + "kind": "npc", + "x": 9, + "y": 2, + "wander": { + "x0": 5, + "y0": 2, + "x1": 10, + "y1": 2 + } + }, + { + "name": "youngster", + "kind": "npc", + "x": 1, + "y": 7 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route24.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route24.json index 2c3bd7ee03..c8402d903e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route24.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route24.json @@ -173,6 +173,11 @@ "side": "south", "to_map": "CeruleanCity", "offset": -12 + }, + { + "side": "east", + "to_map": "Route25", + "offset": 0 } ] } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route25.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route25.json new file mode 100644 index 0000000000..29dcb02843 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route25.json @@ -0,0 +1,200 @@ +{ + "name": "Route25", + "source": { + "decomp_file": "data/maps/Route25/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "########################################################################", + "########################################################################", + "############...#...#.....#.....#..........##############################", + ".~~~~~~~~......#...#.....#.....#..........####################wwww######", + ".~~~~~~~~................###.#............######S##D##########wwww######", + "................#.#.#....#...#.....#.........................#wwww######", + "vvvvvvvvvv##..##....#.##.#.........#.........................#wwwww#####", + "................#...#....#......##...........................#wwwwwww###", + ".............#..##..#...........#............................#wwwwwwwwww", + ".............#......#.#.........#..........wwwwww.....wwwwww.#wwwwwwwwww", + "###########################################wwwwww.....wwwwww.#wwwwwwwwww", + "...........................................wwwwww.....wwwwww.#wwwwwwwwww", + "#########################################....................#wwwwwwwwww", + "##############################################################wwwwwwwwww", + "#########################################wwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "#########################################wwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "#########################################wwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "#########################################wwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "#########################################wwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "#########################################wwwwwwwwwwwwwwwwwwwwwwwwwwwwwww" + ], + "elevation": [ + "000000000000000000000000000000000000000000000000000000000000000000000000", + "000000000000000000000000000000000000000000000000000000000000000000000000", + "000000000000333033303333303333303333333333000000000000000000000000000000", + "333333333333333033303333303333303333333333000000000000000000001111000000", + "333333333333333333333333300030333333333333000000000000000000001001000000", + "333333333333333303030333303330333330333333333333333333333333301001000000", + "000000000000330033330300303333333330333333333333333333333333301001100000", + "333333333333333303330333303333330033333333333333333333333333301000111000", + "333333333333303300330333333333330333333333333333333333333333301000001111", + "333333333333303333330303333333330333333333311111133333111111301000000000", + "000000000000000000000000000000000000000000011111133333111111301000000000", + "333333333333333333333333333333333333333333311111133333111111301000000000", + "000000000000000000000000000000000000000003333333333333333333301000000000", + "000000000000000000000000000000000000000000000000000000000000001000000000", + "000000000000000000000000000000000000000001111111111111111111111000000000", + "000000000000000000000000000000000000000001100000000000000000000000000000", + "000000000000000000000000000000000000000001000000000000000000000000000000", + "000000000000000000000000000000000000000001000000000000000000000000000000", + "000000000000000000000000000000000000000001000000000000000000000000000000", + "000000000000000000000000000000000000000001000000000000000000000000000000" + ], + "points": { + "warp_0": [51, 4] + }, + "warps": [ + { + "x": 51, + "y": 4, + "enter": "walk_into", + "to_map": "Route25_SeaCottage", + "to_point": "warp_1" + } + ], + "objects": [ + { + "name": "hiker", + "kind": "npc", + "x": 11, + "y": 4, + "facing": "east", + "trainer": { + "sight": 4 + } + }, + { + "name": "youngster", + "kind": "npc", + "x": 18, + "y": 2, + "facing": "south", + "trainer": { + "sight": 2 + } + }, + { + "name": "hiker", + "kind": "npc", + "x": 17, + "y": 7, + "facing": "east", + "trainer": { + "sight": 2 + } + }, + { + "name": "youngster", + "kind": "npc", + "x": 22, + "y": 4, + "trainer": { + "sight": 2, + "looks": [ + "north", + "east", + "south", + "west" + ] + } + }, + { + "name": "picnicker", + "kind": "npc", + "x": 22, + "y": 8, + "facing": "east", + "trainer": { + "sight": 2 + } + }, + { + "name": "hiker", + "kind": "npc", + "x": 27, + "y": 9, + "facing": "north", + "trainer": { + "sight": 3 + } + }, + { + "name": "camper", + "kind": "npc", + "x": 28, + "y": 4, + "facing": "south", + "trainer": { + "sight": 3 + } + }, + { + "name": "youngster", + "kind": "npc", + "x": 36, + "y": 4, + "trainer": { + "sight": 2, + "looks": [ + "north", + "east", + "south", + "west" + ] + } + }, + { + "name": "lass", + "kind": "npc", + "x": 42, + "y": 5, + "facing": "south", + "trainer": { + "sight": 3 + } + }, + { + "name": "item_ball", + "kind": "item", + "x": 26, + "y": 2, + "hide_flag": "FLAG_HIDE_ROUTE25_TM43" + }, + { + "name": "cut_tree", + "kind": "npc", + "x": 30, + "y": 3, + "hide_flag": "FLAG_TEMP_12" + }, + { + "name": "beauty", + "kind": "npc", + "x": 49, + "y": 10 + }, + { + "name": "man", + "kind": "npc", + "x": 49, + "y": 11 + } + ], + "flag_sets": {}, + "connections": [ + { + "side": "west", + "to_map": "Route24", + "offset": 0 + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route25_SeaCottage.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route25_SeaCottage.json new file mode 100644 index 0000000000..0ec5259806 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route25_SeaCottage.json @@ -0,0 +1,66 @@ +{ + "name": "Route25_SeaCottage", + "source": { + "decomp_file": "data/maps/Route25_SeaCottage/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "###############", + "###############", + "#.##########..#", + "###D######D#.##", + "..............#", + "....##........#", + "..............#", + "#.............#", + "#..............", + "###..........##", + "###############" + ], + "points": { + "warp_1": [7, 9] + }, + "warps": [ + { + "x": 6, + "y": 9, + "enter": "walk_into", + "to_map": "Route25", + "to_point": "warp_0" + }, + { + "x": 7, + "y": 9, + "enter": "walk_south", + "to_map": "Route25", + "to_point": "warp_0" + }, + { + "x": 8, + "y": 9, + "enter": "walk_into", + "to_map": "Route25", + "to_point": "warp_0" + } + ], + "objects": [ + { + "name": "bill", + "kind": "npc", + "x": 7, + "y": 5, + "hide_flag": "FLAG_HIDE_BILL_HUMAN_SEA_COTTAGE" + }, + { + "name": "clefairy", + "kind": "npc", + "x": 10, + "y": 6, + "hide_flag": "FLAG_HIDE_BILL_CLEFAIRY" + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp index 96954b03cc..c859069ceb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -49,6 +49,11 @@ const char* const MAP_NAMES[] = { "CeruleanCity_PokemonCenter_1F", "CeruleanCity", "Route24", + // Cerulean City's Mart and Gym, and Route 25 to Bill's Sea Cottage. + "CeruleanCity_Mart", + "CeruleanCity_Gym", + "Route25", + "Route25_SeaCottage", "CeruleanCave_1F", "CeruleanCave_2F", "CeruleanCave_B1F", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h index cff0668eb5..76a83fa212 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h @@ -4,9 +4,10 @@ * * The FRLG maps the overworld routes know about so far: every map from a * new game in Pallet Town through Viridian City, Route 2 and Viridian - * Forest to Brock's gym in Pewter City; and Cerulean City, its Pokemon - * Center, Route 24 to its north, and the three floors of Cerulean Cave, - * where Mewtwo waits on B1F. + * Forest to Brock's gym in Pewter City, Route 3, Mt. Moon and Route 4; + * and Cerulean City, its Pokemon Center, Mart and Gym, Route 24 (Nugget + * Bridge) and Route 25 to Bill's Sea Cottage, and the three floors of + * Cerulean Cave, where Mewtwo waits on B1F. * * Each map is a JSON file, Maps/.json next to this file, generated by * devtools/maps/frlg_maps.py and built into the program by CMake diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp index d8083fe067..3777318e59 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp @@ -311,6 +311,42 @@ UnitTestResult test_mt_moon_closed_floor_wild(){ return true; } +// Whether a route plans from Cerulean's Pokemon Center, at its nurse, to +// `goal` on `maps`. +bool plans_from_cerulean_nurse(const std::vector& maps, const RouteGoal& goal){ + const MapPosition nurse{"CeruleanCity_PokemonCenter_1F", map_point(maps, "CeruleanCity_PokemonCenter_1F", "nurse_front")}; + return plan_overworld_route(maps, nurse, goal, default_route_costs(), Direction::NORTH).has_value(); +} + +// Cerulean City's Mart and Gym and Route 25's Sea Cottage load with the +// points the story walks to, and join the city: from its Pokemon Center +// a route plans to the Mart's clerk, to Misty, and north over Nugget +// Bridge (Route 24) and east along Route 25 into Bill's Sea Cottage. +UnitTestResult test_cerulean_maps(){ + const std::vector maps = overworld_maps(); + for (const char* name : {"CeruleanCity_Mart", "CeruleanCity_Gym", "Route25", "Route25_SeaCottage"}){ + if (built_in(maps, name) == nullptr){ + return std::string("Missing map ") + name + "."; + } + } + const MapPosition clerk{"CeruleanCity_Mart", map_point(maps, "CeruleanCity_Mart", "clerk_front")}; + const MapPosition misty{"CeruleanCity_Gym", map_point(maps, "CeruleanCity_Gym", "misty_front")}; + if (clerk.point != TilePos{4, 3} || misty.point != TilePos{8, 7}){ + return "Unexpected clerk_front or misty_front."; + } + if (!plans_from_cerulean_nurse(maps, RouteGoal{clerk, Direction::WEST})){ + return "No route from Cerulean's Pokemon Center to the Mart's clerk."; + } + if (!plans_from_cerulean_nurse(maps, RouteGoal{misty, Direction::NORTH})){ + return "No route from Cerulean's Pokemon Center to Misty."; + } + const MapPosition bill_front{"Route25_SeaCottage", {7, 7}}; + if (!plans_from_cerulean_nurse(maps, RouteGoal{bill_front, Direction::NORTH})){ + return "No route from Cerulean's Pokemon Center over Nugget Bridge and Route 25 into Bill's Sea Cottage."; + } + return true; +} + // A planned step walks where its map doesn't let the player run, and only // there: out of Pewter's Pokemon Center to the Gym. UnitTestResult test_walk_only_indoors(){ @@ -512,6 +548,7 @@ void add_tests_OverworldMaps(UnitTestDatabase& database){ database.add("Route 3, Route 4 and its Pokemon Center", test_route3_route4_maps); database.add("Mt. Moon joins Route 4's cave mouths", test_mt_moon_maps); database.add("Mt. Moon's floor closed on one side is wild", test_mt_moon_closed_floor_wild); + database.add("Cerulean's Mart and Gym, Route 25 and Bill's Sea Cottage", test_cerulean_maps); database.add("walks where running isn't allowed", test_walk_only_indoors); database.add("Route 4's Pokemon Center escalator", test_route4_center_escalator); database.add("trainer sight", test_trainer_sight); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index b1a6852e36..b684bf8b40 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -773,6 +773,72 @@ UnitTestResult test_mt_moon_way_out(){ return true; } +// Story row 00.14: from Route 4 east of Mt. Moon along the upper road to +// above the ledge row 9, out of Lass Crissy's line (75, 3); one step +// south hops the ledge onto the lower road two tiles down; from there +// into Cerulean City and its Pokemon Center by one door. No route plans +// past the ledge: the upper road reaches the city only by water. +UnitTestResult test_route4_to_cerulean(){ + const SimCase upper{.start = at("Route4", 32, 6), .facing = Direction::SOUTH, .goal = at("Route4", 93, 8)}; + const SimRun walk = run_case(upper); + if (!arrived(walk, upper.goal) || !walk.state.trainers_beaten.empty()){ + return "Expected the upper road walked to above the ledge, no trainer met:" + describe(walk); + } + OverworldSim sim(maps(), at("Route4", 93, 8), Direction::SOUTH); + sim.tap(Direction::SOUTH); + sim.watch({SimWatcher::MESSAGE}, std::chrono::milliseconds(2000)); + if (!(sim.state().position == at("Route4", 93, 10))){ + return "Expected the step south to hop the ledge onto (93, 10), not " + to_string(sim.state().position) + "."; + } + const SimCase lower{.start = at("Route4", 93, 10), .facing = Direction::SOUTH, .goal = at("CeruleanCity_PokemonCenter_1F", 7, 4)}; + const SimRun now = run_case(lower); + if (!arrived(now, lower.goal) || now.state.warps != 1 || !now.state.trainers_beaten.empty()){ + return "Expected Cerulean's Pokemon Center reached by its door, no trainer met:" + describe(now); + } + return true; +} + +// Story rows 00.15 and 00.16: out of Cerulean's Pokemon Center by its mat +// to below the rival's trigger row, and on north across the city's edge +// onto Nugget Bridge, below Cale's row, meeting no one. +UnitTestResult test_cerulean_to_bridge(){ + const MapPosition nurse = at("CeruleanCity_PokemonCenter_1F", 7, 4); + for (const MapPosition& goal : {at("CeruleanCity", 23, 7), at("Route24", 11, 32)}){ + const SimCase c{.start = nurse, .facing = Direction::NORTH, .goal = goal}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.warps != 1 || !now.state.trainers_beaten.empty()){ + return "Expected " + to_string(goal) + " reached by the Center's mat, no trainer met:" + describe(now); + } + } + return true; +} + +// Story row 00.16 up Nugget Bridge: the short legs between the five +// trainers' rows meet no one; and the step from the tile below each row +// onto it is in that trainer's sight, beside them, so they fight there +// without walking: Cale, Ali, Timmy, Reli and Ethan. +UnitTestResult test_nugget_bridge_legs(){ + SimCase between{.start = at("Route24", 11, 31), .facing = Direction::NORTH, .goal = at("Route24", 11, 29)}; + between.route = std::vector{leg(Direction::NORTH, 2, between.goal, false)}; + const SimRun walk = run_case(between); + if (!arrived(walk, between.goal) || !walk.state.trainers_beaten.empty()){ + return "Expected the leg between two rows walked, no trainer met:" + describe(walk); + } + const struct{ + int row; + const char* trainer; + } LINES[] = {{31, "bug_catcher"}, {28, "lass"}, {25, "youngster"}, {22, "lass"}, {19, "camper"}}; + for (const auto& line : LINES){ + SimCase c{.start = at("Route24", 11, line.row + 1), .facing = Direction::NORTH, .goal = at("Route24", 11, line.row)}; + c.route = std::vector{leg(Direction::NORTH, 1, c.goal, false)}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.trainers_beaten != std::vector{line.trainer}){ + return std::format("Expected the {} on row {} fought beside the player:", line.trainer, line.row) + describe(now); + } + } + return true; +} + } @@ -798,6 +864,9 @@ void add_tests_OverworldSim(UnitTestDatabase& database){ database.add("Mt. Moon: a battle seen only after a leg to a wall", test_mt_moon_battle_seen_after_wall_leg); database.add("Mt. Moon: black after a leg not marked wild", test_mt_moon_black_after_unmarked_leg); database.add("Mt. Moon: a battle seen only before the next leg", test_mt_moon_battle_before_next_leg); + database.add("Route 4 east, down its ledge, to Cerulean's Pokemon Center", test_route4_to_cerulean); + database.add("Cerulean: the Center to the rival's row and Nugget Bridge", test_cerulean_to_bridge); + database.add("Nugget Bridge: each trainer met on the step onto their row", test_nugget_bridge_legs); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index a25dcbe4e2..9037adbee4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -324,6 +324,7 @@ std::string save_card_place(const std::string& map){ {"PewterCity", "pewter-city"}, {"Route3", "route-3"}, {"Route4", "route-4"}, + {"CeruleanCity", "cerulean-city"}, }; for (const auto& [prefix, place] : PLACES){ if (map == prefix || map.rfind(prefix + "_", 0) == 0){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp index 6036ac956f..6583d13a39 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -3,18 +3,24 @@ * From: https://github.com/PokemonAutomation/ * * Which rows of the story table a run takes (plan_story_run()) with the - * RNG programs' handoffs on and off, and the rows the handoffs split or - * join. + * RNG programs' handoffs on and off, the rows the handoffs split or + * join, and what 00.15 runs before the rival. * */ #include +#include #include #include #include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryMart.h" #include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTeach.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -111,10 +117,11 @@ class Test_RunPlan : public UnitTest{ }; void add_tests_RunPlan(UnitTestDatabase& database){ - const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13"; + const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.15 00.15b 00.16"; const RunPlanScenario scenarios[] = { {.name = "handoffs off: the story as before", .first = "00.01", .expected_ids = TODAY}, {.name = "handoffs off: a range", .first = "00.02", .last = "00.04", .expected_ids = "00.02 00.03 00.04"}, + {.name = "handoffs off: Mt. Moon to Nugget Bridge", .first = "00.13", .last = "00.16", .expected_ids = "00.13 00.14 00.15 00.15b 00.16"}, { .name = "SID Helper: 00.01 stops on the keyboard", .first = "00.01", @@ -163,8 +170,8 @@ void add_tests_GiftRunPlan(UnitTestDatabase& database){ .expected_ids = "00.11 00.12", .expected_stop = StoryHandoff::GIFT_RNG, }, - {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11", .expected_ids = "00.11 00.12 00.12c 00.13"}, - {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13"}, + {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11", .expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.15 00.15b 00.16"}, + {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13 00.14 00.15 00.15b 00.16"}, { .name = "Gift RNG: started at 00.12b, from the save beside the salesman", .first = "00.12b", @@ -179,7 +186,7 @@ void add_tests_GiftRunPlan(UnitTestDatabase& database){ .expected_ids = "", .expected_stop = StoryHandoff::GIFT_RNG, }, - {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}, .expected_ids = "00.12b 00.12c 00.13"}, + {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}, .expected_ids = "00.12b 00.12c 00.13 00.14 00.15 00.15b 00.16"}, { .name = "Starter RNG first, with both on", .first = "00.03", @@ -248,6 +255,121 @@ UnitTestResult test_after_sid_helper_row(){ return true; } +// The index of the first step of `steps` from `from` that `matches`, or +// steps.size(). +size_t find_step(const std::vector& steps, size_t from, const std::function& matches){ + for (size_t c = from; c < steps.size(); c++){ + if (matches(steps[c])){ + return c; + } + } + return steps.size(); +} + +// Whether a route plans from `from` to `to` (ending facing as `to` does). +bool plans(const Pokemon::Pose& from, const Pokemon::Pose& to){ + const std::optional start = story_pose(from); + const std::optional goal = story_pose(to); + if (!start || !goal){ + return false; + } + return plan_overworld_route( + story_maps(), MapPosition{start->map, start->tile}, RouteGoal{MapPosition{goal->map, goal->tile}, goal->facing}, + default_route_costs(), start->facing.value_or(Direction::NORTH) + ).has_value(); +} + +// 00.15, before the rival, from the Center: first TM03 Water Pulse over +// Tail Whip if the lead doesn't know it, so the sim plays it in the +// GRIND; Potions from Cerulean's Mart (a loaded save's bag may hold too +// few for the wild battles a GRIND fights); then a GRIND on Route 4's +// grass east of Mt. Moon until the sim beats the rival 00.15b fights 70% +// or more, healing at Cerulean's Center; its trip there plans both ways. +UnitTestResult test_cerulean_mart_and_grind(){ + const StoryCheckpoint& row = story_checkpoints()[row_index("00.15")]; + const std::vector& steps = row.steps; + const std::optional start = row.start ? story_pose(*row.start) : std::nullopt; + if (!start || start->map != "CeruleanCity_PokemonCenter_1F"){ + return "00.15: expected it to start in Cerulean's Center."; + } + const std::optional teach = steps.empty() ? std::nullopt : parse_teach_variant(steps[0].variant); + if (!teach || teach->item != "tm03" || teach->move != "water-pulse" || teach->member != 0 || teach->forget_move != "tail-whip"){ + return "00.15: expected it to start with TM03 Water Pulse over the lead's Tail Whip."; + } + const size_t mart = find_step(steps, 0, [](const StoryStep& step){ + const std::optional plan = parse_mart_variant(step.variant); + return plan && plan->map == "CeruleanCity_Mart" && !plan->lines.empty() && plan->lines[0].item == "potion"; + }); + const std::optional rival = story_trainer("CeruleanCity", "rival_cerulean"); + const size_t grind = find_step(steps, mart, [&](const StoryStep& step){ + const std::optional plan = parse_grind_variant(step.variant); + return plan && plan->member == 0 && rival && plan->trainer == *rival && plan->min_chance == MIN_WIN_CHANCE + && plan->level > 25; + }); + if (mart == steps.size() || grind == steps.size()){ + return "00.15: expected a Cerulean Mart step buying Potions, then a GRIND of the lead until the rival is 70%, capped above Lv25."; + } + const std::optional clerk = mart > 0 && steps[mart - 1].target ? story_pose(*steps[mart - 1].target) : std::nullopt; + if (!clerk || clerk->map != "CeruleanCity_Mart"){ + return "00.15: expected the Mart step right after a route to the clerk."; + } + if (grind == 0 || !steps[grind - 1].target || !steps[grind].target){ + return "00.15: expected the GRIND right after a route to its patch, with the nurse as its target."; + } + const Pokemon::Pose& patch = *steps[grind - 1].target; + const Pokemon::Pose& nurse = *steps[grind].target; + const std::optional nurse_pose = story_pose(nurse); + if (!nurse_pose || nurse_pose->map != "CeruleanCity_PokemonCenter_1F" || !plans(patch, nurse) || !plans(nurse, patch)){ + return "00.15: expected the GRIND's trips to Cerulean's nurse and back to plan."; + } + return true; +} + +// 00.15b, the rival's battle as its own checkpoint, as every battle lost +// twice or more gets: it starts from a save, 00.15's on (23, 7) below his +// trigger with the nurse just before, so a verify run or a retry reloads +// right before him; and his is its only battle. First Water Pulse over +// Tail Whip if the lead doesn't know it: console B's save (2026-11-03) is +// past the TEACH that failed there, which does nothing once it is known. +UnitTestResult test_cerulean_rival_row(){ + const std::vector& checkpoints = story_checkpoints(); + const size_t index = row_index("00.15b"); + const StoryCheckpoint& rival = checkpoints[index]; + const std::optional start = rival.start ? story_pose(*rival.start) : std::nullopt; + if (!start || rival.live_start || start->map != "CeruleanCity" || !(start->tile == TilePos{23, 7}) + || save_card_place(start->map).empty() + ){ + return "00.15b: expected it to start from a save on (23, 7) in Cerulean City."; + } + std::string battles; + for (const StoryStep& step : rival.steps){ + battles += step.kind == StoryStepKind::BATTLE ? " " + step.name : ""; + } + if (battles != " rival_cerulean" || rival.steps.size() != 3 || rival.steps[1].name != "rival_trigger"){ + return "00.15b: expected a TEACH, the step onto his trigger and his battle; its battles:" + battles; + } + const std::optional teach = parse_teach_variant(rival.steps[0].variant); + if (rival.steps[0].kind != StoryStepKind::GAME || !teach || teach->item != "tm03" || teach->move != "water-pulse" + || teach->member != 0 || teach->forget_move != "tail-whip" + ){ + return "00.15b: expected it to start with TM03 Water Pulse over the lead's Tail Whip."; + } + const StoryCheckpoint& before = checkpoints[index - 1]; + const size_t n = before.steps.size(); + const std::optional end = n > 0 && before.steps[n - 1].target ? story_pose(*before.steps[n - 1].target) : std::nullopt; + if (before.id != "00.15" || n < 2 || before.steps[n - 2].kind != StoryStepKind::HEAL + || !end || end->map != start->map || !(end->tile == start->tile) + ){ + return "00.15: expected it to end with the nurse, then the walk to (23, 7) where 00.15b starts."; + } + for (const StoryStep& step : before.steps){ + if (step.kind == StoryStepKind::BATTLE){ + return "00.15: expected no trainer battle before its save; found " + step.name + "."; + } + } + return true; +} + } @@ -256,6 +378,8 @@ void add_tests_AutoStoryRunPlan(UnitTestDatabase& database){ add_tests_GiftRunPlan(database); database.add("00.01b and 00.12b: back in after SID Helper and Gift RNG", test_after_sid_helper_row); database.add("00.04 and 00.04b: one checkpoint with Starter RNG off", test_starter_rows_joined); + database.add("00.15: Potions at Cerulean's Mart and a GRIND before the rival", test_cerulean_mart_and_grind); + database.add("00.15b: the rival's battle alone, from a save before him", test_cerulean_rival_row); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp index 53b2da73ad..23353dc736 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include "Common/Cpp/Exceptions.h" @@ -24,6 +25,7 @@ #include "Pokemon/AutoStory/Pokemon_StorySegments.h" #include "Pokemon/AutoStory/Pokemon_StorySteps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_AutoStoryTable.h" namespace PokemonAutomation{ @@ -378,6 +380,84 @@ UnitTestResult test_gives_up_after_attempts(){ return true; } +// The route the program walks for `step`, a ROUTE or LEGS, from `at`: +// planned, or the legs laid out. +std::optional> walked_route(const StoryPose& at, const StoryStep& step){ + const MapPosition start{at.map, at.tile}; + if (step.kind == StoryStepKind::LEGS){ + return fixed_route(story_maps(), start, step.legs); + } + const std::optional target = story_pose(*step.target); + const RouteGoal goal{{target->map, target->tile}, target->facing}; + return plan_overworld_route(story_maps(), start, goal, default_route_costs(), at.facing); +} + +// Walks `step` of 00.14 on `sim` from `at`, where the table says the +// player stands, and moves `at` to where it says the step ends. Returns +// the failure, or empty. +std::string walk_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step){ + const std::string where = "00.14/" + step.name + ": "; + // A walk that ends on an open leg may leave the player short of it. + bool open_end = false; + if (step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS){ + const std::optional> route = walked_route(at, step); + if (!route || route->empty()){ + return where + "no route from " + to_string(MapPosition{at.map, at.tile}) + "."; + } + const RoutePlanning planning{.maps = &story_maps(), .start = MapPosition{at.map, at.tile}, .label = step.name}; + const std::string failure = OverworldSimWalker(sim).run(*route, planning); + if (!failure.empty()){ + return where + failure + " On " + to_string(sim.state().position) + "."; + } + const RouteStep& last = route->back(); + open_end = last.action == RouteAction::WALK && !last.to_wall && !last.warps; + at = StoryPose{last.end.map, last.end.point, last.facing_after}; + }else if (step.kind == StoryStepKind::STEP){ + if (sim.state().facing != step.direction){ + sim.tap(step.direction); + } + sim.tap(step.direction); + sim.watch({SimWatcher::MESSAGE}, std::chrono::milliseconds(2000)); + at = *story_pose(*step.pose_after); + } + if (!open_end && !(sim.state().position == MapPosition{at.map, at.tile})){ + return where + "expected " + to_string(MapPosition{at.map, at.tile}) + ", but on " + to_string(sim.state().position) + "."; + } + return ""; +} + +// Console B, 2026-11-03 (row 00.14): the open leg east from Route 4 +// into Cerulean City ended a tile short, on (6, 20), with nothing on +// screen. No hold to a wall finds the player there, and the walk went on +// a column off, the hold south stopped by the fence on (6, 23), to a +// wall below a row of houses, not the Pokemon Center's door. From the +// foot of Route 4's ledge, the row's walks on the sim reach the nurse +// with that leg whole or a tile short. +UnitTestResult test_cerulean_entry_short(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.14"; }); + if (row == rows.end()){ + return "No checkpoint 00.14."; + } + const auto hop = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "ledge_hop"; }); + if (hop == row->steps.end() || !hop->pose_after){ + return "00.14: no ledge_hop."; + } + for (int short_by : {0, 1}){ + StoryPose at = *story_pose(*hop->pose_after); + SimScript script; + script.short_holds[1] = short_by; + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing, script); + for (auto step = hop + 1; step != row->steps.end() && step->kind != StoryStepKind::HEAL; ++step){ + const std::string failure = walk_on_sim(sim, at, *step); + if (!failure.empty()){ + return std::format("Entry {} tile(s) short: ", short_by) + failure; + } + } + } + return true; +} + } @@ -389,6 +469,7 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database){ add("start and end checkpoints", test_start_and_end); add("a failed walk reloads its checkpoint's save", test_retry_from_save); add("a battle lost every time stops after 3 attempts", test_gives_up_after_attempts); + add("00.14 into Cerulean, the entry leg a tile short", test_cerulean_entry_short); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 91d4112d97..69ec31968c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -6,8 +6,11 @@ #include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryChoice.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryMart.h" #include "PokemonFRLG_AutoStoryPcStorage.h" #include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTeach.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -37,6 +40,10 @@ const char* const ROUTE4 = "Route4"; const char* const ROUTE4_CENTER = "Route4_PokemonCenter_1F"; const char* const MT_MOON_1F = "MtMoon_1F"; const char* const MT_MOON_B2F = "MtMoon_B2F"; +const char* const CERULEAN = "CeruleanCity"; +const char* const CERULEAN_CENTER = "CeruleanCity_PokemonCenter_1F"; +const char* const CERULEAN_MART = "CeruleanCity_Mart"; +const char* const ROUTE24 = "Route24"; Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt){ return to_pose(StoryPose{map, {x, y}, facing}); @@ -94,6 +101,16 @@ StoryStep step_into(std::string name, Direction direction, std::string why){ return step; } +// A step the way a ledge faces, from the tile before it: the player hops +// it and lands on `landing`, two tiles on. Planned routes never hop a +// ledge (they hold into walls, and a held ledge hops), so where the only +// way on is down a ledge the table takes it as this one step. +StoryStep hop(std::string name, Direction direction, Pokemon::Pose landing, std::string why){ + StoryStep step = step_into(std::move(name), direction, std::move(why)); + step.pose_after = std::move(landing); + return step; +} + StoryStep answer(std::string name, bool yes, std::string why){ StoryStep step = make(StoryStepKind::ANSWER, std::move(name), std::move(why)); step.yes = yes; @@ -468,6 +485,131 @@ StoryCheckpoint mt_moon(){ }); } +// Route 4 east of Mt. Moon (Route4 map.json) joins Cerulean City at its +// east end (x 107; Route 4's row 3 is the city's row 13). Its upper road +// (rows 2..8) reaches the city only by water: the way on by land is down +// the ledge row 9 at x 90..96 to the lower road (rows 10..12), which +// joins the city's rows 20..22. Lass Crissy (75, 3) faces east, sight 4, +// over row 3 from x 76 to 79; the way east keeps out of her line and off +// the grass south of the road (75..87, 11..17). +// +// The open leg east across the city's edge ended a tile short on the +// console (2026-11-03: on (6, 20), not (7, 20)), and no hold to a wall +// in the city's west end finds the player from there: the next hold +// south met the fence on (6, 23) and the walk went on a column off. So +// the entry ends on (9, 20), and only legs to walls follow: north up +// column 9 (or 8, a tile short) to row 12, west to (8, 12), east to +// (30, 12) beside the policeman (31, 12), south to (30, 17), west to +// (25, 17), and down the ledge row 18 to (25, 19), a few tiles from the +// door (22, 19). The way keeps out of the boy's (16, 18..24) and the +// balding man's (8..10, 22..24) wander boxes. +StoryCheckpoint route4_to_cerulean(){ + return row("00.14", "Route 4 east to Cerulean's Pokemon Center", at(ROUTE4, 32, 6, SOUTH), after_brock(), StartMenu::FULL, { + running(route("above_ledge", at(ROUTE4, 93, 8, SOUTH), "east along Route 4's upper road to above the ledge row 9")), + hop("ledge_hop", SOUTH, at(ROUTE4, 93, 10, SOUTH), "down the ledge (93, 9) to the lower road"), + running(route("cerulean_entry", at(CERULEAN, 9, 20, EAST), "east into Cerulean City, to (9, 20) below column 9")), + running(legs("above_city_ledge", {{NORTH, 8}, {WEST, 1}, {EAST, 22}, {SOUTH, 5}, {WEST, 5}}, "to walls: (9, 12), (8, 12), (30, 12), (30, 17), (25, 17)")), + hop("city_ledge_hop", SOUTH, at(CERULEAN, 25, 19, SOUTH), "down the ledge (25, 18)"), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "west along row 20 to the Pokemon Center's door (22, 19)")), + heal("heal_cerulean"), + }); +} + +// Before the rival north of Cerulean (cerulean_rival()), from the +// Center: first TM03 Water Pulse over Tail Whip, if the lead doesn't know +// it yet, so the GRIND's sim plays it (a run without TM03 from Misty +// skips the step). Then 5 Potions at the Mart +// (CeruleanCity_Mart_Items: POTION is row 3, 300 each; Mt. Moon's +// trainers leave well over the floor), then a GRIND on Route 4's grass +// east of Mt. Moon (75..86, 11..17; Spearow, Rattata, Ekans and Mankey +// around Lv10, about 80 EXP each), healing at the Center, until the +// battle sim beats the rival 00.15b fights 70% or more: the win chance +// his battle refuses below. Console B's Wartortle (Lv22, 65 HP, Water +// Pulse) is about 42% there and 76% at Lv25; Lv28 (about 88%, some 120 +// battles from Lv22) is the cap. Then the nurse, and the save on (23, 7), +// the tile below his trigger: his battle is its own checkpoint, 00.15b, +// verified and retried from this save. +StoryCheckpoint cerulean_grind(){ + const TeachPlan water_pulse{.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; + const MartPlan potions{.map = CERULEAN_MART, .lines = {{.item = "potion", .quantity = 5}}, .budget = 1500, .money_floor = 1500}; + const GrindPlan for_rival{ + .member = 0, .level = 28, .max_encounters = 150, .pace = WEST, .heal_every = 0, + .trainer = std::string(story_trainer(CERULEAN, "rival_cerulean").value_or("")), .min_chance = MIN_WIN_CHANCE, + }; + return row("00.15", "Cerulean: Water Pulse, Potions, a GRIND until the rival is winnable, then saved below his trigger", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + teach_step("teach_water_pulse", "TM03 Water Pulse in place of Tail Whip, unless the lead knows it or there is no TM03", water_pulse), + running(route("mart_clerk", at(CERULEAN_MART, 4, 3, WEST), "out of the Center to the Mart's clerk")), + mart_step("buy_potions", "5 Potions: the GRIND fights wild battles only with 2 or more", potions), + running(route("grind_patch", at(ROUTE4, 86, 16, WEST), "west out of the city along Route 4's lower road onto its grass's east edge")), + grind_step("grind_for_rival", "wild battles until the sim beats the rival 70% or more, Lv28 at most; the nurse when hurt or low on PP", for_rival, at(CERULEAN_CENTER, 7, 4, NORTH)), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back to the Center: the rival's battle starts at full HP and PP")), + heal("heal_cerulean"), + running(route("below_rival_trigger", at(CERULEAN, 23, 7, NORTH), "out of the Center, north up to (23, 7), short of the trigger row 6")), + }); +} + +// The rival north of Cerulean (CeruleanCity scripts.inc): the only way +// north to Nugget Bridge is columns 22..24, and a step onto row 6 there +// (CeruleanCity_EventScript_RivalTrigger*, VAR_MAP_SCENE_CERULEAN_CITY_RIVAL +// 0) brings him down to the player. From the middle, (23, 6), he stops +// at (23, 5); after the battle he walks round the player and off south, +// and the player, turning to watch him, ends facing south. His FAME +// CHECKER's text is read to the overworld with the battle's. For a player +// who took Squirtle he has Pidgeotto Lv17, Abra Lv16, Rattata Lv15 and +// Bulbasaur Lv18 (sParty_RivalCeruleanBulbasaur), whose Vine Whip and +// Sleep Powder are the danger. The row starts from 00.15's save below the +// trigger, and saves after him. First TM03 Water Pulse over Tail Whip (from +// the bag, where the player stands), if the lead doesn't know it yet: +// console B's save (2026-11-03) is past Misty's TEACH, which failed there. +// Where she taught it, the step reads the moves and does nothing. +StoryCheckpoint cerulean_rival(){ + const TeachPlan water_pulse{.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; + return row("00.15b", "Cerulean: Water Pulse if not known, then the rival north of the city", at(CERULEAN, 23, 7, NORTH), after_brock(), StartMenu::FULL, { + teach_step("teach_water_pulse", "TM03 Water Pulse in place of Tail Whip, unless the lead knows it", water_pulse), + step_into("rival_trigger", NORTH, "onto (23, 6), the middle trigger: \"Yo! !\""), + battle("rival_cerulean", at(CERULEAN, 23, 6, SOUTH), false, "Pidgeotto, Abra, Rattata and Bulbasaur; then the FAME CHECKER"), + }); +} + +// Nugget Bridge (Route24 map.json) is x 10..12 from row 32 north to row +// 16, the city's columns 22..24 (offset 12). Its five trainers stand on +// it facing across, sight 2, so each line crosses the middle column 11 +// on the trainer's row: Bug Catcher Cale (12, 31), Lass Ali (10, 28), +// Youngster Timmy (12, 25), Lass Reli (10, 22) and Camper Ethan (12, 19). +// Each is fought on the step onto their row, beside them: they don't +// walk, and the player keeps facing north. Two tiles separate the rows, +// walked as one short leg. Then the Rocket (12, 15): the step onto +// (11, 15) (Route24_EventScript_RocketTriggerRight, VAR_MAP_SCENE_ROUTE24 +// 0) gives the NUGGET, "join Team Rocket", and his battle; he only turns, +// and the player turns east to him. Camper Shane (5, 21) looks north +// from the west bank, off the way. +StoryCheckpoint nugget_bridge(){ + return row("00.16", "Nugget Bridge: the nurse, five trainers and the Rocket", at(CERULEAN, 23, 6, SOUTH), after_brock(), StartMenu::FULL, { + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back to the Center after the rival, before Nugget Bridge")), + heal("heal_cerulean"), + running(route("below_cale_line", at(ROUTE24, 11, 32, NORTH), "out of the Center, north past the rival's row onto the bridge, below Cale's row")), + step_into("cale_line", NORTH, "onto (11, 31), beside Cale"), + battle("bug_catcher_cale", at(ROUTE24, 11, 31, NORTH), false, "his Bug Pokemon"), + running(legs("below_ali_line", {{NORTH, 2}}, "up to (11, 29)")), + step_into("ali_line", NORTH, "onto (11, 28), beside Ali"), + battle("lass_ali", at(ROUTE24, 11, 28, NORTH), false, "her Pokemon"), + running(legs("below_timmy_line", {{NORTH, 2}}, "up to (11, 26)")), + step_into("timmy_line", NORTH, "onto (11, 25), beside Timmy"), + battle("youngster_timmy", at(ROUTE24, 11, 25, NORTH), false, "his Pokemon"), + running(legs("below_reli_line", {{NORTH, 2}}, "up to (11, 23)")), + step_into("reli_line", NORTH, "onto (11, 22), beside Reli"), + battle("lass_reli", at(ROUTE24, 11, 22, NORTH), false, "her Pokemon"), + running(legs("below_ethan_line", {{NORTH, 2}}, "up to (11, 20)")), + step_into("ethan_line", NORTH, "onto (11, 19), beside Ethan"), + battle("camper_ethan", at(ROUTE24, 11, 19, NORTH), false, "his Pokemon"), + // A route, not legs: the nurse may have been needed. + ready("ready_for_rocket", at(CERULEAN_CENTER, 7, 4, NORTH), "items for a status or low HP; the nurse only if they can't"), + running(route("below_rocket_trigger", at(ROUTE24, 11, 16, NORTH), "up the bridge to below the Rocket's trigger (11, 15)")), + step_into("rocket_trigger", NORTH, "onto (11, 15), beside the Rocket"), + battle("rocket_grunt", at(ROUTE24, 11, 15, EAST), false, "the NUGGET, then Ekans and Zubat, Lv15"), + }); +} + } @@ -475,6 +617,7 @@ const std::vector& story_checkpoints(){ static const std::vector checkpoints{ new_game(), after_sid_helper(), leave_house(), oaks_trigger(), to_starter_ball(), starter(), parcel(), pokedex(), shopping(), forest(), brock(), running_shoes(), route3(), magikarp_salesman(), after_gift_rng(), magikarp_to_box(), mt_moon(), + route4_to_cerulean(), cerulean_grind(), cerulean_rival(), nugget_bridge(), }; return checkpoints; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h index d7421214f1..f1bc862172 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h @@ -3,10 +3,11 @@ * From: https://github.com/PokemonAutomation/ * * FireRed from a new game to the Boulder Badge, the Running Shoes, Route - * 3 to the Magikarp salesman in Route 4's Pokemon Center, and through Mt. - * Moon to Route 4 east of it, as story checkpoints 00.01 to 00.13, each - * saved at its end. After 00.10 every route and leg runs. Taken from a - * by-hand run on console B (2026-11-01) and the pokefirered map scripts. + * 3 to the Magikarp salesman in Route 4's Pokemon Center, through Mt. + * Moon to Route 4 east of it, and on to Cerulean City, its rival and + * Nugget Bridge, as story checkpoints 00.01 to 00.16, each saved at its + * end. After 00.10 every route and leg runs. Taken from a by-hand run on + * console B (2026-11-01) and the pokefirered map scripts. * */ @@ -22,10 +23,11 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -// Checkpoints 00.01 to 00.13, in order, with 00.01b, the way back in after +// Checkpoints 00.01 to 00.16, in order, with 00.01b, the way back in after // SID Helper, after 00.01, 00.04 split in two at Squirtle's ball for // Starter RNG: 00.04 and 00.04b, 00.12b, the way back in after Gift RNG, -// after 00.12, and 00.12c, the Magikarp put in BOX 1, after it. +// after 00.12, 00.12c, the Magikarp put in BOX 1, after it, and 00.15b, +// the rival in Cerulean alone, from 00.15's save below his trigger. const std::vector& story_checkpoints(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp index cd22061a59..73aaa3eded 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -23,6 +23,7 @@ #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStoryPcStorage.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -215,8 +216,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 17 || checkpoints[0].start){ - return "Expected 17 checkpoints, the first from a new game."; + if (checkpoints.size() != 21 || checkpoints[0].start){ + return "Expected 21 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -524,8 +525,12 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set{}){ - if (const std::string problem = tile_problem(tile, beaten); !problem.empty()){ + const StoryPose end = to_patch ? one_tile(*step.target) : StoryPose{}; + const bool patch = to_patch && tile.map == end.map && tile.point == end.tile; + if (const std::string problem = tile_problem(tile, beaten); !problem.empty() && !(patch && problem.starts_with("in the grass"))){ return where + problem; } } @@ -552,12 +557,12 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set beaten; bool has_shoes = false; @@ -569,8 +574,8 @@ UnitTestResult test_trainer_lines(){ } has_shoes = has_shoes || checkpoint.id == "00.10"; } - if (beaten.size() != 4){ - return "Expected to fight Colton, Greg and James on Route 3 and the Grunt at (12, 20) in Mt. Moon; the table fights " + if (beaten.size() != 9){ + return "Expected to fight Colton, Greg and James on Route 3, the Grunt at (12, 20) in Mt. Moon and Nugget Bridge's five; the table fights " + std::to_string(beaten.size()) + " trainers."; } return true; @@ -594,6 +599,105 @@ UnitTestResult test_robin_crossing(){ return "No step 00.12/route4_nurse."; } +// A scene the table starts on a step onto its trigger, and the walks of +// its checkpoint before that step, which keep off the trigger's tiles: +// the maps have no triggers, so a route over one would start the scene +// part-way. +struct TriggerCase{ + std::string checkpoint; + std::string step; + std::string map; + // The trigger's tiles: row `y` from `x0` to `x1`. + int y = 0; + int x0 = 0; + int x1 = 0; + // The tile the step lands on. + int onto_x = 0; +}; + +const StoryCheckpoint* find_checkpoint(const std::string& id){ + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.id == id){ + return &checkpoint; + } + } + return nullptr; +} + +UnitTestResult check_trigger(const TriggerCase& c){ + const StoryCheckpoint* checkpoint = find_checkpoint(c.checkpoint); + if (checkpoint == nullptr){ + return "No checkpoint " + c.checkpoint + "."; + } + const MapPosition onto{c.map, {c.onto_x, c.y}}; + StoryPose pose = one_tile(*checkpoint->start); + for (const StoryStep& step : checkpoint->steps){ + const std::string where = c.checkpoint + "/" + step.name + ": "; + if (step.name == c.step && step.kind == StoryStepKind::STEP && onto == MapPosition{pose.map, pose.tile.next(step.direction)}){ + return true; + } + if (step.name == c.step){ + return where + "expected a step onto " + to_string(onto) + ", from " + describe(pose) + "."; + } + const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS; + for (const MapPosition& tile : walks ? tiles_walked(pose, step) : std::vector{}){ + if (tile.map == c.map && tile.point.y == c.y && tile.point.x >= c.x0 && tile.point.x <= c.x1){ + return where + "walks over the trigger at " + to_string(tile) + "."; + } + } + if (UnitTestResult result = advance(pose, step); result.result != UnitTestResult::PASSED){ + return c.checkpoint + " " + result.message; + } + } + return "No step " + c.checkpoint + "/" + c.step + "."; +} + +// 00.15b meets the rival on the middle of his three trigger tiles, row 6 +// at x 22..24 north of Cerulean (VAR_MAP_SCENE_CERULEAN_CITY_RIVAL 0), +// and 00.16 the Rocket on the east one of his two, (10..11, 15) on Nugget +// Bridge (VAR_MAP_SCENE_ROUTE24 0), each from the tile below. +UnitTestResult test_cerulean_triggers(){ + const TriggerCase CASES[] = { + {.checkpoint = "00.15b", .step = "rival_trigger", .map = "CeruleanCity", .y = 6, .x0 = 22, .x1 = 24, .onto_x = 23}, + {.checkpoint = "00.16", .step = "rocket_trigger", .map = "Route24", .y = 15, .x0 = 10, .x1 = 11, .onto_x = 11}, + }; + for (const TriggerCase& c : CASES){ + if (UnitTestResult result = check_trigger(c); result.result != UnitTestResult::PASSED){ + return result; + } + } + return true; +} + +// 00.16 steps beside Nugget Bridge's five trainers from the south, one +// step at a time, each battle right after its step: Cale, Ali, Timmy, +// Reli and Ethan, by where they stand. +UnitTestResult test_nugget_bridge_order(){ + const StoryCheckpoint* checkpoint = find_checkpoint("00.16"); + if (checkpoint == nullptr){ + return "No checkpoint 00.16."; + } + const std::vector& steps = checkpoint->steps; + std::string met; + StoryPose pose = one_tile(*checkpoint->start); + for (size_t c = 0; c < steps.size(); c++){ + const MapData* map = find_map_named(pose.map); + const MapObject* trainer = steps[c].kind == StoryStepKind::STEP && map ? trainer_seeing(*map, pose.tile.next(steps[c].direction), {}) : nullptr; + if (trainer != nullptr && (c + 1 == steps.size() || steps[c + 1].kind != StoryStepKind::BATTLE)){ + return "00.16/" + steps[c].name + ": no battle right after the step."; + } + met += trainer ? " " + to_string(TilePos{trainer->x, trainer->y}) : ""; + if (UnitTestResult result = advance(pose, steps[c]); result.result != UnitTestResult::PASSED){ + return "00.16 " + result.message; + } + } + const std::string expected = " (12, 31) (10, 28) (12, 25) (10, 22) (12, 19)"; + if (met != expected){ + return "00.16: expected the five bridge trainers south to north," + expected + "; met" + met + "."; + } + return true; +} + // Every checkpoint that starts from a save has a place the save card // names, so a save left elsewhere is caught before it runs; and the // places of the starts that matter differ. @@ -611,6 +715,9 @@ UnitTestResult test_save_places(){ if (save_card_place("Route3") != "route-3" || save_card_place("Route4_PokemonCenter_1F") != "route-4"){ return "Wrong save card places on Route 3 and 4."; } + if (save_card_place("CeruleanCity_PokemonCenter_1F") != "cerulean-city"){ + return "Wrong save card place in Cerulean City."; + } // Not Route 2's or Route 4's: a map name is a place's or a building's in it. if (!save_card_place("Route22").empty() || !save_card_place("Route24").empty() || !save_card_place("Route2").empty()){ return "A place for a map the table doesn't use."; @@ -847,6 +954,8 @@ void add_tests_AutoStory(UnitTestDatabase& database){ database.add("routes run after the Running Shoes", test_runs_after_shoes); database.add("Route 3: only the planned trainers' lines", test_trainer_lines); database.add("Route 3: Lass Robin stops the walk on (40, 9)", test_robin_crossing); + database.add("Cerulean: the rival's and the Rocket's triggers", test_cerulean_triggers); + database.add("Nugget Bridge: its five trainers, south to north", test_nugget_bridge_order); database.add("intro: names, the rival's from the list", test_intro_names); database.add("options: RIGHT presses from the shown value", test_option_presses); database.add("00.12c: the Magikarp's deposit is skipped without Gift RNG", test_magikarp_deposit_skippable); diff --git a/devtools/maps/frlg_maps.py b/devtools/maps/frlg_maps.py index f72a9ecca0..e7bc65e726 100644 --- a/devtools/maps/frlg_maps.py +++ b/devtools/maps/frlg_maps.py @@ -86,6 +86,10 @@ 'Route4_PokemonCenter_1F': {'nurse_front': (7, 4), 'salesman_front': (2, 3)}, # The tile in front of the PC. 'CeruleanCity_PokemonCenter_1F': {'pc': (11, 2), 'nurse_front': (7, 4)}, + # The clerk (2, 3) is across the counter to the west. + 'CeruleanCity_Mart': {'clerk_front': (4, 3)}, + # Below Misty (8, 6). + 'CeruleanCity_Gym': {'misty_front': (8, 7)}, # Where the player stands after entering from Cerulean City. 'CeruleanCave_1F': {'entrance': (33, 21)}, # Mewtwo's tile. Its battle starts by talking to it from the south.