From 92a3502412b6a5108b7bfb23b359e8154b63fa9b Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 11:07:38 -0800 Subject: [PATCH 01/12] FRLG AutoStory stage 5: Route 25 and Bill's S.S. Ticket 00.17 walks from Nugget Bridge to Bill's Sea Cottage, fighting Route 25's four trainers the way can't avoid (Franklin, Kelsey, Nob, Haley) and crossing Dan's and Chad's sight only on legs to walls or one-tile legs. 00.18 runs Bill's scene: the Clefairy's YES, the PC's Cell Separator, the S.S. Ticket. 00.19 takes Route 25's south path and Cerulean's north ledge back to the Pokemon Center. The save card knows Route 24 and Route 25. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Menus/PokemonFRLG_SaveCardReader.cpp | 2 + .../Menus/PokemonFRLG_SaveCardReader.h | 12 ++- .../PokemonFRLG_AutoStoryBattleData.cpp | 6 ++ .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 2 + .../PokemonFRLG_AutoStoryRunPlan_Tests.cpp | 8 +- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 55 ++++++++-- .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 101 ++++++++++++++++++ .../AutoStory/PokemonFRLG_AutoStory_Tests.cpp | 24 +++-- 8 files changed, 182 insertions(+), 28 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp index 9ec73b734a..fd2db94734 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp @@ -71,6 +71,8 @@ class PlaceNameMatcher : public OCR::SmallDictionaryMatcher{ {"route-3", "Route 3"}, {"route-4", "Route 4"}, {"cerulean-city", "Cerulean City"}, + {"route-24", "Route 24"}, + {"route-25", "Route 25"}, }){ 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 94514f94cf..d165670409 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h @@ -4,9 +4,10 @@ * * 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, 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. + * BADGES and TIME). It knows the places from a new game to Route 4, + * Cerulean City, Route 24 and Route 25 (Bill's Sea Cottage is on it), in + * English only so far: in another language nothing is read. Route 3, + * Route 4, Cerulean City, Route 24 and Route 25 have no test capture yet. * */ @@ -31,8 +32,9 @@ 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", "cerulean-city"; empty if it -// can't be read, as in a language the reader has no names for. +// "viridian-forest", "pewter-city", "route-1" to "route-4", "cerulean-city", +// "route-24", "route-25"; 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/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index 4b7e4f2e3c..1eb072ab07 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -54,6 +54,12 @@ constexpr StoryTrainer STORY_TRAINERS[] = { // The Nugget Bridge Rocket, Route24_EventScript_Rocket. {.map = "Route24", .battle = "rocket_grunt", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_6"}, {.map = "CeruleanCity_Gym", .battle = "misty", .trainer = "TRAINER_LEADER_MISTY"}, + // Route 25's four the way can't walk around (Route25 scripts.inc): + // Franklin (11, 4), Kelsey (22, 8), Nob (27, 9) and Haley (42, 5). + {.map = "Route25", .battle = "hiker_franklin", .trainer = "TRAINER_HIKER_FRANKLIN"}, + {.map = "Route25", .battle = "picnicker_kelsey", .trainer = "TRAINER_PICNICKER_KELSEY"}, + {.map = "Route25", .battle = "hiker_nob", .trainer = "TRAINER_HIKER_NOB"}, + {.map = "Route25", .battle = "lass_haley", .trainer = "TRAINER_LASS_HALEY"}, }; using JsonTable = std::map>; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index af964ce8d3..1d2fb09ff4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -340,6 +340,8 @@ std::string save_card_place(const std::string& map){ {"Route3", "route-3"}, {"Route4", "route-4"}, {"CeruleanCity", "cerulean-city"}, + {"Route24", "route-24"}, + {"Route25", "route-25"}, }; 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 f231fda015..540134c1ab 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -118,7 +118,7 @@ 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 00.14 00.14b 00.14c 00.15 00.15b 00.16"; + const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19"; 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"}, @@ -171,8 +171,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 00.14 00.14b 00.14c 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.14b 00.14c 00.15 00.15b 00.16"}, + {.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.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19"}, + {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19"}, { .name = "Gift RNG: started at 00.12b, from the save beside the salesman", .first = "00.12b", @@ -187,7 +187,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 00.14 00.14b 00.14c 00.15 00.15b 00.16"}, + {.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.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19"}, { .name = "Starter RNG first, with both on", .first = "00.03", 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 94eaf61b27..649f3f1094 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -479,16 +479,17 @@ UnitTestResult test_cerulean_entry_short(){ return true; } -// Runs `step` of 00.16 on `sim` as the program does: legs by the sim's -// walker from `at`, where the table says the player stands; a step one -// tap at a time, or for a step into a trainer's line by -// step_until_seen(), each watched for the trainer; a battle from the -// trainer's message, which must be up. Moves `at` to where the table -// says the step ends. Returns the failure, or empty. -std::string bridge_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step){ - const std::string where = "00.16/" + step.name + ": "; +// Runs `step` of row `id` on `sim` as the program does: routes and legs +// by the sim's walker from `at`, where the table says the player stands, +// fighting a trainer who sees the player on the way; a step one tap at a +// time, or for a step into a trainer's line by step_until_seen(), each +// watched for the trainer; a battle from the trainer's message, which +// must be up. Moves `at` to where the table says the step ends. Returns +// the failure, or empty. +std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id){ + const std::string where = id + "/" + step.name + ": "; const auto on = [&sim]{ return " On " + to_string(sim.state().position) + "."; }; - if (step.kind == StoryStepKind::LEGS){ + if (step.kind == StoryStepKind::LEGS || step.kind == StoryStepKind::ROUTE){ const std::optional> route = walked_route(at, step); const RoutePlanning planning{.maps = &story_maps(), .start = MapPosition{at.map, at.tile}, .label = step.name}; const std::string failure = route ? OverworldSimWalker(sim).run(*route, planning) : "no legs."; @@ -540,7 +541,7 @@ UnitTestResult test_nugget_bridge_short(){ StoryPose at = *story_pose(*walk->target); OverworldSim sim(story_maps(), MapPosition{at.map, {at.tile.x, at.tile.y + short_by}}, Direction::NORTH); for (auto step = walk + 1; step != row->steps.end() && step->kind != StoryStepKind::READY; ++step){ - const std::string failure = bridge_step_on_sim(sim, at, *step); + const std::string failure = row_step_on_sim(sim, at, *step, "00.16"); if (!failure.empty()){ return std::format("Walk {} tile(s) short: ", short_by) + failure; } @@ -552,6 +553,39 @@ UnitTestResult test_nugget_bridge_short(){ return true; } +// 00.17 on the sim, from its start on Route 24 to the Sea Cottage: +// Franklin, Kelsey, Nob and Haley are met on the steps into their lines +// and beaten there. The sim's trainers who look around see every way +// they may look, so Youngster Dan sees the player on (20, 4), walks up +// to (21, 4) and is fought there, off the way on. +UnitTestResult test_route25_trainers(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.17"; }); + if (row == rows.end()){ + return "No checkpoint 00.17."; + } + StoryPose at = *story_pose(*row->start); + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + for (const StoryStep& step : row->steps){ + const std::string failure = row_step_on_sim(sim, at, step, "00.17"); + if (!failure.empty()){ + return failure; + } + } + const std::vector expected{"hiker", "youngster", "picnicker", "hiker", "lass"}; + if (sim.state().trainers_beaten != expected){ + std::string beaten; + for (const std::string& name : sim.state().trainers_beaten){ + beaten += " " + name; + } + return "00.17: expected Franklin, Dan, Kelsey, Nob and Haley beaten; beat" + beaten + "."; + } + if (!(sim.state().position == MapPosition{"Route25_SeaCottage", {7, 8}})){ + return "00.17: expected to end in the Sea Cottage on (7, 8), not " + to_string(sim.state().position) + "."; + } + return true; +} + } @@ -565,6 +599,7 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database){ 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); add("00.16 Nugget Bridge, the walk onto it a tile short", test_nugget_bridge_short); + add("00.17 Route 25, its four trainers and Dan", test_route25_trainers); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 1fc3a6f2af..1c9fd46ee3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -45,6 +45,8 @@ const char* const CERULEAN_CENTER = "CeruleanCity_PokemonCenter_1F"; const char* const CERULEAN_MART = "CeruleanCity_Mart"; const char* const CERULEAN_GYM = "CeruleanCity_Gym"; const char* const ROUTE24 = "Route24"; +const char* const ROUTE25 = "Route25"; +const char* const SEA_COTTAGE = "Route25_SeaCottage"; Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt){ return to_pose(StoryPose{map, {x, y}, facing}); @@ -710,6 +712,104 @@ StoryCheckpoint nugget_bridge(){ }); } +// Route 24 north of Nugget Bridge (Route24 map.json) joins Route 25 by +// its rows 7..9; the ledges on Route 25's row 6 (x 0..9) leave the way +// north at x 12..13. Route 25 (Route25 map.json, scripts.inc) to Bill's +// Sea Cottage door (51, 4): of its nine trainers, four can't be walked +// around. +// - Hiker Franklin (11, 4) faces east, sight 4, over (12..15, 4), and +// Hiker Wayne (17, 7) east over (18..19, 7): the only ways east past +// x 15. Franklin is fought, from (15, 5) on the step onto (15, 4), the +// end of his line: he walks up to (14, 4). +// - Youngster Joey (18, 2) faces south over (18, 3..4): the way keeps +// off column 18, south round Wayne to (19, 6) and up to (19, 4). +// - Youngster Dan (22, 4) looks around, sight 2, over the only way east, +// (20..21, 4). On (20, 4), a one-tile leg, he may see the player and +// walk up to (21, 4); so the way goes north from there to row 2, east, +// and down column 23 past him to (23, 5), off every tile he may walk +// up to. Where his sight is crossed, the leg ends on a wall or is one +// tile; if he looks the player's way, story_interrupts() fight him. +// - Picnicker Kelsey (22, 8) faces east over (23..24, 8), across the +// only way on: fought on the step onto (24, 8); she walks up to (23, 8). +// - Hiker Nob (27, 9) faces north over (27, 6..8), Camper Flint (28, 4) +// south over (28, 5..7): every way east crosses one. Nob is fought on +// the step onto (27, 8), beside him; the way on keeps off Flint's +// column, by one-tile legs and legs to walls. +// - Youngster Chad (36, 4) walks up and down column 36 (rows 2..7), so +// he may see any tile of it down to row 9. The way crosses his column +// on row 9, a leg to the water at (42, 9), where he can only walk up +// beside it. +// - Lass Haley (42, 5) faces south over (42, 6..8), the only way east of +// x 42: fought on the step onto (42, 8); she walks up to (42, 7). +// The row saves inside the Sea Cottage, which the save card names +// ROUTE 25. +StoryCheckpoint route25(){ + return row("00.17", "Route 24 and 25 to Bill's Sea Cottage: Franklin, Kelsey, Nob and Haley", at(ROUTE24, 11, 15, EAST), after_brock(), StartMenu::FULL, { + running(route("below_franklin_line", at(ROUTE25, 15, 5, NORTH), "north off Nugget Bridge, east into Route 25, round its ledges to (15, 5), below the end of Franklin's line")), + into_line("franklin_line", NORTH, "onto (15, 4), at the end of Franklin's line"), + battle("hiker_franklin", at(ROUTE25, 15, 4, NORTH), false, "Machop and Geodude, Lv15; Franklin stops at (14, 4)"), + running(legs("west_of_dan", {{EAST, 2}, {SOUTH, 2}, {EAST, 2}, {NORTH, 2}}, "to (17, 4), short of Joey's (18, 4); round Wayne (17, 7) to (19, 6); up to (19, 4)")), + running(legs("round_dan", { + {EAST, 1}, {NORTH, 2}, {EAST, 4}, {WEST, 1}, {SOUTH, 3}, {EAST, 1}, {SOUTH, 2}, + }, "onto (20, 4); up to (20, 2), east to (24, 2), down column 23 past Dan to (23, 5), to (24, 7) above Kelsey's line")), + into_line("kelsey_line", SOUTH, "onto (24, 8), at the end of Kelsey's line"), + battle("picnicker_kelsey", at(ROUTE25, 24, 8, SOUTH), false, "Nidoran M and Nidoran F, Lv15; Kelsey stops at (23, 8)"), + running(legs("west_of_nob_line", {{EAST, 2}}, "along row 8 to (26, 8)")), + into_line("nob_line", EAST, "onto (27, 8), above Nob"), + battle("hiker_nob", at(ROUTE25, 27, 8, EAST), false, "three Geodude and a Machop, Lv13"), + running(legs("below_haley_line", { + {EAST, 1}, {SOUTH, 1}, {EAST, 1}, {NORTH, 3}, {EAST, 5}, {SOUTH, 3}, {EAST, 8}, + }, "below Flint's column to (29, 9), up to (29, 6), east to (34, 6), down to (34, 9), east along row 9 past Chad's column to the water, (42, 9)")), + into_line("haley_line", NORTH, "onto (42, 8), at the end of Haley's line"), + battle("lass_haley", at(ROUTE25, 42, 8, NORTH), false, "Oddish, Pidgey and Oddish, Lv13; Haley stops at (42, 7)"), + running(route("sea_cottage", at(SEA_COTTAGE, 7, 8, NORTH), "east along row 8, up column 51 into the Sea Cottage (51, 4), a step in from its mat")), + }); +} + +// Bill's Sea Cottage (Route25_SeaCottage scripts.inc). Bill is the +// Clefairy at (10, 6) (FLAG_HELPED_BILL_IN_SEA_COTTAGE unset): talked to +// from (10, 7), below him, he asks "Help me out!" (YES/NO; NO goes on the +// same), says to run the Cell Separation on the PC, and walks up into +// the right teleporter (10, 3); with the player facing north he walks +// straight up. The PC (4, 5) answers only from the south, (4, 6). Its +// scene: the teleporters run, Bill comes out of the left one (3, 3) and +// walks to (7, 6) (BillWalkToMiddleOfRoom); the player isn't moved. +// Talked to from (6, 6), Bill gives the S.S. TICKET, no YES/NO, and goes +// on about the S.S. ANNE (FLAG_GOT_SS_TICKET, which also hides Nugget +// Bridge's Rocket). Out by the mat; the door puts the player on (51, 5), +// facing south. The maps keep Bill at (7, 5) and the Clefairy at (10, 6), +// where they don't block the way. +StoryCheckpoint bill(){ + return row("00.18", "Bill's Sea Cottage: the Cell Separator and the S.S. Ticket", at(SEA_COTTAGE, 7, 8, NORTH), after_brock(), StartMenu::FULL, { + running(route("below_bill_clefairy", at(SEA_COTTAGE, 10, 7, NORTH), "below the Clefairy (10, 6), facing it")), + talk("bill_clefairy", "\"Hiya! I'm a POKEMON... ...No I'm not!\" ... \"Help me out!\""), + answer("help_bill", true, "YES: \"run the Cell Separation on the PC\""), + scene("bill_into_teleporter", at(SEA_COTTAGE, 10, 7, NORTH), "the Clefairy walks up into the right teleporter"), + running(route("pc_front", at(SEA_COTTAGE, 4, 6, NORTH), "below the PC (4, 5), facing it")), + talk("cell_separator", "the PC: \" initiated the TELEPORTER's CELL SEPARATOR!\""), + scene("bill_comes_out", at(SEA_COTTAGE, 4, 6, NORTH), "the teleporters run; Bill comes out of the left one and walks to (7, 6)"), + running(legs("beside_bill", {{EAST, 2}}, "to (6, 6), west of Bill, facing him")), + talk("thank_bill", "\"Thanks, bud! ... take this\""), + scene("ss_ticket", at(SEA_COTTAGE, 6, 6, EAST), "the S.S. TICKET; \"... the S.S. ANNE ... go instead\""), + running(route("out_of_cottage", at(ROUTE25, 51, 5, SOUTH), "down to the mat and out; the door puts the player below it")), + }); +} + +// Back to Cerulean by Route 25's south path (Route25 map.json): from +// below the cottage down column 51 to row 12, west along row 12 and row +// 11 into Route 24 (x 14..23, row 11), and down its column 14..16 east +// of Nugget Bridge to Cerulean's north edge (x 26..28, offset 12). No +// trainer stands on it. Cerulean's row 1 there is a ledge (26..28, 1), +// hopped south from (27, 0); then on into the city to the Pokemon Center. +StoryCheckpoint back_to_cerulean(){ + return row("00.19", "Route 25's south path back to Cerulean's Pokemon Center", at(ROUTE25, 51, 5, SOUTH), after_brock(), StartMenu::FULL, { + running(route("above_cerulean_ledge", at(CERULEAN, 27, 0, SOUTH), "Route 25's south path west, Route 24 east of the bridge south, to above Cerulean's ledge (27, 1)")), + hop("cerulean_ledge_hop", SOUTH, at(CERULEAN, 27, 2, SOUTH), "down the ledge (27, 1)"), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "into the city to the Pokemon Center")), + heal("heal_cerulean"), + }); +} + } @@ -718,6 +818,7 @@ const std::vector& story_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_gym(), misty(), cerulean_grind(), cerulean_rival(), nugget_bridge(), + route25(), bill(), back_to_cerulean(), }; return 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 c7924fa06d..a7d1c0ec6b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -216,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() != 23 || checkpoints[0].start){ - return "Expected 23 checkpoints, the first from a new game."; + if (checkpoints.size() != 26 || checkpoints[0].start){ + return "Expected 26 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -463,7 +463,9 @@ std::vector> wanderer_stops(const StoryPose& pose, co // Where a leg of a ROUTE or LEGS `step` from `pose` crosses the sight of a // trainer who looks around, the leg ends on a wall: if the trainer looks // the player's way, they walk up beside the leg, and the walker holds it -// on to its wall after the battle. Empty if so, else the leg that doesn't. +// on to its wall after the battle. Or the leg is one tile: the trainer +// sees the player when the step is done, on its end. Empty if so, else +// the leg that doesn't. std::string held_past_lookers(const StoryPose& pose, const StoryStep& step){ MapPosition from{pose.map, pose.tile}; for (const RouteStep& leg : legs_walked(pose, step)){ @@ -474,7 +476,7 @@ std::string held_past_lookers(const StoryPose& pose, const StoryStep& step){ crosses |= looks_around(object) && trainer_may_see(*map, object, tile.point); } } - if (crosses && !leg.to_wall){ + if (crosses && !leg.to_wall && leg.tiles != 1){ return "crosses a trainer's sight on " + to_string(leg) + ", not a leg to a wall."; } from = leg.end; @@ -577,8 +579,8 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set Date: Tue, 3 Nov 2026 11:30:31 -0800 Subject: [PATCH 02/12] FRLG AutoStory teach: move tutors, as for Route 4's Mega Kick A TEACH step's item may be "tutor": the player faces a move tutor, the step talks to it, says YES to its offer and to "can only be learned once", and goes on in the same party menu as a TM's (pokefirered move_tutors.inc). After it the party menu closes onto the tutor's last line in the overworld, which leave() pages through. The fake screens test it over Withdraw, with a free slot, with the texts unread and not compatible. No level-up rule replaces Mega Kick, so a Wartortle keeps it over Rapid Spin and Protect (tested). No table row uses it yet: the Mega Kick tutor (Route 4 (50, 3)) can't be reached from Cerulean without Surf; the walkable way is from Mt. Moon's exit (Route 4 (32, 6)). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonFRLG_AutoStoryParty.cpp | 4 +- .../PokemonFRLG_AutoStoryParty_Tests.cpp | 16 +++ .../AutoStory/PokemonFRLG_AutoStoryTeach.cpp | 2 +- .../AutoStory/PokemonFRLG_AutoStoryTeach.h | 17 ++- .../PokemonFRLG_AutoStoryTeachScreens.cpp | 46 ++++++++ .../PokemonFRLG_AutoStoryTeachScreens.h | 13 ++ ...okemonFRLG_AutoStoryTeachScreens_Tests.cpp | 111 ++++++++++++++++-- .../PokemonFRLG_AutoStoryTeach_Tests.cpp | 6 +- 8 files changed, 200 insertions(+), 15 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp index 117355debf..bc252b5ac5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp @@ -93,7 +93,9 @@ constexpr Evolution EVOLUTIONS[] = { // it learns over a full move list, and the move whose slot it takes. Any // other move offered over four is declined. Squirtle's is the speedrun's: // Water Gun where Bubble was (so the attack stays bottom left), Bite over -// Tail Whip. TM moves are not level-up prompts and are not here. +// Tail Whip. TM and tutor moves are not level-up prompts and are not +// here; no rule replaces them, so Water Pulse (TM03, 00.14c) and Mega Kick +// (Route 4's tutor, 00.20) are kept over every later level-up move. struct LearnRule{ std::string_view line; std::string_view move; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp index 1b5388de98..4e55d3a6e6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp @@ -152,6 +152,21 @@ UnitTestResult test_learn_prompts(Logger&){ return true; } +// Mega Kick, from Route 4's tutor over Withdraw: no level-up rule takes +// its slot, so Rapid Spin and Protect are declined. +UnitTestResult test_mega_kick_kept(Logger&){ + const std::vector moves{"bite", "water-pulse", "bubble", "mega-kick"}; + for (const auto& [level, offered] : {std::pair{24, "rapid-spin"}, {30, "protect"}}){ + PartyMember member = with_moves("wartortle", moves, level); + const LearnDecision decision = learn_move(member); + if (decision.move != offered || decision.forget_slot || move_names(member) != moves){ + return std::format("Lv{}: expected {} declined, leaving {}; got {}, leaving {}.", + level + 1, offered, joined(moves), decision.move.value_or("nothing"), joined(move_names(member))); + } + } + return true; +} + // NO to "Delete a move?" is followed by YES to "Stop learning?" (B there // asks "Delete a move?" again, console B 2026-11-01); a move the plan // wants is YES with the slot to forget. @@ -804,6 +819,7 @@ UnitTestResult test_nurse_before_battle(Logger&){ void add_tests_AutoStoryParty(UnitTestDatabase& database){ database.add("movesets the game gives and the story plays", test_movesets); database.add("move offered and slot forgotten", test_learn_prompts); + database.add("Mega Kick kept over later level-up moves", test_mega_kick_kept); database.add("YES/NO answers to move prompts", test_prompt_answers); database.add("fainted lead runs or sends the next member", test_fainted_lead); database.add("PP: preferred attack, then Tackle; nurse restores", test_pp); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp index 392ce78949..bde31a4444 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp @@ -82,7 +82,7 @@ std::optional parse_teach_variant(std::string_view variant){ return std::nullopt; } const std::vector words = variant_words(variant.substr(TEACH_VARIANT_PREFIX.size())); - if (words.size() < 3 || !is_tm_or_hm(words[0]) || variant_pair(words[1])){ + if (words.size() < 3 || !(is_tm_or_hm(words[0]) || words[0] == TEACH_TUTOR) || variant_pair(words[1])){ return std::nullopt; } TeachPlan plan{.item = std::string(words[0]), .move = std::string(words[1])}; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h index 27ba63fcef..c3e94e1872 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h @@ -23,6 +23,14 @@ * A TM or HM the TM CASE hasn't got skips the step, with a log line: the * table can teach a TM a run may not have won yet. * + * A move tutor teaches the same way from the overworld, the player facing + * the tutor (pokefirered data/scripts/move_tutors.inc, e.g. + * Route4_EventScript_MegaKickTutor): its offer, YES/NO; "This move can be + * learned only once. Is that okay?", YES/NO; "Which POKEMON wants to + * learn it?", then the party menu (ChooseMonForMoveTutor) with the TM's + * prompts. After it, the party menu closes onto the tutor's last line, + * not the TM CASE. + * * The step reads the member's moves first (MemberReader) and decides * everything before it opens the bag: the move to forget, by name or * slot; a refusal if the member already knows the move, has four moves @@ -56,7 +64,8 @@ namespace PokemonFRLG{ // What a TEACH step teaches. struct TeachPlan{ - // The TM or HM's slug, e.g. "hm03", "tm28". + // The TM or HM's slug, e.g. "hm03", "tm28"; or TEACH_TUTOR, the move + // tutor the player faces. std::string item = {}; // The move it holds, e.g. "surf". std::string move = {}; @@ -68,6 +77,9 @@ struct TeachPlan{ std::optional forget_slot = std::nullopt; }; +// TeachPlan::item for a move tutor. +inline constexpr std::string_view TEACH_TUTOR = "tutor"; + // Whether `move` is an HM's, which the game won't let a Pokemon forget. bool is_hm_move(std::string_view move); @@ -129,7 +141,8 @@ class TeachActions{ virtual ~TeachActions() = default; // From the overworld: the bag, the TM Case, `item` (which holds - // `move`), USE, to the party menu. + // `move`), USE, to the party menu. For TEACH_TUTOR: talk to the + // tutor the player faces, YES to its questions, to the party menu. virtual ItemOpen open_item(std::string_view item, std::string_view move) = 0; // On "Teach which POKEMON?": A on `slot`. virtual TeachPrompt choose_member(size_t slot) = 0; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp index fdaa5b8d21..26eeee569f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp @@ -10,6 +10,7 @@ #include "CommonFramework/Tools/DebugDumper.h" #include "CommonFramework/VideoPipeline/VideoFeed.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryTeachScreens.h" @@ -131,6 +132,14 @@ ItemOpen ScreenTeachActions::open_row(TeachScreen screen, std::string_view name) } ItemOpen ScreenTeachActions::open_item(std::string_view item, std::string_view move){ + m_tutor = item == TEACH_TUTOR; + if (m_tutor){ + if (!m_screens.talk_to_tutor()){ + m_logger.log(std::format("Teach: the tutor's questions for {} didn't lead to the party menu.", move), COLOR_RED); + return ItemOpen::FAILED; + } + return wait_for(TeachScreen::PARTY_PROMPT).has_value() ? ItemOpen::OPENED : ItemOpen::FAILED; + } if (!m_screens.open_bag()){ m_logger.log("Teach: the bag didn't open.", COLOR_RED); return ItemOpen::FAILED; @@ -160,6 +169,7 @@ TeachPrompt ScreenTeachActions::choose_member(size_t slot){ TeachPrompt ScreenTeachActions::member_answer(){ bool incompatible = false; + bool texts = false; size_t prompts = 0; for (size_t c = 0; c < MAX_LOOKS; c++){ const TeachView view = m_screens.look(); @@ -169,8 +179,15 @@ TeachPrompt ScreenTeachActions::member_answer(){ return TeachPrompt::DELETE_PROMPT; } incompatible |= view.text == TeachText::NOT_COMPATIBLE; + texts = true; press(TeachPress::A); break; + case TeachScreen::OTHER: + // A tutor's party menu closes onto the overworld once learned. + if (m_tutor && texts){ + return TeachPrompt::LEARNED; + } + break; case TeachScreen::PARTY_YES_NO: return view.text == TeachText::STOP_QUESTION ? TeachPrompt::FAILED : TeachPrompt::DELETE_PROMPT; case TeachScreen::KNOWN_MOVES: @@ -233,6 +250,10 @@ ForgetEnd ScreenTeachActions::forget(size_t slot){ case TeachScreen::ITEM_MENU: return picked ? ForgetEnd::LEARNED : ForgetEnd::FAILED; default: + // A tutor's: leave() pages through them; the moves read decide. + if (picked && m_tutor){ + return ForgetEnd::LEARNED; + } // After the pick, its messages: B pages through them, once the // moves page has closed (the first look). if (picked && ++unplaced > 1){ @@ -318,6 +339,31 @@ bool ConsoleTeachScreens::open_bag(){ } } +bool ConsoleTeachScreens::talk_to_tutor(){ + try{ + // A talks, pages through its lines and says YES, the top row. + answer_yes_no(m_console, m_context, true, BUTTON_A, 30000ms); + answer_yes_no(m_console, m_context, true, BUTTON_A, 15000ms); + }catch (OperationFailedException& e){ + m_console.log("Teach: " + e.message(), COLOR_RED); + return false; + } + // "Which POKEMON wants to learn it?": A only on its advance arrow, as + // A on the party menu would choose the lead. + for (size_t c = 0; c < 50; c++){ + m_context.wait_for_all_requests(); + VideoSnapshot snapshot = m_console.video().snapshot(); + if (snapshot && AdvanceWhiteDialogDetector(COLOR_RED).detect(snapshot)){ + pbf_press_button(m_context, BUTTON_A, 160ms, 240ms); + }else if (snapshot && read_teach_screen(snapshot) == TeachScreen::PARTY_PROMPT){ + return true; + }else{ + pbf_wait(m_context, 300ms); + } + } + return false; +} + TeachView ConsoleTeachScreens::look(){ // Two looks 150 ms apart that agree: the screen has stopped changing. std::optional>> last; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h index 8fbb380d00..e04de0ddf5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h @@ -24,6 +24,11 @@ * YES/NO, YES: " did not learn the move ." and the TM CASE's * list again, the TM kept. * - From the TM CASE's list, B closes it to the bag, and B the bag. + * - A move tutor (not seen on console B yet; pokefirered + * move_tutors.inc): its lines and two YES/NOs in the overworld's + * message box, then the same party menu and prompts. After the learn, + * or "did not learn", the party menu closes onto the tutor's last line + * in the overworld, which the reader doesn't place. * * Each step looks at the screen before it presses, and stops at a screen * it doesn't expect instead of pressing on. The screens come from a @@ -83,6 +88,10 @@ class TeachScreens{ // From the overworld: START, BAG. False if the bag didn't open. virtual bool open_bag() = 0; + // From the overworld, facing a move tutor: talks, YES to its offer and + // to "This move can be learned only once", its messages to the party + // menu. False if a YES/NO or the party menu didn't show. + virtual bool talk_to_tutor() = 0; // The screen, once it has stopped changing. virtual TeachView look() = 0; virtual void press(TeachPress press) = 0; @@ -123,6 +132,9 @@ class ScreenTeachActions : public TeachActions{ Logger& m_logger; TeachScreens& m_screens; + // Taught by a tutor: after the party menu comes the overworld, not + // the TM CASE. + bool m_tutor = false; }; @@ -133,6 +145,7 @@ class ConsoleTeachScreens : public TeachScreens{ ConsoleTeachScreens(ConsoleHandle& console, ProControllerContext& context, Language language); virtual bool open_bag() override; + virtual bool talk_to_tutor() override; virtual TeachView look() override; virtual void press(TeachPress press) override; virtual bool to_overworld() override; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp index d46eef5517..cc27f10a19 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp @@ -55,6 +55,8 @@ enum class FakeScreen{ DELETE_YES_NO, STOP_YES_NO, KNOWN_MOVES, + // A tutor's last line in the overworld, after its party menu. + TUTOR_TEXT, }; // The bag, TM CASE and party menu as console B showed them. @@ -65,7 +67,11 @@ struct FakeTeachGame{ size_t bag_cursor = 0; std::vector tms{{"water-pulse", 1}, {"rock-tomb", 1}}; std::vector moves{"bite", "tail-whip", "bubble", "withdraw"}; - std::set compatible{"water-pulse", "rock-tomb"}; + std::set compatible{"water-pulse", "rock-tomb", "mega-kick"}; + // The move of the tutor the player faces; empty: none. + std::string tutor_move = {}; + // The party menu is the tutor's. + bool tutoring = false; FakeScreen screen = FakeScreen::OVERWORLD; size_t cursor = 0; @@ -107,12 +113,23 @@ void move_cursor(FakeTeachGame& game, TeachPress press, size_t rows){ } } +// Where the party menu goes once done: the TM CASE, or a tutor's last line. +FakeScreen after_party(const FakeTeachGame& game){ + return game.tutoring ? FakeScreen::TUTOR_TEXT : FakeScreen::TM_CASE; +} + std::string tm_move(const FakeTeachGame& game){ + if (game.tutoring){ + return game.tutor_move; + } const std::vector rows = tm_rows(game); return game.tm < rows.size() ? rows[game.tm] : ""; } void use_up(FakeTeachGame& game){ + if (game.tutoring){ + return; + } const std::string move = tm_move(game); for (FakeTm& tm : game.tms){ if (tm.move == move && tm.count > 0){ @@ -132,7 +149,7 @@ void choose_member(FakeTeachGame& game){ game.moves.push_back(move); use_up(game); game.cursor = 0; - show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::UNKNOWN}, FakeScreen::TM_CASE); + show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::UNKNOWN}, after_party(game)); return; } show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::KNOWS_FOUR}, FakeScreen::DELETE_YES_NO); @@ -211,7 +228,7 @@ void press_yes_no(FakeTeachGame& game, TeachPress press){ show_texts(game, {TeachText::WHICH_MOVE}, FakeScreen::KNOWN_MOVES); game.cursor = 0; }else if (game.screen == FakeScreen::STOP_YES_NO && yes){ - show_texts(game, {TeachText::DID_NOT_LEARN}, FakeScreen::TM_CASE); + show_texts(game, {TeachText::DID_NOT_LEARN}, after_party(game)); game.cursor = game.tm; }else if (no){ game.screen = game.screen == FakeScreen::DELETE_YES_NO ? FakeScreen::STOP_YES_NO : FakeScreen::DELETE_YES_NO; @@ -231,7 +248,7 @@ void press_known_moves(FakeTeachGame& game, TeachPress press){ } game.moves[game.cursor] = tm_move(game); use_up(game); - show_texts(game, {TeachText::UNKNOWN, TeachText::UNKNOWN, TeachText::UNKNOWN}, FakeScreen::TM_CASE); + show_texts(game, {TeachText::UNKNOWN, TeachText::UNKNOWN, TeachText::UNKNOWN}, after_party(game)); if (game.forget_texts_unread){ game.screen = FakeScreen::UNREAD_TEXT; } @@ -258,10 +275,13 @@ void press_game(FakeTeachGame& game, TeachPress press){ if (press == TeachPress::A){ choose_member(game); }else if (press == TeachPress::B){ - game.screen = FakeScreen::TM_CASE; + game.screen = after_party(game); game.cursor = game.tm; } return; + case FakeScreen::TUTOR_TEXT: + game.screen = FakeScreen::OVERWORLD; + return; case FakeScreen::TEXT: case FakeScreen::UNREAD_TEXT: game.texts.pop_front(); @@ -277,6 +297,7 @@ TeachView view_of(const FakeTeachGame& game){ case FakeScreen::OVERWORLD: case FakeScreen::START_MENU: case FakeScreen::UNREAD_TEXT: + case FakeScreen::TUTOR_TEXT: return {}; case FakeScreen::BAG:{ const bool key_items = game.pockets[game.pocket] == "key-items"; @@ -317,6 +338,16 @@ class FakeTeachScreens : public TeachScreens{ m_game.cursor = 0; return true; } + virtual bool talk_to_tutor() override{ + if (m_game.screen != FakeScreen::OVERWORLD || m_game.tutor_move.empty()){ + m_game.error = "talked to no tutor"; + return false; + } + m_game.tutoring = true; + m_game.screen = FakeScreen::PROMPT; + m_game.cursor = 0; + return true; + } virtual TeachView look() override{ return view_of(m_game); } @@ -328,7 +359,11 @@ class FakeTeachScreens : public TeachScreens{ press_game(m_game, press); } virtual bool to_overworld() override{ - if (m_game.screen == FakeScreen::START_MENU || m_game.screen == FakeScreen::OVERWORLD){ + // B pages through a tutor's lines too. + const bool tutor_lines = m_game.screen == FakeScreen::TUTOR_TEXT + || (m_game.tutoring && m_game.after_texts == FakeScreen::TUTOR_TEXT + && (m_game.screen == FakeScreen::TEXT || m_game.screen == FakeScreen::UNREAD_TEXT)); + if (tutor_lines || m_game.screen == FakeScreen::START_MENU || m_game.screen == FakeScreen::OVERWORLD){ m_game.screen = FakeScreen::OVERWORLD; return true; } @@ -343,6 +378,8 @@ class FakeTeachScreens : public TeachScreens{ }; +const TeachPlan MEGA_KICK{.item = std::string(TEACH_TUTOR), .move = "mega-kick", .member = 0, .forget_move = "withdraw"}; + struct ScreensCase{ std::string name = {}; std::function setup = {}; @@ -368,6 +405,23 @@ void forget_texts_unread(FakeTeachGame& game){ void nothing_compatible(FakeTeachGame& game){ game.compatible.clear(); } +// Route 4's Mega Kick tutor, the party as console B's (2026-11-03). +void mega_kick_tutor(FakeTeachGame& game){ + game.tutor_move = "mega-kick"; + game.moves = {"bite", "water-pulse", "bubble", "withdraw"}; +} +void mega_kick_tutor_unread(FakeTeachGame& game){ + mega_kick_tutor(game); + game.forget_texts_unread = true; +} +void mega_kick_tutor_room(FakeTeachGame& game){ + mega_kick_tutor(game); + game.moves = {"bite", "water-pulse", "bubble"}; +} +void mega_kick_tutor_incompatible(FakeTeachGame& game){ + mega_kick_tutor(game); + game.compatible.clear(); +} std::vector screens_cases(){ return { @@ -404,6 +458,41 @@ std::vector screens_cases(){ }; } +// Route 4's Mega Kick tutor: the TM03 still in the TM CASE after each. +std::vector tutor_screens_cases(){ + return { + { + .name = "the Mega Kick tutor: over WITHDRAW, back to the overworld past his line", + .setup = mega_kick_tutor, + .plan = MEGA_KICK, + .moves = {"bite", "water-pulse", "bubble", "mega-kick"}, + .tm03 = 1, + }, + { + .name = "the Mega Kick tutor, the texts after the forget read as other", + .setup = mega_kick_tutor_unread, + .plan = MEGA_KICK, + .moves = {"bite", "water-pulse", "bubble", "mega-kick"}, + .tm03 = 1, + }, + { + .name = "the Mega Kick tutor, a free slot", + .setup = mega_kick_tutor_room, + .plan = MEGA_KICK, + .moves = {"bite", "water-pulse", "bubble", "mega-kick"}, + .tm03 = 1, + }, + { + .name = "the Mega Kick tutor, not compatible: given up past his line", + .setup = mega_kick_tutor_incompatible, + .plan = MEGA_KICK, + .end = TeachEnd::REFUSED, + .moves = {"bite", "water-pulse", "bubble", "withdraw"}, + .tm03 = 1, + }, + }; +} + UnitTestResult check_screens_case(const ScreensCase& item){ QuietLogger logger; FakeTeachGame game; @@ -443,10 +532,12 @@ UnitTestResult check_screens_case(const ScreensCase& item){ } UnitTestResult test_screens(){ - for (const ScreensCase& item : screens_cases()){ - const UnitTestResult result = check_screens_case(item); - if (result.result != UnitTestResult::PASSED){ - return result; + for (const std::vector& cases : {screens_cases(), tutor_screens_cases()}){ + for (const ScreensCase& item : cases){ + const UnitTestResult result = check_screens_case(item); + if (result.result != UnitTestResult::PASSED){ + return result; + } } } return true; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp index 514c3c5540..93a18fb141 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp @@ -376,8 +376,12 @@ UnitTestResult test_teach_variant(){ if (!by_slot || by_slot->member != 1 || by_slot->forget_slot != 3u){ return "\"teach tm28 dig slot=2 forget_slot=4\" didn't read back."; } + const std::optional tutor = parse_teach_variant("teach tutor mega-kick slot=1 forget=withdraw"); + if (!tutor || tutor->item != TEACH_TUTOR || tutor->move != "mega-kick" || tutor->forget_move != "withdraw"){ + return "\"teach tutor mega-kick slot=1 forget=withdraw\" didn't read back."; + } for (std::string_view bad : { - "teach", "teach hm03 surf", "teach potion surf slot=1", "teach hm03 surf slot=7", + "teach", "teach hm03 surf", "teach potion surf slot=1", "teach hm03 surf slot=7", "teach tutors mega-kick slot=1", "teach hm03 surf slot=1 forget_slot=5", "teach hm03 slot=1", "mart PewterCity_Mart potion=1", }){ if (parse_teach_variant(bad)){ From ff5bf0ac1d624d79f82eab58a8368ad3c2ce32f0 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 11:30:31 -0800 Subject: [PATCH 03/12] FRLG AutoStory 00.20: an Oddish from Route 24's grass From Cerulean's Center: 7 Poke Balls at the Mart, then over Nugget Bridge onto Route 24's grass at (4, 19), out of Camper Shane's column 5, a capture of one Oddish (25% of Route 24's battles in FireRed; 30 battles, 7 balls at most), and back to the nurse. The Oddish stays in the party behind the lead, which battles keep in. The chain test lets a capture's patch, like a GRIND's, end on grass; a sim test walks the row's routes past Shane. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_AutoStoryRunPlan_Tests.cpp | 8 ++-- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 48 +++++++++++++++++++ .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 45 ++++++++++++++++- .../AutoStory/PokemonFRLG_AutoStory_Tests.cpp | 12 +++-- 4 files changed, 103 insertions(+), 10 deletions(-) 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 540134c1ab..55f5a80b10 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -118,7 +118,7 @@ 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 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19"; + const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"; 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"}, @@ -171,8 +171,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 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19"}, - {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19"}, + {.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.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"}, + {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"}, { .name = "Gift RNG: started at 00.12b, from the save beside the salesman", .first = "00.12b", @@ -187,7 +187,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 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19"}, + {.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.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"}, { .name = "Starter RNG first, with both on", .first = "00.03", 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 649f3f1094..3058cd4d84 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -586,6 +586,53 @@ UnitTestResult test_route25_trainers(){ return true; } +// 00.20 on the sim, its walks from the Center: over Nugget Bridge, whose +// trainers 00.16 beat (they stand there, but don't battle), onto Route +// 24's grass at (4, 19) out of Camper Shane's sight, and back to the +// nurse. No trainer battles on the way. +UnitTestResult test_oddish_walks(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); + if (row == rows.end()){ + return "No checkpoint 00.20."; + } + std::vector maps = story_maps(); + for (MapData& map : maps){ + if (map.name != "Route24"){ + continue; + } + for (MapObject& object : map.objects){ + if (object.trainer && object.x >= 10 && object.x <= 12){ + object.trainer.reset(); + } + } + } + StoryPose at = *story_pose(*row->start); + OverworldSim sim(maps, MapPosition{at.map, at.tile}, *at.facing); + const std::map ends{ + {"mart_clerk", {"CeruleanCity_Mart", {4, 3}}}, + {"oddish_patch", {"Route24", {4, 19}}}, + {"cerulean_nurse", {"CeruleanCity_PokemonCenter_1F", {7, 4}}}, + }; + for (const StoryStep& step : row->steps){ + if (step.kind != StoryStepKind::ROUTE){ + continue; + } + const std::string failure = row_step_on_sim(sim, at, step, "00.20"); + if (!failure.empty()){ + return failure; + } + const auto end = ends.find(step.name); + if (end == ends.end() || !(sim.state().position == end->second)){ + return "00.20/" + step.name + ": ended on " + to_string(sim.state().position) + "."; + } + } + if (!sim.state().trainers_beaten.empty() || !sim.state().violations.empty()){ + return "00.20: a trainer's battle or a violation on the way."; + } + return true; +} + } @@ -600,6 +647,7 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database){ add("00.14 into Cerulean, the entry leg a tile short", test_cerulean_entry_short); add("00.16 Nugget Bridge, the walk onto it a tile short", test_nugget_bridge_short); add("00.17 Route 25, its four trainers and Dan", test_route25_trainers); + add("00.20 to Route 24's grass and back, past Shane", test_oddish_walks); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 1c9fd46ee3..90aabf2407 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -810,6 +810,49 @@ StoryCheckpoint back_to_cerulean(){ }); } +// An Oddish for HM01 Cut, and a second member behind the lead +// (docs/design/AutoStory-FRLG-FutureEvents.md, "Console B run choices"). +// Route 24's grass (Route24 map.json: x 3..5, rows 19..31, west of Nugget +// Bridge) is the nearest to the Center with Oddish: FireRed's +// sRoute24_FireRed (pokefirered wild_encounters.json) has it in 25% of +// battles, Lv12..14, as Route 25's grass; Routes 5 and 6 aren't on the +// story's maps yet. Up the bridge, whose trainers 00.16 beat, west along +// rows 10..15 and down column 4 to the grass's first row, (4, 19); the +// step paces west to (3, 19) and back. Camper Shane (5, 21) faces north, +// sight 5, over (5, 16..20): column 5 is kept off. +// +// First 7 Poke Balls at the Mart (row 1, 200 each; the bag isn't read, so +// the step buys them whatever the bag holds). The floor, from 00.15's +// 1500 less its 1400: the rival's 288 (4 x 18 x 4), the Rocket's 480 (4 +// x 15 x 8), Franklin's 540 and Nob's 468 (hikers, 9) alone make 1876 +// (pokefirered gTrainerMoneyTable). An Oddish at full HP (catch rate 255) +// stays in a Poke Ball about 1 time in 3 (Gen 3: a = 85, four shakes of +// 0.76), so 7 balls catch it about 94% of the time; the step throws 7 at +// most, in 30 battles (none an Oddish: 0.02%). Nothing weakens it: the HP +// bar isn't read on the console yet. +// +// The Oddish joins the party second. Battles keep the lead in +// (PokemonFRLG_AutoStoryBattleDecisions.h): the Oddish is sent only when +// the lead faints in a trainer's battle, which counts as lost. Back to +// the nurse, and the save in the Center. +StoryCheckpoint oddish(){ + const MartPlan balls{ + .map = CERULEAN_MART, + .lines = {{.item = "poke-ball", .quantity = 7}}, + .budget = 1400, + .money_floor = 1500, + }; + const CapturePlan catch_oddish{.species = "oddish", .catches = 1, .max_encounters = 30, .max_balls = 7, .pace = WEST}; + return row("00.20", "Route 24's grass: an Oddish for Cut, then the Center", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("mart_clerk", at(CERULEAN_MART, 4, 3, WEST), "out of the Center to the Mart's clerk")), + mart_step("buy_poke_balls", "7 Poke Balls for the Oddish", balls), + running(route("oddish_patch", at(ROUTE24, 4, 19), "north over Nugget Bridge, west and down column 4 onto the grass's first row, out of Shane's column 5")), + capture_step("catch_oddish", "wild battles until an Oddish, then Poke Balls; any other species is run from", catch_oddish), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back over the bridge to the Center")), + heal("heal_cerulean"), + }); +} + } @@ -818,7 +861,7 @@ const std::vector& story_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_gym(), misty(), cerulean_grind(), cerulean_rival(), nugget_bridge(), - route25(), bill(), back_to_cerulean(), + route25(), bill(), back_to_cerulean(), oddish(), }; return 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 a7d1c0ec6b..5cf14a080e 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_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStoryPcStorage.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -216,8 +217,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 26 || checkpoints[0].start){ - return "Expected 26 checkpoints, the first from a new game."; + if (checkpoints.size() != 27 || checkpoints[0].start){ + return "Expected 27 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -545,8 +546,9 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set{}){ const StoryPose end = to_patch ? one_tile(*step.target) : StoryPose{}; const bool patch = to_patch && tile.map == end.map && tile.point == end.tile; @@ -577,7 +579,7 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set Date: Tue, 3 Nov 2026 12:15:22 -0800 Subject: [PATCH 04/12] FRLG AutoStory 00.18: walk around Bill on the way out of the Sea Cottage After his scene Bill stands on (7, 6), east of the player, not on the maps' (7, 5). The walk out planned east onto his tile, bumped him, and the hold south ended on (6, 9) beside the mat with no warp (console B, 2026-11-03). Go south 2 and east 1 to (7, 8) first, then out by the mat. The new sim test puts Bill on (7, 6) and fails on the old table with the console's message and tile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 55 +++++++++++++++++++ .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 8 ++- 2 files changed, 60 insertions(+), 3 deletions(-) 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 3058cd4d84..88d8ebb2b3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -633,6 +633,60 @@ UnitTestResult test_oddish_walks(){ return true; } +// Console B, 2026-11-03 (row 00.18): after his scene Bill stands on +// (7, 6), east of the player on (6, 6), not on the maps' (7, 5). The +// walk out planned east onto (7, 6) and south to the mat (7, 9); the +// east tap bumped Bill, and the hold south took the player down column 6 +// to (6, 9), beside the mat, with no warp. On a copy of the maps with +// Bill on (7, 6) and only the mat (7, 9) as the way out (the console +// didn't warp from (6, 9)), the row's walks after the ticket end each +// where the table says and leave on Route 25's (51, 5). +UnitTestResult test_out_of_bill_cottage(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.18"; }); + if (row == rows.end()){ + return "No checkpoint 00.18."; + } + const auto ticket = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "ss_ticket"; }); + if (ticket == row->steps.end() || !ticket->pose_after){ + return "00.18: no ss_ticket."; + } + std::vector maps = story_maps(); + for (MapData& map : maps){ + if (map.name != "Route25_SeaCottage"){ + continue; + } + for (MapObject& object : map.objects){ + if (object.name == "bill"){ + object.x = 7; + object.y = 6; + } + } + std::erase_if(map.warps, [](const MapWarp& warp){ return warp.x != 7; }); + } + StoryPose at = *story_pose(*ticket->pose_after); + OverworldSim sim(maps, MapPosition{at.map, at.tile}, *at.facing); + for (auto step = ticket + 1; step != row->steps.end(); ++step){ + if (step->kind != StoryStepKind::ROUTE && step->kind != StoryStepKind::LEGS){ + continue; + } + const std::string failure = row_step_on_sim(sim, at, *step, "00.18"); + if (!failure.empty()){ + return failure; + } + if (!(sim.state().position == MapPosition{at.map, at.tile})){ + return "00.18/" + step->name + ": expected " + to_string(MapPosition{at.map, at.tile}) + ", but on " + to_string(sim.state().position) + "."; + } + } + if (!(sim.state().position == MapPosition{"Route25", {51, 5}})){ + return "00.18: expected to leave onto Route 25's (51, 5), not " + to_string(sim.state().position) + "."; + } + if (!sim.state().violations.empty()){ + return "00.18: violations:" + joined(sim.state().violations); + } + return true; +} + } @@ -647,6 +701,7 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database){ add("00.14 into Cerulean, the entry leg a tile short", test_cerulean_entry_short); add("00.16 Nugget Bridge, the walk onto it a tile short", test_nugget_bridge_short); add("00.17 Route 25, its four trainers and Dan", test_route25_trainers); + add("00.18 out of Bill's Sea Cottage, Bill on (7, 6)", test_out_of_bill_cottage); add("00.20 to Route 24's grass and back, past Shane", test_oddish_walks); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 90aabf2407..ee665f8b4e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -776,9 +776,10 @@ StoryCheckpoint route25(){ // walks to (7, 6) (BillWalkToMiddleOfRoom); the player isn't moved. // Talked to from (6, 6), Bill gives the S.S. TICKET, no YES/NO, and goes // on about the S.S. ANNE (FLAG_GOT_SS_TICKET, which also hides Nugget -// Bridge's Rocket). Out by the mat; the door puts the player on (51, 5), -// facing south. The maps keep Bill at (7, 5) and the Clefairy at (10, 6), -// where they don't block the way. +// Bridge's Rocket). Out by the mat (7, 9); the door puts the player on +// (51, 5), facing south. The maps keep Bill at (7, 5), but after his +// scene he stands on (7, 6), in the way from (6, 6) to the mat (console +// B, 2026-11-03), so the walk out goes around him to (7, 8) first. StoryCheckpoint bill(){ return row("00.18", "Bill's Sea Cottage: the Cell Separator and the S.S. Ticket", at(SEA_COTTAGE, 7, 8, NORTH), after_brock(), StartMenu::FULL, { running(route("below_bill_clefairy", at(SEA_COTTAGE, 10, 7, NORTH), "below the Clefairy (10, 6), facing it")), @@ -791,6 +792,7 @@ StoryCheckpoint bill(){ running(legs("beside_bill", {{EAST, 2}}, "to (6, 6), west of Bill, facing him")), talk("thank_bill", "\"Thanks, bud! ... take this\""), scene("ss_ticket", at(SEA_COTTAGE, 6, 6, EAST), "the S.S. TICKET; \"... the S.S. ANNE ... go instead\""), + running(legs("around_bill", {{SOUTH, 2}, {EAST, 1}}, "to (7, 8), above the mat; Bill stands on (7, 6) after his scene")), running(route("out_of_cottage", at(ROUTE25, 51, 5, SOUTH), "down to the mat and out; the door puts the player below it")), }); } From dc384b101efbb28bd70e6236faaa64ba3b860670 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 12:30:49 -0800 Subject: [PATCH 05/12] FRLG image tests: expected values from the file names TeachScreenReader's frames drop their order prefix (the list keeps the order). The battle-loop tests read True/False and Hp_of from the image name, as upstream's image tests do; Wild Encounter's eng_chansey.jpg keeps its answer in the registration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Menus/PokemonFRLG_TeachScreenReader.cpp | 31 ++++---- .../PokemonFRLG_AutoStoryItems_Tests.cpp | 75 +++++++++++++------ 2 files changed, 68 insertions(+), 38 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp index f25c255d12..1dd88d6d85 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp @@ -489,22 +489,23 @@ class Test_TeachScreen : public ImageUnitTest{ void add_tests_TeachScreenReader(UnitTestDatabase& database){ // FireRed on console B, 2026-11-03: TM03 taught to the lead from the - // bag, B on the move to forget, and YES to stop. + // bag, B on the move to forget, and YES to stop. Listed in the order + // the screens came up. for (const char* image : { - "01_BagItems_eng.png", - "02_BagKeyItems_eng.png", - "03_KeyItemsTmCase_eng.png", - "04_TmCaseMenu_eng.png", - "05_TmCaseList_eng.png", - "06_Tm03Menu_eng.png", - "07_TeachWhich_eng.png", - "08_WantsToLearn_eng.png", - "09_KnowsFour_eng.png", - "10_DeleteQuestion_eng.png", - "11_WhichMove_eng.png", - "12_KnownMoves_eng.png", - "13_StopQuestion_eng.png", - "14_DidNotLearn_eng.png", + "BagItems_eng.png", + "BagKeyItems_eng.png", + "KeyItemsTmCase_eng.png", + "TmCaseMenu_eng.png", + "TmCaseList_eng.png", + "Tm03Menu_eng.png", + "TeachWhich_eng.png", + "WantsToLearn_eng.png", + "KnowsFour_eng.png", + "DeleteQuestion_eng.png", + "WhichMove_eng.png", + "KnownMoves_eng.png", + "StopQuestion_eng.png", + "DidNotLearn_eng.png", }){ database.add(std::string("PokemonFRLG/TeachScreenReader/") + image); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp index b2a9db0751..5bb4224fba 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp @@ -7,21 +7,25 @@ * */ +#include #include #include #include #include #include +#include #include #include #include #include "Common/Cpp/Color.h" #include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Filesystem/Filesystem.h" #include "Common/Cpp/Logging/AbstractLogger.h" #include "Common/Cpp/TestRunners/UnitTest.h" #include "CommonFramework/ImageTypes/ImageRGB32.h" #include "CommonFramework/Language.h" #include "Tests/ImageUnitTest.h" +#include "Tests/TestUtils.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" @@ -324,10 +328,11 @@ UnitTestResult test_late_battle_placed(Logger&){ // battle_shows(), the battle loop's look at whether the battle is still on -// screen, on console B's captures. +// screen, on console B's captures named _.png. +// An image whose name doesn't say is registered with its answer. class Test_BattleShows : public ImageUnitTest{ public: - Test_BattleShows(const std::string& image, bool expected) + Test_BattleShows(const std::string& image, std::optional expected = std::nullopt) : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) , m_expected(expected) {} @@ -336,46 +341,66 @@ class Test_BattleShows : public ImageUnitTest{ Logger& logger, CancellableScope& scope, const std::vector& images ) const override{ - return battle_shows(images[0]) == m_expected; + bool expected = false; + if (m_expected){ + expected = *m_expected; + }else{ + const std::vector words = parse_words(Filesystem::Path(image_paths()[0]).stem().string()); + if (words.empty() || !parse_bool(words.back(), expected)){ + return "Error: filename must be _."; + } + } + return battle_shows(images[0]) == expected; } private: - bool m_expected; + std::optional m_expected; }; -// read_party_status() on console B's captures: the bar's color, and its -// colored share within a pixel of the 48-pixel bar of `hp` / `max_hp`. +// read_party_status() on console B's captures named +// _Hp_of[_].png: the bar's color, +// and its colored share within a pixel of the 48-pixel bar of hp / max hp. // Console B, 2026-11-03, before Miguel: 17/55 HP read as 15% of the bar, // then 2% with the box a pixel lower. class Test_PartyHp : public ImageUnitTest{ public: - Test_PartyHp(const std::string& image, std::string_view color, int hp, int max_hp) + Test_PartyHp(const std::string& image) : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) - , m_color(color) - , m_share(double(hp) / max_hp) {} virtual UnitTestResult run_on_images( Logger& logger, CancellableScope& scope, const std::vector& images ) const override{ + std::string expected_color; + int hp = 0; + int max_hp = 0; + for (const std::string& word : parse_words(Filesystem::Path(image_paths()[0]).stem().string())){ + const size_t of = word.find("of"); + if (word.starts_with("Hp") && word.size() > 2){ + expected_color = word.substr(2); + expected_color[0] = char(std::tolower((unsigned char)expected_color[0])); + }else if (of != std::string::npos){ + parse_int(word.substr(0, of), hp); + parse_int(word.substr(of + 2), max_hp); + } + } + if (expected_color.empty() || max_hp <= 0){ + return "Error: filename must be _Hp_of[_]."; + } + const double share = double(hp) / max_hp; const PartyStatus status = read_party_status(images[0]); const std::string_view color = !status.hp_seen ? "none" : status.hp_red ? "red" : status.hp_yellow ? "yellow" : "green"; - if (color != m_color){ - return std::format("Expected an HP bar read as {}, got {}.", m_color, color); + if (color != expected_color){ + return std::format("Expected an HP bar read as {}, got {}.", expected_color, color); } - if (std::abs(status.hp_fill - m_share) > 1.0 / 48){ - return std::format("Expected {:.0f}% of the bar, got {:.0f}%.", 100 * m_share, 100 * status.hp_fill); + if (std::abs(status.hp_fill - share) > 1.0 / 48){ + return std::format("Expected {:.0f}% of the bar, got {:.0f}%.", 100 * share, 100 * status.hp_fill); } return true; } - -private: - std::string_view m_color; - double m_share; }; - } @@ -394,11 +419,15 @@ void add_tests_AutoStoryItems(UnitTestDatabase& database){ // after "Got away safely!", whose black lower half reads as the battle's // text box (console B, 2026-11-03: 9 minutes of A and B there). database.add("PokemonFRLG/WildEncounterReader/eng_chansey.jpg", true); - database.add("PokemonFRLG/AutoStoryBattle/MtMoonB2F_AfterRun_False.png", false); - database.add("PokemonFRLG/AutoStoryBattle/MtMoon1F_HpGreen_Full.png", "green", 45, 45); - database.add("PokemonFRLG/AutoStoryBattle/MtMoon1F_HpGreen_27of45.png", "green", 27, 45); - database.add("PokemonFRLG/AutoStoryBattle/MtMoonB2F_HpYellow_17of55.png", "yellow", 17, 55); - database.add("PokemonFRLG/AutoStoryBattle/MtMoonB2F_HpYellow_17of55_BarLow.png", "yellow", 17, 55); + database.add("PokemonFRLG/AutoStoryBattle/MtMoonB2F_AfterRun_False.png"); + for (const char* image : { + "MtMoon1F_HpGreen_45of45.png", + "MtMoon1F_HpGreen_27of45.png", + "MtMoonB2F_HpYellow_17of55.png", + "MtMoonB2F_HpYellow_17of55_BarLow.png", + }){ + database.add(std::string("PokemonFRLG/AutoStoryBattle/") + image); + } // The battle's choices, registered here to keep add_tests() within its size. add_tests_AutoStoryParty(database); } From 7e5a54993411477fdb9332101e1cd90d8c17e61e Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 12:30:49 -0800 Subject: [PATCH 06/12] FRLG AutoStory 00.14: Mega Kick from Route 4's tutor on the way to Cerulean From Mt. Moon's exit the row walks up through (48, 5) into the tutors' pen to (50, 4), below the Mega Kick tutor (50, 3), and teaches Mega Kick over Withdraw before going on east as before. The Simulation test walks the row from the exit to the tutor and on to above the ledge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonFRLG_AutoStoryParty.cpp | 2 +- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 42 +++++++++++++++++++ .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 14 ++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp index bc252b5ac5..780d49faaa 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp @@ -95,7 +95,7 @@ constexpr Evolution EVOLUTIONS[] = { // Water Gun where Bubble was (so the attack stays bottom left), Bite over // Tail Whip. TM and tutor moves are not level-up prompts and are not // here; no rule replaces them, so Water Pulse (TM03, 00.14c) and Mega Kick -// (Route 4's tutor, 00.20) are kept over every later level-up move. +// (Route 4's tutor, 00.14) are kept over every later level-up move. struct LearnRule{ std::string_view line; std::string_view move; 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 88d8ebb2b3..f1cddeadec 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -633,6 +633,47 @@ UnitTestResult test_oddish_walks(){ return true; } +// 00.14 on the sim, from Mt. Moon's exit (32, 6): east and up through +// (48, 5) into the tutors' pen on Route 4 to (50, 4), facing the Mega +// Kick tutor (50, 3), then on to above the ledge (93, 8). Each walk ends +// where the table says, and no trainer sees the player on the way. +UnitTestResult test_mega_kick_tutor_walks(){ + 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 ledge = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "above_ledge"; }); + if (ledge == row->steps.end()){ + return "00.14: no above_ledge."; + } + StoryPose at = *story_pose(*row->start); + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + bool at_tutor = false; + for (auto step = row->steps.begin(); step != ledge + 1; ++step){ + if (step->kind != StoryStepKind::ROUTE && step->kind != StoryStepKind::LEGS){ + continue; + } + const std::string failure = row_step_on_sim(sim, at, *step, "00.14"); + if (!failure.empty()){ + return failure; + } + if (!(sim.state().position == MapPosition{at.map, at.tile})){ + return "00.14/" + step->name + ": expected " + to_string(MapPosition{at.map, at.tile}) + ", but on " + to_string(sim.state().position) + "."; + } + if (sim.state().position == MapPosition{"Route4", {50, 4}} && sim.state().facing == Direction::NORTH){ + at_tutor = true; + } + } + if (!at_tutor){ + return "00.14: no walk ended on Route 4's (50, 4) facing the Mega Kick tutor (50, 3)."; + } + if (!sim.state().trainers_beaten.empty() || !sim.state().violations.empty()){ + return "00.14: a trainer's battle or a violation on the way."; + } + return true; +} + // Console B, 2026-11-03 (row 00.18): after his scene Bill stands on // (7, 6), east of the player on (6, 6), not on the maps' (7, 5). The // walk out planned east onto (7, 6) and south to the mat (7, 9); the @@ -699,6 +740,7 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database){ 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); + add("00.14 from Mt. Moon to the Mega Kick tutor, then above the ledge", test_mega_kick_tutor_walks); add("00.16 Nugget Bridge, the walk onto it a tile short", test_nugget_bridge_short); add("00.17 Route 25, its four trainers and Dan", test_route25_trainers); add("00.18 out of Bill's Sea Cottage, Bill on (7, 6)", test_out_of_bill_cottage); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index ee665f8b4e..be5fcf15ad 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -505,6 +505,15 @@ StoryCheckpoint mt_moon(){ // 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). // +// On the way, the Mega Kick tutor (Black Belt (50, 3), facing west, +// Route4_EventScript_MegaKickTutor) stands in a pen (46..51, 2..4) north +// of the road, beside the Mega Punch tutor (47, 3); the pen opens south +// only on (48, 5) and (49, 5), the rest of row 5 ledges out of it. From +// Mt. Moon's exit the walk goes east and up into the pen to (50, 4), +// below the tutor, and Mega Kick takes Withdraw's slot. Cerulean can't +// be walked back to Route 4's upper road without Surf, so it is now or +// much later. +// // 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 @@ -516,7 +525,10 @@ StoryCheckpoint mt_moon(){ // 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, { + const TeachPlan mega_kick{.item = std::string(TEACH_TUTOR), .move = "mega-kick", .member = 0, .forget_move = "withdraw"}; + return row("00.14", "Route 4: Mega Kick from its tutor, then east to Cerulean's Pokemon Center", at(ROUTE4, 32, 6, SOUTH), after_brock(), StartMenu::FULL, { + running(route("mega_kick_tutor", at(ROUTE4, 50, 4, NORTH), "east and up through (48, 5) into the tutors' pen, below the Mega Kick tutor (50, 3)")), + teach_step("teach_mega_kick", "the tutor's Mega Kick in place of Withdraw, unless the lead knows it", mega_kick), 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")), From 7a05318a22ebe1dad012aba293a0f2454a998c1d Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 13:10:14 -0800 Subject: [PATCH 07/12] FRLG AutoStory: hold a tile longer per map connection a leg crosses On console B a hold that crossed a map connection ended a tile short (rows 00.14, 00.16, 00.20): the game holds the player up while it loads the connected map. The overworld sim now models that stall, the planner records each leg's steps onto a connected map (RouteStep::connection_steps), and leg_hold() adds a tile for each stalled step the hold covers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Source/Pokemon/Navigation/Pokemon_LegWalker.cpp | 10 ++++++++-- .../Source/Pokemon/Navigation/Pokemon_LegWalker.h | 8 +++++++- .../Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp | 9 +++++++++ .../Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp | 1 + .../Overworld/PokemonFRLG_OverworldRoute.cpp | 5 +++++ .../Overworld/PokemonFRLG_OverworldRoute.h | 5 +++++ .../Overworld/PokemonFRLG_OverworldSim.cpp | 12 ++++++++++++ .../PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h | 8 ++++++++ .../Overworld/PokemonFRLG_OverworldWalk.cpp | 4 +++- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 6 +++++- 10 files changed, 63 insertions(+), 5 deletions(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp index 84ed2fa37e..0076a03eed 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp @@ -17,10 +17,16 @@ std::string to_string(const LegSpec& leg){ Milliseconds leg_hold(const LegSpec& leg, int tiles){ + const int first = leg.tiles - tiles + 1; + const int stalls = (int)std::count_if( + leg.stalled_steps.begin(), leg.stalled_steps.end(), + [&](int step){ return step >= first && step <= leg.tiles; } + ); + const Milliseconds stalled = leg.tile_time * stalls; if (leg.to_wall){ - return leg.tile_time * (tiles + 2); + return leg.tile_time * (tiles + 2) + stalled; } - return leg.tile_time * (tiles - 1) + leg.tile_time / 2; + return leg.tile_time * (tiles - 1) + leg.tile_time / 2 + stalled; } int tiles_before_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay, int remaining){ diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h index 3993176f26..a8c9c82b3e 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h @@ -84,6 +84,10 @@ struct LegSpec{ // interrupted again right away: something on the way, like a sign, // interrupts every hold, so the wall is never reached. bool stop_if_blocked = false; + // The steps (from 1) the game holds the player up a tile before, as + // FireRed does on a step onto a connected map: each makes a hold over + // it a tile longer. + std::vector stalled_steps = {}; }; std::string to_string(const LegSpec& leg); @@ -103,7 +107,9 @@ struct LegResult{ // How long to hold for `tiles` tiles: an open leg lets go half a tile before // the last step would end, so the step under way finishes and no other -// starts; a leg to a wall is held two tiles longer than it takes. +// starts; a leg to a wall is held two tiles longer than it takes. The last +// `tiles` of the leg are held for, a tile more for each of its stalled +// steps among them. Milliseconds leg_hold(const LegSpec& leg, int tiles); // How many tiles an open leg had walked when an interrupt was seen diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp index 9f9564e275..7ad548ec8c 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp @@ -69,6 +69,15 @@ UnitTestResult test_hold_times(){ if (leg_hold(wall, 4) != TILE * 6){ return std::format("Wall leg: {}ms", leg_hold(wall, 4).count()); } + // A stall on step 2: a tile longer while the hold covers it. + LegSpec stalled = open; + stalled.stalled_steps = {2}; + if (leg_hold(stalled, 4) != TILE * 4 + TILE / 2 || leg_hold(stalled, 3) != TILE * 3 + TILE / 2){ + return std::format("Stalled leg: {}ms, {}ms", leg_hold(stalled, 4).count(), leg_hold(stalled, 3).count()); + } + if (leg_hold(stalled, 2) != TILE + TILE / 2){ + return std::format("Stalled leg past its stall: {}ms", leg_hold(stalled, 2).count()); + } return true; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 4dba0fbe26..109dd87b09 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -37,6 +37,7 @@ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ leg.tile_time = tile_time(step, speed); leg.stop_on_interrupt = !step.to_wall; leg.stop_if_blocked = step.to_wall; + leg.stalled_steps = step.connection_steps; return leg; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 89262939fb..be6898561c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTest.h" @@ -360,6 +361,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); + } return step; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index 900862e44e..9a29b82acb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -64,6 +64,11 @@ struct RouteStep{ Direction facing_after = Direction::NORTH; // Where the player is after the step. MapPosition end; + // WALK: the steps (from 1) onto a connected map. The game holds the + // player up a tile before each while it loads the map: a hold timed + // by the tiles alone ended a tile short (console B, 2026-11-03, rows + // 00.14, 00.16 and 00.20). + std::vector connection_steps = {}; }; std::string to_string(const RouteStep& step); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index f350abf558..113099a1fb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -371,6 +371,15 @@ void OverworldSim::walk_hold(Direction direction, Milliseconds duration, int sho bump(direction, pressed); return; } + if (step->map != m_map && !step->warped){ + // The step onto a connected map waits a tile while the game + // loads it: a hold timed by its tiles alone ended a tile short + // (console B, 2026-11-03, rows 00.14, 00.16 and 00.20). + pressed += tile; + if (pressed >= end){ + return; + } + } if (step->walked_out){ m_state.facing = Direction::SOUTH; } @@ -610,6 +619,9 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, leg.tile_time = TILE_TIME; leg.stop_on_interrupt = !step.to_wall; leg.stop_if_blocked = step.to_wall && m_rules.stop_if_blocked; + if (m_rules.hold_over_connections){ + leg.stalled_steps = step.connection_steps; + } Pokemon::LegCalls calls; calls.hold_watching = [&](Milliseconds hold) -> std::optional{ const std::optional seen = hold_watching(step, hold, ints); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index f7ff383c24..45cf81ce46 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -17,6 +17,9 @@ * with the walker's rules as switches, so a test can show a failure with * the rule that was missing and its absence with the rule. * + * A step onto a connected map waits a tile first, as the console does + * while the game loads the map. + * * Not modelled: Surf, bikes, running, HP and status. Wandering people * stand on their tile. * @@ -276,6 +279,11 @@ struct SimWalkerRules{ // on for, and the step before it taken as stopped part-way, not the // step held from where the battle left the player. bool battle_before_step = true; + // A hold over a step onto a connected map is a tile longer for it + // (RouteStep::connection_steps). Without it the open leg north from + // Cerulean City onto Nugget Bridge ended a tile short (console B, + // 2026-11-03, rows 00.16 and 00.20). + bool hold_over_connections = true; }; // The console side of run_route() on an OverworldSim, as diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 59563a1c16..4c1812e7fe 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -147,7 +147,9 @@ class RepelWoreOffInterrupt : public MessageInterrupt{ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ - return Pokemon::LegSpec{step.direction, step.tiles, step.to_wall, tile_time(step, speed)}; + Pokemon::LegSpec leg{step.direction, step.tiles, step.to_wall, tile_time(step, speed)}; + leg.stalled_steps = step.connection_steps; + return leg; } } 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 f1cddeadec..5fb323786e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -589,7 +589,11 @@ UnitTestResult test_route25_trainers(){ // 00.20 on the sim, its walks from the Center: over Nugget Bridge, whose // trainers 00.16 beat (they stand there, but don't battle), onto Route // 24's grass at (4, 19) out of Camper Shane's sight, and back to the -// nurse. No trainer battles on the way. +// nurse. No trainer battles on the way. The open leg north from Cerulean +// City onto the bridge crosses onto Route 24, where the game holds the +// player up a tile: held for its tiles alone it ended on (11, 16), a tile +// short, and the leg west bumped the bridge's posts (console B, +// 2026-11-03, row 00.20, twice). UnitTestResult test_oddish_walks(){ const std::vector& rows = story_checkpoints(); const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); From b5e3a2e41d57f56b7702e727c1c0262422ad6a42 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 13:16:50 -0800 Subject: [PATCH 08/12] FRLG AutoStory 00.20: mark the Oddish row a stopgap for console B's save A new playthrough should catch a Rattata on Route 1 or 22 early as the Cut user and spare member, and drop this row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index be5fcf15ad..8aa1560b3b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -824,6 +824,11 @@ StoryCheckpoint back_to_cerulean(){ }); } +// Stopgap for console B's save, which caught nothing on Route 1. A new +// playthrough should instead catch a Rattata on Route 1 or 22 early, as +// its Cut user and spare member, and drop this row (and its walk over +// Nugget Bridge and back). +// // An Oddish for HM01 Cut, and a second member behind the lead // (docs/design/AutoStory-FRLG-FutureEvents.md, "Console B run choices"). // Route 24's grass (Route24 map.json: x 3..5, rows 19..31, west of Nugget From 51eb5de1403f3e8eb2f93c85bbff914a5e23269b Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 13:39:41 -0800 Subject: [PATCH 09/12] FRLG AutoStory: plan the walk after pacing from both of its tiles A GRIND or capture paces between its patch and the tile its pace leads to, and the last battle can come on either. Console B (00.20, run m61) caught the Oddish on the far step, (3, 19); the walk back planned from the table's (4, 19) went down Nugget Bridge's column 10 into a beaten Lass and never reached the Center. The pacing step now reports both tiles (paced_pose()), and a ROUTE from a set of tiles first holds to walls that bring them to one, as the walker's re-anchor finds them (holds_to_one_tile()), then plans from there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldReanchor.cpp | 17 ++ .../Overworld/PokemonFRLG_OverworldReanchor.h | 9 + .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 90 ++++++-- .../AutoStory/PokemonFRLG_AutoStoryEvents.h | 15 ++ .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 202 +++++++++++++++++- 5 files changed, 307 insertions(+), 26 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index 4c162db4bf..a087f5a38f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -368,6 +368,23 @@ std::optional plan_reanchor( return std::nullopt; } +std::optional> holds_to_one_tile( + const std::vector& maps, const std::vector& tiles +){ + std::vector positions; + for (const MapPosition& tile : tiles){ + add_unique(positions, Hypothesis{0, tile}); + } + if (positions.empty()){ + return std::nullopt; + } + const std::vector found = find_player({maps}, positions, false); + if (found.empty()){ + return std::nullopt; + } + return found[0].holds; +} + } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index 9037aa83ce..bbd8fe0993 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -104,6 +104,15 @@ std::optional plan_reanchor( const RouteCosts& costs = default_route_costs() ); +// Holds to walls that bring the player to one tile from every one of +// `tiles`, found as plan_reanchor() finds them, with no trainer about: for +// a player known to be on one of a few tiles without a stop (a wild +// battle's pacing). Empty if `tiles` are one tile; the last hold ends on +// it otherwise. Nothing if no holds bring them together. +std::optional> holds_to_one_tile( + const std::vector& maps, const std::vector& tiles +); + void add_tests_OverworldReanchor(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 1d2fb09ff4..00666703bb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -12,6 +12,7 @@ #include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "Pokemon/Navigation/Pokemon_RouteLog.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" @@ -19,6 +20,7 @@ #include "PokemonFRLG_AutoStoryBattleData.h" #include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStorySummary.h" #include "PokemonFRLG_AutoStoryTeachScreens.h" #include "PokemonFRLG_AutoStoryWinChance.h" @@ -88,33 +90,47 @@ WallCheck wall_check(const StoryEnv& env){ return env.checks == StoryChecks::THOROUGH ? WallCheck::TAP : WallCheck::SKIP; } +// Where a ROUTE from `pose` starts: its tile, or nothing for a set of +// tiles (paced_pose()), which the route's first holds bring together. +std::optional route_start(const Pokemon::Pose& pose){ + const std::vector tiles = Pokemon::tiles_of(pose); + if (tiles.size() != 1){ + return std::nullopt; + } + return MapPosition{pose.map, tiles[0]}; +} + Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step){ const std::vector tiles = Pokemon::tiles_of(state.pose); - if (tiles.size() != 1 || state.pose.certainty != Pokemon::PoseCertainty::TILE){ + const bool known = state.pose.certainty == Pokemon::PoseCertainty::TILE + || (state.pose.certainty == Pokemon::PoseCertainty::SET && tiles.size() > 1); + if (tiles.empty() || !known){ return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); } - const MapPosition start{state.pose.map, tiles[0]}; + const std::optional start = route_start(state.pose); std::optional facing = facing_of(state.pose); - if (!facing){ - facing = face_a_wall(env, start); + if (!facing && start){ + facing = face_a_wall(env, *start); } - if (!facing){ - return Pokemon::outcome_failed("unknown_facing", to_string(start)); + if (!facing && start){ + return Pokemon::outcome_failed("unknown_facing", to_string(*start)); } const std::optional target = step.target ? story_pose(*step.target) : std::nullopt; if (!target){ return Pokemon::outcome_failed("bad_target", step.target ? Pokemon::to_string(*step.target) : "none"); } const RouteGoal goal{{target->map, target->tile}, target->facing}; - const std::optional> route = plan_overworld_route( - story_maps(), start, goal, default_route_costs(), facing - ); + const std::optional> route = plan_story_route(story_maps(), state.pose, facing, goal); if (!route){ - return Pokemon::outcome_failed("no_route", to_string(start) + " to " + to_string(goal.position)); + return Pokemon::outcome_failed("no_route", Pokemon::to_string(state.pose) + " to " + to_string(goal.position)); + } + if (!start){ + env.console.log("Story: " + step.name + ": on one of " + Pokemon::to_string(state.pose) + "; holds to walls first, to one tile."); } const OverworldInterruptSet interrupts = story_interrupts(env.supplies, step.fight_wild, env.checks, env.language); // Planning lets the walker recover a stopped open leg: it plans back to - // the leg's end, then walks the rest as given. + // the leg's end, then walks the rest as given. From a set of tiles + // the start isn't known: the walker reads the facing. const RoutePlanning planning{.maps = &story_maps(), .costs = default_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; walk_overworld_route_with_recovery(env.console, env.context, *route, planning, facing, interrupts, move_speed(step)); log_arrival(env, step.name, target->map, target->tile); @@ -395,6 +411,43 @@ std::optional> fixed_route( return route; } +Pokemon::Pose paced_pose(const Pokemon::Pose& patch, const StoryStep& step){ + std::optional pace; + if (const std::optional grind = parse_grind_variant(step.variant)){ + pace = grind->pace; + }else if (const std::optional capture = parse_capture_variant(step.variant)){ + pace = capture->pace; + } + const std::vector tiles = Pokemon::tiles_of(patch); + if (!pace || tiles.size() != 1){ + return patch; + } + return Pokemon::tile_pose(patch.map, {tiles[0], tiles[0].next(*pace)}, std::nullopt); +} + +std::optional> plan_story_route( + const std::vector& maps, const Pokemon::Pose& pose, + std::optional facing, const RouteGoal& goal +){ + std::vector tiles; + for (const TilePos& tile : Pokemon::tiles_of(pose)){ + tiles.push_back(MapPosition{pose.map, tile}); + } + const std::optional> holds = holds_to_one_tile(maps, tiles); + if (!holds){ + return std::nullopt; + } + const MapPosition from = holds->empty() ? tiles[0] : holds->back().end; + if (!holds->empty()){ + facing = holds->back().facing_after; + } + std::optional> route = plan_overworld_route(maps, from, goal, default_route_costs(), facing); + if (route){ + route->insert(route->begin(), holds->begin(), holds->end()); + } + return route; +} + std::optional wall_beside(const MapData& map, const TilePos& tile){ for (Direction direction : {Direction::WEST, Direction::EAST, Direction::NORTH, Direction::SOUTH}){ const TilePos next = tile.next(direction); @@ -538,13 +591,16 @@ Outcome StoryExecutor::game(RunState& state, const StoryStep& step){ } ConsoleCaptureActions capture(m_env.console, m_env.context, m_env.language); ConsolePcStorageActions pc(m_env.console, m_env.context); - const std::optional stored = run_storage_game_step(m_env.console.logger(), &m_env.console, step, capture, pc); - if (stored){ - return *stored; + std::optional done = run_storage_game_step(m_env.console.logger(), &m_env.console, step, capture, pc); + if (!done){ + done = run_abilities(m_env, state, step); + } + if (done && done->succeeded() && !done->pose){ + // Pacing leaves the player on either of its tiles. + done->pose = paced_pose(state.pose, step); } - const std::optional ability = run_abilities(m_env, state, step); - if (ability){ - return *ability; + if (done){ + return *done; } return Pokemon::unsupported_step(step); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index b847e23204..3d3d4de3f5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -160,6 +160,21 @@ std::optional> fixed_route( const std::vector& maps, const MapPosition& start, const std::vector& legs ); +// Where a GRIND or capture `step` that paces (pace = ...) leaves the +// player who started it on `patch`: the patch's tile or the one its pace +// leads to, as the last battle came, facing unknown. `patch` for any +// other step, or a `patch` not on one tile. +Pokemon::Pose paced_pose(const Pokemon::Pose& patch, const StoryStep& step); + +// A ROUTE's legs from `pose` to `goal`: from one tile, as planned; from +// a set of tiles (paced_pose()), first the holds to walls that bring them +// all to one tile (holds_to_one_tile()), then the route from there. +// Nothing if either can't be planned. +std::optional> plan_story_route( + const std::vector& maps, const Pokemon::Pose& pose, + std::optional facing, const RouteGoal& goal +); + // A way the player at `tile` can turn to without stepping: a wall there, // with no one standing in it. West and east first: walking north or south // into a sign would read it. 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 5fb323786e..7e828d7f5a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -19,14 +19,17 @@ #include #include #include +#include #include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTest.h" #include "Pokemon/AutoStory/Pokemon_StorySegments.h" #include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -189,7 +192,10 @@ class FakeGame : public Pokemon::StoryStepExecutor{ virtual Outcome mash(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } virtual Outcome battle(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } virtual Outcome heal(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome game(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } + // Pacing leaves the player on either of its tiles, as on the console. + virtual Outcome game(RunState& state, const StoryStep& step) override{ + return Pokemon::outcome_ok(paced_pose(state.pose, step)); + } // The Viridian Mart's list as the game shows it. After a purchase the // cursor stays on the row bought (console B, 2026-11-01), so each // order moves it from there, as buy_items() does since ad5d40885. @@ -217,6 +223,17 @@ class FakeGame : public Pokemon::StoryStepExecutor{ private: Outcome walk_to(const Pokemon::Pose& pose, const StoryStep& step){ + if (Pokemon::tiles_of(pose).size() > 1){ + // From the tiles a pacing step leaves: the route must plan, and + // test_routes_after_pacing() walks it on the sim from each. + const std::optional target = story_pose(*step.target); + const RouteGoal goal{{target->map, target->tile}, target->facing}; + const std::optional> route = plan_story_route(story_maps(), pose, std::nullopt, goal); + if (!route || route->empty()){ + return Pokemon::outcome_failed("no_route", Pokemon::to_string(pose) + " to " + to_string(goal.position)); + } + return Pokemon::outcome_ok(to_pose(StoryPose{target->map, target->tile, route->back().facing_after})); + } const Standing at = standing(pose); if (!at.error.empty()){ return Pokemon::outcome_failed("no_pose", at.error); @@ -594,12 +611,9 @@ UnitTestResult test_route25_trainers(){ // player up a tile: held for its tiles alone it ended on (11, 16), a tile // short, and the leg west bumped the bridge's posts (console B, // 2026-11-03, row 00.20, twice). -UnitTestResult test_oddish_walks(){ - const std::vector& rows = story_checkpoints(); - const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); - if (row == rows.end()){ - return "No checkpoint 00.20."; - } +// The story maps after 00.16: Nugget Bridge's trainers beaten, standing +// on columns 10 and 12 without battling. +std::vector maps_bridge_beaten(){ std::vector maps = story_maps(); for (MapData& map : maps){ if (map.name != "Route24"){ @@ -611,15 +625,25 @@ UnitTestResult test_oddish_walks(){ } } } + return maps; +} + +UnitTestResult test_oddish_walks(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); + if (row == rows.end()){ + return "No checkpoint 00.20."; + } StoryPose at = *story_pose(*row->start); - OverworldSim sim(maps, MapPosition{at.map, at.tile}, *at.facing); + OverworldSim sim(maps_bridge_beaten(), MapPosition{at.map, at.tile}, *at.facing); const std::map ends{ {"mart_clerk", {"CeruleanCity_Mart", {4, 3}}}, {"oddish_patch", {"Route24", {4, 19}}}, + {"oddish_patch_cliff", {"Route24", {3, 19}}}, {"cerulean_nurse", {"CeruleanCity_PokemonCenter_1F", {7, 4}}}, }; for (const StoryStep& step : row->steps){ - if (step.kind != StoryStepKind::ROUTE){ + if (step.kind != StoryStepKind::ROUTE && step.kind != StoryStepKind::LEGS){ continue; } const std::string failure = row_step_on_sim(sim, at, step, "00.20"); @@ -637,6 +661,164 @@ UnitTestResult test_oddish_walks(){ return true; } +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +// Pacing on the sim as on the console: `pace` and back, a tap to turn +// first when the player faces another way (step_once()). +class SimPatch : public PatchSteps{ +public: + SimPatch(OverworldSim& sim, Direction pace, Direction back) + : m_sim(sim), m_pace(pace), m_back(back) + {} + virtual bool step() override{ + const Direction way = m_displaced ? m_back : m_pace; + if (m_sim.state().facing != way){ + m_sim.tap(way); + } + m_sim.tap(way); + m_displaced = !m_displaced; + return m_sim.watch({SimWatcher::FLASH}, std::chrono::milliseconds(1000)).has_value(); + } + virtual bool battle_shows() override{ + return m_sim.watch({SimWatcher::BATTLE_SCREEN}, std::chrono::milliseconds(5000)).has_value(); + } + virtual bool overworld_shows() override{ + return m_sim.state().screen == SimScreen::OVERWORLD; + } + virtual void dump_screen(std::string_view) override{} +private: + OverworldSim& m_sim; + Direction m_pace; + Direction m_back; + bool m_displaced = false; +}; + +// Walks `route`, planned from the pacing step's tiles by +// plan_story_route(), on `sim`, as walk_route() does: no start tile, the +// facing read. Returns the failure, or empty. +std::string walk_paced_route(OverworldSim& sim, const std::vector& route, const std::string& label){ + const RoutePlanning planning{.maps = &story_maps(), .start = std::nullopt, .label = label}; + const std::string failure = OverworldSimWalker(sim).run(route, planning); + return failure.empty() ? "" : label + ": " + failure + " On " + to_string(sim.state().position) + "."; +} + +// Console B, 2026-11-03 (row 00.20, run m61): the Oddish's battle came +// on the pace's far step, so the capture left the player on (3, 19), not +// the table's (4, 19). The walk back planned from (4, 19) went a column +// west of its plan, down the bridge's column 10, not 11, and stopped +// against the beaten Lass on (10, 22); every later leg bumped, and the +// warp to the Center never came. On the sim, the battle on either step +// of the pace, the row's later walks, from the capture's tiles, reach the +// nurse. +UnitTestResult test_oddish_battle_on_far_step(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); + if (row == rows.end()){ + return "No checkpoint 00.20."; + } + const auto capture = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "catch_oddish"; }); + if (capture == row->steps.end() || capture + 1 == row->steps.end() || !(capture + 1)->target){ + return "00.20: no catch_oddish with a route after it."; + } + const auto patch = std::find_if(row->steps.begin(), capture, [](const StoryStep& step){ return step.name == "oddish_patch"; }); + if (patch == capture || !patch->target){ + return "00.20: no oddish_patch before catch_oddish."; + } + // The sim starts on the patch (4, 19): grass step 1 is the pace's far + // step onto (3, 19), step 2 the one back. + for (int grass_step : {1, 2}){ + SimScript script; + script.encounters.push_back(SimEncounter{.grass_step = grass_step}); + const StoryPose start = *story_pose(*patch->target); + OverworldSim sim(maps_bridge_beaten(), MapPosition{start.map, start.tile}, Direction::SOUTH, script); + const std::string label = std::format("Battle on grass step {}: ", grass_step); + QuietLogger logger; + SimPatch pacing(sim, Direction::WEST, Direction::EAST); + if (!pace_until_battle(logger, pacing, PACE_MAX_STEPS) || sim.handle_battle(SimBattleChoice::RUN) != SimBattleEnd::ENDED){ + return label + "no wild battle on the pace."; + } + const StoryStep& back = *(capture + 1); + StoryPose at = *story_pose(*back.target); + const std::optional> route = plan_story_route( + story_maps(), paced_pose(*patch->target, *capture), std::nullopt, RouteGoal{{at.map, at.tile}, at.facing} + ); + if (!route){ + return label + "no route from the capture's tiles."; + } + if (const std::string failure = walk_paced_route(sim, *route, "00.20/" + back.name); !failure.empty()){ + return label + failure; + } + for (auto step = capture + 2; step != row->steps.end(); ++step){ + const std::string failure = row_step_on_sim(sim, at, *step, "00.20"); + if (!failure.empty()){ + return label + failure; + } + } + if (!(sim.state().position == MapPosition{"CeruleanCity_PokemonCenter_1F", {7, 4}})){ + return label + "00.20 ended on " + to_string(sim.state().position) + ", not the nurse."; + } + } + return true; +} + +// Whether `row`'s step `c` is a GRIND or capture that paces, with a ROUTE +// after it: nothing if not; else the route, planned from the pace's two +// tiles (paced_pose()), walked on the sim from each: the failure, or empty. +std::optional route_after_pacing(const StoryCheckpoint& row, size_t c){ + if (c == 0 || c + 1 >= row.steps.size() || row.steps[c + 1].kind != StoryStepKind::ROUTE || !row.steps[c - 1].target){ + return std::nullopt; + } + const StoryStep& next = row.steps[c + 1]; + const Pokemon::Pose paced = paced_pose(*row.steps[c - 1].target, row.steps[c]); + const std::vector tiles = Pokemon::tiles_of(paced); + if (tiles.size() < 2){ + return std::nullopt; + } + const StoryPose end = *story_pose(*next.target); + const std::optional> route = plan_story_route( + story_maps(), paced, std::nullopt, RouteGoal{{end.map, end.tile}, end.facing} + ); + const std::string where = row.id + "/" + next.name; + if (!route){ + return where + ": no route from " + Pokemon::to_string(paced) + "."; + } + for (const TilePos& tile : tiles){ + const std::string from = "From " + to_string(MapPosition{paced.map, tile}) + ": "; + OverworldSim sim(maps_bridge_beaten(), MapPosition{paced.map, tile}, Direction::SOUTH); + if (const std::string failure = walk_paced_route(sim, *route, where); !failure.empty()){ + return from + failure; + } + if (!(sim.state().position == MapPosition{end.map, end.tile})){ + return from + where + " ended on " + to_string(sim.state().position) + "."; + } + } + return std::string(); +} + +// Every GRIND or capture that paces, and a ROUTE after it: the route +// planned from the pace's two tiles walks on the sim from either to its +// end, its first holds bringing them to one tile. +UnitTestResult test_routes_after_pacing(){ + size_t checked = 0; + for (const StoryCheckpoint& row : story_checkpoints()){ + for (size_t c = 0; c < row.steps.size(); c++){ + const std::optional failure = route_after_pacing(row, c); + if (failure && !failure->empty()){ + return *failure; + } + checked += failure ? 1 : 0; + } + } + if (checked < 3){ + return std::format("Expected the routes after 00.14b's and 00.15's GRINDs and 00.20's capture; checked {}.", checked); + } + return true; +} + // 00.14 on the sim, from Mt. Moon's exit (32, 6): east and up through // (48, 5) into the tutors' pen on Route 4 to (50, 4), facing the Mega // Kick tutor (50, 3), then on to above the ledge (93, 8). Each walk ends @@ -749,6 +931,8 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database){ add("00.17 Route 25, its four trainers and Dan", test_route25_trainers); add("00.18 out of Bill's Sea Cottage, Bill on (7, 6)", test_out_of_bill_cottage); add("00.20 to Route 24's grass and back, past Shane", test_oddish_walks); + add("00.20 back to the Center, the Oddish's battle on the pace's far step", test_oddish_battle_on_far_step); + add("the routes after pacing, from either of its tiles", test_routes_after_pacing); } From 1c22de043d98c2f103fcefb56c71452a9a447279 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 14:19:07 -0800 Subject: [PATCH 10/12] FRLG AutoStory 00.20: walk west to the cliff, out of Shane's sight On console B (m63) the open leg west from Nugget Bridge's head ended a tile short, and the walk down onto the grass came into Camper Shane's sight. The patch is now (3, 19): west to the cliff, down column 3, and the capture paces east. Sim test: the leg west a tile short keeps out of his sight. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 49 +++++++++++++++++-- .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 13 +++-- 2 files changed, 53 insertions(+), 9 deletions(-) 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 7e828d7f5a..53e5b089de 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -638,7 +638,7 @@ UnitTestResult test_oddish_walks(){ OverworldSim sim(maps_bridge_beaten(), MapPosition{at.map, at.tile}, *at.facing); const std::map ends{ {"mart_clerk", {"CeruleanCity_Mart", {4, 3}}}, - {"oddish_patch", {"Route24", {4, 19}}}, + {"oddish_patch", {"Route24", {3, 19}}}, {"oddish_patch_cliff", {"Route24", {3, 19}}}, {"cerulean_nurse", {"CeruleanCity_PokemonCenter_1F", {7, 4}}}, }; @@ -714,6 +714,46 @@ std::string walk_paced_route(OverworldSim& sim, const std::vector& ro // warp to the Center never came. On the sim, the battle on either step // of the pace, the row's later walks, from the capture's tiles, reach the // nurse. +// Console B, 2026-11-03 (m63, 00.20 verify run 2): the open leg west +// from Nugget Bridge's head ended a tile short, so the walk down onto the +// grass ended on (5, 19), in Camper Shane's sight; after the first wild +// battle he walked up ("I saw your feat from the grass!") and the capture +// stopped. From the Mart, the walk to the patch on the sim keeps out of +// his sight with the leg west whole or a tile short. +UnitTestResult test_oddish_patch_west_short(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); + if (row == rows.end()){ + return "No checkpoint 00.20."; + } + const auto mart = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "mart_clerk"; }); + const auto patch = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "oddish_patch"; }); + if (mart == row->steps.end() || patch == row->steps.end() || !mart->target || !patch->target){ + return "00.20: no mart_clerk or oddish_patch."; + } + for (int short_by : {0, 1}){ + StoryPose at = *story_pose(*mart->target); + SimScript script; + // Holds from the Mart: south, west, north, east, north, east, north + // over the bridge, then the 8th, west from the bridge's head. + script.short_holds[8] = short_by; + OverworldSim sim(maps_bridge_beaten(), MapPosition{at.map, at.tile}, *at.facing, script); + const std::string failure = row_step_on_sim(sim, at, *patch, "00.20"); + const std::string label = std::format("West leg {} tile(s) short: ", short_by); + if (!failure.empty()){ + return label + failure; + } + if (!sim.state().trainers_beaten.empty()){ + return label + "a trainer saw the player, on " + to_string(sim.state().position) + "."; + } + const StoryPose end = *story_pose(*patch->target); + if (!(sim.state().position == MapPosition{end.map, end.tile})){ + return label + "ended on " + to_string(sim.state().position) + ", not " + to_string(MapPosition{end.map, end.tile}) + "."; + } + } + return true; +} + UnitTestResult test_oddish_battle_on_far_step(){ const std::vector& rows = story_checkpoints(); const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); @@ -728,8 +768,8 @@ UnitTestResult test_oddish_battle_on_far_step(){ if (patch == capture || !patch->target){ return "00.20: no oddish_patch before catch_oddish."; } - // The sim starts on the patch (4, 19): grass step 1 is the pace's far - // step onto (3, 19), step 2 the one back. + // The sim starts on the patch (3, 19): grass step 1 is the pace's far + // step onto (4, 19), step 2 the one back. for (int grass_step : {1, 2}){ SimScript script; script.encounters.push_back(SimEncounter{.grass_step = grass_step}); @@ -737,7 +777,7 @@ UnitTestResult test_oddish_battle_on_far_step(){ OverworldSim sim(maps_bridge_beaten(), MapPosition{start.map, start.tile}, Direction::SOUTH, script); const std::string label = std::format("Battle on grass step {}: ", grass_step); QuietLogger logger; - SimPatch pacing(sim, Direction::WEST, Direction::EAST); + SimPatch pacing(sim, Direction::EAST, Direction::WEST); if (!pace_until_battle(logger, pacing, PACE_MAX_STEPS) || sim.handle_battle(SimBattleChoice::RUN) != SimBattleEnd::ENDED){ return label + "no wild battle on the pace."; } @@ -932,6 +972,7 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database){ add("00.18 out of Bill's Sea Cottage, Bill on (7, 6)", test_out_of_bill_cottage); add("00.20 to Route 24's grass and back, past Shane", test_oddish_walks); add("00.20 back to the Center, the Oddish's battle on the pace's far step", test_oddish_battle_on_far_step); + add("00.20 onto the grass out of Shane's sight, the leg west a tile short", test_oddish_patch_west_short); add("the routes after pacing, from either of its tiles", test_routes_after_pacing); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 8aa1560b3b..1e60d17d1c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -836,9 +836,12 @@ StoryCheckpoint back_to_cerulean(){ // sRoute24_FireRed (pokefirered wild_encounters.json) has it in 25% of // battles, Lv12..14, as Route 25's grass; Routes 5 and 6 aren't on the // story's maps yet. Up the bridge, whose trainers 00.16 beat, west along -// rows 10..15 and down column 4 to the grass's first row, (4, 19); the -// step paces west to (3, 19) and back. Camper Shane (5, 21) faces north, -// sight 5, over (5, 16..20): column 5 is kept off. +// rows 10..15 to the cliff (3, 15) and down column 3 to the grass's first +// row, (3, 19); the step paces east to (4, 19) and back. Camper Shane +// (5, 21) faces north, sight 5, over (5, 16..20): column 5 is kept off. +// The leg west ends on the cliff, not open: on console B (m63) an open +// leg west to (4, 15) ended a tile short, and the walk down column 5 +// came into Shane's sight. // // First 7 Poke Balls at the Mart (row 1, 200 each; the bag isn't read, so // the step buys them whatever the bag holds). The floor, from 00.15's @@ -861,11 +864,11 @@ StoryCheckpoint oddish(){ .budget = 1400, .money_floor = 1500, }; - const CapturePlan catch_oddish{.species = "oddish", .catches = 1, .max_encounters = 30, .max_balls = 7, .pace = WEST}; + const CapturePlan catch_oddish{.species = "oddish", .catches = 1, .max_encounters = 30, .max_balls = 7, .pace = EAST}; return row("00.20", "Route 24's grass: an Oddish for Cut, then the Center", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { running(route("mart_clerk", at(CERULEAN_MART, 4, 3, WEST), "out of the Center to the Mart's clerk")), mart_step("buy_poke_balls", "7 Poke Balls for the Oddish", balls), - running(route("oddish_patch", at(ROUTE24, 4, 19), "north over Nugget Bridge, west and down column 4 onto the grass's first row, out of Shane's column 5")), + running(route("oddish_patch", at(ROUTE24, 3, 19), "north over Nugget Bridge, west to the cliff and down column 3 onto the grass's first row, out of Shane's column 5")), capture_step("catch_oddish", "wild battles until an Oddish, then Poke Balls; any other species is run from", catch_oddish), running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back over the bridge to the Center")), heal("heal_cerulean"), From 360701b053f294cae5e652e2bcfb60fd25608306 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 14:36:53 -0800 Subject: [PATCH 11/12] FRLG AutoStory: fight a trainer who sees the player while pacing On console B (m63) Camper Shane walked up to the player pacing for the Oddish on Route 24; his message stayed up and pacing ended "no-battle-not-overworld". Pacing now fights a trainer whose message shows, logs it, walks back onto the patch from either pacing tile and goes on. SimPatch moves to its own file with a pace-sim test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG/PokemonFRLG_Tests.cpp | 2 + .../AutoStory/PokemonFRLG_AutoStoryBattle.cpp | 18 ++- .../AutoStory/PokemonFRLG_AutoStoryBattle.h | 10 ++ .../PokemonFRLG_AutoStoryCapture.cpp | 35 ++++- .../AutoStory/PokemonFRLG_AutoStoryCapture.h | 39 ++++- .../PokemonFRLG_AutoStoryCapture_Tests.cpp | 6 + .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 27 +++- .../AutoStory/PokemonFRLG_AutoStoryGrind.cpp | 7 +- .../AutoStory/PokemonFRLG_AutoStoryGrind.h | 3 +- .../PokemonFRLG_AutoStoryPaceSim.cpp | 64 ++++++++ .../AutoStory/PokemonFRLG_AutoStoryPaceSim.h | 58 +++++++ .../PokemonFRLG_AutoStoryPaceSim_Tests.cpp | 145 ++++++++++++++++++ .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 31 +--- SerialPrograms/cmake/SourceFiles.cmake | 3 + 14 files changed, 398 insertions(+), 50 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 18dda426ef..d3730de6f9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -47,6 +47,7 @@ #include "Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h" +#include "Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryTable.h" @@ -740,6 +741,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_PlayerFacingReader(database); add_tests_AutoStory(database); add_tests_AutoStorySimulation(database); + add_tests_AutoStoryPaceSim(database); add_tests_AutoStoryItems(database); add_tests_AutoStoryRngLoop(database); add_tests_AutoStoryCapture(database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 23e10c53b4..77d7330881 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -769,13 +769,7 @@ class StoryMessage : public OverworldInterrupt{ return 400ms; } virtual void clear(ConsoleHandle& console, ProControllerContext& context) const override{ - if (advance_text(console, context, AFTER_BATTLE_TEXT, true) == TextEnd::OVERWORLD){ - return; - } - console.log("Story: a trainer's battle after a message on the overworld."); - fail_on_blackout(console, fight_battle(console, context, m_supplies, BattleKind::TRAINER, m_checks, m_language)); - mash_until_overworld(console, context, AFTER_BATTLE_TEXT); - recover_after_battle(console, context, m_supplies, m_checks, m_language); + clear_overworld_message(console, context, m_supplies, m_checks, m_language); } private: @@ -978,6 +972,16 @@ BattleEnd fight_battle( return BattleLoop(console, context, supplies, kind, checks, language).run(); } +void clear_overworld_message(ConsoleHandle& console, ProControllerContext& context, Supplies& supplies, StoryChecks checks, Language language){ + if (advance_text(console, context, AFTER_BATTLE_TEXT, true) == TextEnd::OVERWORLD){ + return; + } + console.log("Story: a trainer's battle after a message on the overworld."); + fail_on_blackout(console, fight_battle(console, context, supplies, BattleKind::TRAINER, checks, language)); + mash_until_overworld(console, context, AFTER_BATTLE_TEXT); + recover_after_battle(console, context, supplies, checks, language); +} + OverworldInterruptSet story_interrupts(Supplies& supplies, bool fight_wild, StoryChecks checks, Language language){ // Battles first: their screens are checked before the message box's. OverworldInterruptSet ret; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h index 50cffc65a4..81b2a67341 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h @@ -196,6 +196,16 @@ void recover_after_battle( ); +// From a message on the overworld: advances it until the overworld is back +// and, when a battle starts after it (a trainer who saw the player), +// fights it and recovers after it. A battle lost (a blackout) throws +// OperationFailedException. +void clear_overworld_message( + ConsoleHandle& console, ProControllerContext& context, + Supplies& supplies, StoryChecks checks, Language language +); + + // The interrupts for walking story routes. Wild battles are run from, or // fought if `fight_wild`. A message is advanced until the overworld is // back; a battle that starts after it (a trainer who saw the player) is diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp index d4ca584649..b489c715d5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp @@ -12,6 +12,7 @@ #include "CommonTools/Async/InferenceRoutines.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_BattleDialogs.h" +#include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_BagDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h" @@ -448,6 +449,13 @@ bool pace_until_battle(Logger& logger, PatchSteps& steps, size_t max_steps){ logger.log(std::format("Pacing: a battle shows after step {}; its flash was missed.", c + 1), COLOR_ORANGE); return true; } + // Console B, 2026-11-03 (m63): Camper Shane on Route 24 saw the + // player pace in his sight and walked up; his message stayed up + // while the rest of the steps paced in vain. + if (steps.message_shows()){ + const std::string trainer = steps.fight_trainer(); + logger.log(std::format("Pacing: trainer battle during pacing: {}, after step {}.", trainer, c + 1), COLOR_ORANGE); + } } if (steps.overworld_shows()){ return false; @@ -462,11 +470,15 @@ bool pace_until_battle(Logger& logger, PatchSteps& steps, size_t max_steps){ } -ConsolePatchSteps::ConsolePatchSteps(ConsoleHandle& console, ProControllerContext& context, Direction pace, PaceSpot& spot) +ConsolePatchSteps::ConsolePatchSteps( + ConsoleHandle& console, ProControllerContext& context, Direction pace, PaceSpot& spot, + const PaceTrainerBattle& trainer_battle +) : m_console(console) , m_context(context) , m_pace(pace) , m_spot(spot) + , m_trainer_battle(trainer_battle) {} bool ConsolePatchSteps::step(){ @@ -493,21 +505,38 @@ bool ConsolePatchSteps::overworld_shows(){ return PokemonFRLG::overworld_shows(m_console, m_context); } +bool ConsolePatchSteps::message_shows(){ + m_context.wait_for_all_requests(); + VideoSnapshot screen = m_console.video().snapshot(); + return screen && WhiteDialogDetector(COLOR_RED).detect(screen); +} + +std::string ConsolePatchSteps::fight_trainer(){ + m_trainer_battle(); + // Back on the patch's tile, facing as the walk back left it. + m_spot = {}; + return "a trainer"; +} + void ConsolePatchSteps::dump_screen(std::string_view label){ m_context.wait_for_all_requests(); dump_debug_image(m_console, "AutoStory/pace", std::string(label), m_console.video().snapshot()); } -ConsoleCaptureActions::ConsoleCaptureActions(ConsoleHandle& console, ProControllerContext& context, Language language) +ConsoleCaptureActions::ConsoleCaptureActions( + ConsoleHandle& console, ProControllerContext& context, Language language, + PaceTrainerBattle trainer_battle +) : m_console(console) , m_context(context) , m_language(language) + , m_trainer_battle(std::move(trainer_battle)) {} bool ConsoleCaptureActions::find_encounter(Direction pace){ PaceSpot spot; - ConsolePatchSteps steps(m_console, m_context, pace, spot); + ConsolePatchSteps steps(m_console, m_context, pace, spot, m_trainer_battle); return pace_until_battle(m_console.logger(), steps, PACE_MAX_STEPS); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h index 17a8017b84..f95fc2b71e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h @@ -24,6 +24,7 @@ #define PokemonAutomation_PokemonFRLG_AutoStoryCapture_H #include +#include #include #include #include @@ -215,27 +216,49 @@ class PatchSteps{ virtual bool battle_shows() = 0; // Whether the overworld is up, with no text or menu open. virtual bool overworld_shows() = 0; + // Whether a message shows on the overworld: a trainer who saw the + // player walked up and speaks. + virtual bool message_shows() = 0; + // From that message: closes it, fights the trainer's battle after it, + // and stands the player back on the patch's tile, from either tile + // pacing may have left them on. Returns who was fought, for the log. + virtual std::string fight_trainer() = 0; // Saves the screen as a debug image, named `label`. virtual void dump_screen(std::string_view label) = 0; }; // From the overworld, in the patch: steps until a wild battle starts, -// `max_steps` at most. True once one has. +// `max_steps` at most. True once one has. A trainer who sees the player +// meanwhile is fought (PatchSteps::fight_trainer()) and pacing goes on; +// their battle isn't a wild one. bool pace_until_battle(Logger& logger, PatchSteps& steps, size_t max_steps); // Steps in a patch before pacing gives up: about 10 % of steps in grass // start a battle at Route 4's rates. constexpr size_t PACE_MAX_STEPS = 200; +// On the console, from a trainer's message during pacing: closes it, +// fights the battle after it and walks the player back onto the patch's +// tile, planned from both of pacing's tiles. While a trainer walks up the +// player can't move, so the steps pacing took meanwhile went nowhere and +// the spot pacing kept can be a tile off. A battle lost throws, as on a +// route. +using PaceTrainerBattle = std::function; + // The console's PatchSteps: `pace` and back from `spot`, which each step -// updates. +// updates; a trainer is fought by `trainer_battle`. class ConsolePatchSteps : public PatchSteps{ public: - ConsolePatchSteps(ConsoleHandle& console, ProControllerContext& context, Pokemon::Direction pace, PaceSpot& spot); + ConsolePatchSteps( + ConsoleHandle& console, ProControllerContext& context, Pokemon::Direction pace, PaceSpot& spot, + const PaceTrainerBattle& trainer_battle + ); virtual bool step() override; virtual bool battle_shows() override; virtual bool overworld_shows() override; + virtual bool message_shows() override; + virtual std::string fight_trainer() override; virtual void dump_screen(std::string_view label) override; private: @@ -243,6 +266,7 @@ class ConsolePatchSteps : public PatchSteps{ ProControllerContext& m_context; Pokemon::Direction m_pace; PaceSpot& m_spot; + const PaceTrainerBattle& m_trainer_battle; }; @@ -250,10 +274,14 @@ class ConsolePatchSteps : public PatchSteps{ // shiny hunt finds them, a shiny heard as the shiny hunts hear it // (handle_encounter()), and runs as they run (flee_battle()). The wild // Pokemon's HP bar has no reader yet, so it reads UNKNOWN and nothing is -// weakened on the console. +// weakened on the console. A trainer who sees the player while pacing is +// fought by `trainer_battle`. class ConsoleCaptureActions : public CaptureActions{ public: - ConsoleCaptureActions(ConsoleHandle& console, ProControllerContext& context, Language language); + ConsoleCaptureActions( + ConsoleHandle& console, ProControllerContext& context, Language language, + PaceTrainerBattle trainer_battle + ); virtual bool find_encounter(Pokemon::Direction pace) override; virtual EncounterSeen read_encounter() override; @@ -272,6 +300,7 @@ class ConsoleCaptureActions : public CaptureActions{ ConsoleHandle& m_console; ProControllerContext& m_context; Language m_language; + PaceTrainerBattle m_trainer_battle; }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp index f052619db5..982d499ee0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp @@ -547,6 +547,12 @@ struct FakePatch : public PatchSteps{ virtual bool overworld_shows() override{ return !battle_started() && overworld; } + virtual bool message_shows() override{ + return false; + } + virtual std::string fight_trainer() override{ + return ""; + } virtual void dump_screen(std::string_view label) override{ dumps.emplace_back(label); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 00666703bb..e0db491cfa 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -560,6 +560,25 @@ bool grind_heal_trip(StoryEnv& env, RunState& state, const StoryStep& step, cons return walked(env, state, walk); } +// A trainer saw the player pacing `step` on `patch` (console B, +// 2026-11-03, m63: Camper Shane on Route 24): fights them, then walks back +// onto the patch from either of pacing's tiles (see PaceTrainerBattle). +void pace_trainer_battle(StoryEnv& env, RunState& state, const StoryStep& step, const Pokemon::Pose& patch){ + clear_overworld_message(env.console, env.context, env.supplies, env.checks, env.language); + state.pose = paced_pose(patch, step); + StoryStep walk; + walk.kind = StoryStepKind::ROUTE; + walk.name = step.name + "_back_after_trainer"; + walk.target = patch; + if (!walked(env, state, walk)){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "Pacing: no way back onto the patch after a trainer's battle.", + env.console + ); + } +} + // A MART, TEACH, LEVEL, GRIND or fossil step on the console; nothing for // another variant. std::optional run_abilities(StoryEnv& env, RunState& state, const StoryStep& step){ @@ -571,6 +590,9 @@ std::optional run_abilities(StoryEnv& env, RunState& state, const Story env.console, env.context, env.language, env.supplies, env.checks, [&](const GrindSpot& spot){ return grind_heal_trip(env, state, step, patch, spot); + }, + [&]{ + pace_trainer_battle(env, state, step, patch); } ); ConsoleChoiceActions choice(env.console, env.context); @@ -589,7 +611,10 @@ Outcome StoryExecutor::game(RunState& state, const StoryStep& step){ leave_gift_summary(m_env.console, m_env.context); return Pokemon::outcome_ok(); } - ConsoleCaptureActions capture(m_env.console, m_env.context, m_env.language); + const Pokemon::Pose patch = state.pose; + ConsoleCaptureActions capture(m_env.console, m_env.context, m_env.language, [&]{ + pace_trainer_battle(m_env, state, step, patch); + }); ConsolePcStorageActions pc(m_env.console, m_env.context); std::optional done = run_storage_game_step(m_env.console.logger(), &m_env.console, step, capture, pc); if (!done){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp index 132e54b268..f7d8eabf4f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp @@ -316,21 +316,22 @@ std::optional healed_win_chance(PartyMember lead, const SimItems& i ConsoleGrindActions::ConsoleGrindActions( ConsoleHandle& console, ProControllerContext& context, Language language, - Supplies& supplies, StoryChecks checks, HealTrip heal_trip + Supplies& supplies, StoryChecks checks, HealTrip heal_trip, PaceTrainerBattle trainer_battle ) : m_console(console) , m_context(context) , m_language(language) , m_supplies(supplies) , m_checks(checks) - , m_encounters(console, context, language) + , m_encounters(console, context, language, trainer_battle) , m_heal_trip(std::move(heal_trip)) + , m_trainer_battle(std::move(trainer_battle)) {} bool ConsoleGrindActions::find_encounter(Direction pace){ // As ConsoleCaptureActions::find_encounter(), keeping track of the // tile: a battle starts on the tile just stepped onto. - ConsolePatchSteps steps(m_console, m_context, pace, m_spot); + ConsolePatchSteps steps(m_console, m_context, pace, m_spot, m_trainer_battle); return pace_until_battle(m_console.logger(), steps, PACE_MAX_STEPS); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h index 7487bbc861..d197be0478 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h @@ -175,7 +175,7 @@ class ConsoleGrindActions : public GrindActions{ ConsoleGrindActions( ConsoleHandle& console, ProControllerContext& context, Language language, - Supplies& supplies, StoryChecks checks, HealTrip heal_trip + Supplies& supplies, StoryChecks checks, HealTrip heal_trip, PaceTrainerBattle trainer_battle ); virtual bool find_encounter(Pokemon::Direction pace) override; @@ -195,6 +195,7 @@ class ConsoleGrindActions : public GrindActions{ StoryChecks m_checks; ConsoleCaptureActions m_encounters; HealTrip m_heal_trip; + PaceTrainerBattle m_trainer_battle; GrindSpot m_spot; }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp new file mode 100644 index 0000000000..be9c617b07 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp @@ -0,0 +1,64 @@ +/* AutoStory Pace Sim + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "PokemonFRLG_AutoStoryPaceSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using Pokemon::Direction; + + +SimPatch::SimPatch(OverworldSim& sim, Direction pace, Direction back, std::function back_to_patch) + : m_sim(sim) + , m_pace(pace) + , m_back(back) + , m_back_to_patch(std::move(back_to_patch)) +{} + +bool SimPatch::step(){ + const Direction way = m_displaced ? m_back : m_pace; + if (m_sim.state().facing != way){ + m_sim.tap(way); + } + m_sim.tap(way); + m_displaced = !m_displaced; + return m_sim.watch({SimWatcher::FLASH}, std::chrono::milliseconds(1000)).has_value(); +} + +bool SimPatch::battle_shows(){ + return m_sim.watch({SimWatcher::BATTLE_SCREEN}, std::chrono::milliseconds(5000)).has_value(); +} + +bool SimPatch::overworld_shows(){ + return m_sim.state().screen == SimScreen::OVERWORLD; +} + +bool SimPatch::message_shows(){ + return m_sim.state().screen == SimScreen::MESSAGE; +} + +std::string SimPatch::fight_trainer(){ + if (!m_sim.close_message() || m_sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ + return "no trainer's battle"; + } + if (m_back_to_patch){ + m_back_to_patch(); + m_displaced = false; + } + return m_sim.state().trainers_beaten.back(); +} + +void SimPatch::dump_screen(std::string_view){} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h new file mode 100644 index 0000000000..8e636c4af7 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h @@ -0,0 +1,58 @@ +/* AutoStory Pace Sim + * + * From: https://github.com/PokemonAutomation/ + * + * Pacing in a patch (PatchSteps) on the OverworldSim, as the console + * paces: `pace` and back, a tap to turn first when the player faces + * another way (step_once()). A trainer who sees the player is fought, then + * the test's walk back onto the patch runs, as pace_trainer_battle() does + * on the console. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryPaceSim_H +#define PokemonAutomation_PokemonFRLG_AutoStoryPaceSim_H + +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryCapture.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +class SimPatch : public PatchSteps{ +public: + // `back_to_patch`: after a trainer's battle, the walk back onto the + // patch's tile; none: the player stays where the trainer stopped them. + SimPatch(OverworldSim& sim, Pokemon::Direction pace, Pokemon::Direction back, std::function back_to_patch = {}); + + virtual bool step() override; + virtual bool battle_shows() override; + virtual bool overworld_shows() override; + virtual bool message_shows() override; + virtual std::string fight_trainer() override; + virtual void dump_screen(std::string_view label) override; + +private: + OverworldSim& m_sim; + Pokemon::Direction m_pace; + Pokemon::Direction m_back; + std::function m_back_to_patch; + bool m_displaced = false; +}; + + +void add_tests_AutoStoryPaceSim(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp new file mode 100644 index 0000000000..53b93cd456 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp @@ -0,0 +1,145 @@ +/* AutoStory Pace Sim Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Pacing on the OverworldSim (SimPatch) where something other than a wild + * battle stops it. + * + */ + +#include +#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_AutoStoryPaceSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using Pokemon::Direction; + +std::string joined(const std::vector& lines){ + std::string text; + for (const std::string& line : lines){ + text += "\n " + line; + } + return text; +} + +class LinesLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string& line, Color) override{ lines.push_back(line); } + std::vector lines; +}; + +// A capture on the sim: battles found by pacing `patch`, each the target, +// caught with the first ball. +class SimCapture : public CaptureActions{ +public: + SimCapture(Logger& logger, OverworldSim& sim, SimPatch& patch) + : m_logger(logger), m_sim(sim), m_patch(patch) + {} + virtual bool find_encounter(Direction) override{ + return pace_until_battle(m_logger, m_patch, PACE_MAX_STEPS); + } + virtual EncounterSeen read_encounter() override{ return EncounterSeen{.species = "oddish"}; } + virtual bool flee() override{ return m_sim.handle_battle(SimBattleChoice::RUN) == SimBattleEnd::ENDED; } + virtual FoeHp read_foe_hp() override{ return FoeHp::UNKNOWN; } + virtual TurnEnd attack(size_t) override{ return TurnEnd::FAILED; } + virtual ThrowEnd throw_ball() override{ return ThrowEnd::CAUGHT; } + virtual bool finish_catch(CatchDestination) override{ + return m_sim.handle_battle(SimBattleChoice::FIGHT) == SimBattleEnd::ENDED; + } +private: + Logger& m_logger; + OverworldSim& m_sim; + SimPatch& m_patch; +}; + +// Console B, 2026-11-03 (m63, 00.20 run 2): the capture paced on Route +// 24's (5, 19), in Camper Shane's sight; after the first wild battle he +// walked up ("I saw your feat from the grass!"), and pacing ended +// "no-battle-not-overworld" under his message. On the sim, a capture +// pacing east from (4, 19) onto (5, 19) fights him, walks back onto the +// patch from either tile, as pace_trainer_battle() does, and goes on to +// its catch. +UnitTestResult test_capture_paced_in_trainer_sight(){ + const MapPosition patch{"Route24", {4, 19}}; + const CapturePlan plan{.species = "oddish", .max_encounters = 5, .pace = Direction::EAST}; + const StoryStep capture = capture_step("catch_in_sight", "", plan); + SimScript script; + // The first step onto (5, 19) is seen; the battle comes on a later one. + script.encounters.push_back(SimEncounter{.grass_step = 3}); + OverworldSim sim(story_maps(), patch, Direction::SOUTH, script); + std::string walk_failure; + SimPatch pacing(sim, Direction::EAST, Direction::WEST, [&]{ + const Pokemon::Pose paced = paced_pose(Pokemon::tile_pose(patch.map, {patch.point}), capture); + const std::optional> route = plan_story_route( + story_maps(), paced, std::nullopt, RouteGoal{patch, std::nullopt} + ); + const RoutePlanning planning{.maps = &story_maps(), .start = std::nullopt, .label = "back_after_trainer"}; + walk_failure = route ? OverworldSimWalker(sim).run(*route, planning) : "no route back onto the patch."; + }); + LinesLogger logger; + SimCapture actions(logger, sim, pacing); + const CaptureResult result = run_capture(logger, actions, plan, 2); + if (result.end != CaptureEnd::CAUGHT || !walk_failure.empty()){ + return capture_message(plan, result) + " " + walk_failure + joined(logger.lines) + joined(sim.state().log); + } + if (sim.state().trainers_beaten != std::vector{"camper"} || !sim.state().violations.empty()){ + return std::format("Expected Camper Shane beaten and no violations; got {} beaten.", sim.state().trainers_beaten.size()) + + joined(sim.state().violations); + } + const bool logged = std::any_of(logger.lines.begin(), logger.lines.end(), [](const std::string& line){ + return line.starts_with("Pacing: trainer battle during pacing: camper"); + }); + if (!logged || result.encounters != 1){ + return std::format("Expected the trainer's battle logged and 1 wild encounter, got {}.", result.encounters) + joined(logger.lines); + } + return true; +} + + +class Test_AutoStoryPaceSim : public UnitTest{ +public: + Test_AutoStoryPaceSim(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStory - Pace Sim: " + 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; +}; + +} + + +void add_tests_AutoStoryPaceSim(UnitTestDatabase& database){ + database.add( + "a capture paced in a trainer's sight fights them and goes on", test_capture_paced_in_trainer_sight + ); +} + + + +} +} +} 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 53e5b089de..c53027b24b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -31,6 +31,7 @@ #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryPaceSim.h" #include "PokemonFRLG_AutoStoryTable.h" namespace PokemonAutomation{ @@ -667,36 +668,6 @@ class QuietLogger : public Logger{ virtual void log(const std::string&, Color) override{} }; -// Pacing on the sim as on the console: `pace` and back, a tap to turn -// first when the player faces another way (step_once()). -class SimPatch : public PatchSteps{ -public: - SimPatch(OverworldSim& sim, Direction pace, Direction back) - : m_sim(sim), m_pace(pace), m_back(back) - {} - virtual bool step() override{ - const Direction way = m_displaced ? m_back : m_pace; - if (m_sim.state().facing != way){ - m_sim.tap(way); - } - m_sim.tap(way); - m_displaced = !m_displaced; - return m_sim.watch({SimWatcher::FLASH}, std::chrono::milliseconds(1000)).has_value(); - } - virtual bool battle_shows() override{ - return m_sim.watch({SimWatcher::BATTLE_SCREEN}, std::chrono::milliseconds(5000)).has_value(); - } - virtual bool overworld_shows() override{ - return m_sim.state().screen == SimScreen::OVERWORLD; - } - virtual void dump_screen(std::string_view) override{} -private: - OverworldSim& m_sim; - Direction m_pace; - Direction m_back; - bool m_displaced = false; -}; - // Walks `route`, planned from the pacing step's tiles by // plan_story_route(), on `sim`, as walk_route() does: no start tile, the // facing read. Returns the failure, or empty. diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 7dfc7c1c28..78f1daf405 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1882,6 +1882,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp From 356b927f7171d77201ce463e4c1aa160f14cb0d8 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 02:11:32 -0800 Subject: [PATCH 12/12] FRLG AutoStory: keep the simulation tests' line endings The merge of the start pose check turned 22 untouched LF lines to CRLF. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) 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 46c5167976..5bef3cf9e7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -328,36 +328,36 @@ struct Simulation{ }; -struct AutoStorySimulationInput{ - std::string name; - UnitTestResult (*body)(); -}; - -struct AutoStorySimulationExpectation{ - const char* description; -}; - -struct AutoStorySimulationTestCase{ - AutoStorySimulationInput input; - AutoStorySimulationExpectation expectation; -}; - +struct AutoStorySimulationInput{ + std::string name; + UnitTestResult (*body)(); +}; + +struct AutoStorySimulationExpectation{ + const char* description; +}; + +struct AutoStorySimulationTestCase{ + AutoStorySimulationInput input; + AutoStorySimulationExpectation expectation; +}; + class Test_AutoStorySimulation : public UnitTest{ public: - explicit Test_AutoStorySimulation(AutoStorySimulationTestCase scenario) - : UnitTest("PokemonFRLG::AutoStory - Simulation: " + scenario.input.name) - , m_scenario(std::move(scenario)) + explicit Test_AutoStorySimulation(AutoStorySimulationTestCase scenario) + : UnitTest("PokemonFRLG::AutoStory - Simulation: " + scenario.input.name) + , m_scenario(std::move(scenario)) {} virtual UnitTestResult run(Logger&, CancellableScope&) const override{ try{ - return m_scenario.input.body(); + return m_scenario.input.body(); }catch (Exception& e){ return std::string(e.name()) + ": " + e.message(); } } private: - AutoStorySimulationTestCase m_scenario; + AutoStorySimulationTestCase m_scenario; }; @@ -993,6 +993,6 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database){ -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation