From 71bfb90f5e0cdd755e627d1e1f46aeaa0ea1aae3 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 10:12:09 -0800 Subject: [PATCH 01/24] FRLG AutoStory 00.28-00.29: through Rock Tunnel to Lavender Town's Center 00.28 goes from Route 10's Pokemon Center through the tree (10, 20) into Rock Tunnel, fights the nine trainers the way can't go round (Cooper by the walker on a leg to a wall), and leaves by the south mouth onto Route 10, where it saves. 00.29 goes down Route 10's east side, hops the ledge (11, 74) and walks into Lavender Town to its nurse. Tunnel legs end on walls, ladders or people; open legs are followed only by legs a tile of error can't send astray. Maps: the FRLG map resource lock moves to PokemonSimulation 6c2f5c8 (dayfine/PokemonSimulation#80), which adds RockTunnel_1F/B1F, LavenderTown and its Center, decomp only. Lavender Town is a save card place. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Menus/PokemonFRLG_SaveCardReader.cpp | 1 + .../Menus/PokemonFRLG_SaveCardReader.h | 3 +- .../Overworld/PokemonFRLG_OverworldMaps.cpp | 5 + .../PokemonFRLG/PokemonFRLG_Tests.cpp | 1 + .../PokemonFRLG_AutoStoryBattleData.cpp | 11 + .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 1 + .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 240 ++++++++++++++++++ .../PokemonFRLG_AutoStoryRunPlan_Tests.cpp | 8 +- .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 2 +- .../AutoStory/PokemonFRLG_AutoStoryTable.h | 2 + .../PokemonFRLG_AutoStoryTableLavender.cpp | 131 ++++++++++ .../PokemonFRLG_AutoStoryTableSteps.h | 12 + .../AutoStory/PokemonFRLG_AutoStory_Tests.cpp | 11 +- SerialPrograms/cmake/SourceFiles.cmake | 2 + devtools/maps/map-resources.lock.json | 4 +- 15 files changed, 421 insertions(+), 13 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp index af82fde0ea..995ae469e7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp @@ -74,6 +74,7 @@ class PlaceNameMatcher : public OCR::SmallDictionaryMatcher{ {"route-24", "Route 24"}, {"route-25", "Route 25"}, {"route-10", "Route 10"}, + {"lavender-town", "Lavender Town"}, {"vermilion-city", "Vermilion City"}, }){ JsonArray names; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h index f693d02977..68e82bec51 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h @@ -33,7 +33,8 @@ ImageFloatBox save_card_title_box(); // The place on the save card: "pallet-town", "viridian-city", // "viridian-forest", "pewter-city", "route-1" to "route-4", "cerulean-city", -// "route-10", "route-24", "route-25", "vermilion-city"; empty if it can't be read, as +// "route-10", "route-24", "route-25", "vermilion-city", +// "lavender-town"; empty if it can't be read, as // in a language the reader has no names for. std::string read_save_card_location(Logger& logger, Language language, const ImageViewRGB32& screen); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp index 2735ec7ae4..35934b78c3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -78,6 +78,11 @@ const char* const MAP_NAMES[] = { "Route9", "Route10", "Route10_PokemonCenter_1F", + // Rock Tunnel to Lavender Town's Pokemon Center. + "RockTunnel_1F", + "RockTunnel_B1F", + "LavenderTown", + "LavenderTown_PokemonCenter_1F", "CeruleanCave_1F", "CeruleanCave_2F", "CeruleanCave_B1F", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 2bf7183092..27726c1b9d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -895,6 +895,7 @@ static void add_tests_AutoStoryRows(UnitTestDatabase& database){ add_tests_AutoStoryVermilion(database); add_tests_AutoStoryVermilionGym(database); add_tests_AutoStoryCeladon(database); + add_tests_AutoStoryLavender(database); } void add_tests(UnitTestDatabase& database){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index 5442216c83..0deab3ef7c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -75,6 +75,17 @@ constexpr StoryTrainer STORY_TRAINERS[] = { {.map = "Route9", .battle = "picnicker_alicia", .trainer = "TRAINER_PICNICKER_ALICIA"}, {.map = "Route9", .battle = "hiker_jeremy", .trainer = "TRAINER_HIKER_JEREMY"}, {.map = "Route9", .battle = "bug_catcher_conner", .trainer = "TRAINER_BUG_CATCHER_CONNER"}, + // Rock Tunnel's nine the way can't walk around (RockTunnel_1F, + // RockTunnel_B1F scripts.inc). + {.map = "RockTunnel_1F", .battle = "pokemaniac_ashton", .trainer = "TRAINER_POKEMANIAC_ASHTON"}, + {.map = "RockTunnel_B1F", .battle = "pokemaniac_winston", .trainer = "TRAINER_POKEMANIAC_WINSTON"}, + {.map = "RockTunnel_B1F", .battle = "picnicker_martha", .trainer = "TRAINER_PICNICKER_MARTHA"}, + {.map = "RockTunnel_B1F", .battle = "hiker_allen", .trainer = "TRAINER_HIKER_ALLEN"}, + {.map = "RockTunnel_1F", .battle = "hiker_lenny", .trainer = "TRAINER_HIKER_LENNY"}, + {.map = "RockTunnel_B1F", .battle = "picnicker_sofia", .trainer = "TRAINER_PICNICKER_SOFIA"}, + {.map = "RockTunnel_B1F", .battle = "hiker_dudley", .trainer = "TRAINER_HIKER_DUDLEY"}, + {.map = "RockTunnel_B1F", .battle = "pokemaniac_cooper", .trainer = "TRAINER_POKEMANIAC_COOPER"}, + {.map = "RockTunnel_1F", .battle = "picnicker_dana", .trainer = "TRAINER_PICNICKER_DANA"}, }; using JsonTable = std::map>; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index d06a03851a..00fdd207dd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -395,6 +395,7 @@ std::string save_card_place(const std::string& map){ {"Route25", "route-25"}, {"VermilionCity", "vermilion-city"}, {"Route10", "route-10"}, + {"LavenderTown", "lavender-town"}, }; for (const auto& [prefix, place] : PLACES){ if (map == prefix || map.rfind(prefix + "_", 0) == 0){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp new file mode 100644 index 0000000000..65ad046355 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -0,0 +1,240 @@ +/* AutoStory Lavender Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Rows from 00.28 on (PokemonFRLG_AutoStoryTableLavender.cpp) on the + * overworld sim: 00.28 from Route 10's Pokemon Center through Rock Tunnel + * to its south mouth, and 00.29 down Route 10 to Lavender Town's Center. + * Each row whole, each open leg a tile short or long, and a wild battle + * on every open leg in the tunnel. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct AutoStoryLavenderInput{ + std::string name; + std::function body; +}; + +struct AutoStoryLavenderExpectation{ +}; + +struct AutoStoryLavenderCase{ + AutoStoryLavenderInput input; + AutoStoryLavenderExpectation expectation; +}; + +class Test_AutoStoryLavender : public UnitTest{ +public: + explicit Test_AutoStoryLavender(AutoStoryLavenderCase test_case) + : UnitTest("PokemonFRLG::AutoStory - Simulation: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_case.input.body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + AutoStoryLavenderCase m_case; +}; + +const StoryCheckpoint& checkpoint(std::string_view id){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [&](const StoryCheckpoint& data){ return data.id == id; }); + if (row == rows.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::format("No checkpoint {}.", id)); + } + return *row; +} + +// The maps as 00.27 leaves them: the story's flags, Rock Tunnel's and +// Route 10's trainers unfought. +std::vector maps_after_route10(){ + const std::vector all = overworld_maps(); + std::set flags = map_flag_set(all, "after_hall_of_fame"); + flags.merge(map_flag_set(all, "new_game")); + flags.erase("FLAG_HIDE_VERMILION_CITY_OAKS_AIDE"); + return overworld_maps(flags); +} + +// Row `id`'s steps from `first` on, on `sim` from `at`: the failure, or +// empty once on the row's last route's target. +std::string row_rest(OverworldSim& sim, StoryPose at, const StoryCheckpoint& row, size_t first){ + std::string failure; + for (size_t c = first; c < row.steps.size() && failure.empty(); c++){ + failure = row_step_on_sim(sim, at, row.steps[c], row.id); + } + if (!failure.empty()){ + return std::format("ended on {}; {}", to_string(sim.state().position), failure); + } + return ""; +} + +UnitTestResult row_on_sim(std::string_view id, const SimScript& script, const std::vector& trainers, const MapPosition& end){ + const StoryCheckpoint& row = checkpoint(id); + const StoryPose at = *story_pose(*row.start); + OverworldSim sim(maps_after_route10(), MapPosition{at.map, at.tile}, *at.facing, script); + if (const std::string failure = row_rest(sim, at, row, 0); !failure.empty()){ + return std::string(id) + ": " + failure; + } + if (sim.state().trainers_beaten != trainers){ + std::string fought; + for (const std::string& name : sim.state().trainers_beaten){ + fought += " " + name; + } + return std::format("{}: fought{}.", id, fought); + } + if (!sim.state().violations.empty()){ + return std::string(id) + ": a violation on the way: " + sim.state().violations.back(); + } + if (!(sim.state().position == end)){ + return std::format("{}: ended on {}, not {}.", id, to_string(sim.state().position), to_string(end)); + } + return true; +} + +const std::vector ROCK_TUNNEL_TRAINERS{ + "poke_maniac", "poke_maniac", "picnicker", "hiker", "hiker", "picnicker", "hiker", "poke_maniac", "picnicker", +}; + +// 00.28 on the sim: the tree cut, the nine trainers fought where the +// table steps into their lines (Cooper by the walker, on the leg past +// him), no one else met, and the walk out of the south mouth. +UnitTestResult test_rock_tunnel_row(){ + return row_on_sim("00.28", SimScript{}, ROCK_TUNNEL_TRAINERS, MapPosition{StoryTable::ROUTE10, {8, 58}}); +} + +// 00.28 with a wild battle on the 3rd step on the tunnel's floor, mid-way +// down the leg to the wall (17, 8): the walker runs, holds the leg to its +// wall again, and nothing else changes. +UnitTestResult test_rock_tunnel_wild_battles(){ + SimScript script; + script.encounters.push_back(SimEncounter{.grass_step = 3}); + return row_on_sim("00.28", script, ROCK_TUNNEL_TRAINERS, MapPosition{StoryTable::ROUTE10, {8, 58}}); +} + +// 00.29 on the sim: no trainer met, the ledge hopped, Lavender's nurse. +UnitTestResult test_lavender_row(){ + return row_on_sim("00.29", SimScript{}, {}, MapPosition{StoryTable::LAVENDER_CENTER, {7, 4}}); +} + +// Where an open leg of two tiles or more ends a tile short or long, the +// next leg starts a tile off. From each such start (an open tile), the +// rest of the row still ends where the table says. A leg to a wall or +// onto a warp ends on its tile, and a one-tile leg is one step. +UnitTestResult tile_off_after_open_legs(std::string_view id){ + const StoryCheckpoint& row = checkpoint(id); + const std::vector& maps = story_maps(); + std::optional before; + StoryPose at = *story_pose(*row.start); + for (size_t s = 0; s < row.steps.size(); s++){ + const StoryStep& step = row.steps[s]; + const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS; + if (!walks){ + // A step that isn't a walk starts where the table says. + before.reset(); + if (step.pose_after){ + at = *story_pose(*step.pose_after); + }else if (step.kind == StoryStepKind::STEP && step.variant == CUT_TREE_VARIANT){ + at = StoryPose{at.map, at.tile.next(step.direction), step.direction}; + } + continue; + } + const std::vector route = walked_route(at, step).value_or(std::vector{}); + MapPosition from{at.map, at.tile}; + for (size_t c = 0; c < route.size(); c++){ + const bool off = before && before->action == RouteAction::WALK && !before->to_wall && !before->warps && before->tiles > 1; + const TilePos on = from.point.next(before ? before->direction : Direction::NORTH); + const TilePos short_of{2 * from.point.x - on.x, 2 * from.point.y - on.y}; + const auto map = std::find_if(maps.begin(), maps.end(), [&](const MapData& data){ return data.name == from.map; }); + for (const TilePos& start : off ? std::vector{short_of, on} : std::vector{}){ + const bool open = map != maps.end() && in_bounds(*map, start) && object_at(*map, start) == nullptr + && (tile_at(*map, start) == TileKind::OPEN || tile_at(*map, start) == TileKind::TALL_GRASS); + if (!open){ + continue; + } + OverworldSim sim(maps_after_route10(), MapPosition{from.map, start}, before->direction); + const std::vector rest(route.begin() + c, route.end()); + const RoutePlanning planning{.maps = &maps, .costs = story_route_costs(), .start = from, .label = step.name}; + std::string failure = OverworldSimWalker(sim).run(rest, planning); + const StoryPose next{route.back().end.map, route.back().end.point, route.back().facing_after}; + failure = failure.empty() ? row_rest(sim, next, row, s + 1) : failure; + if (!failure.empty()){ + return std::format("{} {} leg {} ({}) from {}, not {}: {}", id, step.name, c + 1, to_string(route[c]), to_string(start), to_string(from.point), failure); + } + } + before = route[c]; + from = route[c].end; + } + at = route.empty() ? at : StoryPose{route.back().end.map, route.back().end.point, route.back().facing_after}; + } + return true; +} + +UnitTestResult test_rock_tunnel_tile_off(){ + return tile_off_after_open_legs("00.28"); +} + +UnitTestResult test_lavender_tile_off(){ + return tile_off_after_open_legs("00.29"); +} + +// 00.29 saves in Lavender Town's Pokemon Center, LAVENDER TOWN on the +// save card; 00.28 on Route 10. +UnitTestResult test_save_places(){ + if (save_card_place(StoryTable::LAVENDER_CENTER) != "lavender-town" || save_card_place(StoryTable::ROUTE10) != "route-10"){ + return "Wrong save card places for Lavender Town's Center or Route 10."; + } + return true; +} + +} // namespace + + +const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { + {.input = {"00.28 the tree, Rock Tunnel's nine trainers, out of its south mouth", test_rock_tunnel_row}, .expectation = {}}, + {.input = {"00.28 a wild battle mid-leg in the tunnel", test_rock_tunnel_wild_battles}, .expectation = {}}, + {.input = {"00.28 a tile off after each open leg", test_rock_tunnel_tile_off}, .expectation = {}}, + {.input = {"00.29 Route 10 south, the ledge, Lavender Town's Pokemon Center", test_lavender_row}, .expectation = {}}, + {.input = {"00.29 a tile off after each open leg", test_lavender_tile_off}, .expectation = {}}, + {.input = {"00.28 and 00.29 save on Route 10 and in Lavender Town", test_save_places}, .expectation = {}}, +}; + +void add_tests_AutoStoryLavender(UnitTestDatabase& database){ + for (const auto& item : AUTO_STORY_LAVENDER_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp index 39230fefbc..e595c875e0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -138,7 +138,7 @@ class Test_RunPlan : public UnitTest{ const RunPlanScenario m_scenario; }; -const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27"; +const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27 00.28 00.29"; const RunPlanScenario RUN_PLAN_CASES[] = { { @@ -229,11 +229,11 @@ const RunPlanScenario RUN_PLAN_CASES[] = { }, { .input = {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11"}, - .expectation = {.expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27"}, + .expectation = {.expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27 00.28 00.29"}, }, { .input = {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b"}, - .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27"}, + .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27 00.28 00.29"}, }, { .input = { .name = "Gift RNG: started at 00.12b, from the save beside the salesman", @@ -258,7 +258,7 @@ const RunPlanScenario RUN_PLAN_CASES[] = { }, { .input = {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}}, - .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27"}, + .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25 00.26 00.27 00.28 00.29"}, }, { .input = { .name = "Starter RNG first, with both on", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 3f88fe03e1..94437d8c5c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -791,7 +791,7 @@ const std::vector& story_checkpoints(){ running_shoes(), route3(), magikarp_salesman(), after_gift_rng(), magikarp_to_box(), mt_moon(), route4_to_cerulean(), cerulean_gym(), misty(), cerulean_grind(), cerulean_rival(), nugget_bridge(), route25(), bill(), back_to_cerulean(), oddish(), to_vermilion(), ss_anne(), - vermilion_gym(), to_lt_surge(), lt_surge(), bike_voucher(), route10_center(), + vermilion_gym(), to_lt_surge(), lt_surge(), bike_voucher(), route10_center(), rock_tunnel(), lavender_center(), }; return checkpoints; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h index 2778f00eb5..5b964af410 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h @@ -46,6 +46,8 @@ void add_tests_AutoStoryVermilion(UnitTestDatabase& database); void add_tests_AutoStoryVermilionGym(UnitTestDatabase& database); // Rows from 00.26 on on the overworld sim (PokemonFRLG_AutoStoryCeladon_Tests.cpp). void add_tests_AutoStoryCeladon(UnitTestDatabase& database); +// Rows from 00.28 on on the overworld sim (PokemonFRLG_AutoStoryLavender_Tests.cpp). +void add_tests_AutoStoryLavender(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp new file mode 100644 index 0000000000..73ae6ffbf4 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp @@ -0,0 +1,131 @@ +/* AutoStory Table: to Lavender Town + * + * From: https://github.com/PokemonAutomation/ + * + * Rows from 00.28 on: through Rock Tunnel, without Flash, and down Route + * 10 to Lavender Town's Pokemon Center. + * + */ + +#include "PokemonFRLG_AutoStoryTableSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryTable{ + + +// From Route 10's Pokemon Center through Rock Tunnel (pokefirered +// RockTunnel_1F, RockTunnel_B1F map.json and scripts.inc) to Route 10 +// south of it. The save is on Route 10 by the tunnel's south mouth: the +// save card's places don't include Rock Tunnel. +// - The tunnel's north mouth (8, 19) is reached from the Center through +// the tree (10, 20), cut from below; Heidi (9, 27) sees rows 28..29 +// below the trees and is left. Two single steps west then put the +// player below the mouth on (8, 20). +// - Every floor tile of the tunnel has wild encounters, which are run +// from (the table has no step that uses a Repel), so the legs end on +// walls, ladders or someone standing still, which the walker holds +// again after a battle. An open leg (two tiles or more, not to a wall) +// is followed only by legs a tile of error in it can't send astray. +// - Nine trainers stand where the way can't go round them. The others +// (1F Lucas, Oliver, Ariana, Leah; B1F Eric, Steve) are walked round. +// - 1F Ashton (27, 10) looks west over (24..26, 10), the only way south +// from the mouth's room: stepped into from (24, 9). +// - B1F Winston (29, 35) looks south over the south passage (29, +// 36..38): stepped into from (32, 37), after the leg down column 33 +// to the wall and a tile back. +// - B1F Martha (15, 33) looks east over (16..17, 33), the corridor +// north: stepped into from (17, 34), the wall east of row 34. +// - B1F Allen (36, 11) looks south over (36, 12..14), the way west to +// the ladder (33, 3): stepped into from (38, 12), below the wall. +// - 1F Lenny (7, 5) looks south over (7, 6..9), the way east from the +// ladder (4, 2): stepped into from (6, 9), the end of column 6. +// The way on to the ladder (20, 13) keeps west of Lucas's line (16..19, +// 17): down column 11, up column 14 to row 16. +// - B1F Sofia (11, 15) looks south over (11, 16..19), the way west: +// stepped into along row 16 from (12..14, 16). +// - B1F Dudley (5, 11) looks south over (5, 12..15): stepped into from +// (8, 15) along row 15. +// - B1F Cooper (2, 6) looks around (sight 2) by the ladder (2, 3): the +// leg up column 1 to the wall (1, 2) passes him on (1, 6); if he +// looks west, story_interrupts() fights him and the leg is held on +// to its wall, as Mt. Moon's Grunt in 00.13. +// - 1F Dana (26, 28) looks south over (26, 29..31), the only way west +// to the south mouth: stepped into along row 31 from (27..29, 31). +// - Unverified on the console: Rock Tunnel is dark without Flash, and +// the overworld, fade, dialog and battle detectors haven't been +// measured under its darkness (~/FRLGLab/notes/2026-11-09-0028-dark- +// measure.md). Every step from rock_tunnel_1f to route10_south depends +// on them. +StoryCheckpoint rock_tunnel(){ + return row("00.28", "Through Rock Tunnel: Ashton, Winston, Martha, Allen, Lenny, Sofia, Dudley, Cooper and Dana", at(ROUTE10_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("below_route10_tree", at(ROUTE10, 10, 21, NORTH), "out of the Center (13, 20), west along row 21 to the wall (9, 21), below the tree (10, 20)")), + cut_tree("cut_route10_tree", at(ROUTE10, 10, 20, NORTH), "the tree; a step onto its tile"), + step_onto("west_of_tree", WEST, at(ROUTE10, 9, 20, WEST), "a step west along row 20"), + step_onto("below_rock_tunnel", WEST, at(ROUTE10, 8, 20, WEST), "a step west, below the mouth (8, 19)"), + running(route("rock_tunnel_1f", at(ROCK_TUNNEL_1F, 17, 2, NORTH), "north into Rock Tunnel")), + running(legs("above_ashton_line", {{SOUTH, 6}, {EAST, 8}, {SOUTH, 1}, {WEST, 1}}, "down column 17 to the wall (17, 8), east along row 8 to (25, 8), onto row 9 and west to the wall (24, 9)")), + into_line("ashton_line", SOUTH, "onto (24, 10), in Ashton's line: he walks up to (25, 10)"), + battle("pokemaniac_ashton", at(ROCK_TUNNEL_1F, 24, 10, EAST), false, "Slowpoke, Slowpoke and Slowpoke"), + running(route("b1f_south_ladder", at(ROCK_TUNNEL_B1F, 38, 28, NORTH), "east along row 12 and up column 45 to the ladder (45, 2), down to B1F")), + running(legs("winston_line_end", {{WEST, 5}, {SOUTH, 10}, {NORTH, 1}, {WEST, 1}}, "west to the wall (33, 28), down column 33 to the wall (33, 38), up to (33, 37) and west to (32, 37)")), + into_line("winston_line", WEST, "along row 37 onto (29, 37), the end of Winston's line: he walks down to (29, 36)"), + battle("pokemaniac_winston", at(ROCK_TUNNEL_B1F, 29, 37, NORTH), false, "Slowpoke and Slowpoke"), + running(legs("below_martha_line", {{WEST, 16}, {NORTH, 2}, {EAST, 6}, {WEST, 3}, {NORTH, 1}, {EAST, 1}}, "west along row 37 to (13, 37), up to the wall (13, 35), east to the wall (19, 35), west to (16, 35), up to row 34 and east to the wall (17, 34)")), + into_line("martha_line", NORTH, "onto (17, 33), the end of Martha's line: she walks up to (16, 33)"), + battle("picnicker_martha", at(ROCK_TUNNEL_B1F, 17, 33, WEST), false, "Oddish and Bulbasaur"), + running(legs("below_allen_line", {{NORTH, 5}, {EAST, 12}, {NORTH, 9}, {EAST, 11}, {NORTH, 2}, {WEST, 2}, {NORTH, 5}}, "up column 17 to the wall (17, 28), east to the wall (29, 28), up column 29 past Steve's line to the wall (29, 19), east to (40, 19), up to row 17, west to the wall (38, 17) and up to the wall (38, 12)")), + into_line("allen_line", WEST, "along row 12 onto (36, 12), below Allen"), + battle("hiker_allen", at(ROCK_TUNNEL_B1F, 36, 12, NORTH), false, "Geodude, Geodude and Graveler"), + running(legs("west_of_b1f_ladder", {{WEST, 4}, {NORTH, 10}}, "west along row 12 to the wall (32, 12), up column 32 to the wall (32, 2)")), + running(route("1f_west_ladder", at(ROCK_TUNNEL_1F, 4, 2, NORTH), "onto the ladder (33, 3), up to 1F")), + running(legs("above_lenny_line", {{EAST, 2}, {SOUTH, 7}}, "east to the wall (6, 2), down column 6 to the wall (6, 9)")), + into_line("lenny_line", EAST, "onto (7, 9), the end of Lenny's line: he walks down to (7, 8)"), + battle("hiker_lenny", at(ROCK_TUNNEL_1F, 7, 9, NORTH), false, "Machop and Onix"), + running(legs("west_of_lucas_line", {{EAST, 4}, {SOUTH, 15}, {EAST, 7}, {WEST, 4}, {NORTH, 8}, {EAST, 6}}, "east to the wall (11, 9), down column 11 to the wall (11, 24), east to the wall (18, 24), west to (14, 24), up column 14 to the wall (14, 16), east to the wall (20, 16)")), + running(route("b1f_middle_ladder", at(ROCK_TUNNEL_B1F, 27, 12, NORTH), "up column 20 onto the ladder (20, 13), down to B1F")), + running(legs("east_of_sofia_line", {{SOUTH, 3}, {WEST, 12}, {SOUTH, 1}, {WEST, 2}}, "down to the wall (27, 15), west along row 15 to the wall (15, 15), down to row 16 and west to (13, 16)")), + into_line("sofia_line", WEST, "along row 16 onto (11, 16), below Sofia"), + battle("picnicker_sofia", at(ROCK_TUNNEL_B1F, 11, 16, NORTH), false, "Pidgey, Pidgey and Pidgeotto"), + running(legs("east_of_dudley_line", {{WEST, 3}, {NORTH, 1}}, "west to the wall (8, 16), up to (8, 15)")), + into_line("dudley_line", WEST, "along row 15 onto (5, 15), the end of Dudley's line: he walks down to (5, 14)"), + battle("hiker_dudley", at(ROCK_TUNNEL_B1F, 5, 15, NORTH), false, "Geodude, Geodude and Graveler"), + running(legs("past_cooper", {{WEST, 4}, {NORTH, 13}, {SOUTH, 1}}, "west to the wall (1, 15), up column 1 past Cooper (2, 6) to the wall (1, 2), back to (1, 3)")), + running(route("1f_east_ladder", at(ROCK_TUNNEL_1F, 45, 21, NORTH), "east onto the ladder (2, 3), up to 1F")), + running(legs("above_dana_line", { + {WEST, 6}, {SOUTH, 5}, {EAST, 6}, {WEST, 1}, {SOUTH, 4}, {WEST, 5}, {SOUTH, 5}, + {WEST, 2}, {NORTH, 3}, {WEST, 6}, {NORTH, 3}, {WEST, 3}, {SOUTH, 2}, + }, "west to the wall (39, 21), down column 39 to Ariana (39, 27), east to the wall (45, 26), down column 44 to the wall (44, 30), west to the wall (39, 30), down to the wall (39, 35), west to (37, 35), up to the wall (37, 32), west to the wall (31, 32), up to (31, 29), west to (28, 29) and down to the wall (28, 31)")), + into_line("dana_line", WEST, "along row 31 onto (26, 31), the end of Dana's line: she walks down to (26, 30)"), + battle("picnicker_dana", at(ROCK_TUNNEL_1F, 26, 31, NORTH), false, "Meowth, Oddish and Pidgey"), + running(legs("above_south_mouth", {{WEST, 6}, {SOUTH, 1}, {WEST, 8}, {SOUTH, 5}, {EAST, 2}, {NORTH, 1}, {EAST, 5}, {WEST, 1}}, "west along row 31 to (20, 31), onto row 32 and west to the wall (12, 32), down column 12 to the wall (12, 37), east to the wall (14, 37), up to (14, 36), east to the wall (19, 36), back to (18, 36)")), + running(route("route10_south", at(ROUTE10, 8, 58, SOUTH), "onto the mouth (18, 37), out onto Route 10")), + }); +} + +// Route 10 south of Rock Tunnel (Route10 map.json) to Lavender Town's +// Pokemon Center. It has no grass. Carol (7, 60) and Clark (4, 62) look +// around, Trent (4, 68) looks west and Herman wanders row 70 (13..17), +// looking west and east (sight 2, to x 19): the way keeps to the east +// side, columns 17..21, and crosses row 70 on column 21. The ledges of +// row 62 leave a gap at (18, 62); row 66's leaves (21, 66). Row 74's +// ledge (11, 74) is hopped, and the hold south goes on over the +// connection into Lavender Town to the wall (11, 9). West of the +// Center's building, the wall (8, 9) sets the door's column. +StoryCheckpoint lavender_center(){ + return row("00.29", "Route 10 south to Lavender Town's Pokemon Center", at(ROUTE10, 8, 58, SOUTH), after_brock(), StartMenu::FULL, { + running(legs("route10_east_side", {{EAST, 10}, {NORTH, 2}, {WEST, 1}, {EAST, 1}, {SOUTH, 8}, {EAST, 3}, {SOUTH, 9}, {WEST, 10}}, "east along row 58 to (18, 58), up to the wall (18, 56), west to the wall (17, 56), back to (18, 56), down through the ledges' gap to (18, 64), east to the wall (21, 64), down column 21 to the wall (21, 73), west along row 73 to the wall (11, 73)")), + hop("route10_ledge_hop", SOUTH, at(ROUTE10, 11, 75, SOUTH), "down the ledge (11, 74)"), + running(route("lavender_north_wall", at(LAVENDER, 11, 8, SOUTH), "down column 11 over the connection into Lavender Town, to the wall (11, 9)")), + running(legs("below_center_door", {{WEST, 6}, {SOUTH, 1}, {EAST, 2}, {WEST, 1}}, "west along row 8 to (5, 8), onto row 9, east to the wall (7, 9), west to (6, 9)")), + running(route("lavender_nurse", at(LAVENDER_CENTER, 7, 4, NORTH), "up column 6 onto the Center's door (6, 5), to the nurse")), + heal("heal_lavender"), + }); +} + + +} // namespace StoryTable +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h index 8775379b84..d0a314a2ac 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h @@ -101,6 +101,14 @@ inline constexpr const char* ROUTE9 = "Route9"; inline constexpr const char* ROUTE10 = "Route10"; // Route 10 Pokemon Center map. inline constexpr const char* ROUTE10_CENTER = "Route10_PokemonCenter_1F"; +// Rock Tunnel 1F map. +inline constexpr const char* ROCK_TUNNEL_1F = "RockTunnel_1F"; +// Rock Tunnel B1F map. +inline constexpr const char* ROCK_TUNNEL_B1F = "RockTunnel_B1F"; +// Lavender Town overworld map. +inline constexpr const char* LAVENDER = "LavenderTown"; +// Lavender Town Pokemon Center map. +inline constexpr const char* LAVENDER_CENTER = "LavenderTown_PokemonCenter_1F"; // Constructs pose at map coordinates. Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt); @@ -187,6 +195,10 @@ StoryCheckpoint lt_surge(); StoryCheckpoint bike_voucher(); // Route 10 Pokemon Center navigation checkpoint. StoryCheckpoint route10_center(); +// Through Rock Tunnel to Route 10 south of it (PokemonFRLG_AutoStoryTableLavender.cpp). +StoryCheckpoint rock_tunnel(); +// Route 10 south to Lavender Town's Pokemon Center. +StoryCheckpoint lavender_center(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp index 4512a610ac..da54867cd9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -230,8 +230,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 34 || checkpoints[0].start){ - return "Expected 34 checkpoints, the first from a new game."; + if (checkpoints.size() != 36 || checkpoints[0].start){ + return "Expected 36 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -600,7 +600,7 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set Date: Mon, 9 Nov 2026 10:14:07 -0800 Subject: [PATCH 02/24] FRLG AutoStory: register the 00.28-00.29 sim tests from the Celadon ones PokemonFRLG_Tests.cpp is at the 1000-line limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp | 1 - .../Programs/AutoStory/PokemonFRLG_AutoStoryCeladon_Tests.cpp | 1 + .../Programs/AutoStory/PokemonFRLG_AutoStoryTable.h | 3 ++- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 27726c1b9d..2bf7183092 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -895,7 +895,6 @@ static void add_tests_AutoStoryRows(UnitTestDatabase& database){ add_tests_AutoStoryVermilion(database); add_tests_AutoStoryVermilionGym(database); add_tests_AutoStoryCeladon(database); - add_tests_AutoStoryLavender(database); } void add_tests(UnitTestDatabase& database){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCeladon_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCeladon_Tests.cpp index 853b4e4af7..922b9c0e18 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCeladon_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCeladon_Tests.cpp @@ -434,6 +434,7 @@ void add_tests_AutoStoryCeladon(UnitTestDatabase& database){ for (const auto& item : AUTO_STORY_CELADON_CASES){ database.add(item); } + add_tests_AutoStoryLavender(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h index 5b964af410..0e97ce995e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h @@ -44,7 +44,8 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database); void add_tests_AutoStoryVermilion(UnitTestDatabase& database); // Rows 00.23 to 00.25 on the overworld sim (PokemonFRLG_AutoStoryVermilionGym_Tests.cpp). void add_tests_AutoStoryVermilionGym(UnitTestDatabase& database); -// Rows from 00.26 on on the overworld sim (PokemonFRLG_AutoStoryCeladon_Tests.cpp). +// Rows from 00.26 on on the overworld sim (PokemonFRLG_AutoStoryCeladon_Tests.cpp), +// with add_tests_AutoStoryLavender(). void add_tests_AutoStoryCeladon(UnitTestDatabase& database); // Rows from 00.28 on on the overworld sim (PokemonFRLG_AutoStoryLavender_Tests.cpp). void add_tests_AutoStoryLavender(UnitTestDatabase& database); From 117649846eccd7712a4b7c8760d59561c958d0d5 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 10:56:15 -0800 Subject: [PATCH 03/24] FRLG AutoStory 00.28: sim tests for wild battles every few steps in Rock Tunnel Console B, 2026-11-09: B1F row 37 met a wild battle 10 to 14 tiles into every try and the walker gave up after 9 stops. Red: both fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index 65ad046355..4c96611513 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -140,6 +141,67 @@ UnitTestResult test_rock_tunnel_wild_battles(){ return row_on_sim("00.28", script, ROCK_TUNNEL_TRAINERS, MapPosition{StoryTable::ROUTE10, {8, 58}}); } +// Wild battles on the tunnel's floor, `gaps` steps apart in turn, from the +// first step: as many as a row walks. +SimScript battles_every(const std::vector& gaps){ + SimScript script; + int step = 0; + for (size_t c = 0; step < 3000; c++){ + step += gaps[c % gaps.size()]; + script.encounters.push_back(SimEncounter{.grass_step = step}); + } + return script; +} + +// 00.28's step `name`: where it is in the row. +size_t step_index(const StoryCheckpoint& row, std::string_view name){ + for (size_t c = 0; c < row.steps.size(); c++){ + if (row.steps[c].name == name){ + return c; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::format("No step {} in {}.", name, row.id)); +} + +// Console B, 2026-11-09: after Winston, the walk west along B1F row 37 to +// (13, 37) met a wild battle 10 to 14 tiles into every try. Each time the +// walker found the player again at the east wall (45, 37), behind where +// the leg started, and walked the whole 32 tiles again, until it gave up +// ("stopped part-way 9 times"). From Winston's tile, with a battle every +// 10 to 14 steps, the steps from below_martha_line on reach Martha's line. +UnitTestResult test_b1f_row37_battles_every_12(){ + const StoryCheckpoint& row = checkpoint("00.28"); + const size_t first = step_index(row, "below_martha_line"); + StoryPose at{StoryTable::ROCK_TUNNEL_B1F, {29, 37}, Direction::NORTH}; + OverworldSim sim(maps_after_route10(), MapPosition{at.map, at.tile}, *at.facing, battles_every({12, 10, 14, 11, 13})); + const std::string failure = row_step_on_sim(sim, at, row.steps[first], row.id); + if (!failure.empty()){ + return std::format("below_martha_line: ended on {}; {}", to_string(sim.state().position), failure); + } + if (!(sim.state().position == MapPosition{StoryTable::ROCK_TUNNEL_B1F, {17, 34}})){ + return std::format("below_martha_line: ended on {}, not (17, 34).", to_string(sim.state().position)); + } + return true; +} + +// 00.28 whole with a wild battle every 7 to 25 steps on the tunnel's floor, +// the gaps drawn from each of these seeds: the row ends where it should. +UnitTestResult test_rock_tunnel_frequent_battles(){ + for (unsigned seed = 1; seed <= 12; seed++){ + std::mt19937 random(seed); + std::uniform_int_distribution gap(7, 25); + std::vector gaps; + for (int c = 0; c < 400; c++){ + gaps.push_back(gap(random)); + } + const UnitTestResult result = row_on_sim("00.28", battles_every(gaps), ROCK_TUNNEL_TRAINERS, MapPosition{StoryTable::ROUTE10, {8, 58}}); + if (result.result != UnitTestResult::PASSED){ + return std::format("Seed {}: {}", seed, result.message); + } + } + return true; +} + // 00.29 on the sim: no trainer met, the ledge hopped, Lavender's nurse. UnitTestResult test_lavender_row(){ return row_on_sim("00.29", SimScript{}, {}, MapPosition{StoryTable::LAVENDER_CENTER, {7, 4}}); @@ -222,6 +284,8 @@ const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { {.input = {"00.28 the tree, Rock Tunnel's nine trainers, out of its south mouth", test_rock_tunnel_row}, .expectation = {}}, {.input = {"00.28 a wild battle mid-leg in the tunnel", test_rock_tunnel_wild_battles}, .expectation = {}}, {.input = {"00.28 a tile off after each open leg", test_rock_tunnel_tile_off}, .expectation = {}}, + {.input = {"00.28 B1F row 37 with a wild battle every 10 to 14 steps", test_b1f_row37_battles_every_12}, .expectation = {}}, + {.input = {"00.28 a wild battle every 7 to 25 steps, 12 seeds", test_rock_tunnel_frequent_battles}, .expectation = {}}, {.input = {"00.29 Route 10 south, the ledge, Lavender Town's Pokemon Center", test_lavender_row}, .expectation = {}}, {.input = {"00.29 a tile off after each open leg", test_lavender_tile_off}, .expectation = {}}, {.input = {"00.28 and 00.29 save on Route 10 and in Lavender Town", test_save_places}, .expectation = {}}, From 4febace20fc474ae53ec0da33ff95c7474227450 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 11:01:45 -0800 Subject: [PATCH 04/24] FRLG walker: after a stop, find the player the way that walks the fewest tiles Rock Tunnel B1F row 37 (console B, 2026-11-09): a wild battle 10 to 14 tiles into every try of "west 16". The only single hold that found the player went east to the wall (45, 37), behind the leg's start, and the way back walked all 32 tiles again, until the route gave up. The reanchor search now also tries each first hold with the fewest holds after it, and plan_reanchor() takes the way whose holds and way back walk the fewest tiles together: there, south to row 38 and west to the wall (1, 38), then 13 tiles back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_OverworldReanchor.cpp | 61 ++++++++++++++++--- .../Overworld/PokemonFRLG_OverworldReanchor.h | 9 ++- .../PokemonFRLG_OverworldRecovery.cpp | 14 +---- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index 588b63c357..cdacd42e30 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -236,14 +236,46 @@ bool walks_same_everywhere( return true; } -// The ways to find the player from all of `positions`: fewest holds first, -// then fewest tiles held. None needed if they are all on one tile. +int held_tiles(const std::vector& holds){ + int tiles = 0; + for (const RouteStep& hold : holds){ + tiles += hold.tiles; + } + return tiles; +} + +// The ways to find the player from all of `positions`, fewest holds first, +// then fewest tiles held. None needed if they are all on one tile. Not +// only the fewest holds: for each first hold, the fewest after it, so a +// caller can pick a way that keeps what a stopped leg walked. On Rock +// Tunnel B1F row 37 the one single hold went back east to the wall +// behind the leg's start, while a hold south and one west, ahead, were +// found too (console B, 2026-11-09). std::vector find_player( const std::vector>& worlds, const std::vector& positions, bool surfing ){ - return Pokemon::find_wall_anchors( - positions, ReanchorContext{.worlds = worlds, .surfing = surfing}, {.max_holds = MAX_HOLDS} - ); + const ReanchorContext context{.worlds = worlds, .surfing = surfing}; + std::vector found = Pokemon::find_wall_anchors(positions, context, {.max_holds = 0}); + if (!found.empty()){ + return found; + } + for (Direction direction : Pokemon::ALL_DIRECTIONS){ + const auto first = ReanchorPolicy::hold(context, positions, direction); + if (!first || first->positions == positions || first->hold.tiles == 0){ + continue; + } + for (Found& rest : Pokemon::find_wall_anchors(first->positions, context, {.max_holds = MAX_HOLDS - 1})){ + rest.holds.insert(rest.holds.begin(), first->hold); + found.push_back(std::move(rest)); + } + } + std::stable_sort(found.begin(), found.end(), [](const Found& a, const Found& b){ + if (a.holds.size() != b.holds.size()){ + return a.holds.size() < b.holds.size(); + } + return held_tiles(a.holds) < held_tiles(b.holds); + }); + return found; } } // namespace @@ -326,6 +358,10 @@ std::vector stop_candidates( return candidates; } +int reanchor_tiles(const Reanchor& anchor){ + return held_tiles(anchor.holds) + held_tiles(anchor.back); +} + std::optional plan_reanchor( const std::vector& maps, const std::vector& candidates, @@ -351,7 +387,12 @@ std::optional plan_reanchor( add_unique(positions, Hypothesis{world, candidate.player}); } + // Of the ways found, the one that walks fewest tiles, holds and way back + // together: it keeps the most of what the leg walked. A wild battle + // every 10 to 14 tiles stopped every try of a leg whose way back + // started behind it (console B, 2026-11-09, Rock Tunnel B1F row 37). const std::vector clear_of_trainers = with_trainers(maps, trainers); + std::optional best; for (const Found& at : find_player(worlds, positions, step.surfing)){ const Direction facing = at.holds.empty() ? step.direction : at.holds.back().direction; // The way back keeps clear of every trainer who may stand in it if @@ -360,11 +401,15 @@ std::optional plan_reanchor( if (!back && !trainers.empty()){ back = plan_overworld_route(maps, at.at, RouteGoal{step.end, std::nullopt}, costs, facing); } - if (back && (trainers.empty() || walks_same_everywhere(worlds, positions, at.at, *back))){ - return Reanchor{.holds = at.holds, .found = at.at, .back = std::move(*back)}; + if (!back || (!trainers.empty() && !walks_same_everywhere(worlds, positions, at.at, *back))){ + continue; + } + Reanchor anchor{.holds = at.holds, .found = at.at, .back = std::move(*back)}; + if (!best || reanchor_tiles(anchor) < reanchor_tiles(*best)){ + best = std::move(anchor); } } - return std::nullopt; + return best; } std::optional> holds_to_one_tile( diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index 157917283e..1d1983c608 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -106,8 +106,13 @@ struct Reanchor{ std::vector back; }; -// After `step` stopped part-way, from `candidates`. Nothing if no holds -// bring them all to one tile, or no way back walks the same from there. +// How many tiles `anchor`'s holds and way back walk, as far as planned. +int reanchor_tiles(const Reanchor& anchor); + +// After `step` stopped part-way, from `candidates`: of the ways that find +// the player, the one that walks the fewest tiles, holds and way back +// together. Nothing if no holds bring them all to one tile, or no way +// back walks the same from there. std::optional plan_reanchor( const std::vector& maps, const std::vector& candidates, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index 9053f963ea..b5b2c8d0f1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -86,18 +86,6 @@ bool hold_finds(const std::vector& maps, const std::vector anchor = plan_reanchor(*maps(), candidates, step, m_planning->costs); - if (!anchor || anchor->holds.empty() || anchor_tiles(*anchor) > 2 * step.tiles){ + if (!anchor || anchor->holds.empty() || reanchor_tiles(*anchor) > 2 * step.tiles){ m_calls.log("Route: no holds to walls within " + std::to_string(2 * step.tiles) + " tiles find the player if " + to_string(step) + " ended short; taken as walked."); return; } From 1484c5e6fa286338c93dfed97a3a49a26f1e8c43 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 11:03:00 -0800 Subject: [PATCH 05/24] FRLG AutoStory: watch a warp's fade from the start of the hold onto it The walk waited for a warp's fade only once the hold onto the warp had ended, so a fade that was over by then went unseen and the walk stopped on "no fade in and out after walking onto the warp" though the player had warped (00.27, console B, 2026-11-09 01:41, the warp to CeruleanCity: the watch started about 1.3 s into the hold). The walk and the recovery walk now show the hold's frames to the same BlackScreenOverWatcher (Pokemon::WatchAlong) and wait on with it only if it has not fired. The watcher and its thresholds are unchanged, so a dim textured floor is still no fade. The overworld sim's walker watches for the fade from where the console does (SimWalkerRules::watch_fade_along_hold), and fails on a warp whose fade it missed, as the console does. OverworldWarpFade walks out of Viridian Mart with a 200 ms fade that is over before the hold ends: seen when watched from the start of the hold, missed from its end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_OverworldRecoveryWalk.cpp | 7 +- .../Overworld/PokemonFRLG_OverworldSim.cpp | 26 +++- .../Overworld/PokemonFRLG_OverworldSim.h | 12 +- .../PokemonFRLG_OverworldSim_Tests.cpp | 1 + .../Overworld/PokemonFRLG_OverworldWalk.cpp | 54 ++++++-- .../Overworld/PokemonFRLG_OverworldWalk.h | 11 +- .../PokemonFRLG_OverworldWarpFade_Tests.cpp | 129 ++++++++++++++++++ SerialPrograms/cmake/SourceFiles.cmake | 1 + 8 files changed, 222 insertions(+), 19 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWarpFade_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 8aac65097e..e886503d5f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -16,6 +16,7 @@ #include "CommonTools/Async/InferenceSession.h" #include "CommonTools/VisualDetectors/BlackScreenDetector.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_OverworldLateBattle.h" #include "PokemonFRLG_OverworldRecoveryWalk.h" @@ -72,16 +73,18 @@ StepWalk walk_step( } context.wait_for_all_requests(); out.hold_start = current_time(); + BlackScreenOverWatcher arrival(COLOR_RED); + Pokemon::WatchAlong fade(arrival); const Pokemon::LegResult result = Pokemon::walk_leg( console, context, leg_mover(step, speed), step.wild ? interrupts.wild : interrupts.other, - leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG + leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG, warp_hold_watchers(step, fade) ); if (result.stopped){ out.stopped = walked_near(step, result.tiles, tile_time(step, speed)); return out; } if (step.warps){ - wait_out_warp(console, context, step); + wait_out_warp(console, context, step, fade); static_assert(AFTER_WARP_BATTLE_WATCH >= AFTER_WARP); if (run_from_battle_after_warp(console, context)){ console.log("Route: a wild battle, not the warp to " + step.end.map + "; the player is on " + to_string(step) + " before the warp.", COLOR_ORANGE); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 3395eda537..b904273c14 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -650,17 +650,30 @@ std::optional OverworldSimWalker::hold_watching(const RouteStep& step, return m_sim.hold(step.direction, hold, STEP_RELEASE, hold + STEP_RELEASE + m_rules.watch_past_hold, watchers(interrupts)); } +// Whether a warp's fade showed and was over between `from` and now. +bool OverworldSimWalker::fade_over_since(Milliseconds from) const{ + const SimState& state = m_sim.state(); + if (state.screen == SimScreen::FADE){ + return false; + } + return std::any_of( + state.shown.begin(), state.shown.end(), + [&](const SimShown& shown){ return shown.screen == SimScreen::FADE && shown.at >= from; } + ); +} + // wait_out_warp(), then run_from_battle_after_warp(): false if the fade // was a wild battle's, run from. wait_out_warp()'s watcher takes a wild // battle's wipe for the fade (run 7; console B, 2026-11-03, Mt. Moon 1F): -// the battle screen stands in for the wipe. -bool OverworldSimWalker::after_warp(const RouteStep& step, int warps_before){ +// the battle screen stands in for the wipe. The fade is watched for from +// `watched_from`: a fade over by then is missed. +bool OverworldSimWalker::after_warp(const RouteStep& step, int warps_before, Milliseconds watched_from){ if (m_sim.state().warps == warps_before){ if (!m_sim.watch({SimWatcher::BATTLE_SCREEN}, WARP_TIMEOUT)){ fail("walk_overworld_route(): no fade in and out after walking onto the warp to " + step.end.map + "."); } - }else{ - m_sim.watch({SimWatcher::FADE}, WARP_TIMEOUT); + }else if (!fade_over_since(watched_from) && !m_sim.watch({SimWatcher::FADE}, WARP_TIMEOUT)){ + fail("walk_overworld_route(): no fade in and out after walking onto the warp to " + step.end.map + "."); } m_sim.watch({}, AFTER_WARP); if (!m_sim.watch(watchers({m_wild[1]}), AFTER_WARP_BATTLE_WATCH - AFTER_WARP)){ @@ -712,7 +725,8 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, if (result.stopped){ return walked_near(step, result.tiles, TILE_TIME); } - if (step.warps && !after_warp(step, warps_before)){ + const Milliseconds fade_watched_from = m_rules.watch_fade_along_hold ? m_leg_start : m_sim.state().time; + if (step.warps && !after_warp(step, warps_before, fade_watched_from)){ if (!m_rules.find_player_after_warp_battle){ fail_off_route(step); } @@ -795,7 +809,7 @@ void OverworldSimWalker::hold_to_warp(const RouteStep& step){ const Milliseconds hold = TILE_TIME * (step.tiles + 2); const std::optional seen = m_sim.hold(step.direction, hold, STEP_RELEASE, hold + STEP_RELEASE, fade_first); if (seen && seen->watcher == 0){ - if (!after_warp(step, warps_before)){ + if (!after_warp(step, warps_before, m_sim.state().time)){ fail_off_route(step); } return; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 9483c2e289..32751834dc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -328,6 +328,13 @@ struct SimWalkerRules{ // player may be anywhere on the leg: on Route 10, "east 11" was found // again from all 12 of its tiles (console B, 2026-11-09, 00.27 run 2). bool place_late_battle = true; + // The watch for a warp's fade starts with the hold onto the warp + // (Pokemon::WatchAlong), not when it ends. Without it a fade over + // before the hold ended was missed, and the walk stopped on "no fade in + // and out after walking onto the warp" though it had warped (console B, + // 2026-11-09 01:41, 00.27, the warp to CeruleanCity: the watch started + // about 1.3 s into the hold). + bool watch_fade_along_hold = true; }; // The console side of run_route() on an OverworldSim, as @@ -367,7 +374,8 @@ class OverworldSimWalker{ StepOutcome walk(const RouteStep& step, std::optional facing); std::optional late_stop(const RouteStep& step); std::optional walk_step(const RouteStep& step, std::optional facing); - bool after_warp(const RouteStep& step, int warps_before); + bool after_warp(const RouteStep& step, int warps_before, Milliseconds watched_from); + bool fade_over_since(Milliseconds from) const; [[noreturn]] void fail_off_route(const RouteStep& step); std::optional hold_watching(const RouteStep& step, Milliseconds hold, const std::vector& interrupts); size_t clear_late(const std::vector& interrupts, Milliseconds watch, size_t max); @@ -394,6 +402,8 @@ class OverworldSimWalker{ // Register overworld simulation unit tests. void add_tests_OverworldSim(UnitTestDatabase& database); +// Register the tests of a warp's fade over before the hold onto it ends. +void add_tests_OverworldWarpFade(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index f8a2c4ca80..3179e1829a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -983,6 +983,7 @@ void add_tests_OverworldSim(UnitTestDatabase& database){ database.add(item); } add_tests_OverworldLateBattle(database); + add_tests_OverworldWarpFade(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 8e57b1cc73..f6332251fb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -213,12 +213,22 @@ std::string speed_note(const RouteStep& step, MoveSpeed speed){ ); } +std::vector warp_hold_watchers(const RouteStep& step, Pokemon::WatchAlong& fade){ + if (!step.warps){ + return {}; + } + return {&fade}; +} + // One watcher for the whole fade: a ladder's black is short enough to be // over before a second watcher, started once the fade is seen, looks. -void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ - BlackScreenOverWatcher arrival(COLOR_RED); +void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, Pokemon::WatchAlong& fade){ + if (fade.fired()){ + console.log("Route: the fade to " + step.end.map + " was over before the hold ended."); + return; + } context.wait_for_all_requests(); - if (wait_until(console, context, WARP_TIMEOUT, {arrival}) < 0){ + if (wait_until(console, context, WARP_TIMEOUT, {fade.watcher()}) < 0){ OperationFailedExceptionWithScreenshot::fire( ErrorReportMode::SEND_ERROR_REPORT, "walk_overworld_route(): no fade in and out after walking onto the warp to " + step.end.map + ".", @@ -227,6 +237,12 @@ void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const } } +void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ + BlackScreenOverWatcher arrival(COLOR_RED); + Pokemon::WatchAlong fade(arrival); + wait_out_warp(console, context, step, fade); +} + // Fails unless the overworld is up. Under the START menu or a message the // D-pad only moves a cursor, and legs to a wall seem to go fine: three // "walked" that way in Viridian Forest without the player moving (console B, @@ -454,6 +470,31 @@ const OverworldInterruptSet& default_overworld_interrupts(){ } +namespace{ + +// A WALK step's leg, and the fade of the warp it walks onto, watched from +// the start of the hold. +void walk_step_leg( + ConsoleHandle& console, ProControllerContext& context, + const RouteStep& step, const OverworldInterruptSet& interrupts, MoveSpeed speed +){ + BlackScreenOverWatcher arrival(COLOR_RED); + Pokemon::WatchAlong fade(arrival); + Pokemon::walk_leg( + console, context, leg_mover(step, speed), step.wild ? interrupts.wild : interrupts.other, + leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG, warp_hold_watchers(step, fade) + ); + if (!step.warps){ + return; + } + wait_out_warp(console, context, step, fade); + static_assert(AFTER_WARP_BATTLE_WATCH >= AFTER_WARP); + check_warp_was_no_battle(console, context, step); +} + +} // namespace + + void walk_overworld_route( ConsoleHandle& console, ProControllerContext& context, const std::vector& route, @@ -479,12 +520,7 @@ void walk_overworld_route( if (const std::string note = speed_note(step, speed); !note.empty()){ console.log(note); } - Pokemon::walk_leg(console, context, leg_mover(step, speed), step.wild ? interrupts.wild : interrupts.other, leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG); - if (step.warps){ - wait_out_warp(console, context, step); - static_assert(AFTER_WARP_BATTLE_WATCH >= AFTER_WARP); - check_warp_was_no_battle(console, context, step); - } + walk_step_leg(console, context, step, interrupts, speed); break; case RouteAction::SURF: start_surf(console, context, step.direction, facing == step.direction); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index e166617c2a..c74361044e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -176,7 +176,16 @@ Milliseconds leg_hold_time(const RouteStep& step, MoveSpeed speed); // the tiles at the assumed pace and the hold. Empty for other steps. std::string speed_note(const RouteStep& step, MoveSpeed speed); -// After a hold onto `step`'s warp: waits out its fade. +// The hold watchers (Pokemon::walk_leg()) of a leg onto `step`: `fade`, +// which watches for the warp's fade from the start of the hold, if the +// step warps. A fade can be over before the hold ends. +std::vector warp_hold_watchers(const RouteStep& step, Pokemon::WatchAlong& fade); + +// After a hold onto `step`'s warp: waits out its fade, unless `fade`, a +// BlackScreenOverWatcher watched along the hold, saw it over already; +// else waits on with that watcher, which may have seen the black. +void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, Pokemon::WatchAlong& fade); +// The same, watching only from now: for a caller that saw the black itself. void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step); // Before `step`: fails unless the overworld is up. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWarpFade_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWarpFade_Tests.cpp new file mode 100644 index 0000000000..de344c2dc1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWarpFade_Tests.cpp @@ -0,0 +1,129 @@ +/* Overworld Warp Fade Tests + * + * From: https://github.com/PokemonAutomation/ + * + * A warp whose fade is over before the hold onto it ends, on the sim: the + * walker must watch for the fade from the start of the hold. + * + */ + +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_OverworldWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using namespace std::chrono_literals; + +struct WarpFadeInput{ + std::string name; + // How long the fade shows, from the end of the step onto the warp. + Milliseconds fade{0}; + // SimWalkerRules::watch_fade_along_hold. + bool watch_along_hold = true; +}; + +struct WarpFadeExpectation{ + // The failure the walk stops on; empty: the route is walked. + std::string failure; +}; + +struct WarpFadeCase{ + WarpFadeInput input; + WarpFadeExpectation expectation; +}; + +const std::vector& maps(){ + static const std::vector MAPS = overworld_maps(); + return MAPS; +} + +const MapPosition MART{"ViridianCity_Mart", {4, 3}}; +// Where the Mart's exit mat, (4, 7), puts the player. +const MapPosition OUTSIDE{"ViridianCity", {36, 20}}; + +// 00.27 (console B, 2026-11-09 01:41), the warp to CeruleanCity: "no fade +// in and out after walking onto the warp", the watch for the fade having +// started about 1.3 s into the hold, after the fade was over. Here the +// walk out of Viridian Mart, south 5 onto its mat, is held as to a wall, +// two tiles past the mat; a 200 ms fade is over before the hold ends. +class Test_OverworldWarpFade : public UnitTest{ +public: + explicit Test_OverworldWarpFade(WarpFadeCase test_case) + : UnitTest("PokemonFRLG::OverworldWarpFade - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + RouteStep out; + out.direction = Direction::SOUTH; + out.tiles = 5; + out.warps = true; + out.to_wall = true; + out.facing_after = Direction::SOUTH; + out.end = OUTSIDE; + SimScript script; + script.timing.fade = m_case.input.fade; + SimWalkerRules rules; + rules.watch_fade_along_hold = m_case.input.watch_along_hold; + OverworldSim sim(maps(), MART, Direction::SOUTH, script); + OverworldSimWalker walker(sim, rules); + const RoutePlanning planning{.maps = &maps(), .costs = default_route_costs(), .start = MART, .label = "Sim"}; + const std::string failure = walker.run({out}, planning); + const std::string& expected = m_case.expectation.failure; + const bool as_expected = expected.empty() ? failure.empty() : failure.find(expected) != std::string::npos; + if (as_expected && sim.state().warps == 1){ + return true; + } + std::string log; + for (const std::string& line : walker.log()){ + log += "\n " + line; + } + return std::format( + "Expected failure \"{}\" after 1 warp; got \"{}\" after {}:{}", + expected, failure, sim.state().warps, log + ); + } + +private: + WarpFadeCase m_case; +}; + +} // namespace + + +const WarpFadeCase WARP_FADE_CASES[] = { + { + .input = {.name = "a fade over before the hold ends, watched from its start", .fade = 200ms}, + .expectation = {.failure = ""}, + }, + { + .input = {.name = "a fade over before the hold ends, watched from its end", .fade = 200ms, .watch_along_hold = false}, + .expectation = {.failure = "no fade in and out after walking onto the warp to ViridianCity"}, + }, + { + .input = {.name = "a fade still on when the hold ends, watched from its end", .fade = 1000ms, .watch_along_hold = false}, + .expectation = {.failure = ""}, + }, +}; + +void add_tests_OverworldWarpFade(UnitTestDatabase& database){ + for (const WarpFadeCase& item : WARP_FADE_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index f3d79ac4b8..0b44daf242 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1871,6 +1871,7 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWarpFade_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.h Source/VideoGames/PokemonFRLG/PokemonFRLG_Navigation.cpp From ec9cf9fc6840340c9df71c535fc6877cdaf27af9 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 11:19:42 -0800 Subject: [PATCH 06/24] FRLG walker: walk on after a wild battle when the next holds find the player Rock Tunnel 00.28 with a wild battle every 7 to 25 steps (sim) still lost the open legs' progress after the first fix: - Walk on: after an open leg stops, if the route's next holds bring every tile the player may be on to one tile (and none of those ways crosses a trainer's sight off the planned way), the walker walks on the rest of the leg from the middle candidate instead of finding the player at walls first. The player still faces the leg's way, so no turn tap. A stop on the walked-on leg counts from every tile it may start on. - A leg onto a warp stopped by a wild battle, where no trainer may see the player, holds on to the warp as off the grass: below Rock Tunnel 1F's ladder (20, 13) the holds that found the player went down a column Hiker Lucas looks over. - plan_reanchor() weighs open-leg tiles 4x over held ones, and puts ways whose holds cross a trainer's sight last. - The sim looks for trainers before rolling for a wild battle, as the game does (pokefirered ProcessPlayerFieldInput()). OverworldReanchor rules and two OverworldSim rule tests follow the new choices. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_OverworldReanchor.cpp | 218 ++++++++++++++++-- .../Overworld/PokemonFRLG_OverworldReanchor.h | 32 ++- .../PokemonFRLG_OverworldReanchor_Tests.cpp | 19 +- .../PokemonFRLG_OverworldRecovery.cpp | 72 +++++- .../PokemonFRLG_OverworldRecoveryWalk.cpp | 15 +- .../Overworld/PokemonFRLG_OverworldSim.cpp | 13 +- .../PokemonFRLG_OverworldSim_Tests.cpp | 18 +- .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 30 ++- 8 files changed, 363 insertions(+), 54 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index cdacd42e30..8917415cfb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -175,6 +175,11 @@ std::optional, RouteStep>> hold_all( return std::make_pair(std::move(ends), hold); } +// Whether `step` is a hold to a wall. +bool is_hold(const RouteStep& step){ + return step.action == RouteAction::WALK && step.to_wall && !step.warps; +} + using Found = Pokemon::WallAnchor; // Runtime world alternatives and movement mode, separate from the policy type. @@ -362,37 +367,104 @@ int reanchor_tiles(const Reanchor& anchor){ return held_tiles(anchor.holds) + held_tiles(anchor.back); } -std::optional plan_reanchor( - const std::vector& maps, - const std::vector& candidates, - const RouteStep& step, - const RouteCosts& costs +namespace{ + +// How much more a tile of an open leg costs than one held to a wall: a +// wild battle on a hold costs only the battle, as the hold goes on to its +// wall, while one on an open leg stops it and needs finding the player +// again. Not measured: weighted so that on Rock Tunnel B1F row 38 the way +// west to the wall (1, 38) and 13 tiles back beats 10 held east to the +// wall (30, 38) and 17 back. +constexpr int OPEN_TILE_WEIGHT = 4; + +// How costly `anchor` is to walk: its tiles, with open legs' weighted. +int reanchor_cost(const Reanchor& anchor){ + int open = 0; + for (const RouteStep& step : anchor.back){ + if (step.action == RouteAction::WALK && !step.to_wall && !step.warps){ + open += step.tiles; + } + } + return reanchor_tiles(anchor) + (OPEN_TILE_WEIGHT - 1) * open; +} + +// Whether `holds`, from any of `positions`, pass a tile a trainer may see. +// A trainer the route keeps clear of would stop the player and fight +// (the sim, 2026-11-09: Hiker Lucas, on a hold down Rock Tunnel 1F's +// column 16 after a wild battle below the ladder (20, 13)). +bool holds_in_sight( + const std::vector>& worlds, const std::vector& positions, const std::vector& holds ){ - if (candidates.empty() || step.action != RouteAction::WALK){ - return std::nullopt; + for (const Hypothesis& position : positions){ + const std::vector& world = worlds[position.world]; + MapPosition at = position.at; + for (const RouteStep& hold : holds){ + const std::optional wall = hold_to_wall(world, at, hold.surfing, hold.direction); + if (!wall){ + break; + } + RouteStep held = hold; + held.tiles = wall->tiles; + for (const MapPosition& tile : leg_tiles(world, at, held)){ + if (!trainers_seeing(world, tile).empty()){ + return true; + } + } + at = wall->end; + } } + return false; +} - // The maps as they may stand: as they are, and with each trainer who - // may have walked up. - std::vector> worlds{maps}; +// Ways whose holds pass a trainer's sight come after every other way. +constexpr int IN_SIGHT_COST = 1000; + +// The maps as they may stand after a stop on one of `candidates`: as they +// are, and with each trainer who may have walked up; where those trainers +// stand, and where the player may be on which of the maps. +struct StopWorlds{ + std::vector> worlds; std::vector trainers; std::vector positions; +}; +StopWorlds stop_worlds(const std::vector& maps, const std::vector& candidates){ + StopWorlds out{.worlds = {maps}, .trainers = {}, .positions = {}}; for (const StopCandidate& candidate : candidates){ size_t world = 0; if (candidate.trainer){ - worlds.push_back(with_trainers(maps, {candidate.trainer->at})); - world = worlds.size() - 1; - add_unique(trainers, candidate.trainer->at); + out.worlds.push_back(with_trainers(maps, {candidate.trainer->at})); + world = out.worlds.size() - 1; + add_unique(out.trainers, candidate.trainer->at); } - add_unique(positions, Hypothesis{world, candidate.player}); + add_unique(out.positions, Hypothesis{world, candidate.player}); } + return out; +} - // Of the ways found, the one that walks fewest tiles, holds and way back - // together: it keeps the most of what the leg walked. A wild battle - // every 10 to 14 tiles stopped every try of a leg whose way back - // started behind it (console B, 2026-11-09, Rock Tunnel B1F row 37). +} // namespace + +std::optional plan_reanchor( + const std::vector& maps, + const std::vector& candidates, + const RouteStep& step, + const RouteCosts& costs +){ + if (candidates.empty() || step.action != RouteAction::WALK){ + return std::nullopt; + } + + const StopWorlds stop = stop_worlds(maps, candidates); + const std::vector>& worlds = stop.worlds; + const std::vector& trainers = stop.trainers; + const std::vector& positions = stop.positions; + + // Of the ways found, the cheapest (reanchor_cost()): it keeps the most + // of what the leg walked. A wild battle every 10 to 14 tiles stopped + // every try of a leg whose way back started behind it (console B, + // 2026-11-09, Rock Tunnel B1F row 37). const std::vector clear_of_trainers = with_trainers(maps, trainers); std::optional best; + int best_cost = 0; for (const Found& at : find_player(worlds, positions, step.surfing)){ const Direction facing = at.holds.empty() ? step.direction : at.holds.back().direction; // The way back keeps clear of every trainer who may stand in it if @@ -405,13 +477,121 @@ std::optional plan_reanchor( continue; } Reanchor anchor{.holds = at.holds, .found = at.at, .back = std::move(*back)}; - if (!best || reanchor_tiles(anchor) < reanchor_tiles(*best)){ + const int cost = reanchor_cost(anchor) + (holds_in_sight(worlds, positions, anchor.holds) ? IN_SIGHT_COST : 0); + if (!best || cost < best_cost){ best = std::move(anchor); + best_cost = cost; } } return best; } +namespace{ + +// How many tiles `from` is before `to`, walking `direction` on one map; +// nothing if `to` isn't ahead on that line within `max_tiles`. +std::optional tiles_ahead(const MapPosition& from, const MapPosition& to, Direction direction, int max_tiles){ + if (from.map != to.map){ + return std::nullopt; + } + TilePos at = from.point; + for (int c = 0; c <= max_tiles; c++){ + if (at == to.point){ + return c; + } + at = at.next(direction); + } + return std::nullopt; +} + +// One candidate's way on: the tiles it walks, and where it stands. +struct WayOn{ + std::vector tiles; + MapPosition at; +}; + +// `hold` from where each of `ways` stands. False if any isn't stopped by a +// wall, or may be stopped short by a person. +bool hold_ways(const std::vector& maps, const RouteStep& hold, std::vector& ways){ + for (WayOn& way : ways){ + const std::optional wall = hold_to_wall(maps, way.at, hold.surfing, hold.direction); + if (!wall || person_on_the_way(maps, way.at, hold.surfing, hold.direction, *wall)){ + return false; + } + RouteStep held = hold; + held.tiles = wall->tiles; + const std::vector tiles = leg_tiles(maps, way.at, held); + way.tiles.insert(way.tiles.end(), tiles.begin() + 1, tiles.end()); + way.at = wall->end; + } + return true; +} + +// Whether any of `ways` walks onto a tile off `planned` that a trainer may +// see: walking on a few tiles off the planned way may cross a trainer's +// line the route keeps clear of. +bool off_way_in_sight(const std::vector& maps, const std::vector& ways, const std::vector& planned){ + for (const WayOn& way : ways){ + for (const MapPosition& tile : way.tiles){ + const bool off_way = std::find(planned.begin(), planned.end(), tile) == planned.end(); + if (off_way && !trainers_seeing(maps, tile).empty()){ + return true; + } + } + } + return false; +} + +// Each of `candidates` walking `rest`; nothing if any is stopped short. +std::optional> walk_rest( + const std::vector& maps, const std::vector& candidates, const RouteStep& rest +){ + std::vector ways; + for (const StopCandidate& candidate : candidates){ + std::vector tiles = leg_tiles(maps, candidate.player, rest); + if (tiles.size() != (size_t)rest.tiles + 1){ + return std::nullopt; + } + const MapPosition at = tiles.back(); + ways.push_back(WayOn{std::move(tiles), at}); + } + return ways; +} + +} // namespace + +std::optional plan_resume( + const std::vector& maps, + const std::vector& candidates, + const RouteStep& step, + const std::vector& after +){ + const bool open_leg = step.action == RouteAction::WALK && !step.to_wall && !step.warps && step.connection_steps.empty(); + const bool by_trainer = std::any_of(candidates.begin(), candidates.end(), [](const StopCandidate& c){ return c.trainer.has_value(); }); + if (candidates.size() < 2 || !open_leg || by_trainer){ + return std::nullopt; + } + const std::optional remaining = tiles_ahead(candidates[candidates.size() / 2].player, step.end, step.direction, step.tiles); + if (!remaining){ + return std::nullopt; + } + RouteStep rest = step; + rest.tiles = *remaining; + std::optional> ways = walk_rest(maps, candidates, rest); + // The way as planned, from the leg's end. + std::vector planned{WayOn{{step.end}, step.end}}; + for (size_t c = 0; ways && c < after.size() && c < MAX_HOLDS; c++){ + if (!is_hold(after[c]) || !hold_ways(maps, after[c], *ways) || !hold_ways(maps, after[c], planned)){ + return std::nullopt; + } + const bool found = std::all_of(ways->begin(), ways->end(), [&](const WayOn& way){ return way.at == after[c].end; }); + if (found){ + return off_way_in_sight(maps, *ways, planned[0].tiles) ? std::nullopt : std::optional(Resume{rest, c + 1}); + } + } + return std::nullopt; +} + std::optional> holds_to_one_tile( const std::vector& maps, const std::vector& tiles ){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index 1d1983c608..7d0c2e2e69 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -111,8 +111,9 @@ int reanchor_tiles(const Reanchor& anchor); // After `step` stopped part-way, from `candidates`: of the ways that find // the player, the one that walks the fewest tiles, holds and way back -// together. Nothing if no holds bring them all to one tile, or no way -// back walks the same from there. +// together, with tiles of open legs weighted over held ones. Nothing if +// no holds bring them all to one tile, or no way back walks the same from +// there. std::optional plan_reanchor( const std::vector& maps, const std::vector& candidates, @@ -120,6 +121,33 @@ std::optional plan_reanchor( const RouteCosts& costs = default_route_costs() ); +// Walking on along a stopped open leg instead of finding the player at +// walls first: the rest of the leg from the middle of the tiles they may +// be on, after which the route's next holds, as planned, bring them to +// one tile from every one of them. Walking on from how far a leg got, +// trusting the timing, went wrong on the console; here the holds find the +// player whichever tile they were on. +struct Resume{ + // From the middle candidate to the stopped leg's end; no tiles if the + // middle candidate is its end. + RouteStep rest; + // How many of the steps after the stopped leg, all holds to walls, + // bring every candidate to one tile. + size_t holds = 0; +}; + +// After `step`, an open leg, stopped part-way on one of `candidates` +// (none stopped by a trainer), with `after` the route's steps after it: +// the way to walk on. Nothing if the holds that start `after` don't bring +// every candidate to one tile, or if any would walk onto a tile off the +// planned way that a trainer may see. +std::optional plan_resume( + const std::vector& maps, + const std::vector& candidates, + const RouteStep& step, + const std::vector& after +); + // Holds to walls that bring the player to one tile from every one of // `tiles`, found as plan_reanchor() finds them, with no trainer about: for // a player known to be on one of a few tiles without a stop (a wild diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp index 8e91a54445..650317683e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor_Tests.cpp @@ -152,20 +152,29 @@ const char* const TEST_FIELD_JSON = R"({ })"; // Stopped part-way east, the player holds back west to the wall, the -// shorter way, and walks back. Stopped part-way south above the ledges, -// which would be hopped, they hold back north. +// shorter way, and walks back. Stopped a tile further from that wall, they +// hold on east to the far wall: two more tiles held, one fewer to walk +// back on an open leg, which a wild battle would stop again. Stopped +// part-way south above the ledges, which would be hopped, they hold back +// north. UnitTestResult test_rules(){ const std::vector maps{parse_map(TEST_FIELD_JSON, "Field")}; RouteStep east; east.direction = Direction::EAST; - east.tiles = 3; - east.end = {"Field", {4, 1}}; + east.tiles = 2; + east.end = {"Field", {3, 1}}; std::optional anchor = plan_reanchor(maps, stop_candidates(maps, MapPosition{"Field", {1, 1}}, east, whole_leg(east)), east); if (!anchor || anchor->holds.size() != 1 || anchor->holds[0].direction != Direction::WEST || !(anchor->found.point == TilePos{1, 1})){ return "Expected a hold west to (1, 1), got " + describe(anchor); } if (anchor->back.size() != 1 || !(anchor->back[0].end == east.end)){ - return "Expected one leg back east to (4, 1), got " + describe(anchor); + return "Expected one leg back east to (3, 1), got " + describe(anchor); + } + east.tiles = 3; + east.end = {"Field", {4, 1}}; + anchor = plan_reanchor(maps, stop_candidates(maps, MapPosition{"Field", {1, 1}}, east, whole_leg(east)), east); + if (!anchor || anchor->holds.size() != 1 || anchor->holds[0].direction != Direction::EAST || !(anchor->found.point == TilePos{6, 1})){ + return "Expected a hold east to (6, 1), got " + describe(anchor); } RouteStep south; south.direction = Direction::SOUTH; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index b5b2c8d0f1..ca9e278392 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -299,7 +299,7 @@ class RouteRun{ if (step.to_wall){ return after_wall_stop(step, outcome, start, crossing); } - if (outcome.end == StepEnd::STOPPED && step.warps && !step.wild){ + if (outcome.end == StepEnd::STOPPED && step.warps && (!step.wild || by_wild_battle(step, start, outcome))){ m_calls.hold_to_warp(step); m_at = step.end; m_facing = step.facing_after; @@ -313,7 +313,40 @@ class RouteRun{ return false; } const WalkedTiles walked = outcome.walked.value_or(whole_leg(step)); - return rejoin(step, stop_candidates(*maps(), start, step, walked), walked); + return rejoin(step, candidates_on(step, start, walked), walked); + } + + // Whether `step`, a leg with wild battles stopped part-way, was stopped + // by one: no trainer may see the player on it. A wild battle leaves the + // player on the leg's line, so holding on reaches its warp; found again + // at walls instead, below Rock Tunnel 1F's ladder (20, 13), the holds + // went down a column Hiker Lucas looks over (the sim, 2026-11-09). + bool by_wild_battle(const RouteStep& step, const std::optional& start, const StepOutcome& outcome) const{ + if (maps() == nullptr){ + return false; + } + const std::vector candidates = candidates_on(step, start, outcome.walked.value_or(whole_leg(step))); + return !candidates.empty() && std::none_of(candidates.begin(), candidates.end(), [](const StopCandidate& candidate){ + return candidate.trainer.has_value(); + }); + } + + // Where `step`, m_plan[m_next] from `start`, may have stopped. The rest + // of a leg walked on after a stop (walk_on()) starts on any of the + // tiles that stop left the player on. + std::vector candidates_on(const RouteStep& step, const std::optional& start, WalkedTiles walked) const{ + if (m_next != 0 || m_walk_on_starts.empty()){ + return stop_candidates(*maps(), start, step, walked); + } + std::vector candidates; + for (const MapPosition& from : m_walk_on_starts){ + for (const StopCandidate& candidate : stop_candidates(*maps(), from, step, walked)){ + if (std::find(candidates.begin(), candidates.end(), candidate) == candidates.end()){ + candidates.push_back(candidate); + } + } + } + return candidates; } // A leg to a wall seen interrupted after its hold is walked again: the @@ -386,11 +419,36 @@ class RouteRun{ plan.insert(plan.end(), m_plan.begin() + rest, m_plan.end()); m_plan = std::move(plan); m_walked.reset(); + m_walk_on_starts.clear(); m_holds = anchor.holds.size(); m_unchecked = m_holds + anchor.back.size(); m_next = 0; } + // The plan from here: the rest of `step`, the stopped leg m_plan[m_next], + // from the middle of `candidates`, then the old plan after it, whose + // first `resume.holds` steps find the player. The player still faces + // the leg's way: a turn tap first would step a tile. + void walk_on(const RouteStep& step, const Resume& resume, const std::vector& candidates){ + std::vector plan; + m_walk_on_starts.clear(); + if (resume.rest.tiles > 0){ + plan.push_back(resume.rest); + for (const StopCandidate& candidate : candidates){ + m_walk_on_starts.push_back(candidate.player); + } + } + plan.insert(plan.end(), m_plan.begin() + m_next + 1, m_plan.end()); + m_plan = std::move(plan); + m_walked.reset(); + m_holds = (resume.rest.tiles > 0 ? 1 : 0) + resume.holds; + m_unchecked = m_holds; + m_next = 0; + m_at = std::nullopt; + m_facing = step.direction; + m_lines.plan(std::nullopt, m_plan); + } + // Finds the player on one of `candidates`, plans the way back to the // end of `step` (m_plan[m_next]), and the rest of the route after it. bool rejoin(const RouteStep& step, const std::vector& candidates, WalkedTiles walked){ @@ -402,6 +460,15 @@ class RouteRun{ "Route: {} stopped {} to {} tiles in; the player is on one of: {}.", to_string(step), walked.min, walked.max, where.empty() ? "nothing (no start to work from)" : where )); + const std::vector after(m_plan.begin() + m_next + 1, m_plan.end()); + if (const std::optional resume = plan_resume(*maps(), candidates, step, after)){ + m_calls.log(std::format( + "Route: walking on {} tiles along {}; the next {} hold(s) find the player from every one of those tiles.", + resume->rest.tiles, to_string(step), resume->holds + )); + walk_on(step, *resume, candidates); + return true; + } const std::optional anchor = candidates.empty() ? std::nullopt : plan_reanchor(*maps(), candidates, step, m_planning->costs); @@ -444,6 +511,7 @@ class RouteRun{ size_t m_holds = 0; // of the plan, holds to walls that find the player size_t m_unchecked = 0; // of the plan, the holds and the way back after them std::optional m_walked; // the step walked last, if it may have stopped part-way + std::vector m_walk_on_starts; // where the plan's first step, a leg walked on, may start }; } // namespace diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 8aac65097e..a5dced5853 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -186,12 +186,13 @@ StepOutcome walk_and_watch( return StepOutcome{.end = StepEnd::LATE, .walked = recorder.place(console, step, tile_time(step, speed), walked.hold_start)}; } -// Holds on along `step`, stopped part-way off the grass, until its warp's -// fade. Off the grass only a message stops a leg (a trainer who saw the -// player, a Repel wearing off), and the player then stands on the leg's -// line, so holding on reaches the warp however far they got (console B, -// 2026-11-01: Camper Liam stopped "south 9 (warp)" out of Pewter Gym). -// After a trainer the player faces them, so this turns first. +// Holds on along `step`, stopped part-way off the grass or by a wild +// battle where no trainer may see the player, until its warp's fade. Off +// the grass only a message stops a leg (a trainer who saw the player, a +// Repel wearing off), and the player then stands on the leg's line, as +// after a wild battle, so holding on reaches the warp however far they +// got (console B, 2026-11-01: Camper Liam stopped "south 9 (warp)" out of +// Pewter Gym). After a trainer the player faces them, so this turns first. void hold_to_warp( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, const OverworldInterrupts& interrupts, MoveSpeed speed @@ -303,7 +304,7 @@ void walk_overworld_route_with_recovery( return walk_and_watch(console, context, step, from_facing, interrupts, speed); }; calls.hold_to_warp = [&](const RouteStep& step){ - hold_to_warp(console, context, step, interrupts.other, speed); + hold_to_warp(console, context, step, step.wild ? interrupts.wild : interrupts.other, speed); }; calls.log = [&](const std::string& message){ console.log(message, COLOR_ORANGE); }; calls.fail = [&](const std::string& message){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 3395eda537..f39bf0f5ed 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -345,7 +345,7 @@ bool OverworldSim::trainer_sees(Milliseconds pressed){ } // The player has taken `step`, pressed at `pressed`. Returns whether the -// game stopped them there: a warp, a wild battle, a trainer. +// game stopped them there: a warp, a trainer, a wild battle. bool OverworldSim::after_step(const Step& step, Milliseconds pressed){ const SimTiming& timing = m_script.timing; m_map = step.map; @@ -364,7 +364,11 @@ bool OverworldSim::after_step(const Step& step, Milliseconds pressed){ note("warped to " + to_string(m_state.position) + "."); return true; } - return wild_battle(pressed) || trainer_sees(pressed); + // The game looks for trainers who see the player before it rolls for a + // wild battle, and doesn't roll once one does (pokefirered + // ProcessPlayerFieldInput(): CheckForTrainersWantingBattle() first, + // CheckStandardWildEncounter() last). + return trainer_sees(pressed) || wild_battle(pressed); } // A wild battle the script starts on this step onto tall grass, or onto a @@ -785,8 +789,9 @@ size_t OverworldSimWalker::clear_unseen_battle(const RouteStep& step){ } void OverworldSimWalker::hold_to_warp(const RouteStep& step){ + const std::vector& ints = interrupts(step); std::vector fade_first{SimWatcher::FADE}; - for (const SimWatcher watcher : watchers(m_other)){ + for (const SimWatcher watcher : watchers(ints)){ fade_first.push_back(watcher); } const int warps_before = m_sim.state().warps; @@ -803,7 +808,7 @@ void OverworldSimWalker::hold_to_warp(const RouteStep& step){ if (!seen){ break; } - clear(m_other[seen->watcher - 1]); + clear(ints[seen->watcher - 1]); } fail("walk_overworld_route(): no fade holding on toward the warp to " + step.end.map + "."); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index f8a2c4ca80..d289fb2b1d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -296,10 +296,10 @@ UnitTestResult test_forest_north_gate(){ // on the leg a tile short of the ladder. Here on the leg through the // forest's grass onto its north gate: a battle on the leg's first or // second step is run from, the player found again on the leg, and the -// gate reached through its warp. If the walk back onto the warp then -// ends short of it (hold 4: the leg, the hold that finds the player and -// its re-hold after the Bug Catcher), the route still stops on no fade. -// Without the rule it stops as off the route, as on the console. +// gate reached through its warp: the player is on the leg's line, and +// holds on to it. If that hold then ends well short of the warp (hold 2: +// the leg, then the hold on), the route still stops on no fade. Without the +// rule it stops as off the route, as on the console. UnitTestResult test_battle_on_warp_leg(){ const SimCase c{.start = at("ViridianForest", 5, 20), .facing = Direction::NORTH, .goal = at("Route2_ViridianForest_NorthEntrance", 7, 9)}; for (size_t tile : {1, 2}){ @@ -312,9 +312,9 @@ UnitTestResult test_battle_on_warp_leg(){ } } SimCase no_warp = c; - no_warp.script.short_holds[4] = 2; + no_warp.script.short_holds[2] = 6; const std::optional short_back = run_with_battle_on_warp_leg(no_warp, 1); - if (!short_back || !stopped_with(*short_back, "no fade in and out") || short_back->state.warps != 0){ + if (!short_back || !stopped_with(*short_back, "no fade holding on toward the warp") || short_back->state.warps != 0){ return "Expected a stop on no fade when the walk back ends short of the warp:" + (short_back ? describe(*short_back) : ""); } SimCase off = c; @@ -658,7 +658,9 @@ UnitTestResult test_mt_moon_battle_on_ladder_leg(){ // timing. The text box after the watch is now watched on for, the leg // walked again to its wall, and the route reaches B2F's ladder. Without // the rule the leg is taken as walked, and the run stops on no fade onto -// the ladder down to B2F, as it did. +// the ladder down to B2F, as it did (the console's walker stopped on the +// leg; this one, which holds on toward a warp after a wild battle, stops +// holding on). UnitTestResult test_mt_moon_battle_seen_after_wall_leg(){ SimCase c{.start = at("MtMoon_B1F", 3, 3), .facing = Direction::SOUTH, .goal = at("MtMoon_B2F", 26, 21)}; c.script.encounters = {{.grass_step = 13, .seen_late = true}}; @@ -679,7 +681,7 @@ UnitTestResult test_mt_moon_battle_seen_after_wall_leg(){ const bool taken_as_walked = std::any_of(old.log.begin(), old.log.end(), [](const std::string& line){ return line.find("south 15 (to wall): ended at MtMoon_B1F (3, 18) (walked)") != std::string::npos; }); - if (!taken_as_walked || !stopped_with(old, "no fade in and out after walking onto the warp to MtMoon_B2F.")){ + if (!taken_as_walked || !stopped_with(old, "no fade holding on toward the warp to MtMoon_B2F.")){ return "Expected the leg taken as walked and the run stopped on no fade, as on the console, without the rule:" + describe(old); } return true; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index 4c96611513..faff538e47 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -98,14 +98,18 @@ std::string row_rest(OverworldSim& sim, StoryPose at, const StoryCheckpoint& row return ""; } -UnitTestResult row_on_sim(std::string_view id, const SimScript& script, const std::vector& trainers, const MapPosition& end){ +// Row `id` on the sim with `script`: it ends on `end`, with no violation +// on the way, having fought the trainers of one of `fought`, in order. +UnitTestResult row_on_sim( + std::string_view id, const SimScript& script, const std::vector>& fought, const MapPosition& end +){ const StoryCheckpoint& row = checkpoint(id); const StoryPose at = *story_pose(*row.start); OverworldSim sim(maps_after_route10(), MapPosition{at.map, at.tile}, *at.facing, script); if (const std::string failure = row_rest(sim, at, row, 0); !failure.empty()){ return std::string(id) + ": " + failure; } - if (sim.state().trainers_beaten != trainers){ + if (std::find(fought.begin(), fought.end(), sim.state().trainers_beaten) == fought.end()){ std::string fought; for (const std::string& name : sim.state().trainers_beaten){ fought += " " + name; @@ -124,12 +128,18 @@ UnitTestResult row_on_sim(std::string_view id, const SimScript& script, const st const std::vector ROCK_TUNNEL_TRAINERS{ "poke_maniac", "poke_maniac", "picnicker", "hiker", "hiker", "picnicker", "hiker", "poke_maniac", "picnicker", }; +// Cooper unfought: a wild battle on the step into his sight, on the leg +// past him, starts first, and the sim's trainers look only after a step. +// The leg goes on past him to its wall either way. +const std::vector ROCK_TUNNEL_TRAINERS_BUT_COOPER{ + "poke_maniac", "poke_maniac", "picnicker", "hiker", "hiker", "picnicker", "hiker", "picnicker", +}; // 00.28 on the sim: the tree cut, the nine trainers fought where the // table steps into their lines (Cooper by the walker, on the leg past // him), no one else met, and the walk out of the south mouth. UnitTestResult test_rock_tunnel_row(){ - return row_on_sim("00.28", SimScript{}, ROCK_TUNNEL_TRAINERS, MapPosition{StoryTable::ROUTE10, {8, 58}}); + return row_on_sim("00.28", SimScript{}, {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); } // 00.28 with a wild battle on the 3rd step on the tunnel's floor, mid-way @@ -138,7 +148,7 @@ UnitTestResult test_rock_tunnel_row(){ UnitTestResult test_rock_tunnel_wild_battles(){ SimScript script; script.encounters.push_back(SimEncounter{.grass_step = 3}); - return row_on_sim("00.28", script, ROCK_TUNNEL_TRAINERS, MapPosition{StoryTable::ROUTE10, {8, 58}}); + return row_on_sim("00.28", script, {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); } // Wild battles on the tunnel's floor, `gaps` steps apart in turn, from the @@ -187,6 +197,7 @@ UnitTestResult test_b1f_row37_battles_every_12(){ // 00.28 whole with a wild battle every 7 to 25 steps on the tunnel's floor, // the gaps drawn from each of these seeds: the row ends where it should. UnitTestResult test_rock_tunnel_frequent_battles(){ + std::string failures; for (unsigned seed = 1; seed <= 12; seed++){ std::mt19937 random(seed); std::uniform_int_distribution gap(7, 25); @@ -194,17 +205,22 @@ UnitTestResult test_rock_tunnel_frequent_battles(){ for (int c = 0; c < 400; c++){ gaps.push_back(gap(random)); } - const UnitTestResult result = row_on_sim("00.28", battles_every(gaps), ROCK_TUNNEL_TRAINERS, MapPosition{StoryTable::ROUTE10, {8, 58}}); + const UnitTestResult result = row_on_sim( + "00.28", battles_every(gaps), {ROCK_TUNNEL_TRAINERS, ROCK_TUNNEL_TRAINERS_BUT_COOPER}, MapPosition{StoryTable::ROUTE10, {8, 58}} + ); if (result.result != UnitTestResult::PASSED){ - return std::format("Seed {}: {}", seed, result.message); + failures += std::format("{}Seed {}: {}", failures.empty() ? "" : " | ", seed, result.message); } } + if (!failures.empty()){ + return failures; + } return true; } // 00.29 on the sim: no trainer met, the ledge hopped, Lavender's nurse. UnitTestResult test_lavender_row(){ - return row_on_sim("00.29", SimScript{}, {}, MapPosition{StoryTable::LAVENDER_CENTER, {7, 4}}); + return row_on_sim("00.29", SimScript{}, {{}}, MapPosition{StoryTable::LAVENDER_CENTER, {7, 4}}); } // Where an open leg of two tiles or more ends a tile short or long, the From 291f72d5c9a4c2bff7b6b04fd3c2db9cfc40b8cf Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 11:26:58 -0800 Subject: [PATCH 07/24] FRLG AutoStory: run from wild battles on steps into a trainer's line; walk on through open legs The 00.28 sim with a wild battle every 7 to 25 steps now passes at all 12 seeds (before these changes: 0 of 12). - A step into a trainer's line that sets off a wild battle took its battle screen for the trainer's, and the battle step after it found no message. The step now watches 5 s for a wild battle, runs from one seen with no message before it, and steps on (program and sim). The game looks for trainers before it rolls for a wild battle, so the trainer didn't see the player on that step. - Walking on after a stop may go through open legs of the plan before the hold that finds the player (Rock Tunnel 1F row 31: west, then south 1, then the hold west to (12, 32)); a stop on any of them counts from every tile it may start on. - A hold to a wall where wild battles start, stopped right away after another interrupt (Cooper, then a wild battle), is walked again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_OverworldReanchor.cpp | 55 ++++++++++++------- .../Overworld/PokemonFRLG_OverworldReanchor.h | 23 ++++---- .../PokemonFRLG_OverworldRecovery.cpp | 38 ++++++++----- .../PokemonFRLG_AutoStoryActions.cpp | 9 ++- .../AutoStory/PokemonFRLG_AutoStoryActions.h | 11 +++- .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 11 +++- 6 files changed, 99 insertions(+), 48 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index 8917415cfb..f5328d5980 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -17,6 +17,9 @@ namespace PokemonFRLG{ namespace{ constexpr size_t MAX_HOLDS = 3; +// How many of the route's steps after a leg walked on may go by before +// they find the player. +constexpr size_t MAX_WALK_ON_STEPS = 4; Direction opposite(Direction direction){ return (Direction)(((int)direction + 2) % 4); @@ -175,11 +178,6 @@ std::optional, RouteStep>> hold_all( return std::make_pair(std::move(ends), hold); } -// Whether `step` is a hold to a wall. -bool is_hold(const RouteStep& step){ - return step.action == RouteAction::WALK && step.to_wall && !step.warps; -} - using Found = Pokemon::WallAnchor; // Runtime world alternatives and movement mode, separate from the policy type. @@ -510,19 +508,28 @@ struct WayOn{ MapPosition at; }; -// `hold` from where each of `ways` stands. False if any isn't stopped by a -// wall, or may be stopped short by a person. -bool hold_ways(const std::vector& maps, const RouteStep& hold, std::vector& ways){ +// `step`, a hold to a wall or an open leg onto no warp, from where each of +// `ways` stands. False if a hold isn't stopped by a wall, an open leg is +// stopped short, or either may be stopped short by a person. +bool walk_ways(const std::vector& maps, const RouteStep& step, std::vector& ways){ + if (step.action != RouteAction::WALK || step.warps || !step.connection_steps.empty()){ + return false; + } for (WayOn& way : ways){ - const std::optional wall = hold_to_wall(maps, way.at, hold.surfing, hold.direction); - if (!wall || person_on_the_way(maps, way.at, hold.surfing, hold.direction, *wall)){ + std::optional walked = step.to_wall + ? hold_to_wall(maps, way.at, step.surfing, step.direction) + : std::optional(WallHold{.end = way.at, .tiles = step.tiles}); + if (!walked || person_on_the_way(maps, way.at, step.surfing, step.direction, *walked)){ return false; } - RouteStep held = hold; - held.tiles = wall->tiles; + RouteStep held = step; + held.tiles = walked->tiles; const std::vector tiles = leg_tiles(maps, way.at, held); + if (tiles.size() != (size_t)held.tiles + 1){ + return false; + } way.tiles.insert(way.tiles.end(), tiles.begin() + 1, tiles.end()); - way.at = wall->end; + way.at = tiles.back(); } return true; } @@ -575,18 +582,26 @@ std::optional plan_resume( if (!remaining){ return std::nullopt; } - RouteStep rest = step; - rest.tiles = *remaining; - std::optional> ways = walk_rest(maps, candidates, rest); + Resume resume{.rest = step, .steps = 0, .starts = {{}}}; + resume.rest.tiles = *remaining; + for (const StopCandidate& candidate : candidates){ + resume.starts[0].push_back(candidate.player); + } + std::optional> ways = walk_rest(maps, candidates, resume.rest); // The way as planned, from the leg's end. std::vector planned{WayOn{{step.end}, step.end}}; - for (size_t c = 0; ways && c < after.size() && c < MAX_HOLDS; c++){ - if (!is_hold(after[c]) || !hold_ways(maps, after[c], *ways) || !hold_ways(maps, after[c], planned)){ + for (size_t c = 0; ways && c < after.size() && c < MAX_WALK_ON_STEPS; c++){ + resume.starts.emplace_back(); + for (const WayOn& way : *ways){ + resume.starts.back().push_back(way.at); + } + if (!walk_ways(maps, after[c], *ways) || !walk_ways(maps, after[c], planned)){ return std::nullopt; } - const bool found = std::all_of(ways->begin(), ways->end(), [&](const WayOn& way){ return way.at == after[c].end; }); + const bool found = after[c].to_wall && std::all_of(ways->begin(), ways->end(), [&](const WayOn& way){ return way.at == after[c].end; }); if (found){ - return off_way_in_sight(maps, *ways, planned[0].tiles) ? std::nullopt : std::optional(Resume{rest, c + 1}); + resume.steps = c + 1; + return off_way_in_sight(maps, *ways, planned[0].tiles) ? std::nullopt : std::optional(std::move(resume)); } } return std::nullopt; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index 7d0c2e2e69..28530d8c11 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -123,24 +123,27 @@ std::optional plan_reanchor( // Walking on along a stopped open leg instead of finding the player at // walls first: the rest of the leg from the middle of the tiles they may -// be on, after which the route's next holds, as planned, bring them to -// one tile from every one of them. Walking on from how far a leg got, -// trusting the timing, went wrong on the console; here the holds find the -// player whichever tile they were on. +// be on, after which the route's next steps, as planned, end with a hold +// that brings them to one tile from every one of them. Walking on from +// how far a leg got, trusting the timing, went wrong on the console; here +// a hold finds the player whichever tile they were on. struct Resume{ // From the middle candidate to the stopped leg's end; no tiles if the // middle candidate is its end. RouteStep rest; - // How many of the steps after the stopped leg, all holds to walls, - // bring every candidate to one tile. - size_t holds = 0; + // How many of the steps after the stopped leg go by until one, a hold + // to a wall, brings every candidate to one tile. + size_t steps = 0; + // Where each of those steps may start, `rest` first. + std::vector> starts; }; // After `step`, an open leg, stopped part-way on one of `candidates` // (none stopped by a trainer), with `after` the route's steps after it: -// the way to walk on. Nothing if the holds that start `after` don't bring -// every candidate to one tile, or if any would walk onto a tile off the -// planned way that a trainer may see. +// the way to walk on. Nothing if, from some candidate, a step of `after` +// before a hold that brings them all to one tile would walk otherwise +// than planned, or a way would cross a tile off the planned way that a +// trainer may see. std::optional plan_resume( const std::vector& maps, const std::vector& candidates, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index ca9e278392..cddb0e2026 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -335,11 +335,11 @@ class RouteRun{ // of a leg walked on after a stop (walk_on()) starts on any of the // tiles that stop left the player on. std::vector candidates_on(const RouteStep& step, const std::optional& start, WalkedTiles walked) const{ - if (m_next != 0 || m_walk_on_starts.empty()){ + if (m_next >= m_walk_on_starts.size()){ return stop_candidates(*maps(), start, step, walked); } std::vector candidates; - for (const MapPosition& from : m_walk_on_starts){ + for (const MapPosition& from : m_walk_on_starts[m_next]){ for (const StopCandidate& candidate : stop_candidates(*maps(), from, step, walked)){ if (std::find(candidates.begin(), candidates.end(), candidate) == candidates.end()){ candidates.push_back(candidate); @@ -358,6 +358,15 @@ class RouteRun{ const RouteStep& step, const StepOutcome& outcome, const std::optional& start, const std::optional& crossing ){ + if (outcome.end == StepEnd::STOPPED && step.wild){ + // Where wild battles start, one on the first step held again + // after another interrupt stops the hold the same way: Cooper's + // battle, then a wild one, on Rock Tunnel B1F's column 1 (the + // sim, 2026-11-09). Walked again, as after any stop; a sign + // that interrupts every hold ends the route at MAX_ROUTE_STOPS. + m_lines.end(step, std::nullopt, EndBasis::UNKNOWN, "interrupted again right away where wild battles start; walking it again"); + return true; + } if (outcome.end == StepEnd::STOPPED){ // Interrupted at once, hold after hold: by something on the // way that speaks, a sign, which the plan didn't have there. @@ -426,22 +435,21 @@ class RouteRun{ } // The plan from here: the rest of `step`, the stopped leg m_plan[m_next], - // from the middle of `candidates`, then the old plan after it, whose - // first `resume.holds` steps find the player. The player still faces - // the leg's way: a turn tap first would step a tile. - void walk_on(const RouteStep& step, const Resume& resume, const std::vector& candidates){ + // then the old plan after it, whose first `resume.steps` steps find the + // player. The player still faces the leg's way: a turn tap first would + // step a tile. + void walk_on(const RouteStep& step, const Resume& resume){ std::vector plan; - m_walk_on_starts.clear(); + m_walk_on_starts = resume.starts; if (resume.rest.tiles > 0){ plan.push_back(resume.rest); - for (const StopCandidate& candidate : candidates){ - m_walk_on_starts.push_back(candidate.player); - } + }else{ + m_walk_on_starts.erase(m_walk_on_starts.begin()); } plan.insert(plan.end(), m_plan.begin() + m_next + 1, m_plan.end()); m_plan = std::move(plan); m_walked.reset(); - m_holds = (resume.rest.tiles > 0 ? 1 : 0) + resume.holds; + m_holds = (resume.rest.tiles > 0 ? 1 : 0) + resume.steps; m_unchecked = m_holds; m_next = 0; m_at = std::nullopt; @@ -463,10 +471,10 @@ class RouteRun{ const std::vector after(m_plan.begin() + m_next + 1, m_plan.end()); if (const std::optional resume = plan_resume(*maps(), candidates, step, after)){ m_calls.log(std::format( - "Route: walking on {} tiles along {}; the next {} hold(s) find the player from every one of those tiles.", - resume->rest.tiles, to_string(step), resume->holds + "Route: walking on {} tiles along {}; the next {} step(s) find the player from every one of those tiles.", + resume->rest.tiles, to_string(step), resume->steps )); - walk_on(step, *resume, candidates); + walk_on(step, *resume); return true; } const std::optional anchor = candidates.empty() @@ -511,7 +519,7 @@ class RouteRun{ size_t m_holds = 0; // of the plan, holds to walls that find the player size_t m_unchecked = 0; // of the plan, the holds and the way back after them std::optional m_walked; // the step walked last, if it may have stopped part-way - std::vector m_walk_on_starts; // where the plan's first step, a leg walked on, may start + std::vector> m_walk_on_starts; // after walking on: where each of the plan's first steps may start }; } // namespace diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp index 0f4ef4de91..cc73c34012 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp @@ -27,6 +27,7 @@ #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_StartMenuDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_SelectionArrowDetector.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_NamingScreen.h" #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_StartMenuNavigation.h" @@ -611,7 +612,13 @@ std::optional step_into_line( [&]{ WhiteDialogWatcher message(COLOR_RED); BattleScreenWatcher battle(COLOR_RED); - return wait_until(console, context, watch, {message, battle}) >= 0; + const int seen = wait_until(console, context, std::max(watch, WILD_BATTLE_IN_LINE_WATCH), {message, battle}); + if (seen == 1){ + console.log("Story: a wild battle on the step into a trainer's line; running from it, then stepping on.", COLOR_ORANGE); + flee_battle(console, context); + return false; + } + return seen == 0; } ); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h index e08122242b..2940c2fab4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h @@ -136,6 +136,14 @@ std::optional step_until_seen(int max_tiles, const std::function& s // "!", then their words, as the route walker watches for a trainer // with no tiles to walk up (PokemonFRLG_OverworldRecoveryWalk.cpp). inline constexpr Milliseconds TRAINER_LINE_WATCH(2500); +// How long a step into a trainer's line is watched for a wild battle it +// set off: its HP boxes, with no message before them, came over 5 s after +// the step in Mt. Moon (UNSEEN_BATTLE_WATCH). The trainer didn't see the +// player on that step (the game looks for trainers before it rolls for a +// wild battle), so it is run from and the stepping goes on: in Rock +// Tunnel the battle step after it found no trainer's message (the sim, +// 2026-11-09). +inline constexpr Milliseconds WILD_BATTLE_IN_LINE_WATCH(5000); // How long a step into the line of a trainer who looks around waits for // them to turn to the player (TRAINER_WAIT_VARIANT). @@ -143,7 +151,8 @@ inline constexpr Milliseconds TRAINER_WAIT_WATCH(30000); // From the overworld: step_until_seen() `direction`, turning first // unless `facing` is `direction`, each step watched `watch` for a -// trainer's message or battle. +// trainer's message, and at least WILD_BATTLE_IN_LINE_WATCH for a wild +// battle, which is run from. std::optional step_into_line( ConsoleHandle& console, ProControllerContext& context, Pokemon::Direction direction, std::optional facing, int max_tiles, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index 791fe18e85..d9ba3fcd69 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -405,8 +405,17 @@ std::string OverworldStepSim::step_ahead(const StoryStep& step){ } m_sim.tap(step.direction); }; + // A wild battle, with no message before it, is run from, and the + // stepping goes on, as step_into_line() does. const auto seen = [this]{ - return m_sim.watch({SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, TRAINER_LINE_WATCH).has_value(); + const std::optional shown = m_sim.watch( + {SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, std::max(TRAINER_LINE_WATCH, WILD_BATTLE_IN_LINE_WATCH) + ); + if (shown && shown->watcher == 1){ + m_sim.handle_battle(SimBattleChoice::RUN); + return false; + } + return shown.has_value(); }; const int tiles = step.variant == TRAINER_LINE_VARIANT ? TRAINER_LINE_TILES : 1; return step_until_seen(tiles, tap, seen) ? "" : "no trainer saw the player."; From 62aa1d4e4289520a3a69cb254c8fe7c629d59a75 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 11:35:52 -0800 Subject: [PATCH 08/24] FRLG sim: wild battles at the game's own odds; 00.28 Monte Carlo numbers SimScript::encounter_rates rolls each step onto a wild tile as pokefirered TryStandardWildEncounter() does (8 - rate / 10 cooldown steps at 5%, then rate * 16 / 1600 plus a buff that grows with each roll lost; a battle clears both), seeded per run. Rock Tunnel 1F/B1F use rate 7, Route 10 21 (wild_encounters.json). SimState counts wild battles and their time. New test: 00.28 at those odds from 30 seeds must all pass, and logs the runs' numbers. At the same seeds, before this PR's walker fixes 13/30 passed; now 30/30. Over passing runs, before -> after: time 1037 -> 912 s mean (p90 1190 -> 1004), tiles 535 -> 406 (p90 679 -> 437), tiles walked again 158 -> 3 (p90 302 -> 34), wild battles 22.6 -> 17.7, time lost per battle 4.5 -> 0.3 s (p90 6.6 -> 2.0). With trainers checked before wild battles in the sim, Cooper is always fought: the 7-to-25-step test no longer allows him unfought. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_OverworldSim.cpp | 57 ++++++- .../Overworld/PokemonFRLG_OverworldSim.h | 16 ++ .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 153 +++++++++++++++--- 3 files changed, 202 insertions(+), 24 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 59bfea0c60..e8631f50f0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -144,6 +144,7 @@ OverworldSim::OverworldSim(std::vector maps, MapPosition start, Directi : m_maps(std::move(maps)) , m_script(std::move(script)) , m_map(find_map(m_maps, start.map)) + , m_encounter_rng(m_script.encounter_seed) { if (m_map < 0){ throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Sim: start on a map that isn't loaded: " + start.map); @@ -378,13 +379,12 @@ bool OverworldSim::wild_battle(Milliseconds pressed){ return false; } m_grass_steps++; - for (const SimEncounter& encounter : m_script.encounters){ - if (encounter.grass_step != m_grass_steps){ - continue; - } + if (const std::optional encounter = next_encounter()){ m_frozen = true; + m_state.wild_battles++; + m_wild_battle_from = pressed; m_battle_pending = true; - if (!encounter.seen_late){ + if (!encounter->seen_late){ schedule(pressed + m_script.timing.flash, SimScreen::BATTLE_FLASH, false); } if (m_script.timing.battle_wipe){ @@ -394,12 +394,54 @@ bool OverworldSim::wild_battle(Milliseconds pressed){ schedule(pressed + *m_script.timing.battle_opening, SimScreen::BATTLE_OPENING, false); } schedule(pressed + m_script.timing.battle_screen, SimScreen::BATTLE, false); - note(std::format("a wild battle on {}{}.", to_string(m_state.position), encounter.seen_late ? " (flashes missed)" : "")); + note(std::format("a wild battle on {}{}.", to_string(m_state.position), encounter->seen_late ? " (flashes missed)" : "")); return true; } return false; } +// The encounter this step onto a wild tile starts, if any: the script's, +// or the game's roll on a map with an encounter rate. +std::optional OverworldSim::next_encounter(){ + const auto rate = m_script.encounter_rates.find(map().name); + if (rate != m_script.encounter_rates.end()){ + return rolls_encounter(rate->second) ? std::optional(SimEncounter{.grass_step = m_grass_steps}) : std::nullopt; + } + for (const SimEncounter& encounter : m_script.encounters){ + if (encounter.grass_step == m_grass_steps){ + return encounter; + } + } + return std::nullopt; +} + +// pokefirered TryStandardWildEncounter() on land, for a land encounter +// `rate`, with no Repel, flute, Cleanse Tag, lead ability or bike: +// - HandleWildEncounterCooldown(): until `min_steps` steps since the last +// battle (8 - rate / 10, 8 below rate 10, 0 from 80), a step goes on to +// the roll only 5 times in 100; +// - DoWildEncounterRateTest(): a battle if rand % 1600 < rate * 16 plus +// a buff, which grows by `rate` with each roll lost and is cleared by a +// battle, as the step count is. +// StandardWildEncounter()'s 60% roll on a step onto a tile of another +// behavior is left out: a cave's floor is one behavior. +bool OverworldSim::rolls_encounter(int rate){ + const int min_steps = rate >= 80 ? 0 : rate < 10 ? 8 : 8 - rate / 10; + if (m_steps_since_encounter < min_steps){ + m_steps_since_encounter++; + if (m_encounter_rng() % 100 >= 5){ + return false; + } + } + if ((int)(m_encounter_rng() % 1600) >= rate * 16 + m_encounter_rate_buff * 16 / 200){ + m_encounter_rate_buff += rate; + return false; + } + m_encounter_rate_buff = 0; + m_steps_since_encounter = 0; + return true; +} + // Blocked holding `direction` at `pressed`: a sign walked into moving north // or south shows its message. void OverworldSim::bump(Direction direction, Milliseconds pressed){ @@ -511,6 +553,9 @@ SimBattleEnd OverworldSim::handle_battle(SimBattleChoice choice){ show(SimScreen::OVERWORLD, m_now); m_frozen = false; m_battle_pending = false; + if (!m_trainer){ + m_state.in_wild_battles += m_now - m_wild_battle_from; + } if (m_trainer){ MapObject& trainer = m_maps[m_map].objects[*m_trainer]; trainer.trainer.reset(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 32751834dc..fc90e5ba2b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -30,6 +30,7 @@ #include #include +#include #include #include #include @@ -115,6 +116,11 @@ struct SimScript{ // 2026-11-03, rows 00.16 and 00.20), not south from Route 6 into // Vermilion City (row 00.24). bool connections_stall = true; + // Wild battles at the game's own odds instead of `encounters`, on the + // maps named here, with their land encounter rate (pokefirered + // src/data/wild_encounters.json), rolled from `encounter_seed`. + std::map encounter_rates; + uint32_t encounter_seed = 0; SimTiming timing; }; @@ -138,6 +144,9 @@ struct SimState{ bool black_below = false; int steps = 0; int warps = 0; + int wild_battles = 0; + // From each wild battle's step to the overworld after it, together. + Milliseconds in_wild_battles{0}; int signs_read = 0; std::vector trainers_beaten; // Things the walker did that the game wouldn't let pass: D-pad holds @@ -243,6 +252,8 @@ class OverworldSim{ std::optional cross_edge(Direction direction) const; bool after_step(const Step& step, Milliseconds pressed); bool wild_battle(Milliseconds pressed); + std::optional next_encounter(); + bool rolls_encounter(int rate); bool trainer_sees(Milliseconds pressed); void update_view(); void note(const std::string& line); @@ -267,6 +278,11 @@ class OverworldSim{ // The trees cut since the last warp, and their maps. std::vector> m_cut_trees; int m_grass_steps = 0; + // The game's wild encounter state (SimScript::encounter_rates). + std::mt19937 m_encounter_rng; + int m_steps_since_encounter = 0; + int m_encounter_rate_buff = 0; + Milliseconds m_wild_battle_from{0}; int m_holds = 0; SimState m_state; }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index faff538e47..7e123f4fcf 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include #include @@ -37,7 +39,7 @@ namespace{ struct AutoStoryLavenderInput{ std::string name; - std::function body; + std::function body; }; struct AutoStoryLavenderExpectation{ @@ -54,9 +56,9 @@ class Test_AutoStoryLavender : public UnitTest{ : UnitTest("PokemonFRLG::AutoStory - Simulation: " + test_case.input.name) , m_case(std::move(test_case)) {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ try{ - return m_case.input.body(); + return m_case.input.body(logger); }catch (Exception& e){ return std::string(e.name()) + ": " + e.message(); } @@ -98,6 +100,10 @@ std::string row_rest(OverworldSim& sim, StoryPose at, const StoryCheckpoint& row return ""; } +UnitTestResult row_checked( + OverworldSim& sim, std::string_view id, const std::vector>& fought, const MapPosition& end +); + // Row `id` on the sim with `script`: it ends on `end`, with no violation // on the way, having fought the trainers of one of `fought`, in order. UnitTestResult row_on_sim( @@ -106,6 +112,15 @@ UnitTestResult row_on_sim( const StoryCheckpoint& row = checkpoint(id); const StoryPose at = *story_pose(*row.start); OverworldSim sim(maps_after_route10(), MapPosition{at.map, at.tile}, *at.facing, script); + return row_checked(sim, id, fought, end); +} + +// Row `id` on `sim`, from its start, checked as row_on_sim() does. +UnitTestResult row_checked( + OverworldSim& sim, std::string_view id, const std::vector>& fought, const MapPosition& end +){ + const StoryCheckpoint& row = checkpoint(id); + const StoryPose at = *story_pose(*row.start); if (const std::string failure = row_rest(sim, at, row, 0); !failure.empty()){ return std::string(id) + ": " + failure; } @@ -128,24 +143,18 @@ UnitTestResult row_on_sim( const std::vector ROCK_TUNNEL_TRAINERS{ "poke_maniac", "poke_maniac", "picnicker", "hiker", "hiker", "picnicker", "hiker", "poke_maniac", "picnicker", }; -// Cooper unfought: a wild battle on the step into his sight, on the leg -// past him, starts first, and the sim's trainers look only after a step. -// The leg goes on past him to its wall either way. -const std::vector ROCK_TUNNEL_TRAINERS_BUT_COOPER{ - "poke_maniac", "poke_maniac", "picnicker", "hiker", "hiker", "picnicker", "hiker", "picnicker", -}; // 00.28 on the sim: the tree cut, the nine trainers fought where the // table steps into their lines (Cooper by the walker, on the leg past // him), no one else met, and the walk out of the south mouth. -UnitTestResult test_rock_tunnel_row(){ +UnitTestResult test_rock_tunnel_row(Logger&){ return row_on_sim("00.28", SimScript{}, {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); } // 00.28 with a wild battle on the 3rd step on the tunnel's floor, mid-way // down the leg to the wall (17, 8): the walker runs, holds the leg to its // wall again, and nothing else changes. -UnitTestResult test_rock_tunnel_wild_battles(){ +UnitTestResult test_rock_tunnel_wild_battles(Logger&){ SimScript script; script.encounters.push_back(SimEncounter{.grass_step = 3}); return row_on_sim("00.28", script, {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); @@ -179,7 +188,7 @@ size_t step_index(const StoryCheckpoint& row, std::string_view name){ // the leg started, and walked the whole 32 tiles again, until it gave up // ("stopped part-way 9 times"). From Winston's tile, with a battle every // 10 to 14 steps, the steps from below_martha_line on reach Martha's line. -UnitTestResult test_b1f_row37_battles_every_12(){ +UnitTestResult test_b1f_row37_battles_every_12(Logger&){ const StoryCheckpoint& row = checkpoint("00.28"); const size_t first = step_index(row, "below_martha_line"); StoryPose at{StoryTable::ROCK_TUNNEL_B1F, {29, 37}, Direction::NORTH}; @@ -196,7 +205,7 @@ UnitTestResult test_b1f_row37_battles_every_12(){ // 00.28 whole with a wild battle every 7 to 25 steps on the tunnel's floor, // the gaps drawn from each of these seeds: the row ends where it should. -UnitTestResult test_rock_tunnel_frequent_battles(){ +UnitTestResult test_rock_tunnel_frequent_battles(Logger&){ std::string failures; for (unsigned seed = 1; seed <= 12; seed++){ std::mt19937 random(seed); @@ -206,7 +215,7 @@ UnitTestResult test_rock_tunnel_frequent_battles(){ gaps.push_back(gap(random)); } const UnitTestResult result = row_on_sim( - "00.28", battles_every(gaps), {ROCK_TUNNEL_TRAINERS, ROCK_TUNNEL_TRAINERS_BUT_COOPER}, MapPosition{StoryTable::ROUTE10, {8, 58}} + "00.28", battles_every(gaps), {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}} ); if (result.result != UnitTestResult::PASSED){ failures += std::format("{}Seed {}: {}", failures.empty() ? "" : " | ", seed, result.message); @@ -218,8 +227,115 @@ UnitTestResult test_rock_tunnel_frequent_battles(){ return true; } +// The sim at row `id`'s start, with `script`. +OverworldSim row_start_sim(std::string_view id, SimScript script){ + const StoryPose at = *story_pose(*checkpoint(id).start); + return OverworldSim(maps_after_route10(), MapPosition{at.map, at.tile}, *at.facing, std::move(script)); +} + +// One run of a row on the sim, against the same row with no wild battle. +struct RowRunNumbers{ + bool passed = false; + double seconds = 0; + int tiles = 0; + // Tiles walked beyond the run with no wild battle: walked again after + // a battle, finding the player and the way back. + int tiles_again = 0; + int battles = 0; + // Per wild battle, the time beyond the run with no wild battle, less + // the battles' own: from each battle's end until the walk is back + // where it was cut off. + double seconds_lost_per_battle = 0; +}; + +RowRunNumbers row_numbers(const SimState& run, bool passed, const SimState& clean){ + RowRunNumbers out; + out.passed = passed; + out.seconds = run.time.count() / 1000.; + out.tiles = run.steps; + out.tiles_again = run.steps - clean.steps; + out.battles = run.wild_battles; + const Milliseconds lost = run.time - clean.time - run.in_wild_battles; + out.seconds_lost_per_battle = run.wild_battles > 0 ? lost.count() / 1000. / run.wild_battles : 0; + return out; +} + +// The mean and 90th percentile of `values`. +std::string mean_p90(std::vector values){ + if (values.empty()){ + return "-"; + } + std::sort(values.begin(), values.end()); + const double mean = std::accumulate(values.begin(), values.end(), 0.) / values.size(); + const double p90 = values[std::min(values.size() - 1, (size_t)(0.9 * values.size()))]; + return std::format("mean {:.1f}, p90 {:.1f}", mean, p90); +} + +// How many of `runs` passed, and over those, the mean and 90th percentile +// of each number. +std::string numbers_summary(const std::vector& runs){ + std::vector seconds, tiles, again, battles, lost; + size_t passed = 0; + for (const RowRunNumbers& run : runs){ + passed += run.passed ? 1 : 0; + if (!run.passed){ + continue; + } + seconds.push_back(run.seconds); + tiles.push_back(run.tiles); + again.push_back(run.tiles_again); + battles.push_back(run.battles); + if (run.battles > 0){ + lost.push_back(run.seconds_lost_per_battle); + } + } + return std::format( + "passed {}/{}; of those passed: time (s) {}; tiles {}; tiles walked again {}; wild battles {}; s lost per battle {}", + passed, runs.size(), mean_p90(seconds), mean_p90(tiles), mean_p90(again), mean_p90(battles), mean_p90(lost) + ); +} + +// The land encounter rates of the maps 00.28 and 00.29 walk (pokefirered +// src/data/wild_encounters.json, FireRed). +const std::map ROCK_TUNNEL_ENCOUNTER_RATES{ + {StoryTable::ROCK_TUNNEL_1F, 7}, + {StoryTable::ROCK_TUNNEL_B1F, 7}, + {StoryTable::ROUTE10, 21}, +}; + +constexpr unsigned GAME_ODDS_SEEDS = 30; + +// 00.28 whole, its wild battles at the game's own odds (OverworldSim:: +// rolls_encounter()), from each of these seeds: every run ends where it +// should. Logs the runs' numbers. +UnitTestResult test_rock_tunnel_game_odds(Logger& logger){ + OverworldSim clean_sim = row_start_sim("00.28", SimScript{}); + if (const UnitTestResult clean = row_checked(clean_sim, "00.28", {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); clean.result != UnitTestResult::PASSED){ + return clean; + } + std::vector runs; + std::string failures; + for (unsigned seed = 1; seed <= GAME_ODDS_SEEDS; seed++){ + SimScript script; + script.encounter_rates = ROCK_TUNNEL_ENCOUNTER_RATES; + script.encounter_seed = seed; + OverworldSim sim = row_start_sim("00.28", script); + const UnitTestResult result = row_checked(sim, "00.28", {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); + const bool passed = result.result == UnitTestResult::PASSED; + runs.push_back(row_numbers(sim.state(), passed, clean_sim.state())); + if (!passed){ + failures += std::format("{}Seed {}: {}", failures.empty() ? "" : " | ", seed, result.message); + } + } + logger.log("00.28 at the game's encounter odds: " + numbers_summary(runs)); + if (!failures.empty()){ + return failures; + } + return true; +} + // 00.29 on the sim: no trainer met, the ledge hopped, Lavender's nurse. -UnitTestResult test_lavender_row(){ +UnitTestResult test_lavender_row(Logger&){ return row_on_sim("00.29", SimScript{}, {{}}, MapPosition{StoryTable::LAVENDER_CENTER, {7, 4}}); } @@ -276,17 +392,17 @@ UnitTestResult tile_off_after_open_legs(std::string_view id){ return true; } -UnitTestResult test_rock_tunnel_tile_off(){ +UnitTestResult test_rock_tunnel_tile_off(Logger&){ return tile_off_after_open_legs("00.28"); } -UnitTestResult test_lavender_tile_off(){ +UnitTestResult test_lavender_tile_off(Logger&){ return tile_off_after_open_legs("00.29"); } // 00.29 saves in Lavender Town's Pokemon Center, LAVENDER TOWN on the // save card; 00.28 on Route 10. -UnitTestResult test_save_places(){ +UnitTestResult test_save_places(Logger&){ if (save_card_place(StoryTable::LAVENDER_CENTER) != "lavender-town" || save_card_place(StoryTable::ROUTE10) != "route-10"){ return "Wrong save card places for Lavender Town's Center or Route 10."; } @@ -302,6 +418,7 @@ const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { {.input = {"00.28 a tile off after each open leg", test_rock_tunnel_tile_off}, .expectation = {}}, {.input = {"00.28 B1F row 37 with a wild battle every 10 to 14 steps", test_b1f_row37_battles_every_12}, .expectation = {}}, {.input = {"00.28 a wild battle every 7 to 25 steps, 12 seeds", test_rock_tunnel_frequent_battles}, .expectation = {}}, + {.input = {"00.28 wild battles at the game's odds, 30 seeds", test_rock_tunnel_game_odds}, .expectation = {}}, {.input = {"00.29 Route 10 south, the ledge, Lavender Town's Pokemon Center", test_lavender_row}, .expectation = {}}, {.input = {"00.29 a tile off after each open leg", test_lavender_tile_off}, .expectation = {}}, {.input = {"00.28 and 00.29 save on Route 10 and in Lavender Town", test_save_places}, .expectation = {}}, From d5910eb861327b10d4ebcca4a0fca87d74640ae7 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 12:10:16 -0800 Subject: [PATCH 09/24] FRLG AutoStory: reach Martha's line up column 16, no one-tile step Console B run 2 of 00.28 lost the one-tile step north onto B1F row 34 after a wild battle on row 38; the walk east "to the wall (17, 34)" then ran along row 35 to the rock (20, 35) and martha_line found no trainer. below_martha_line now ends at (16, 35) and martha_line walks up column 16 (or 17, a tile short) into her line; below_allen_line starts with the walk east to the wall (17, 33). Sim test replays the console's battle and the short step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 42 ++++++++++++++++++- .../PokemonFRLG_AutoStoryTableLavender.cpp | 8 ++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index 7e123f4fcf..d4c9a95e63 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -197,8 +197,45 @@ UnitTestResult test_b1f_row37_battles_every_12(Logger&){ if (!failure.empty()){ return std::format("below_martha_line: ended on {}; {}", to_string(sim.state().position), failure); } - if (!(sim.state().position == MapPosition{StoryTable::ROCK_TUNNEL_B1F, {17, 34}})){ - return std::format("below_martha_line: ended on {}, not (17, 34).", to_string(sim.state().position)); + if (!(sim.state().position == MapPosition{StoryTable::ROCK_TUNNEL_B1F, {16, 35}})){ + return std::format("below_martha_line: ended on {}, not (16, 35).", to_string(sim.state().position)); + } + return true; +} + +// From Winston's tile, below_martha_line, martha_line and Martha's battle +// on `sim`: the failure, or empty once Martha alone is fought. +std::string winston_to_martha(OverworldSim& sim){ + const StoryCheckpoint& row = checkpoint("00.28"); + StoryPose at{StoryTable::ROCK_TUNNEL_B1F, {29, 37}, Direction::NORTH}; + for (size_t c = step_index(row, "below_martha_line"); c <= step_index(row, "picnicker_martha"); c++){ + if (const std::string failure = row_step_on_sim(sim, at, row.steps[c], row.id); !failure.empty()){ + return std::format("ended on {}; {}", to_string(sim.state().position), failure); + } + } + if (sim.state().trainers_beaten != std::vector{"picnicker"}){ + return std::format("ended on {}, Martha not fought.", to_string(sim.state().position)); + } + return ""; +} + +// Console B, 2026-11-09 (run 2): after Winston, the walk west along row +// 37 was found again at the wall (1, 38); a wild battle 0 to 2 tiles into +// the walk east along row 38 left the player on one of (1..3, 38), and +// the walk went on from there to the wall (19, 35). The one-tile step +// north onto row 34 then ended short with nothing seen, the walk east +// "to the wall (17, 34)" ran along row 35 to the rock (20, 35) instead +// (the end frame shows the wall (19, 34) north, the rock east), and +// martha_line found no trainer. Here the same battle, and the 10th hold +// (that step, before the route went up column 16 into her line) a tile +// short: Martha still sees the player. +UnitTestResult test_b1f_martha_after_row38_battle(Logger&){ + SimScript script; + script.encounters = {SimEncounter{.grass_step = 31}}; + script.short_holds = {{1, 2}, {10, 1}}; + OverworldSim sim(maps_after_route10(), MapPosition{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}, Direction::NORTH, script); + if (const std::string failure = winston_to_martha(sim); !failure.empty()){ + return failure; } return true; } @@ -419,6 +456,7 @@ const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { {.input = {"00.28 B1F row 37 with a wild battle every 10 to 14 steps", test_b1f_row37_battles_every_12}, .expectation = {}}, {.input = {"00.28 a wild battle every 7 to 25 steps, 12 seeds", test_rock_tunnel_frequent_battles}, .expectation = {}}, {.input = {"00.28 wild battles at the game's odds, 30 seeds", test_rock_tunnel_game_odds}, .expectation = {}}, + {.input = {"00.28 B1F a wild battle into row 38, then a step short: Martha still sees the player", test_b1f_martha_after_row38_battle}, .expectation = {}}, {.input = {"00.29 Route 10 south, the ledge, Lavender Town's Pokemon Center", test_lavender_row}, .expectation = {}}, {.input = {"00.29 a tile off after each open leg", test_lavender_tile_off}, .expectation = {}}, {.input = {"00.28 and 00.29 save on Route 10 and in Lavender Town", test_save_places}, .expectation = {}}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp index 73ae6ffbf4..9613070677 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp @@ -72,10 +72,10 @@ StoryCheckpoint rock_tunnel(){ running(legs("winston_line_end", {{WEST, 5}, {SOUTH, 10}, {NORTH, 1}, {WEST, 1}}, "west to the wall (33, 28), down column 33 to the wall (33, 38), up to (33, 37) and west to (32, 37)")), into_line("winston_line", WEST, "along row 37 onto (29, 37), the end of Winston's line: he walks down to (29, 36)"), battle("pokemaniac_winston", at(ROCK_TUNNEL_B1F, 29, 37, NORTH), false, "Slowpoke and Slowpoke"), - running(legs("below_martha_line", {{WEST, 16}, {NORTH, 2}, {EAST, 6}, {WEST, 3}, {NORTH, 1}, {EAST, 1}}, "west along row 37 to (13, 37), up to the wall (13, 35), east to the wall (19, 35), west to (16, 35), up to row 34 and east to the wall (17, 34)")), - into_line("martha_line", NORTH, "onto (17, 33), the end of Martha's line: she walks up to (16, 33)"), - battle("picnicker_martha", at(ROCK_TUNNEL_B1F, 17, 33, WEST), false, "Oddish and Bulbasaur"), - running(legs("below_allen_line", {{NORTH, 5}, {EAST, 12}, {NORTH, 9}, {EAST, 11}, {NORTH, 2}, {WEST, 2}, {NORTH, 5}}, "up column 17 to the wall (17, 28), east to the wall (29, 28), up column 29 past Steve's line to the wall (29, 19), east to (40, 19), up to row 17, west to the wall (38, 17) and up to the wall (38, 12)")), + running(legs("below_martha_line", {{WEST, 16}, {NORTH, 2}, {EAST, 6}, {WEST, 3}}, "west along row 37 to (13, 37), up to the wall (13, 35), east to the wall (19, 35), west to (16, 35)")), + into_line("martha_line", NORTH, "up column 16 onto (16, 33), beside Martha; from (17, 35), a tile short, up column 17 onto (17, 33), the end of her line"), + battle("picnicker_martha", at(ROCK_TUNNEL_B1F, 16, 33, WEST), false, "Oddish and Bulbasaur"), + running(legs("below_allen_line", {{EAST, 1}, {NORTH, 5}, {EAST, 12}, {NORTH, 9}, {EAST, 11}, {NORTH, 2}, {WEST, 2}, {NORTH, 5}}, "east to the wall (17, 33), up column 17 to the wall (17, 28), east to the wall (29, 28), up column 29 past Steve's line to the wall (29, 19), east to (40, 19), up to row 17, west to the wall (38, 17) and up to the wall (38, 12)")), into_line("allen_line", WEST, "along row 12 onto (36, 12), below Allen"), battle("hiker_allen", at(ROCK_TUNNEL_B1F, 36, 12, NORTH), false, "Geodude, Geodude and Graveler"), running(legs("west_of_b1f_ladder", {{WEST, 4}, {NORTH, 10}}, "west along row 12 to the wall (32, 12), up column 32 to the wall (32, 2)")), From d9dae0120b16d0b32b7eb6419bb6e217852f0860 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 12:17:41 -0800 Subject: [PATCH 10/24] FRLG walker: check each turn tap by the facing on screen, tap again if missed Console B lost the one-tile step north onto Rock Tunnel B1F row 34 in 00.28 run 2: the game missed the 40 ms turn tap, the leg's short hold then only turned the player (pokefirered PlayerTurnInPlace(), 8 frames), and the leg was taken as walked. OverworldWalkSteps::turn_to() taps, reads the facing (read_player_facing()) and taps again while it shows another way, up to 3 taps; an unreadable facing is taken as turned. Both walkers, step_once() and face_wall() turn with it. Sim: SimScript drops turn taps by number or at a rate; sim_turn_to() mirrors turn_to(), switched by SimWalkerRules::confirm_turn. New tests: a dropped tap before a one-tile leg (red without the rule), and 00.28 at the game's odds with 1 in 10 turn taps dropped, 30 seeds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_OverworldRecoveryWalk.cpp | 2 +- .../Overworld/PokemonFRLG_OverworldSim.cpp | 38 ++++++++++++++++++- .../Overworld/PokemonFRLG_OverworldSim.h | 28 +++++++++++++- .../PokemonFRLG_OverworldSim_Tests.cpp | 22 +++++++++++ .../Overworld/PokemonFRLG_OverworldWalk.cpp | 18 ++++++++- .../Overworld/PokemonFRLG_OverworldWalk.h | 13 +++++++ .../PokemonFRLG_AutoStoryActions.cpp | 15 +++----- .../AutoStory/PokemonFRLG_AutoStoryActions.h | 11 ++++-- .../PokemonFRLG_AutoStoryCapture.cpp | 2 +- .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 8 ++-- .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 29 ++++++++++++-- .../PokemonFRLG_AutoStoryPaceSim.cpp | 2 +- .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 6 +-- 13 files changed, 163 insertions(+), 31 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index bff34929c3..8bf845563a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -66,7 +66,7 @@ StepWalk walk_step( // facing the right way: holding through a turn lost a tile on the // console, while holds from a standstill were exact over 15 tiles. if (facing != step.direction){ - pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TURN_SETTLE); + turn_to(console, context, step.direction); } if (const std::string note = speed_note(step, speed); !note.empty()){ console.log(note); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index e8631f50f0..c3bb6346e6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -145,6 +145,7 @@ OverworldSim::OverworldSim(std::vector maps, MapPosition start, Directi , m_script(std::move(script)) , m_map(find_map(m_maps, start.map)) , m_encounter_rng(m_script.encounter_seed) + , m_tap_rng(m_script.turn_tap_seed) { if (m_map < 0){ throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Sim: start on a map that isn't loaded: " + start.map); @@ -524,13 +525,36 @@ void OverworldSim::tap(Direction direction){ if (m_state.screen != SimScreen::OVERWORLD || m_frozen){ stuck_hold(direction); }else if (m_state.facing != direction){ - m_state.facing = direction; + if (drops_turn_tap()){ + m_state.turn_taps_dropped++; + note(std::string("the turn tap ") + direction_name(direction) + " is dropped."); + }else{ + m_state.facing = direction; + } }else{ walk_hold(direction, m_script.timing.tile / 2, 0); } advance_to(m_now + TAP_TIME); } +std::optional OverworldSim::facing_on_screen() const{ + if (m_state.screen != SimScreen::OVERWORLD){ + return std::nullopt; + } + return m_state.facing; +} + +bool OverworldSim::drops_turn_tap(){ + m_turn_taps++; + if (m_script.dropped_turn_taps.contains(m_turn_taps)){ + return true; + } + if (m_script.turn_tap_drop_rate <= 0){ + return false; + } + return std::uniform_real_distribution(0, 1)(m_tap_rng) < m_script.turn_tap_drop_rate; +} + std::optional OverworldSim::watch(const std::vector& watchers, Milliseconds duration){ return watch_until(watchers, m_now, m_now + duration); } @@ -745,7 +769,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, fail("Sim: Surf isn't modelled."); } if (facing != step.direction){ - m_sim.tap(step.direction); + sim_turn_to(m_sim, step.direction, m_rules.confirm_turn); } const int warps_before = m_sim.state().warps; const std::vector& ints = interrupts(step); @@ -894,6 +918,16 @@ RouteCalls OverworldSimWalker::calls(){ return calls; } +void sim_turn_to(OverworldSim& sim, Direction direction, bool confirm){ + for (size_t tap = 1; tap <= MAX_TURN_TAPS; tap++){ + sim.tap(direction); + const std::optional facing = sim.facing_on_screen(); + if (!confirm || !facing || *facing == direction){ + return; + } + } +} + std::string OverworldSimWalker::run(const std::vector& route, const RoutePlanning& planning){ try{ run_route(route, m_sim.state().facing, &planning, calls()); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index fc90e5ba2b..f2db259d5f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -121,6 +121,14 @@ struct SimScript{ // src/data/wild_encounters.json), rolled from `encounter_seed`. std::map encounter_rates; uint32_t encounter_seed = 0; + // Turn taps (taps while the player faces another way) the game misses: + // the player goes on facing the old way. By number, from 1, and each + // with probability `turn_tap_drop_rate`, rolled from `turn_tap_seed`. + // After one, a hold of a tile only turns the player (console B, + // 2026-11-09, 00.28 run 2). + std::set dropped_turn_taps; + double turn_tap_drop_rate = 0; + uint32_t turn_tap_seed = 0; SimTiming timing; }; @@ -148,6 +156,7 @@ struct SimState{ // From each wild battle's step to the overworld after it, together. Milliseconds in_wild_battles{0}; int signs_read = 0; + int turn_taps_dropped = 0; std::vector trainers_beaten; // Things the walker did that the game wouldn't let pass: D-pad holds // under a battle, a trainer's battle fled, a battle fought that wasn't @@ -202,9 +211,13 @@ class OverworldSim{ ); // A short press of `direction`: turns the player, or steps if they - // already face that way. + // already face that way. A turn may be dropped (SimScript::dropped_turn_taps). void tap(Direction direction); + // The way the player faces, as read_player_facing() reads it: on the + // overworld only. + std::optional facing_on_screen() const; + // Watches `watchers` for `duration`, as wait_until(). std::optional watch(const std::vector& watchers, Milliseconds duration); @@ -254,6 +267,7 @@ class OverworldSim{ bool wild_battle(Milliseconds pressed); std::optional next_encounter(); bool rolls_encounter(int rate); + bool drops_turn_tap(); bool trainer_sees(Milliseconds pressed); void update_view(); void note(const std::string& line); @@ -282,6 +296,9 @@ class OverworldSim{ std::mt19937 m_encounter_rng; int m_steps_since_encounter = 0; int m_encounter_rate_buff = 0; + // The turn taps so far, and what rolls their drops. + int m_turn_taps = 0; + std::mt19937 m_tap_rng; Milliseconds m_wild_battle_from{0}; int m_holds = 0; SimState m_state; @@ -351,8 +368,17 @@ struct SimWalkerRules{ // 2026-11-09 01:41, 00.27, the warp to CeruleanCity: the watch started // about 1.3 s into the hold). bool watch_fade_along_hold = true; + // A turn tap is checked by the facing on screen, and tapped again + // while the player faces another way (OverworldWalkSteps::turn_to()). + // Without it a dropped tap left a one-tile leg turning the player + // only, taken as walked (console B, 2026-11-09, 00.28 run 2). + bool confirm_turn = true; }; +// OverworldWalkSteps::turn_to() on `sim`; with `confirm` false, the one +// tap before it. +void sim_turn_to(OverworldSim& sim, Direction direction, bool confirm = SimWalkerRules{}.confirm_turn); + // The console side of run_route() on an OverworldSim, as // walk_overworld_route_with_recovery() does it, with the story's // interrupts: a wild battle (flashes, or seen late) is fought or run from, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index 7b7fb39c46..43d3a16ad6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -486,6 +486,27 @@ UnitTestResult test_short_leg_unseen(){ return true; } +// Console B, 2026-11-09 (00.28 run 2): the game missed the turn tap before +// a one-tile open leg, the leg's hold only turned the player, and the leg +// was taken as walked. The walker now reads the facing after the tap and +// taps again; without that the player stays where they started. +UnitTestResult test_dropped_turn_tap(){ + SimCase c{.start = at("ViridianForest", 13, 28), .facing = Direction::NORTH, .goal = at("ViridianForest", 12, 28)}; + c.route = std::vector{leg(Direction::WEST, 1, c.goal, false)}; + c.script.dropped_turn_taps = {1}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.turn_taps_dropped != 1){ + return "Expected the turn tapped again and the step walked:" + describe(now); + } + SimCase unchecked = c; + unchecked.rules.confirm_turn = false; + const SimRun old = run_case(unchecked); + if (!old.failure.empty() || !(old.state.position == c.start)){ + return "Expected the leg taken as walked from the start without the check:" + describe(old); + } + return true; +} + // r18 (console B, 2026-11-01), Pewter Gym: after Brock, Camper Liam sees // the player on the walk south to the door, walks up and battles. The // route holds on south to the door's mat; finding the player again at @@ -961,6 +982,7 @@ const OverworldSimCase OVERWORLD_SIM_CASES[] = { {.input = {"no hold over a ledge", test_no_hold_over_ledge}, .expectation = {},}, {.input = {"a short leg, then a sign", test_short_leg_then_sign}, .expectation = {},}, {.input = {"a short leg, nothing seen", test_short_leg_unseen}, .expectation = {},}, + {.input = {"a dropped turn tap before a one-tile leg", test_dropped_turn_tap}, .expectation = {},}, {.input = {"a trainer on the way out of Pewter Gym", test_trainer_on_way_out}, .expectation = {},}, {.input = {"out of Viridian Mart from the clerk's scene", test_mart_exit}, .expectation = {},}, {.input = {"Mt. Moon: down the ladders to B2F", test_mt_moon_ladders}, .expectation = {},}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index f6332251fb..008e7089cc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -377,6 +377,22 @@ std::optional read_facing(ConsoleHandle& console, ProControllerContex return facing; } +void turn_to(ConsoleHandle& console, ProControllerContext& context, Direction direction){ + for (size_t tap = 1; tap <= MAX_TURN_TAPS; tap++){ + pbf_press_dpad(context, dpad(direction), TURN_TAP, TURN_SETTLE); + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + const std::optional facing = screen ? read_player_facing(screen) : std::nullopt; + if (!facing || *facing == direction){ + return; + } + console.log(std::format( + "Route: after turn tap {} {}, the player faces {}.", + tap, Pokemon::direction_name(direction), Pokemon::direction_name(*facing) + ), COLOR_ORANGE); + } +} + void start_surf(ConsoleHandle& console, ProControllerContext& context, Direction direction, bool facing_water){ // "The water is a deep blue... Would you like to SURF?", with YES selected. // An A pressed while the player is still turning is lost. @@ -515,7 +531,7 @@ void walk_overworld_route( // facing the right way: holding through a turn lost a tile on the // console, while holds from a standstill were exact over 15 tiles. if (facing != step.direction){ - pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TURN_SETTLE); + turn_to(console, context, step.direction); } if (const std::string note = speed_note(step, speed); !note.empty()){ console.log(note); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index c74361044e..8325dac772 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -110,6 +110,8 @@ inline constexpr Milliseconds RUN_TILE_TIME(133); inline constexpr Milliseconds TURN_TAP(40); // duration to wait after turning to allow state to settle. inline constexpr Milliseconds TURN_SETTLE(300); +// How many taps turn_to() tries before it takes the player as turned. +inline constexpr size_t MAX_TURN_TAPS = 3; // short enough to turn without stepping. inline constexpr Milliseconds FACE_HOLD(40); // duration of button release between movement steps. @@ -235,6 +237,17 @@ void check_warp_was_no_battle(ConsoleHandle& console, ProControllerContext& cont // The way the player faces, read from the screen; nothing if it can't be read. std::optional read_facing(ConsoleHandle& console, ProControllerContext& context); +// Turns the player to `direction` with a tap (TURN_TAP), then reads the +// facing from the screen and taps again while it shows another way, up to +// MAX_TURN_TAPS taps. A tap the game misses leaves the player facing the +// old way, and a leg's hold then spends its first 8 frames turning +// (pokefirered PlayerTurnInPlace(), a fast walk in place): a one-tile +// hold only turns the player, with nothing to show it (console B, +// 2026-11-09, 00.28 run 2: the step north onto Rock Tunnel B1F row 34). +// A facing that can't be read is taken as turned: a tap while facing the +// way already would take a step. +void turn_to(ConsoleHandle& console, ProControllerContext& context, Direction direction); + // Facing `direction` (turning first unless `facing_water`), starts Surf. void start_surf(ConsoleHandle& console, ProControllerContext& context, Direction direction, bool facing_water); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp index cc73c34012..e009d01701 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp @@ -27,6 +27,7 @@ #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_StartMenuDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_SelectionArrowDetector.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_NamingScreen.h" @@ -57,9 +58,6 @@ constexpr Milliseconds INTRO_TIMEOUT = 600000ms; // "Wait about 8 s" after YES at the nurse (console B, 2026-11-01): 1.5 s // between presses did not heal. constexpr Milliseconds HEAL_TIME = 8000ms; -// FRLG takes a tap this short as a turn, without a step. -constexpr Milliseconds TURN_TAP = 40ms; -constexpr Milliseconds TURN_SETTLE = 300ms; DpadPosition dpad(Direction direction){ switch (direction){ @@ -580,9 +578,9 @@ std::optional talk_and_count_pages(ConsoleHandle& console, ProController return pages; } -void step_once(ProControllerContext& context, Direction direction, std::optional facing){ +void step_once(ConsoleHandle& console, ProControllerContext& context, Direction direction, std::optional facing){ if (facing != direction){ - pbf_press_dpad(context, dpad(direction), TURN_TAP, TURN_SETTLE); + OverworldWalkSteps::turn_to(console, context, direction); } // A step, once started, finishes on its own. pbf_press_dpad(context, dpad(direction), 120ms, 500ms); @@ -606,7 +604,7 @@ std::optional step_into_line( return step_until_seen( max_tiles, [&]{ - step_once(context, direction, facing); + step_once(console, context, direction, facing); facing = direction; }, [&]{ @@ -623,9 +621,8 @@ std::optional step_into_line( ); } -void face_wall(ProControllerContext& context, Direction direction){ - pbf_press_dpad(context, dpad(direction), TURN_TAP, TURN_SETTLE); - context.wait_for_all_requests(); +void face_wall(ConsoleHandle& console, ProControllerContext& context, Direction direction){ + OverworldWalkSteps::turn_to(console, context, direction); } SaveAction SaveDialog::next(SaveScreen screen){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h index 2940c2fab4..efbd5b8679 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h @@ -121,8 +121,11 @@ std::optional talk_and_count_pages(ConsoleHandle& console, ProController // From the overworld: one step `direction`, onto a trigger or into a door. // `facing` is the way the player faces, if known; a player facing another -// way is turned first. -void step_once(ProControllerContext& context, Pokemon::Direction direction, std::optional facing); +// way is turned first (OverworldWalkSteps::turn_to()). +void step_once( + ConsoleHandle& console, ProControllerContext& context, + Pokemon::Direction direction, std::optional facing +); // Steps with `step` a tile at a time, at most `max_tiles`, until `seen` // says a trainer saw the player. Returns the tiles stepped, or nothing @@ -160,8 +163,8 @@ std::optional step_into_line( ); // From the overworld, turns to face `direction` without stepping: the -// player must have a wall that way. -void face_wall(ProControllerContext& context, Pokemon::Direction direction); +// player must have a wall that way (OverworldWalkSteps::turn_to()). +void face_wall(ConsoleHandle& console, ProControllerContext& context, Pokemon::Direction direction); // The save dialog after SAVE, as pokefirered's start_menu.c runs it: // "Would you like to save the game?" YES/NO (YES first). Then, over a save diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp index 860a78a259..c99038983f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp @@ -487,7 +487,7 @@ bool ConsolePatchSteps::step(){ const int ret = run_until( m_console, m_context, [&](ProControllerContext& context){ - step_once(context, way, m_spot.facing); + step_once(m_console, context, way, m_spot.facing); }, {battle} ); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 00fdd207dd..13a39e6cae 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -77,7 +77,7 @@ std::optional face_a_wall(StoryEnv& env, const MapPosition& position) const std::optional wall = wall_beside(*map, position.point); if (wall){ env.console.log(std::string("Story: facing unknown, turning to the wall ") + direction_name(*wall) + "."); - face_wall(env.context, *wall); + face_wall(env.console, env.context, *wall); } return wall; } @@ -192,7 +192,7 @@ Outcome take_step(StoryEnv& env, RunState& state, const StoryStep& step){ return Pokemon::outcome_failed("no_trainer", std::format("{} s on the tile {}", TRAINER_WAIT_WATCH.count() / 1000, direction_name(step.direction))); } }else{ - step_once(env.context, step.direction, facing_of(state.pose)); + step_once(env.console, env.context, step.direction, facing_of(state.pose)); } const std::vector tiles = Pokemon::tiles_of(state.pose); if (tiles.size() != 1){ @@ -618,12 +618,12 @@ Outcome cut_tree(StoryEnv& env, RunState& state, const StoryStep& step){ } if (here->facing != step.direction){ // The tree is in the way: a tap only turns the player to it. - face_wall(env.context, step.direction); + face_wall(env.console, env.context, step.direction); } pbf_press_button(env.context, BUTTON_A, 160ms, 800ms); answer_yes_no(env.console, env.context, true, BUTTON_B, YES_NO_TIMEOUT); mash_until_overworld(env.console, env.context, SCENE_TIMEOUT); - step_once(env.context, step.direction, step.direction); + step_once(env.console, env.context, step.direction, step.direction); return Pokemon::outcome_ok(to_pose(StoryPose{here->map, here->tile.next(step.direction), step.direction})); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index d4c9a95e63..4de9243862 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -341,36 +341,56 @@ const std::map ROCK_TUNNEL_ENCOUNTER_RATES{ }; constexpr unsigned GAME_ODDS_SEEDS = 30; +// The share of turn taps the sim drops (SimScript::turn_tap_drop_rate): +// a guess well above what the console showed, one in 00.28 run 2. +constexpr double TURN_TAP_DROP_RATE = 0.1; // 00.28 whole, its wild battles at the game's own odds (OverworldSim:: -// rolls_encounter()), from each of these seeds: every run ends where it -// should. Logs the runs' numbers. -UnitTestResult test_rock_tunnel_game_odds(Logger& logger){ +// rolls_encounter()) and `turn_tap_drop_rate` of its turn taps dropped, +// from each of these seeds: every run ends where it should. Logs the +// runs' numbers. +UnitTestResult rock_tunnel_game_odds(Logger& logger, double turn_tap_drop_rate){ OverworldSim clean_sim = row_start_sim("00.28", SimScript{}); if (const UnitTestResult clean = row_checked(clean_sim, "00.28", {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); clean.result != UnitTestResult::PASSED){ return clean; } std::vector runs; std::string failures; + int dropped = 0; for (unsigned seed = 1; seed <= GAME_ODDS_SEEDS; seed++){ SimScript script; script.encounter_rates = ROCK_TUNNEL_ENCOUNTER_RATES; script.encounter_seed = seed; + script.turn_tap_drop_rate = turn_tap_drop_rate; + script.turn_tap_seed = seed; OverworldSim sim = row_start_sim("00.28", script); const UnitTestResult result = row_checked(sim, "00.28", {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); const bool passed = result.result == UnitTestResult::PASSED; runs.push_back(row_numbers(sim.state(), passed, clean_sim.state())); + dropped += sim.state().turn_taps_dropped; if (!passed){ failures += std::format("{}Seed {}: {}", failures.empty() ? "" : " | ", seed, result.message); } } - logger.log("00.28 at the game's encounter odds: " + numbers_summary(runs)); + logger.log(std::format( + "00.28 at the game's encounter odds, {} turn taps dropped: {}", dropped, numbers_summary(runs) + )); if (!failures.empty()){ return failures; } return true; } +UnitTestResult test_rock_tunnel_game_odds(Logger& logger){ + return rock_tunnel_game_odds(logger, 0); +} + +// The same with one turn tap in TURN_TAP_DROP_RATE dropped by the game +// (console B, 2026-11-09, 00.28 run 2). +UnitTestResult test_rock_tunnel_dropped_turn_taps(Logger& logger){ + return rock_tunnel_game_odds(logger, TURN_TAP_DROP_RATE); +} + // 00.29 on the sim: no trainer met, the ledge hopped, Lavender's nurse. UnitTestResult test_lavender_row(Logger&){ return row_on_sim("00.29", SimScript{}, {{}}, MapPosition{StoryTable::LAVENDER_CENTER, {7, 4}}); @@ -456,6 +476,7 @@ const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { {.input = {"00.28 B1F row 37 with a wild battle every 10 to 14 steps", test_b1f_row37_battles_every_12}, .expectation = {}}, {.input = {"00.28 a wild battle every 7 to 25 steps, 12 seeds", test_rock_tunnel_frequent_battles}, .expectation = {}}, {.input = {"00.28 wild battles at the game's odds, 30 seeds", test_rock_tunnel_game_odds}, .expectation = {}}, + {.input = {"00.28 wild battles at the game's odds and 1 in 10 turn taps dropped, 30 seeds", test_rock_tunnel_dropped_turn_taps}, .expectation = {}}, {.input = {"00.28 B1F a wild battle into row 38, then a step short: Martha still sees the player", test_b1f_martha_after_row38_battle}, .expectation = {}}, {.input = {"00.29 Route 10 south, the ledge, Lavender Town's Pokemon Center", test_lavender_row}, .expectation = {}}, {.input = {"00.29 a tile off after each open leg", test_lavender_tile_off}, .expectation = {}}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp index 19cb975a72..46a8fe1ea7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp @@ -25,7 +25,7 @@ SimPatch::SimPatch(OverworldSim& sim, Direction pace, Direction back, std::funct bool SimPatch::step(){ const Direction way = m_displaced ? m_back : m_pace; if (m_sim.state().facing != way){ - m_sim.tap(way); + sim_turn_to(m_sim, way); } m_sim.tap(way); m_displaced = !m_displaced; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index d9ba3fcd69..e54680468e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -401,7 +401,7 @@ std::string OverworldStepSim::step_ahead(const StoryStep& step){ } const auto tap = [this, &step]{ if (m_sim.state().facing != step.direction){ - m_sim.tap(step.direction); + sim_turn_to(m_sim, step.direction); } m_sim.tap(step.direction); }; @@ -424,7 +424,7 @@ std::string OverworldStepSim::step_ahead(const StoryStep& step){ // A hop() or step_onto(): one step, ending where the table says. std::string OverworldStepSim::step_onto(const StoryStep& step){ if (m_sim.state().facing != step.direction){ - m_sim.tap(step.direction); + sim_turn_to(m_sim, step.direction); } m_sim.tap(step.direction); m_at = *story_pose(*step.pose_after); @@ -435,7 +435,7 @@ std::string OverworldStepSim::step_onto(const StoryStep& step){ // tile. std::string OverworldStepSim::cut(const StoryStep& step){ if (m_sim.state().facing != step.direction){ - m_sim.tap(step.direction); + sim_turn_to(m_sim, step.direction); } if (!m_sim.cut_tree()){ return "no tree to cut."; From d40ae84694f2660248fec28403d4bc4e41dd469b Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 12:25:20 -0800 Subject: [PATCH 11/24] FRLG walker: turn tap tests in the walk tests; no extra public sim method Code health: OverworldSim kept at 7 public methods (sim_turn_to() reads the state) and OverworldSim_Tests under 1000 lines (the turn tap cases moved to OverworldWalk_Tests, as a table). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_OverworldSim.cpp | 11 +-- .../Overworld/PokemonFRLG_OverworldSim.h | 8 +-- .../PokemonFRLG_OverworldSim_Tests.cpp | 22 ------ .../PokemonFRLG_OverworldWalk_Tests.cpp | 72 +++++++++++++++++++ 4 files changed, 76 insertions(+), 37 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index c3bb6346e6..3b92f27ef2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -537,13 +537,6 @@ void OverworldSim::tap(Direction direction){ advance_to(m_now + TAP_TIME); } -std::optional OverworldSim::facing_on_screen() const{ - if (m_state.screen != SimScreen::OVERWORLD){ - return std::nullopt; - } - return m_state.facing; -} - bool OverworldSim::drops_turn_tap(){ m_turn_taps++; if (m_script.dropped_turn_taps.contains(m_turn_taps)){ @@ -921,8 +914,8 @@ RouteCalls OverworldSimWalker::calls(){ void sim_turn_to(OverworldSim& sim, Direction direction, bool confirm){ for (size_t tap = 1; tap <= MAX_TURN_TAPS; tap++){ sim.tap(direction); - const std::optional facing = sim.facing_on_screen(); - if (!confirm || !facing || *facing == direction){ + const SimState& state = sim.state(); + if (!confirm || state.screen != SimScreen::OVERWORLD || state.facing == direction){ return; } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index f2db259d5f..a349edbd64 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -214,10 +214,6 @@ class OverworldSim{ // already face that way. A turn may be dropped (SimScript::dropped_turn_taps). void tap(Direction direction); - // The way the player faces, as read_player_facing() reads it: on the - // overworld only. - std::optional facing_on_screen() const; - // Watches `watchers` for `duration`, as wait_until(). std::optional watch(const std::vector& watchers, Milliseconds duration); @@ -375,8 +371,8 @@ struct SimWalkerRules{ bool confirm_turn = true; }; -// OverworldWalkSteps::turn_to() on `sim`; with `confirm` false, the one -// tap before it. +// OverworldWalkSteps::turn_to() on `sim`, which reads the facing on the +// overworld only; with `confirm` false, the one tap before it. void sim_turn_to(OverworldSim& sim, Direction direction, bool confirm = SimWalkerRules{}.confirm_turn); // The console side of run_route() on an OverworldSim, as diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index 43d3a16ad6..7b7fb39c46 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -486,27 +486,6 @@ UnitTestResult test_short_leg_unseen(){ return true; } -// Console B, 2026-11-09 (00.28 run 2): the game missed the turn tap before -// a one-tile open leg, the leg's hold only turned the player, and the leg -// was taken as walked. The walker now reads the facing after the tap and -// taps again; without that the player stays where they started. -UnitTestResult test_dropped_turn_tap(){ - SimCase c{.start = at("ViridianForest", 13, 28), .facing = Direction::NORTH, .goal = at("ViridianForest", 12, 28)}; - c.route = std::vector{leg(Direction::WEST, 1, c.goal, false)}; - c.script.dropped_turn_taps = {1}; - const SimRun now = run_case(c); - if (!arrived(now, c.goal) || now.state.turn_taps_dropped != 1){ - return "Expected the turn tapped again and the step walked:" + describe(now); - } - SimCase unchecked = c; - unchecked.rules.confirm_turn = false; - const SimRun old = run_case(unchecked); - if (!old.failure.empty() || !(old.state.position == c.start)){ - return "Expected the leg taken as walked from the start without the check:" + describe(old); - } - return true; -} - // r18 (console B, 2026-11-01), Pewter Gym: after Brock, Camper Liam sees // the player on the walk south to the door, walks up and battles. The // route holds on south to the door's mat; finding the player again at @@ -982,7 +961,6 @@ const OverworldSimCase OVERWORLD_SIM_CASES[] = { {.input = {"no hold over a ledge", test_no_hold_over_ledge}, .expectation = {},}, {.input = {"a short leg, then a sign", test_short_leg_then_sign}, .expectation = {},}, {.input = {"a short leg, nothing seen", test_short_leg_unseen}, .expectation = {},}, - {.input = {"a dropped turn tap before a one-tile leg", test_dropped_turn_tap}, .expectation = {},}, {.input = {"a trainer on the way out of Pewter Gym", test_trainer_on_way_out}, .expectation = {},}, {.input = {"out of Viridian Mart from the clerk's scene", test_mart_exit}, .expectation = {},}, {.input = {"Mt. Moon: down the ladders to B2F", test_mt_moon_ladders}, .expectation = {},}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp index 60568958df..10e79ccb20 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp @@ -6,9 +6,12 @@ #include #include +#include #include #include #include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_OverworldWalk.h" namespace PokemonAutomation{ @@ -116,6 +119,72 @@ const OverworldWalkTestCase OVERWORLD_WALK_CASES[] = { }, }; + +struct TurnTapInput{ + std::string name; + // SimScript::dropped_turn_taps. + std::set dropped_turn_taps; + // SimWalkerRules::confirm_turn. + bool confirm_turn = true; +}; + +struct TurnTapExpectation{ + // Where the leg ends. + MapPosition end; +}; + +struct TurnTapCase{ + TurnTapInput input; + TurnTapExpectation expectation; +}; + +const MapPosition TURN_START{"ViridianForest", {13, 28}}; +const MapPosition TURN_END{"ViridianForest", {12, 28}}; + +// Console B, 2026-11-09 (00.28 run 2): the game missed the turn tap before +// a one-tile open leg, the leg's hold only turned the player, and the leg +// was taken as walked. On the sim, a leg west 1 for a player facing north. +class Test_OverworldTurnTap : public UnitTest{ +public: + explicit Test_OverworldTurnTap(TurnTapCase test_case) + : UnitTest("PokemonFRLG::OverworldWalk - turn tap: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + static const std::vector MAPS = overworld_maps(); + RouteStep west; + west.direction = Direction::WEST; + west.tiles = 1; + west.facing_after = Direction::WEST; + west.end = TURN_END; + SimScript script; + script.dropped_turn_taps = m_case.input.dropped_turn_taps; + SimWalkerRules rules; + rules.confirm_turn = m_case.input.confirm_turn; + OverworldSim sim(MAPS, TURN_START, Direction::NORTH, script); + OverworldSimWalker walker(sim, rules); + const RoutePlanning planning{.maps = &MAPS, .costs = default_route_costs(), .start = TURN_START, .label = "Sim"}; + const std::string failure = walker.run({west}, planning); + if (failure.empty() && sim.state().position == m_case.expectation.end){ + return true; + } + return std::format( + "Expected the leg to end on {}; ended on {}, failure \"{}\".", + to_string(m_case.expectation.end), to_string(sim.state().position), failure + ); + } + +private: + TurnTapCase m_case; +}; + +const TurnTapCase TURN_TAP_CASES[] = { + {.input = {.name = "taken"}, .expectation = {.end = TURN_END}}, + {.input = {.name = "dropped, tapped again", .dropped_turn_taps = {1}}, .expectation = {.end = TURN_END}}, + {.input = {.name = "dropped twice, tapped again", .dropped_turn_taps = {1, 2}}, .expectation = {.end = TURN_END}}, + {.input = {.name = "dropped, not checked: the leg only turns", .dropped_turn_taps = {1}, .confirm_turn = false}, .expectation = {.end = TURN_START}}, +}; + } // namespace @@ -123,6 +192,9 @@ void add_tests_OverworldWalk(UnitTestDatabase& database){ for (const auto& scenario : OVERWORLD_WALK_CASES){ database.add(scenario); } + for (const TurnTapCase& item : TURN_TAP_CASES){ + database.add(item); + } } From 75c8c8532f2d01e2026ee3abf431060fa963955f Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 13:35:46 -0800 Subject: [PATCH 12/24] FRLG AutoStory: test that every resource it loads is in Resources A lab deployment without PokemonFRLG/PlayerSprites/red_normal.png failed 00.28 with a FileException; no test checked the files the readers load. autostory_resource_paths() lists the files AutoStory and its readers and detectors load, and the test fails naming each one missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 2 + .../PokemonFRLG_AutoStoryResources.cpp | 87 +++++++++++++++++++ .../PokemonFRLG_AutoStoryResources.h | 35 ++++++++ .../PokemonFRLG_AutoStoryResources_Tests.cpp | 78 +++++++++++++++++ SerialPrograms/cmake/SourceFiles.cmake | 3 + 5 files changed, 205 insertions(+) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index 4de9243862..49849c4edc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -26,6 +26,7 @@ #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryResources.h" #include "PokemonFRLG_AutoStoryRowSim.h" #include "PokemonFRLG_AutoStoryTable.h" #include "PokemonFRLG_AutoStoryTableSteps.h" @@ -487,6 +488,7 @@ void add_tests_AutoStoryLavender(UnitTestDatabase& database){ for (const auto& item : AUTO_STORY_LAVENDER_CASES){ database.add(item); } + add_tests_AutoStoryResources(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.cpp new file mode 100644 index 0000000000..f0004cf491 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.cpp @@ -0,0 +1,87 @@ +/* AutoStory Resources + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "PokemonFRLG_AutoStoryResources.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +const std::string_view PATHS[] = { + // Overworld/PokemonFRLG_PlayerFacingReader.cpp + "PokemonFRLG/PlayerSprites/red_normal.png", + + // Inference/PokemonFRLG_SelectionArrowDetector.cpp + "PokemonFRLG/SelectionArrow.png", + // Inference/PokemonFRLG_BattleSelectionArrowDetector.cpp + "PokemonFRLG/BattleSelectionArrow.png", + // Inference/PokemonFRLG_PokedexRegisteredDetector.cpp + "PokemonFRLG/PokedexButtonA.png", + // Inference/Menus/PokemonFRLG_PartyHeldItemDetector.cpp + "PokemonFRLG/PartyHeldItem.png", + // Inference/PokemonFRLG_StatsReader.cpp + "PokemonFRLG/NatureCheckerOCR.json", + + // Inference/Box/PokemonFRLG_BoxSelectionModeDetector.cpp + "PokemonFRLG/Box/SelectionHand_White.png", + "PokemonFRLG/Box/SelectionHand_White2.png", + "PokemonFRLG/Box/SelectionHand_White_Inverted.png", + "PokemonFRLG/Box/SelectionHand_Gold.png", + "PokemonFRLG/Box/SelectionHand_Gold2.png", + "PokemonFRLG/Box/SelectionHand_Gold_Inverted.png", + "PokemonFRLG/Box/SelectionHand_Closed_White.png", + "PokemonFRLG/Box/SelectionHand_Closed_Gold.png", + + // Inference/PokemonFRLG_DigitReader.cpp, digits 0 to 9 of each box. + "PokemonFRLG/Digits/0.png", "PokemonFRLG/Digits/1.png", "PokemonFRLG/Digits/2.png", + "PokemonFRLG/Digits/3.png", "PokemonFRLG/Digits/4.png", "PokemonFRLG/Digits/5.png", + "PokemonFRLG/Digits/6.png", "PokemonFRLG/Digits/7.png", "PokemonFRLG/Digits/8.png", + "PokemonFRLG/Digits/9.png", + "PokemonFRLG/LevelDigits/0.png", "PokemonFRLG/LevelDigits/1.png", "PokemonFRLG/LevelDigits/2.png", + "PokemonFRLG/LevelDigits/3.png", "PokemonFRLG/LevelDigits/4.png", "PokemonFRLG/LevelDigits/5.png", + "PokemonFRLG/LevelDigits/6.png", "PokemonFRLG/LevelDigits/7.png", "PokemonFRLG/LevelDigits/8.png", + "PokemonFRLG/LevelDigits/9.png", + "PokemonFRLG/DialogDigits/0.png", "PokemonFRLG/DialogDigits/1.png", "PokemonFRLG/DialogDigits/2.png", + "PokemonFRLG/DialogDigits/3.png", "PokemonFRLG/DialogDigits/4.png", "PokemonFRLG/DialogDigits/5.png", + "PokemonFRLG/DialogDigits/6.png", "PokemonFRLG/DialogDigits/7.png", "PokemonFRLG/DialogDigits/8.png", + "PokemonFRLG/DialogDigits/9.png", + + // Inference/Sounds/PokemonFRLG_ShinySoundDetector.cpp and + // PokemonFRLG_CatchFanfareDetector.cpp, one per sample rate. + "PokemonFRLG/ShinySound-44100.wav", + "PokemonFRLG/ShinySound-48000.wav", + "PokemonFRLG/CatchFanfare-44100.wav", + "PokemonFRLG/CatchFanfare-48000.wav", + + // Resources/PokemonFRLG_PokemonSprites.cpp + "PokemonFRLG/PokemonSprites.png", + "PokemonFRLG/PokemonSpritesFlipped.png", + "PokemonFRLG/PokemonSprites.json", + + // Programs/RngManipulation/PokemonFRLG_SeedsDatabase.cpp, for the + // story's RNG gifts. + "PokemonFRLG/RngSeeds/FR_Eng.json", + "PokemonFRLG/RngSeeds/FR_Jpn.json", + "PokemonFRLG/RngSeeds/LG_Eng.json", + "PokemonFRLG/RngSeeds/LG_Jpn.json", +}; + +} // namespace + + +const std::vector& autostory_resource_paths(){ + static const std::vector LIST(std::begin(PATHS), std::end(PATHS)); + return LIST; +} + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.h new file mode 100644 index 0000000000..818fa41e34 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.h @@ -0,0 +1,35 @@ +/* AutoStory Resources + * + * From: https://github.com/PokemonAutomation/ + * + * The files under the Resources folder that AutoStory, and the readers + * and detectors it calls, load while a program runs. A deployment whose + * Packages lack one of them stops mid-story with a FileException, so the + * test checks every one of them exists. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryResources_H +#define PokemonAutomation_PokemonFRLG_AutoStoryResources_H + +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Paths relative to RESOURCE_PATH(). When a reader or detector that +// AutoStory uses starts loading a new file, add it here. +const std::vector& autostory_resource_paths(); + +// Adds the test that every path above is under RESOURCE_PATH(). +void add_tests_AutoStoryResources(UnitTestDatabase& database); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources_Tests.cpp new file mode 100644 index 0000000000..e49c7efce9 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources_Tests.cpp @@ -0,0 +1,78 @@ +/* AutoStory Resources Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Checks that every file in autostory_resource_paths() is in the + * Resources folder the program runs with. + * + */ + +#include +#include +#include +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "PokemonFRLG_AutoStoryResources.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +namespace{ + +struct ResourcesInput{ + const char* name; + const std::vector& (*paths)(); +}; +struct ResourcesExpectation{ + // The paths that may be missing; none, for a list a program loads. + size_t missing; +}; +struct ResourcesCase{ + ResourcesInput input; + ResourcesExpectation expectation; +}; + +class Test_AutoStoryResources : public UnitTest{ +public: + explicit Test_AutoStoryResources(ResourcesCase test_case) + : UnitTest(std::string("PokemonFRLG::AutoStoryResources - ") + test_case.input.name) + , m_case(test_case) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + std::string missing; + size_t count = 0; + for (std::string_view path : m_case.input.paths()){ + if (!Filesystem::exists(Filesystem::Path(RESOURCE_PATH() + std::string(path)))){ + missing += "\n " + std::string(path); + count++; + } + } + if (count == m_case.expectation.missing){ + return true; + } + return std::to_string(count) + " missing under " + RESOURCE_PATH() + ":" + missing; + } + +private: + ResourcesCase m_case; +}; + +const ResourcesCase RESOURCES_CASES[] = { + {.input = {"every file AutoStory loads is in Resources", autostory_resource_paths}, .expectation = {.missing = 0}}, +}; + +} // namespace + + +void add_tests_AutoStoryResources(UnitTestDatabase& database){ + for (const ResourcesCase& item : RESOURCES_CASES){ + database.add(item); + } +} + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 0b44daf242..9d8ef5b060 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1931,6 +1931,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryResources_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp From 2134dbb2311e80ef0220c5c9194b9f2690d03fba Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 13:35:36 -0800 Subject: [PATCH 13/24] FRLG: movement mechanics table; sim taps as measured on the console Move the walker's pace, tap and release times into PokemonFRLG_MovementMechanics.h with the tap rules measured on console B (2026-11-09, Route 10 Center 1F): a tap from a standstill only turns a player facing another way and steps once for one facing its way, a step tap within 620 ms of the last one's start is ignored, and taps with no release between them are one hold. OverworldSim now moves its player by these rules; the random turn-tap drop is gone (dropped_turn_taps stays as fault injection for the turn check). Red: sim_step_once() still taps at the turn tap's pace (340 ms), so three step_once() lose a step under the lockout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_MovementMechanics.h | 88 ++++++++++++++++++ .../PokemonFRLG_OverworldRecoveryWalk.cpp | 1 + .../Overworld/PokemonFRLG_OverworldSim.cpp | 87 +++++++++++++++--- .../Overworld/PokemonFRLG_OverworldSim.h | 57 +++++++++--- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 2 +- .../Overworld/PokemonFRLG_OverworldWalk.h | 21 +---- .../PokemonFRLG_OverworldWalk_Tests.cpp | 92 +++++++++++++++++++ .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 28 ++---- .../PokemonFRLG_AutoStoryPaceSim.cpp | 5 +- .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 12 +-- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 5 +- SerialPrograms/cmake/SourceFiles.cmake | 1 + 12 files changed, 314 insertions(+), 85 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h new file mode 100644 index 0000000000..f8d328d617 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h @@ -0,0 +1,88 @@ +/* Movement Mechanics + * + * From: https://github.com/PokemonAutomation/ + * + * How FireRed and LeafGreen move the player for a press of the D-pad, as + * measured on the console, and the presses the walker sends because of + * it. The overworld walker (OverworldWalkSteps, AutoStory's step_once()) + * presses by this table, and OverworldSim moves its player by the same + * rules. + * + * Taps measured on console B (2026-11-09, Route 10's Pokemon Center 1F, + * D-pad presses with a 700ms release, tiles counted by exact frame match + * against reference shots), 140 of 140 alike: + * - From a standstill, a tap of 16 to 133ms while the player faces + * another way only turns them, never steps (pokefirered turns in 8 + * frames). + * - The same tap while they face its way takes exactly one step. + * - No tap was dropped. + * Repeated step taps (66ms each, after a turn tap and a 1000ms wait): + * - A step tap that starts within STEP_TAP_LOCKOUT of the previous step + * tap's start is ignored. + * - Taps with no release between them are one hold: after a turn, 3 of + * them took 0 or 1 steps. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_MovementMechanics_H +#define PokemonAutomation_PokemonFRLG_MovementMechanics_H + +#include +#include "Common/Cpp/Time.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace MovementMechanics{ + + +// The game. + +// Walking: a tile per 16 frames at 60 fps (console B, 2026-11-09). +inline constexpr Milliseconds TILE_TIME(267); +// Running with B held: a tile per 8 frames (console B, 2026-11-02, video +// of run 00.11). +inline constexpr Milliseconds RUN_TILE_TIME(133); +// Surfing: a tile per 8 frames. +inline constexpr Milliseconds SURF_TILE_TIME(133); +// The longest tap measured to only turn a player facing another way, and +// to take exactly one step for one facing its way. +inline constexpr Milliseconds LONGEST_TAP(133); +// A step tap that starts sooner than this after the previous step tap +// started is ignored. Measured between 566ms (66ms taps 500ms apart: the +// 2nd of 3 ignored, 3 of 3 times) and 666ms (600 to 900ms apart: 8 of 8 +// worked). +inline constexpr Milliseconds STEP_TAP_LOCKOUT(620); +// The shortest step tap spacing, start to start, measured to work. +inline constexpr Milliseconds STEP_TAP_SPACING_WORKED(666); + + +// The walker's presses. + +// Turns a player facing another way, without a step. +inline constexpr Milliseconds TURN_TAP(40); +// How long to wait after a turn tap for the turn to show. +inline constexpr Milliseconds TURN_SETTLE(300); +// How many taps turn_to() tries before it takes the player as turned. +inline constexpr size_t MAX_TURN_TAPS = 3; +// Turns the player to face something, short enough not to step. +inline constexpr Milliseconds FACE_HOLD(40); +// The turn animation, before an A press reaches what is ahead. +inline constexpr Milliseconds FACE_SETTLE(300); +// The release between a leg's hold and what comes next. +inline constexpr Milliseconds STEP_RELEASE(100); +// A one-step tap (step_once()) and the release after it: the next step +// tap starts STEP_TAP + STEP_TAP_RELEASE after it, past the lockout. +inline constexpr Milliseconds STEP_TAP(66); +inline constexpr Milliseconds STEP_TAP_RELEASE(700); + +static_assert(TURN_TAP <= LONGEST_TAP && FACE_HOLD <= LONGEST_TAP && STEP_TAP <= LONGEST_TAP); +static_assert(STEP_TAP + STEP_TAP_RELEASE > STEP_TAP_SPACING_WORKED); +static_assert(STEP_TAP_SPACING_WORKED > STEP_TAP_LOCKOUT); + + +} // namespace MovementMechanics +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 8bf845563a..a2d26f6e2d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -25,6 +25,7 @@ namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ +using namespace MovementMechanics; using namespace OverworldWalkSteps; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 3b92f27ef2..3ded120dcd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -91,8 +91,6 @@ Pokemon::ScreenEventLog screen_event_log(const SimState& state, Milliseconds fro namespace{ -// A short press: TURN_TAP and TURN_SETTLE. -constexpr Milliseconds TAP_TIME(340); // The story's battle loop takes a battle that doesn't show in this long // for none. constexpr Milliseconds NO_BATTLE_WAIT(15000); @@ -145,7 +143,6 @@ OverworldSim::OverworldSim(std::vector maps, MapPosition start, Directi , m_script(std::move(script)) , m_map(find_map(m_maps, start.map)) , m_encounter_rng(m_script.encounter_seed) - , m_tap_rng(m_script.turn_tap_seed) { if (m_map < 0){ throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Sim: start on a map that isn't loaded: " + start.map); @@ -468,7 +465,13 @@ void OverworldSim::walk_hold(Direction direction, Milliseconds duration, int sho m_state.facing = direction; pressed += tile; } - int left = (int)((end - pressed + tile - 1ms) / tile) - short_by; + take_steps(direction, pressed, end, (int)((end - pressed + tile - 1ms) / tile) - short_by); +} + +// Up to `left` steps of a hold of `direction` until `end`, the first +// pressed at `pressed`. +void OverworldSim::take_steps(Direction direction, Milliseconds pressed, Milliseconds end, int left){ + const Milliseconds tile = m_script.timing.tile; for (; left > 0 && pressed < end; left--){ const std::optional step = press(direction); if (!step){ @@ -512,8 +515,13 @@ std::optional OverworldSim::hold( ){ const Milliseconds start = m_now; m_holds++; + m_last_tap.reset(); if (m_state.screen != SimScreen::OVERWORLD || m_frozen){ stuck_hold(direction); + }else if (duration <= MovementMechanics::LONGEST_TAP && m_state.facing == direction){ + // A hold as short as a tap is one (a one-tile leg). + step_tap(direction, duration); + m_last_tap.reset(); }else{ const auto short_hold = m_script.short_holds.find(m_holds); walk_hold(direction, duration, short_hold == m_script.short_holds.end() ? 0 : short_hold->second); @@ -521,10 +529,17 @@ std::optional OverworldSim::hold( return watch_until(watchers, start, start + std::max(duration + release, watch)); } -void OverworldSim::tap(Direction direction){ +void OverworldSim::tap(Direction direction, Milliseconds duration, Milliseconds release){ + const Milliseconds start = m_now; if (m_state.screen != SimScreen::OVERWORLD || m_frozen){ stuck_hold(direction); + m_last_tap.reset(); + }else if (m_last_tap && m_last_tap->direction == direction && m_last_tap->end == start){ + hold_on(direction, start + duration); }else if (m_state.facing != direction){ + // From a standstill a tap only turns the player, even one as long + // as LONGEST_TAP. + m_last_tap = TapPress{direction, start, start + duration, m_state.facing, 0}; if (drops_turn_tap()){ m_state.turn_taps_dropped++; note(std::string("the turn tap ") + direction_name(direction) + " is dropped."); @@ -532,20 +547,54 @@ void OverworldSim::tap(Direction direction){ m_state.facing = direction; } }else{ - walk_hold(direction, m_script.timing.tile / 2, 0); + step_tap(direction, duration); } - advance_to(m_now + TAP_TIME); + advance_to(start + duration + release); } -bool OverworldSim::drops_turn_tap(){ - m_turn_taps++; - if (m_script.dropped_turn_taps.contains(m_turn_taps)){ - return true; +// A tap of `direction` by a player facing that way: one step, unless it +// starts inside the lockout after the last step tap. +void OverworldSim::step_tap(Direction direction, Milliseconds duration){ + const Milliseconds start = m_now; + m_last_tap = TapPress{direction, start, start + duration, direction, 0}; + if (m_last_step_tap && start < *m_last_step_tap + MovementMechanics::STEP_TAP_LOCKOUT){ + m_state.step_taps_ignored++; + note(std::format( + "the step tap {} is ignored, {} ms after the last.", + direction_name(direction), (start - *m_last_step_tap).count() + )); + return; } - if (m_script.turn_tap_drop_rate <= 0){ - return false; + const int before = m_state.steps; + walk_hold(direction, duration, 0); + m_last_tap->steps = m_state.steps - before; + if (m_last_tap->steps > 0){ + m_last_step_tap = start; } - return std::uniform_real_distribution(0, 1)(m_tap_rng) < m_script.turn_tap_drop_rate; +} + +// A tap of the last tap's way with no release before it: the two are one +// hold, from the last one's start until `end`. +void OverworldSim::hold_on(Direction direction, Milliseconds end){ + TapPress& press = *m_last_tap; + const Milliseconds tile = m_script.timing.tile; + Milliseconds pressed = press.start; + if (press.facing_before != direction){ + pressed += tile; + } + m_state.facing = direction; + const int steps = (int)((end - pressed + tile - 1ms) / tile); + if (steps > press.steps){ + const int before = m_state.steps; + take_steps(direction, pressed + tile * press.steps, end, steps - press.steps); + press.steps += m_state.steps - before; + } + press.end = end; +} + +bool OverworldSim::drops_turn_tap(){ + m_turn_taps++; + return m_script.dropped_turn_taps.contains(m_turn_taps); } std::optional OverworldSim::watch(const std::vector& watchers, Milliseconds duration){ @@ -627,6 +676,7 @@ bool OverworldSim::cut_tree(){ namespace{ +using namespace MovementMechanics; using namespace OverworldWalkSteps; // As the console's watch for a trainer (PokemonFRLG_OverworldRecoveryWalk.cpp). @@ -896,7 +946,7 @@ StepEnd OverworldSimWalker::watch_for_trainer(const RouteStep& step, int approac bool OverworldSimWalker::moved_past_wall(const RouteStep& step){ const MapPosition before = m_sim.state().position; - m_sim.tap(step.direction); + m_sim.tap(step.direction, TURN_TAP, TILE_TIME + TURN_SETTLE); return !(m_sim.state().position == before); } @@ -921,6 +971,13 @@ void sim_turn_to(OverworldSim& sim, Direction direction, bool confirm){ } } +void sim_step_once(OverworldSim& sim, Direction direction){ + if (sim.state().facing != direction){ + sim_turn_to(sim, direction); + } + sim.tap(direction); +} + std::string OverworldSimWalker::run(const std::vector& route, const RoutePlanning& planning){ try{ run_route(route, m_sim.state().facing, &planning, calls()); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index a349edbd64..2b3e1df6fb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -38,6 +38,7 @@ #include "Common/Cpp/Time.h" #include "Common/Cpp/TestRunners/UnitTestDatabase.h" #include "Pokemon/AutoStory/Pokemon_ScreenEvents.h" +#include "PokemonFRLG_MovementMechanics.h" #include "PokemonFRLG_OverworldRecovery.h" #include "PokemonFRLG_OverworldRoute.h" #include "PokemonFRLG_OverworldWalk.h" @@ -84,7 +85,7 @@ struct SimEncounter{ // detection delays (OverworldInterrupt::detection_delay()) are its guesses // at these. struct SimTiming{ - Milliseconds tile{267}; + Milliseconds tile = MovementMechanics::TILE_TIME; Milliseconds flash{300}; Milliseconds battle_screen{3700}; // A wild battle's text box, before the HP boxes (battle_screen). Not @@ -121,14 +122,13 @@ struct SimScript{ // src/data/wild_encounters.json), rolled from `encounter_seed`. std::map encounter_rates; uint32_t encounter_seed = 0; - // Turn taps (taps while the player faces another way) the game misses: - // the player goes on facing the old way. By number, from 1, and each - // with probability `turn_tap_drop_rate`, rolled from `turn_tap_seed`. - // After one, a hold of a tile only turns the player (console B, - // 2026-11-09, 00.28 run 2). + // Fault injection: turn taps (taps while the player faces another way) + // the game misses, by number from 1; the player goes on facing the old + // way. The game drops none from a standstill (MovementMechanics: 70 of + // 70 turned); these test the walker's check of the turn + // (SimWalkerRules::confirm_turn) after 00.28 run 2 (console B, + // 2026-11-09), where a one-tile leg only turned the player. std::set dropped_turn_taps; - double turn_tap_drop_rate = 0; - uint32_t turn_tap_seed = 0; SimTiming timing; }; @@ -157,6 +157,9 @@ struct SimState{ Milliseconds in_wild_battles{0}; int signs_read = 0; int turn_taps_dropped = 0; + // Step taps the game ignored, inside the lockout after the last one + // (MovementMechanics::STEP_TAP_LOCKOUT). + int step_taps_ignored = 0; std::vector trainers_beaten; // Things the walker did that the game wouldn't let pass: D-pad holds // under a battle, a trainer's battle fled, a battle fought that wasn't @@ -210,9 +213,18 @@ class OverworldSim{ const std::vector& watchers ); - // A short press of `direction`: turns the player, or steps if they - // already face that way. A turn may be dropped (SimScript::dropped_turn_taps). - void tap(Direction direction); + // A press of `direction` for `duration` (at most MovementMechanics:: + // LONGEST_TAP), then `release`, as the console measured it: a player + // facing another way only turns; one facing that way steps once, + // unless the tap starts inside the lockout after the last step tap + // (STEP_TAP_LOCKOUT), which the game ignores. A tap that starts as + // the last one ends, with no release between, holds it on. A turn may + // be dropped by fault injection (SimScript::dropped_turn_taps). + void tap( + Direction direction, + Milliseconds duration = MovementMechanics::TURN_TAP, + Milliseconds release = MovementMechanics::TURN_SETTLE + ); // Watches `watchers` for `duration`, as wait_until(). std::optional watch(const std::vector& watchers, Milliseconds duration); @@ -253,7 +265,19 @@ class OverworldSim{ bool sees(SimWatcher watcher, SimScreen screen) const; std::optional first_seen(SimWatcher watcher, Milliseconds from, Milliseconds to) const; std::optional watch_until(const std::vector& watchers, Milliseconds from, Milliseconds to); + // A tap, for the next one's lockout and to hold on: when it started + // and ended, the player's facing before it and the steps it took. + struct TapPress{ + Direction direction = Direction::NORTH; + Milliseconds start{0}; + Milliseconds end{0}; + Direction facing_before = Direction::NORTH; + int steps = 0; + }; void walk_hold(Direction direction, Milliseconds duration, int short_by); + void take_steps(Direction direction, Milliseconds pressed, Milliseconds end, int left); + void step_tap(Direction direction, Milliseconds duration); + void hold_on(Direction direction, Milliseconds end); void stuck_hold(Direction direction); void bump(Direction direction, Milliseconds pressed); std::optional press(Direction direction) const; @@ -292,9 +316,12 @@ class OverworldSim{ std::mt19937 m_encounter_rng; int m_steps_since_encounter = 0; int m_encounter_rate_buff = 0; - // The turn taps so far, and what rolls their drops. + // The turn taps so far, for SimScript::dropped_turn_taps. int m_turn_taps = 0; - std::mt19937 m_tap_rng; + // The last tap, until a hold. + std::optional m_last_tap; + // When the last tap that stepped started. + std::optional m_last_step_tap; Milliseconds m_wild_battle_from{0}; int m_holds = 0; SimState m_state; @@ -375,6 +402,10 @@ struct SimWalkerRules{ // overworld only; with `confirm` false, the one tap before it. void sim_turn_to(OverworldSim& sim, Direction direction, bool confirm = SimWalkerRules{}.confirm_turn); +// AutoStory's step_once() on `sim`: turns the player if they face another +// way, then one step tap. +void sim_step_once(OverworldSim& sim, Direction direction); + // The console side of run_route() on an OverworldSim, as // walk_overworld_route_with_recovery() does it, with the story's // interrupts: a wild battle (flashes, or seen late) is fought or run from, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 008e7089cc..04bc3de098 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -26,12 +26,12 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ using namespace std::chrono_literals; +using namespace MovementMechanics; using namespace OverworldWalkSteps; namespace{ -constexpr Milliseconds FACE_SETTLE = 300ms; // the turn animation, before A reaches what is ahead // From the end of the hold, through the fade out and in. constexpr Milliseconds WARP_TIMEOUT = 8000ms; constexpr Milliseconds SURF_PROMPT_TIMEOUT = 5000ms; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index 8325dac772..a81e1a9eda 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -27,6 +27,7 @@ #include "Pokemon/Navigation/Pokemon_LegWalker.h" #include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" #include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "PokemonFRLG_MovementMechanics.h" #include "PokemonFRLG_OverworldRoute.h" namespace PokemonAutomation{ @@ -99,23 +100,9 @@ void walk_overworld_route( // walk_overworld_route() describes. namespace OverworldWalkSteps{ -// Walking duration for a single tile (16 frames at 60 fps). -inline constexpr Milliseconds TILE_TIME(267); -// Surfing duration for a single tile. -inline constexpr Milliseconds SURF_TILE_TIME(133); -// 8 frames: running is 2 pixels a frame, twice walking. Not measured on the -// console yet; the walker logs each leg to a wall (speed_note()) for that. -inline constexpr Milliseconds RUN_TILE_TIME(133); -// turns a player facing another way, without a step. -inline constexpr Milliseconds TURN_TAP(40); -// duration to wait after turning to allow state to settle. -inline constexpr Milliseconds TURN_SETTLE(300); -// How many taps turn_to() tries before it takes the player as turned. -inline constexpr size_t MAX_TURN_TAPS = 3; -// short enough to turn without stepping. -inline constexpr Milliseconds FACE_HOLD(40); -// duration of button release between movement steps. -inline constexpr Milliseconds STEP_RELEASE(100); +// The pace, tap and release times are the game's movement mechanics +// (PokemonFRLG_MovementMechanics.h). + // After a warp's fade: leaving by a door the game walks the player a tile out // on its own, and a leg started before that ends loses tiles (on the console a // 600ms wait cost 2 tiles). diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp index 10e79ccb20..24b97896f0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp @@ -18,6 +18,7 @@ namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ +using namespace MovementMechanics; using namespace OverworldWalkSteps; @@ -185,6 +186,94 @@ const TurnTapCase TURN_TAP_CASES[] = { {.input = {.name = "dropped, not checked: the leg only turns", .dropped_turn_taps = {1}, .confirm_turn = false}, .expectation = {.end = TURN_START}}, }; + +struct TapMechanicsInput{ + std::string name; + // The player's facing at the start. + Direction facing = Direction::NORTH; + // The presses, on the sim. + std::function presses; +}; + +struct TapMechanicsExpectation{ + // Tiles stepped east. + int steps = 0; + Direction facing = Direction::EAST; + // Step taps the game ignored. + int ignored = 0; +}; + +struct TapMechanicsCase{ + TapMechanicsInput input; + TapMechanicsExpectation expectation; +}; + +// Open floor east of the start: where the taps were measured (console B, +// 2026-11-09, MovementMechanics). +const MapPosition TAP_START{"Route10_PokemonCenter_1F", {3, 4}}; + +// The sim's taps east, as the console moved the player for them. +class Test_OverworldTapMechanics : public UnitTest{ +public: + explicit Test_OverworldTapMechanics(TapMechanicsCase test_case) + : UnitTest("PokemonFRLG::OverworldWalk - tap: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + static const std::vector MAPS = overworld_maps(); + OverworldSim sim(MAPS, TAP_START, m_case.input.facing); + m_case.input.presses(sim); + const SimState& state = sim.state(); + const TapMechanicsExpectation& expected = m_case.expectation; + const MapPosition end{TAP_START.map, {TAP_START.point.x + expected.steps, TAP_START.point.y}}; + if (state.position == end && state.facing == expected.facing && state.step_taps_ignored == expected.ignored){ + return true; + } + return std::format( + "Expected {} step(s) east to {} facing {}, {} tap(s) ignored; on {} facing {}, {} ignored.", + expected.steps, to_string(end), Pokemon::direction_name(expected.facing), expected.ignored, + to_string(state.position), Pokemon::direction_name(state.facing), state.step_taps_ignored + ); + } + +private: + TapMechanicsCase m_case; +}; + +// `count` taps east of `duration`, each released for `release`. +std::function taps_east(int count, Milliseconds duration, Milliseconds release){ + return [=](OverworldSim& sim){ + for (int c = 0; c < count; c++){ + sim.tap(Direction::EAST, duration, release); + } + }; +} + +// A turn tap east and a 1000ms wait, then `count` taps east of 66ms, +// each released for `release`. +std::function turn_then_taps(int count, Milliseconds release){ + return [=](OverworldSim& sim){ + sim.tap(Direction::EAST, TURN_TAP, Milliseconds(1000)); + taps_east(count, Milliseconds(66), release)(sim); + }; +} + +const TapMechanicsCase TAP_MECHANICS_CASES[] = { + {.input = {.name = "133ms facing another way only turns", .presses = taps_east(1, LONGEST_TAP, Milliseconds(700))}, .expectation = {}}, + {.input = {.name = "16ms facing that way steps once", .facing = Direction::EAST, .presses = taps_east(1, Milliseconds(16), Milliseconds(700))}, .expectation = {.steps = 1}}, + {.input = {.name = "3 step taps 566ms apart take 2 steps", .presses = turn_then_taps(3, Milliseconds(500))}, .expectation = {.steps = 2, .ignored = 1}}, + {.input = {.name = "3 step taps 666ms apart take 3 steps", .presses = turn_then_taps(3, Milliseconds(600))}, .expectation = {.steps = 3}}, + {.input = {.name = "3 step taps with no release are one hold", .presses = turn_then_taps(3, Milliseconds(0))}, .expectation = {.steps = 1}}, + { + .input = {.name = "step_once 3 times takes 3 steps", .presses = [](OverworldSim& sim){ + for (int c = 0; c < 3; c++){ + sim_step_once(sim, Direction::EAST); + } + }}, + .expectation = {.steps = 3}, + }, +}; + } // namespace @@ -195,6 +284,9 @@ void add_tests_OverworldWalk(UnitTestDatabase& database){ for (const TurnTapCase& item : TURN_TAP_CASES){ database.add(item); } + for (const TapMechanicsCase& item : TAP_MECHANICS_CASES){ + database.add(item); + } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index 49849c4edc..e201a86da0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -342,39 +342,33 @@ const std::map ROCK_TUNNEL_ENCOUNTER_RATES{ }; constexpr unsigned GAME_ODDS_SEEDS = 30; -// The share of turn taps the sim drops (SimScript::turn_tap_drop_rate): -// a guess well above what the console showed, one in 00.28 run 2. -constexpr double TURN_TAP_DROP_RATE = 0.1; // 00.28 whole, its wild battles at the game's own odds (OverworldSim:: -// rolls_encounter()) and `turn_tap_drop_rate` of its turn taps dropped, -// from each of these seeds: every run ends where it should. Logs the -// runs' numbers. -UnitTestResult rock_tunnel_game_odds(Logger& logger, double turn_tap_drop_rate){ +// rolls_encounter()), from each of these seeds: every run ends where it +// should. Logs the runs' numbers and the step taps the game ignored. +UnitTestResult test_rock_tunnel_game_odds(Logger& logger){ OverworldSim clean_sim = row_start_sim("00.28", SimScript{}); if (const UnitTestResult clean = row_checked(clean_sim, "00.28", {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); clean.result != UnitTestResult::PASSED){ return clean; } std::vector runs; std::string failures; - int dropped = 0; + int ignored = 0; for (unsigned seed = 1; seed <= GAME_ODDS_SEEDS; seed++){ SimScript script; script.encounter_rates = ROCK_TUNNEL_ENCOUNTER_RATES; script.encounter_seed = seed; - script.turn_tap_drop_rate = turn_tap_drop_rate; - script.turn_tap_seed = seed; OverworldSim sim = row_start_sim("00.28", script); const UnitTestResult result = row_checked(sim, "00.28", {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); const bool passed = result.result == UnitTestResult::PASSED; runs.push_back(row_numbers(sim.state(), passed, clean_sim.state())); - dropped += sim.state().turn_taps_dropped; + ignored += sim.state().step_taps_ignored; if (!passed){ failures += std::format("{}Seed {}: {}", failures.empty() ? "" : " | ", seed, result.message); } } logger.log(std::format( - "00.28 at the game's encounter odds, {} turn taps dropped: {}", dropped, numbers_summary(runs) + "00.28 at the game's encounter odds, {} step taps ignored: {}", ignored, numbers_summary(runs) )); if (!failures.empty()){ return failures; @@ -382,15 +376,6 @@ UnitTestResult rock_tunnel_game_odds(Logger& logger, double turn_tap_drop_rate){ return true; } -UnitTestResult test_rock_tunnel_game_odds(Logger& logger){ - return rock_tunnel_game_odds(logger, 0); -} - -// The same with one turn tap in TURN_TAP_DROP_RATE dropped by the game -// (console B, 2026-11-09, 00.28 run 2). -UnitTestResult test_rock_tunnel_dropped_turn_taps(Logger& logger){ - return rock_tunnel_game_odds(logger, TURN_TAP_DROP_RATE); -} // 00.29 on the sim: no trainer met, the ledge hopped, Lavender's nurse. UnitTestResult test_lavender_row(Logger&){ @@ -477,7 +462,6 @@ const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { {.input = {"00.28 B1F row 37 with a wild battle every 10 to 14 steps", test_b1f_row37_battles_every_12}, .expectation = {}}, {.input = {"00.28 a wild battle every 7 to 25 steps, 12 seeds", test_rock_tunnel_frequent_battles}, .expectation = {}}, {.input = {"00.28 wild battles at the game's odds, 30 seeds", test_rock_tunnel_game_odds}, .expectation = {}}, - {.input = {"00.28 wild battles at the game's odds and 1 in 10 turn taps dropped, 30 seeds", test_rock_tunnel_dropped_turn_taps}, .expectation = {}}, {.input = {"00.28 B1F a wild battle into row 38, then a step short: Martha still sees the player", test_b1f_martha_after_row38_battle}, .expectation = {}}, {.input = {"00.29 Route 10 south, the ledge, Lavender Town's Pokemon Center", test_lavender_row}, .expectation = {}}, {.input = {"00.29 a tile off after each open leg", test_lavender_tile_off}, .expectation = {}}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp index 46a8fe1ea7..6e16d20bc2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp @@ -24,10 +24,7 @@ SimPatch::SimPatch(OverworldSim& sim, Direction pace, Direction back, std::funct bool SimPatch::step(){ const Direction way = m_displaced ? m_back : m_pace; - if (m_sim.state().facing != way){ - sim_turn_to(m_sim, way); - } - m_sim.tap(way); + sim_step_once(m_sim, way); m_displaced = !m_displaced; return m_sim.watch({SimWatcher::FLASH}, std::chrono::milliseconds(1000)).has_value(); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index e54680468e..a739d0b9e4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -400,10 +400,7 @@ std::string OverworldStepSim::step_ahead(const StoryStep& step){ return step_onto(step); } const auto tap = [this, &step]{ - if (m_sim.state().facing != step.direction){ - sim_turn_to(m_sim, step.direction); - } - m_sim.tap(step.direction); + sim_step_once(m_sim, step.direction); }; // A wild battle, with no message before it, is run from, and the // stepping goes on, as step_into_line() does. @@ -423,10 +420,7 @@ std::string OverworldStepSim::step_ahead(const StoryStep& step){ // A hop() or step_onto(): one step, ending where the table says. std::string OverworldStepSim::step_onto(const StoryStep& step){ - if (m_sim.state().facing != step.direction){ - sim_turn_to(m_sim, step.direction); - } - m_sim.tap(step.direction); + sim_step_once(m_sim, step.direction); m_at = *story_pose(*step.pose_after); return m_sim.state().position == MapPosition{m_at.map, m_at.tile} ? "" : "expected to end on " + to_string(MapPosition{m_at.map, m_at.tile}) + "."; } @@ -440,7 +434,7 @@ std::string OverworldStepSim::cut(const StoryStep& step){ if (!m_sim.cut_tree()){ return "no tree to cut."; } - m_sim.tap(step.direction); + sim_step_once(m_sim, step.direction); m_at = StoryPose{m_at.map, m_at.tile.next(step.direction), step.direction}; if (!(m_sim.state().position == MapPosition{m_at.map, m_at.tile})){ return "expected to step onto the tree's tile " + to_string(MapPosition{m_at.map, m_at.tile}) + "."; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp index 6685da4ef9..a874f2e22b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -349,10 +349,7 @@ std::string walk_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step) open_end = last.action == RouteAction::WALK && !last.to_wall && !last.warps; at = StoryPose{last.end.map, last.end.point, last.facing_after}; }else if (step.kind == StoryStepKind::STEP){ - if (sim.state().facing != step.direction){ - sim.tap(step.direction); - } - sim.tap(step.direction); + sim_step_once(sim, step.direction); sim.watch({SimWatcher::MESSAGE}, std::chrono::milliseconds(2000)); at = *story_pose(*step.pose_after); } diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 9d8ef5b060..10cb4b5792 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1846,6 +1846,7 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_CatchFanfareDetector.h Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_ShinySoundDetector.cpp Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_ShinySoundDetector.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldLateBattle.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldLateBattle.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldLateBattle_Tests.cpp From 2bd587495a9012869842c4e690844b61d4ba2a97 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 13:37:00 -0800 Subject: [PATCH 14/24] FRLG: space step taps past the lockout step_once() tapped 120 ms and released 500 ms, 620 ms from tap to tap: inside the band where the console's lockout ends (a step tap 566 ms after the last is ignored, 666 ms works). It now taps STEP_TAP (66 ms) and releases STEP_TAP_RELEASE (700 ms), 766 ms apart, and sim_step_once() does the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp | 2 +- .../Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 3ded120dcd..be87d3f162 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -975,7 +975,7 @@ void sim_step_once(OverworldSim& sim, Direction direction){ if (sim.state().facing != direction){ sim_turn_to(sim, direction); } - sim.tap(direction); + sim.tap(direction, STEP_TAP, STEP_TAP_RELEASE); } std::string OverworldSimWalker::run(const std::vector& route, const RoutePlanning& planning){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp index e009d01701..72386fb022 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp @@ -582,8 +582,9 @@ void step_once(ConsoleHandle& console, ProControllerContext& context, Direction if (facing != direction){ OverworldWalkSteps::turn_to(console, context, direction); } - // A step, once started, finishes on its own. - pbf_press_dpad(context, dpad(direction), 120ms, 500ms); + // A step, once started, finishes on its own. The release waits out the + // lockout before a next step tap (MovementMechanics::STEP_TAP_LOCKOUT). + pbf_press_dpad(context, dpad(direction), MovementMechanics::STEP_TAP, MovementMechanics::STEP_TAP_RELEASE); context.wait_for_all_requests(); } From 91b9ff706f602e45be63336f44f3f9367093ddf0 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 13:37:53 -0800 Subject: [PATCH 15/24] FRLG: code health for the movement table and tap tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_MovementMechanics.h | 5 +++-- .../PokemonFRLG_OverworldWalk_Tests.cpp | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h index f8d328d617..9fc6df8df6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h @@ -71,9 +71,10 @@ inline constexpr Milliseconds FACE_HOLD(40); inline constexpr Milliseconds FACE_SETTLE(300); // The release between a leg's hold and what comes next. inline constexpr Milliseconds STEP_RELEASE(100); -// A one-step tap (step_once()) and the release after it: the next step -// tap starts STEP_TAP + STEP_TAP_RELEASE after it, past the lockout. +// A one-step tap (step_once()). inline constexpr Milliseconds STEP_TAP(66); +// The release after a step tap: the next one starts STEP_TAP + +// STEP_TAP_RELEASE after it, past the lockout. inline constexpr Milliseconds STEP_TAP_RELEASE(700); static_assert(TURN_TAP <= LONGEST_TAP && FACE_HOLD <= LONGEST_TAP && STEP_TAP <= LONGEST_TAP); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp index 24b97896f0..a488f376ae 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp @@ -258,20 +258,22 @@ std::function turn_then_taps(int count, Milliseconds releas }; } +// AutoStory's step_once() east `count` times. +std::function step_once_east(int count){ + return [=](OverworldSim& sim){ + for (int c = 0; c < count; c++){ + sim_step_once(sim, Direction::EAST); + } + }; +} + const TapMechanicsCase TAP_MECHANICS_CASES[] = { {.input = {.name = "133ms facing another way only turns", .presses = taps_east(1, LONGEST_TAP, Milliseconds(700))}, .expectation = {}}, {.input = {.name = "16ms facing that way steps once", .facing = Direction::EAST, .presses = taps_east(1, Milliseconds(16), Milliseconds(700))}, .expectation = {.steps = 1}}, {.input = {.name = "3 step taps 566ms apart take 2 steps", .presses = turn_then_taps(3, Milliseconds(500))}, .expectation = {.steps = 2, .ignored = 1}}, {.input = {.name = "3 step taps 666ms apart take 3 steps", .presses = turn_then_taps(3, Milliseconds(600))}, .expectation = {.steps = 3}}, {.input = {.name = "3 step taps with no release are one hold", .presses = turn_then_taps(3, Milliseconds(0))}, .expectation = {.steps = 1}}, - { - .input = {.name = "step_once 3 times takes 3 steps", .presses = [](OverworldSim& sim){ - for (int c = 0; c < 3; c++){ - sim_step_once(sim, Direction::EAST); - } - }}, - .expectation = {.steps = 3}, - }, + {.input = {.name = "step_once 3 times takes 3 steps", .presses = step_once_east(3)}, .expectation = {.steps = 3}}, }; } // namespace From 853a6b5af3f727b836ad8315f431143d65ea0673 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 14:01:36 -0800 Subject: [PATCH 16/24] FRLG AutoStory sim: sweep scene end poses, wild battles and late fades over every row Each row with a start runs on the overworld sim clean, then with the classes of failure console B showed injected everywhere they can happen: a scene that moved the player ending on each open tile next to the table's pose; wild battles at pokefirered's land encounter rates on every map the story walks, 30 seeds, logging passed, time, tiles walked again and time lost per battle; and every warp's fade 1.5 s late. Steps the sim does not play put the player on their pose_after; scene triggers and leaders' battles it has no script for pass where the clean run let them. 00.06 is known failing: two seeds give up on Route 1 after 9 stops. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_AutoStoryFaultSweep_Tests.cpp | 433 ++++++++++++++++++ .../PokemonFRLG_AutoStoryFaultSweep_Tests.h | 26 ++ .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 2 + SerialPrograms/cmake/SourceFiles.cmake | 2 + 4 files changed, 463 insertions(+) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp new file mode 100644 index 0000000000..12ac3a535b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp @@ -0,0 +1,433 @@ +/* AutoStory Fault Sweep Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Each class of failure console B showed, injected at every row of the + * story on the overworld sim, instead of only where it was seen: + * + * - SCENE_END_POSE: a step the sim does not play (a scene, a talk, an + * event) that moved the player leaves them on another walkable tile + * within one of the table's pose, while the program goes on from the + * table's (BDSP 00.03, lab-B 2026-11-09). + * - WILD_BATTLES: wild battles at the game's own odds on every map with + * land encounters, over GAME_ODDS_SEEDS seeds (00.28's Rock Tunnel). + * - LATE_FADE: every warp's fade ends LATE_FADE later than the sim's + * usual (00.27's warp to Cerulean City). + * + * Not swept here: a missed fade, which needs a knob in OverworldSim; a + * battle's opening at a step's start, which needs one in OverworldStepSim; + * and lost taps from the input lockout (FaultClass::LOST_TAP), for the + * measured movement table. + * + * The sim plays walks, steps, cuts and trainers' battles; after a step it + * does not play, the player is put on the table's pose. Each row runs + * clean first; a row that fails clean, or has no start, is not swept. A + * variant fails when the row then fails or ends elsewhere. A sweep the + * program fails today is on KNOWN_FAILING with its likely fix: its test + * passes while it still fails, and fails once it passes. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryFaultSweep_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using namespace std::chrono_literals; + +enum class FaultClass{ + SCENE_END_POSE, + WILD_BATTLES, + LATE_FADE, + // Not swept yet: the measured movement table's lost taps. + LOST_TAP, +}; +const char* fault_class_name(FaultClass fault){ + switch (fault){ + case FaultClass::SCENE_END_POSE: + return "scene end pose"; + case FaultClass::WILD_BATTLES: + return "wild battles at the game's odds"; + case FaultClass::LATE_FADE: + return "late warp fade"; + case FaultClass::LOST_TAP: + return "lost tap"; + } + return "?"; +} + +// Sweeps the program fails today, by test name, with the likely fix. +const std::map KNOWN_FAILING{ + {"PokemonFRLG::AutoStory - Fault sweep: 00.06, wild battles at the game's odds", + "below_oak on Route 1 gives up after 9 stops part-way (seeds 16, 30): count stops that make no progress, not every battle"}, +}; + +// The land encounter rates of the maps the story walks (pokefirered +// src/data/wild_encounters.json, FireRed). Mt. Moon's floor is wild +// on every tile (WILD_FLOOR_MAPS). +const std::map STORY_ENCOUNTER_RATES{ + {"Route1", 21}, {"Route2", 21}, {"Route3", 21}, {"Route4", 21}, {"Route5", 21}, {"Route6", 21}, + {"Route9", 21}, {"Route10", 21}, {"Route11", 21}, {"Route22", 21}, {"Route24", 21}, {"Route25", 21}, + {"ViridianForest", 14}, {"MtMoon_1F", 7}, {"MtMoon_B1F", 5}, {"MtMoon_B2F", 7}, + {"RockTunnel_1F", 7}, {"RockTunnel_B1F", 7}, +}; +// Caves wild on every floor tile, also where the maps mark no wild tiles +// (Mt. Moon B2F, console B, 2026-11-03). +const std::set WILD_FLOOR_MAPS{"MtMoon_1F", "MtMoon_B1F", "MtMoon_B2F"}; +constexpr unsigned GAME_ODDS_SEEDS = 30; +// How much later than SimTiming's a late fade ends: the new map shows +// this long after the usual. +constexpr Milliseconds LATE_FADE = 1500ms; + +// The maps as the story after the Hall of Fame and a new game flag them: +// the same set for every row, as each stage's tests use. +const std::vector& sweep_maps(){ + static const std::vector maps = []{ + const std::vector all = overworld_maps(); + std::set flags = map_flag_set(all, "after_hall_of_fame"); + flags.merge(map_flag_set(all, "new_game")); + return overworld_maps(flags); + }(); + return maps; +} + +// Whether the overworld sim plays `step` (OverworldStepSim): the rest is stubbed. +bool sim_plays(const StoryStep& step){ + switch (step.kind){ + case StoryStepKind::ROUTE: + case StoryStepKind::LEGS: + case StoryStepKind::STEP: + case StoryStepKind::BATTLE: + return true; + case StoryStepKind::GAME: + return step.variant == CUT_TREE_VARIANT; + default: + return false; + } +} + +// A row run on the sim. +struct RowRun{ + // The first failure, "" if none. + std::string failure; + // The steps whose failure was let pass, by index (run_row()). + std::set scripted; + // Where the player stood before each step, by index. + std::map before; + MapPosition end; + Milliseconds time{0}; + int steps = 0; + int wild_battles = 0; + Milliseconds in_wild_battles{0}; +}; + +// Where a stubbed step leaves the player instead of its pose_after: by step index. +using Landings = std::map; + +// Whether `failure` is a script the sim does not have: a scene's trigger +// stepped onto (no trainer sees the player there) or a leader's battle +// after a talk (no trainer's message up). +bool unscripted(const std::string& failure){ + return failure.find(": no trainer saw the player.") != std::string::npos + || failure.find(": no_battle.") != std::string::npos; +} + +// Row `row` on the sim with `script`, from its start. After a step the sim +// does not play, the player is put on its pose_after, or on `landings`' +// tile for it, the program going on from the pose_after either way. A +// step that fails as unscripted() is let pass as one the sim does not +// play: in a clean run (`scripted` unset) at any step, else only at the +// steps in `scripted`, where the clean run let it pass. +RowRun run_row( + const StoryCheckpoint& row, const SimScript& script, const Landings& landings = {}, + const std::optional>& scripted = std::nullopt +){ + RowRun out; + StoryPose at = *story_pose(*row.start); + auto sim = std::make_unique(sweep_maps(), MapPosition{at.map, at.tile}, at.facing.value_or(Direction::SOUTH), script); + const auto add = [&out](const SimState& state){ + out.time += state.time; + out.steps += state.steps; + out.wild_battles += state.wild_battles; + out.in_wild_battles += state.in_wild_battles; + }; + for (size_t c = 0; c < row.steps.size() && out.failure.empty(); c++){ + const StoryStep& step = row.steps[c]; + out.before.emplace(c, sim->state().position); + out.failure = row_step_on_sim(*sim, at, step, row.id); + const bool let_pass = unscripted(out.failure) && (!scripted || scripted->contains(c)); + if (let_pass){ + out.failure.clear(); + out.scripted.insert(c); + } + if (!out.failure.empty() || (sim_plays(step) && !let_pass) || !step.pose_after){ + continue; + } + at = *story_pose(*step.pose_after); + const auto landing = landings.find(c); + const MapPosition land{at.map, landing == landings.end() ? at.tile : landing->second}; + if (!(sim->state().position == land)){ + add(sim->state()); + sim = std::make_unique(sweep_maps(), land, at.facing.value_or(sim->state().facing), script); + } + } + if (out.failure.empty() && !sim->state().violations.empty()){ + out.failure = "a violation on the way: " + sim->state().violations.back(); + } + out.end = sim->state().position; + add(sim->state()); + return out; +} + +// Why `run` differs from the clean run `clean`, or "". +std::string differs(const RowRun& run, const RowRun& clean){ + if (!run.failure.empty()){ + return run.failure; + } + if (!(run.end == clean.end)){ + return std::format("ended on {}, not {}.", to_string(run.end), to_string(clean.end)); + } + return ""; +} + +// One injected variant that failed its row. +struct SweepFailure{ + std::string step; + std::string variant; + std::string stopped; +}; + +UnitTestResult sweep_result(Logger& logger, FaultClass fault, const std::string& row, size_t variants, const std::vector& failures){ + logger.log(std::format("Fault sweep {} {}: {} variants, {} fail.", row, fault_class_name(fault), variants, failures.size())); + if (failures.empty()){ + return true; + } + std::string out = std::format("{} of {} variants fail {}:", failures.size(), variants, row); + for (const SweepFailure& failure : failures){ + logger.log(std::format("| {} | {} | {} | {} | {} |", row, failure.step, fault_class_name(fault), failure.variant, failure.stopped)); + out += std::format("\n {} {}: {}", failure.step, failure.variant, failure.stopped); + } + return out; +} + +// The open tiles within one of `pose`, diagonals too: where a scene can +// leave the player instead. +std::vector open_neighbors(const StoryPose& pose){ + const std::vector& maps = sweep_maps(); + const auto map = std::find_if(maps.begin(), maps.end(), [&](const MapData& data){ return data.name == pose.map; }); + std::vector out; + if (map == maps.end()){ + return out; + } + for (int dy = -1; dy <= 1; dy++){ + for (int dx = -1; dx <= 1; dx++){ + const TilePos tile{pose.tile.x + dx, pose.tile.y + dy}; + const bool open = (dx != 0 || dy != 0) && in_bounds(*map, tile) && object_at(*map, tile) == nullptr + && (tile_at(*map, tile) == TileKind::OPEN || tile_at(*map, tile) == TileKind::TALL_GRASS); + if (open){ + out.push_back(tile); + } + } + } + return out; +} + +// Row `row`'s stubbed steps that moved the player and have a walk after +// them, each ending on every open tile next to the table's pose in turn. +UnitTestResult sweep_scene_end_pose(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + std::vector failures; + size_t variants = 0; + for (size_t c = 0; c < row.steps.size(); c++){ + const StoryStep& step = row.steps[c]; + const std::optional after = step.pose_after ? story_pose(*step.pose_after) : std::nullopt; + // A scene that only turns the player (the S.S. Ticket's, the aide's) + // ends where our walk did: not the game's end pose. + const auto from = clean.before.find(c); + // With no walk after it, nothing in the row goes on from the pose. + const bool walk_after = std::any_of(row.steps.begin() + c + 1, row.steps.end(), [](const StoryStep& next){ + return next.kind == StoryStepKind::ROUTE || next.kind == StoryStepKind::LEGS || next.kind == StoryStepKind::STEP; + }); + if (sim_plays(step) || !walk_after || !after || (from != clean.before.end() && from->second == MapPosition{after->map, after->tile})){ + continue; + } + for (const TilePos& tile : open_neighbors(*after)){ + variants++; + const std::string stopped = differs(run_row(row, SimScript{}, {{c, tile}}, clean.scripted), clean); + if (!stopped.empty()){ + failures.push_back({step.name, std::format("ends on {}", to_string(tile)), stopped}); + } + } + } + return sweep_result(logger, FaultClass::SCENE_END_POSE, row.id, variants, failures); +} + +// The mean of `values`, "-" if none. +std::string mean_of(const std::vector& values){ + if (values.empty()){ + return "-"; + } + return std::format("{:.1f}", std::accumulate(values.begin(), values.end(), 0.) / values.size()); +} + +// Row `row` with wild battles at the game's odds, from each seed. Logs +// passed, time, tiles walked again and time lost per battle. +UnitTestResult sweep_wild_battles(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + std::vector failures; + std::vector seconds, again, lost; + int battles = 0; + for (unsigned seed = 1; seed <= GAME_ODDS_SEEDS; seed++){ + SimScript script; + script.encounter_rates = STORY_ENCOUNTER_RATES; + script.encounter_seed = seed; + script.wild_floor_maps = WILD_FLOOR_MAPS; + const RowRun run = run_row(row, script, {}, clean.scripted); + battles += run.wild_battles; + const std::string stopped = differs(run, clean); + if (!stopped.empty()){ + failures.push_back({"", std::format("seed {}", seed), stopped}); + continue; + } + seconds.push_back(run.time.count() / 1000.); + again.push_back(run.steps - clean.steps); + if (run.wild_battles > 0){ + lost.push_back((run.time - clean.time - run.in_wild_battles).count() / 1000. / run.wild_battles); + } + } + logger.log(std::format( + "Fault sweep {} wild battles: passed {}/{}, {} battles; of those passed: time (s) mean {}, clean {:.1f}; tiles walked again mean {}; s lost per battle mean {}", + row.id, GAME_ODDS_SEEDS - failures.size(), GAME_ODDS_SEEDS, battles, mean_of(seconds), clean.time.count() / 1000., mean_of(again), mean_of(lost) + )); + return sweep_result(logger, FaultClass::WILD_BATTLES, row.id, GAME_ODDS_SEEDS, failures); +} + +// Row `row` with every warp's fade ending LATE_FADE late. +UnitTestResult sweep_late_fade(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + SimScript script; + script.timing.fade += LATE_FADE; + const std::string stopped = differs(run_row(row, script, {}, clean.scripted), clean); + std::vector failures; + if (!stopped.empty()){ + failures.push_back({"", std::format("every fade {} ms late", LATE_FADE.count()), stopped}); + } + return sweep_result(logger, FaultClass::LATE_FADE, row.id, 1, failures); +} + +// A fault class swept over row `row` (empty in FAULT_SWEEP_CASES: each +// row with a start). +struct FaultSweepInput{ + FaultClass fault = FaultClass::SCENE_END_POSE; + std::string row = {}; +}; +// Whether a sweep is expected to fail is KNOWN_FAILING's, by name. +struct FaultSweepExpectation{ +}; +struct FaultSweepCase{ + FaultSweepInput input; + FaultSweepExpectation expectation; +}; + +// The table's row `id`. +const StoryCheckpoint& table_row(const std::string& id){ + for (const StoryCheckpoint& row : story_checkpoints()){ + if (row.id == id){ + return row; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No row " + id); +} + +class Test_AutoStoryFaultSweep : public UnitTest{ +public: + explicit Test_AutoStoryFaultSweep(FaultSweepCase test_case) + : UnitTest(std::format("PokemonFRLG::AutoStory - Fault sweep: {}, {}", test_case.input.row, fault_class_name(test_case.input.fault))) + , m_fault(test_case.input.fault) + , m_row(table_row(test_case.input.row)) + {} + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + UnitTestResult result; + try{ + result = sweep(logger); + }catch (Exception& e){ + result = std::string(e.name()) + ": " + e.message(); + } + const auto known = KNOWN_FAILING.find(name()); + if (known == KNOWN_FAILING.end()){ + return result; + } + if (result.result == UnitTestResult::PASSED){ + return "on KNOWN_FAILING (" + known->second + ") but passes now: take it off the list."; + } + logger.log("Known failing until " + known->second + ": " + result.message); + return true; + } + +private: + UnitTestResult sweep(Logger& logger) const{ + const RowRun clean = run_row(m_row, SimScript{}); + if (!clean.failure.empty()){ + logger.log(std::format("Fault sweep {}: not swept, the row fails clean: {}", m_row.id, clean.failure)); + return true; + } + switch (m_fault){ + case FaultClass::SCENE_END_POSE: + return sweep_scene_end_pose(logger, m_row, clean); + case FaultClass::WILD_BATTLES: + return sweep_wild_battles(logger, m_row, clean); + case FaultClass::LATE_FADE: + return sweep_late_fade(logger, m_row, clean); + case FaultClass::LOST_TAP: + break; + } + return true; + } + + FaultClass m_fault; + const StoryCheckpoint& m_row; +}; + +} // namespace + + +const FaultSweepCase FAULT_SWEEP_CASES[] = { + {.input = {.fault = FaultClass::SCENE_END_POSE}, .expectation = {}}, + {.input = {.fault = FaultClass::WILD_BATTLES}, .expectation = {}}, + {.input = {.fault = FaultClass::LATE_FADE}, .expectation = {}}, +}; + +void add_tests_AutoStoryFaultSweep(UnitTestDatabase& database){ + for (const FaultSweepCase& item : FAULT_SWEEP_CASES){ + for (const StoryCheckpoint& row : story_checkpoints()){ + if (!row.start || !story_pose(*row.start)){ + continue; + } + database.add(FaultSweepCase{.input = {.fault = item.input.fault, .row = row.id}, .expectation = item.expectation}); + } + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h new file mode 100644 index 0000000000..cda979a2f3 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h @@ -0,0 +1,26 @@ +/* AutoStory Fault Sweep Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryFaultSweep_Tests_H +#define PokemonAutomation_PokemonFRLG_AutoStoryFaultSweep_Tests_H + +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Each class of console failure injected at every row of the story on the +// overworld sim; the rows the program still fails are known failing +// (PokemonFRLG_AutoStoryFaultSweep_Tests.cpp). +void add_tests_AutoStoryFaultSweep(UnitTestDatabase& database); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index e201a86da0..739cd6bedb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -26,6 +26,7 @@ #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryFaultSweep_Tests.h" #include "PokemonFRLG_AutoStoryResources.h" #include "PokemonFRLG_AutoStoryRowSim.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -473,6 +474,7 @@ void add_tests_AutoStoryLavender(UnitTestDatabase& database){ database.add(item); } add_tests_AutoStoryResources(database); + add_tests_AutoStoryFaultSweep(database); } diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 10cb4b5792..976060be1f 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1917,6 +1917,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.h From f6080f0fa955dc2915e8bdf4336346ffa9e2d367 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 14:04:56 -0800 Subject: [PATCH 17/24] FRLG 00.28: honest stops in the dark cave; Martha via the corner (11, 35) Rock Tunnel without Flash is black but for a lit circle on the player. The battle text box and black screen checks read it as a battle on its way, so the check before each step ran a 15 s battle loop, and an open leg's stop was placed 0 to 3 tiles in when the battle came at its end (console B, 2026-11-09 13:35). The walker then "found" the player at the rock (20, 35) while they stood against the wall (25, 35). - dark_cave_screen(): black corners, lit middle. The observer reports nothing on it, and the check before or after a step sees no battle. - A stop on an open leg that started in a dark cave may be anywhere on the leg (whole_leg()), whatever the timing says. - below_martha_line: west 16, north 2, west 2 to the corner (11, 35), east 5 to (16, 35). The corner finds the player from any of (11..14, 35) and (11..16, 35) after a battle. - Sim: SimScript::dark_maps, with walker rules dark_cave_not_battle and whole_leg_in_dark; 00.28's game-odds runs walk Rock Tunnel dark. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_OverworldRecoveryWalk.cpp | 35 +++++++- .../Overworld/PokemonFRLG_OverworldSim.cpp | 33 ++++++- .../Overworld/PokemonFRLG_OverworldSim.h | 24 +++++- .../Overworld/PokemonFRLG_OverworldWalk.cpp | 40 ++++++++- .../Overworld/PokemonFRLG_OverworldWalk.h | 11 +++ .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 85 +++++++++++++++++++ .../PokemonFRLG_AutoStoryScreenEvents.cpp | 5 ++ .../PokemonFRLG_AutoStoryScreenEvents.h | 2 + ...okemonFRLG_AutoStoryScreenEvents_Tests.cpp | 16 ++++ .../PokemonFRLG_AutoStoryTableLavender.cpp | 5 +- 10 files changed, 245 insertions(+), 11 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index a2d26f6e2d..68c63608af 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -48,19 +48,39 @@ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ } // How one step's walk went: if an open leg stopped part-way, how far the -// timing puts the player along it; and when its hold started. +// timing puts the player along it; when its hold started; and whether the +// screen was a dark cave's then (dark_cave_screen()). struct StepWalk{ std::optional stopped; WallClock hold_start; + bool dark = false; }; +// Where a stop on `step` leaves the player, from `timed`, the timing's +// tiles: anywhere on the leg if the leg started in a dark cave. There the +// timing has no basis: the screen is black before any battle's wipe, and +// a battle at the end of "east 12" on Rock Tunnel B1F was placed 0 to 3 +// tiles in (console B, 2026-11-09, 00.28); the player was found again at +// a wall 5 tiles off. +WalkedTiles stop_tiles(ConsoleHandle& console, const RouteStep& step, bool dark, WalkedTiles timed){ + if (!dark){ + return timed; + } + const WalkedTiles out = whole_leg(step); + console.log(std::format( + "Route: {} started in a dark cave: the stop's timing ({} to {} tiles in) isn't trusted; {} to {} tiles in.", + to_string(step), timed.min, timed.max, out.min, out.max + ), COLOR_ORANGE); + return out; +} + // One step of a route, for a player facing `facing`. StepWalk walk_step( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, const OverworldInterruptSet& interrupts, MoveSpeed speed ){ - StepWalk out{std::nullopt, current_time()}; + StepWalk out{std::nullopt, current_time(), false}; switch (step.action){ case RouteAction::WALK:{ // Turning first, with a tap, makes the hold start from a standstill @@ -73,6 +93,9 @@ StepWalk walk_step( console.log(note); } context.wait_for_all_requests(); + if (VideoSnapshot before = console.video().snapshot()){ + out.dark = dark_cave_screen(*before.frame); + } out.hold_start = current_time(); BlackScreenOverWatcher arrival(COLOR_RED); Pokemon::WatchAlong fade(arrival); @@ -81,7 +104,7 @@ StepWalk walk_step( leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG, warp_hold_watchers(step, fade) ); if (result.stopped){ - out.stopped = walked_near(step, result.tiles, tile_time(step, speed)); + out.stopped = stop_tiles(console, step, out.dark, walked_near(step, result.tiles, tile_time(step, speed))); return out; } if (step.warps){ @@ -187,7 +210,11 @@ StepOutcome walk_and_watch( if (done){ return StepOutcome{}; } - return StepOutcome{.end = StepEnd::LATE, .walked = recorder.place(console, step, tile_time(step, speed), walked.hold_start)}; + std::optional placed = recorder.place(console, step, tile_time(step, speed), walked.hold_start); + if (walked.dark && step.action == RouteAction::WALK && !step.to_wall){ + placed = stop_tiles(console, step, true, placed.value_or(whole_leg(step))); + } + return StepOutcome{.end = StepEnd::LATE, .walked = placed}; } // Holds on along `step`, stopped part-way off the grass or by a wild diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index be87d3f162..29f586f31b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -601,6 +601,10 @@ std::optional OverworldSim::watch(const std::vector& watche return watch_until(watchers, m_now, m_now + duration); } +bool OverworldSim::dark() const{ + return m_script.dark_maps.contains(map().name); +} + SimBattleEnd OverworldSim::handle_battle(SimBattleChoice choice){ if (!m_battle_pending){ violation("fought a battle that isn't there, on " + std::string(sim_screen_name(m_state.screen)) + "."); @@ -837,9 +841,10 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, calls.log = [&](const std::string& message){ m_log.push_back("Leg: " + message); }; calls.fail = [&](const std::string& message){ fail(message); }; m_leg_start = m_sim.state().time; + m_leg_dark = m_sim.dark(); const Pokemon::LegResult result = Pokemon::walk_leg_with(leg, calls, MAX_INTERRUPTIONS_PER_LEG); if (result.stopped){ - return walked_near(step, result.tiles, TILE_TIME); + return dark_stop(step, walked_near(step, result.tiles, TILE_TIME)); } const Milliseconds fade_watched_from = m_rules.watch_fade_along_hold ? m_leg_start : m_sim.state().time; if (step.warps && !after_warp(step, warps_before, fade_watched_from)){ @@ -885,7 +890,13 @@ StepOutcome OverworldSimWalker::walk(const RouteStep& step, std::optional OverworldSimWalker::late_stop(const RouteStep& step){ - if (step.to_wall || !m_rules.place_late_battle){ + if (step.to_wall){ + return std::nullopt; + } + if (m_leg_dark){ + return dark_stop(step, whole_leg(step)); + } + if (!m_rules.place_late_battle){ return std::nullopt; } const WallClock start = sim_wall_clock(m_leg_start); @@ -896,10 +907,26 @@ std::optional OverworldSimWalker::late_stop(const RouteStep& step){ return stop.walked; } +// stop_tiles(): anywhere on `step` if it started in a dark cave. Without +// SimWalkerRules::whole_leg_in_dark, there the dark screen placed the stop +// at the hold's start. +WalkedTiles OverworldSimWalker::dark_stop(const RouteStep& step, WalkedTiles timed){ + if (!m_leg_dark){ + return timed; + } + const WalkedTiles out = m_rules.whole_leg_in_dark ? whole_leg(step) : walked_near(step, 0, TILE_TIME); + m_log.push_back(std::format("Route: {} started in a dark cave: {} to {} tiles in.", to_string(step), out.min, out.max)); + return out; +} + // Whether the screen shows a battle on its way: its wipe's black, or its -// text box before the HP boxes. +// text box before the HP boxes; without SimWalkerRules:: +// dark_cave_not_battle, also a dark cave's overworld. bool OverworldSimWalker::battle_on_its_way() const{ const SimScreen screen = m_sim.state().screen; + if (!m_rules.dark_cave_not_battle && screen == SimScreen::OVERWORLD && m_sim.dark()){ + return true; + } return screen == SimScreen::BATTLE_WIPE || screen == SimScreen::BATTLE_OPENING; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 2b3e1df6fb..d141fa8f63 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -129,6 +129,11 @@ struct SimScript{ // (SimWalkerRules::confirm_turn) after 00.28 run 2 (console B, // 2026-11-09), where a one-tile leg only turned the player. std::set dropped_turn_taps; + // Caves dark without Flash (Rock Tunnel): the overworld is black but + // for a lit circle on the player, which the walker's battle text box + // check and screen observer read as a battle on its way (console B, + // 2026-11-09, 00.28), unless SimWalkerRules::dark_cave_not_battle. + std::set dark_maps; SimTiming timing; }; @@ -204,6 +209,8 @@ class OverworldSim{ OverworldSim(std::vector maps, MapPosition start, Direction facing, SimScript script = {}); const SimState& state() const{ return m_state; } + // Whether the player's map is one of SimScript::dark_maps. + bool dark() const; // Holds `direction` for `duration` then lets go for `release`, while // `watchers` watch until `watch` (at least the hold and release) from @@ -396,6 +403,18 @@ struct SimWalkerRules{ // Without it a dropped tap left a one-tile leg turning the player // only, taken as walked (console B, 2026-11-09, 00.28 run 2). bool confirm_turn = true; + // A dark cave's overworld isn't a battle on its way + // (OverworldWalkSteps::dark_cave_screen()). Without it the check + // before each step watched for a battle on it (console B, 2026-11-09, + // 00.28: "running from a wild Pokemon", then "not a battle"). + bool dark_cave_not_battle = true; + // A stop on an open leg that started in a dark cave may be anywhere on + // the leg (whole_leg()). Without it the stop is placed by the dark + // screen's black, read as the battle at the hold's start: a battle at + // the end of "east 12" on Rock Tunnel B1F was placed 0 to 3 tiles in, + // and the player was found again at a wall 5 tiles off (console B, + // 2026-11-09, 00.28). + bool whole_leg_in_dark = true; }; // OverworldWalkSteps::turn_to() on `sim`, which reads the facing on the @@ -442,6 +461,7 @@ class OverworldSimWalker{ }; StepOutcome walk(const RouteStep& step, std::optional facing); std::optional late_stop(const RouteStep& step); + WalkedTiles dark_stop(const RouteStep& step, WalkedTiles timed); std::optional walk_step(const RouteStep& step, std::optional facing); bool after_warp(const RouteStep& step, int warps_before, Milliseconds watched_from); bool fade_over_since(Milliseconds from) const; @@ -464,8 +484,10 @@ class OverworldSimWalker{ std::vector m_wild; std::vector m_other; std::vector m_log; - // When the last WALK leg's first hold started, on the sim's clock. + // When the last WALK leg's first hold started, on the sim's clock, + // and whether its map was dark then. Milliseconds m_leg_start{0}; + bool m_leg_dark = false; }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 04bc3de098..80cf4de310 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -7,6 +7,8 @@ #include #include #include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageStats.h" #include "CommonTools/Async/InferenceRoutines.h" #include "CommonTools/VisualDetectors/BlackScreenDetector.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" @@ -18,6 +20,7 @@ #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_OverworldWalk.h" @@ -292,11 +295,12 @@ void clear_open_message( namespace{ // What on screen shows a wild battle on its way: its text box, or the -// black of its wipe. Nothing if neither shows. +// black of its wipe. Nothing if neither shows, or on a dark cave's +// overworld (dark_cave_screen()). const char* battle_on_its_way(ConsoleHandle& console, ProControllerContext& context){ context.wait_for_all_requests(); VideoSnapshot screen = console.video().snapshot(); - if (!screen){ + if (!screen || dark_cave_screen(*screen.frame)){ return nullptr; } if (BattleTextBoxDetector(COLOR_RED).detect(screen)){ @@ -308,8 +312,40 @@ const char* battle_on_its_way(ConsoleHandle& console, ProControllerContext& cont return nullptr; } +// The parts of the game screen a dark cave shows black (its corners) and +// lit (the player's tile and the ones next to it), and how bright each +// may be, as the sum of the average's channels. Not measured on the +// console's frames yet: the corners are a GBA screen's 2 by 2 tiles, the +// middle its player's tile. +constexpr double DARK_CORNER_MAX = 45.0; +constexpr double LIT_MIDDLE_MIN = 120.0; + +bool box_brightness_below(const ImageViewRGB32& screen, const ImageFloatBox& box, double limit){ + return image_stats(extract_box_reference(screen, box)).average.sum() < limit; +} + } // namespace +bool dark_cave_screen(const ImageViewRGB32& screen){ + if (screen.width() == 0 || screen.height() == 0){ + return false; + } + const ImageFloatBox game = GameSettings::instance().GAME_BOX; + auto part = [&](double x, double y, double width, double height){ + return ImageFloatBox(game.x + game.width * x, game.y + game.height * y, game.width * width, game.height * height); + }; + const ImageFloatBox corners[] = { + part(0.00, 0.00, 0.13, 0.20), part(0.87, 0.00, 0.13, 0.20), + part(0.00, 0.80, 0.13, 0.20), part(0.87, 0.80, 0.13, 0.20), + }; + for (const ImageFloatBox& corner : corners){ + if (!box_brightness_below(screen, corner, DARK_CORNER_MAX)){ + return false; + } + } + return !box_brightness_below(screen, part(0.45, 0.42, 0.10, 0.16), LIT_MIDDLE_MIN); +} + size_t clear_unseen_battle( ConsoleHandle& console, ProControllerContext& context, const OverworldInterrupts& wild, const RouteStep& step diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index a81e1a9eda..d2120b8e04 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -23,6 +23,7 @@ #include #include #include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" #include "Pokemon/AutoStory/Pokemon_ScreenEvents.h" #include "Pokemon/Navigation/Pokemon_LegWalker.h" #include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" @@ -213,6 +214,16 @@ size_t clear_battle_before( const OverworldInterrupts& wild, const RouteStep& step ); +// Whether `screen` is the overworld of a cave dark without Flash (Rock +// Tunnel): black around a lit circle on the player in the middle. The +// battle text box detector and the black screen check read its black as +// a battle on its way (console B, 2026-11-09, 00.28: the check before +// each step ran a 15 s battle loop on it, "not a battle", and an open +// leg's stop was placed by it 0 to 3 tiles in, where the battle came at +// the leg's end). A battle's wipe is black in the middle too; its text +// box and HP boxes aren't black in the corners. +bool dark_cave_screen(const ImageViewRGB32& screen); + // After a warp's fade: if it was a wild battle's, runs from the battle and // returns true. bool run_from_battle_after_warp(ConsoleHandle& console, ProControllerContext& context); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index e201a86da0..93fdd5741c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -241,6 +241,88 @@ UnitTestResult test_b1f_martha_after_row38_battle(Logger&){ return true; } +// Rock Tunnel has no Flash: both floors are dark. +const std::set ROCK_TUNNEL_DARK{StoryTable::ROCK_TUNNEL_1F, StoryTable::ROCK_TUNNEL_B1F}; + +// below_martha_line as `legs` from Winston's tile (29, 37) on `sim`, by the +// walker with `rules`: the failure, or empty if it ended on (16, 35). +std::string walk_below_martha(OverworldSim& sim, const std::vector& legs, const SimWalkerRules& rules){ + const MapPosition start{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}; + const std::vector& maps = story_maps(); + const std::optional> route = fixed_route(maps, start, legs); + if (!route){ + return "the legs don't fit the map"; + } + const RoutePlanning planning{.maps = &maps, .costs = story_route_costs(), .start = start, .label = "below_martha_line"}; + std::string failure = OverworldSimWalker(sim, rules).run(*route, planning); + if (failure.empty() && !(sim.state().position == MapPosition{StoryTable::ROCK_TUNNEL_B1F, {16, 35}})){ + failure = "ended on " + to_string(sim.state().position) + ", not (16, 35)"; + } + return failure; +} + +// Console B, 2026-11-09 13:35, below_martha_line as it was then: west 16, +// north 2, east 6 to the wall (19, 35), west 3. A battle 6 tiles into +// west 16; the player found again at (1, 38); a battle seen late at the +// end of the walk back east 12 along row 38, placed 0 to 3 tiles in by +// the dark screen; "found again" at the rock (20, 35) while against the +// wall (25, 35); ended on (21, 35), where martha_line found no trainer. +// Here the second battle on each step of that walk back: with the dark +// cave's stops placed anywhere on their leg, every one ends on (16, 35); +// without, some don't. +UnitTestResult test_b1f_dark_row38_battle_old_legs(Logger& logger){ + const std::vector old_legs{{Direction::WEST, 16}, {Direction::NORTH, 2}, {Direction::EAST, 6}, {Direction::WEST, 3}}; + std::string failures; + int old_wrong = 0; + for (int second = 8; second <= 60; second++){ + SimScript script; + script.dark_maps = ROCK_TUNNEL_DARK; + script.encounters = {SimEncounter{.grass_step = 6}, SimEncounter{.grass_step = second, .seen_late = true}}; + OverworldSim fixed(maps_after_route10(), MapPosition{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}, Direction::NORTH, script); + if (const std::string failure = walk_below_martha(fixed, old_legs, SimWalkerRules{}); !failure.empty()){ + failures += std::format("{}battle on step {}: {}", failures.empty() ? "" : " | ", second, failure); + } + SimWalkerRules old_rules; + old_rules.whole_leg_in_dark = false; + old_rules.dark_cave_not_battle = false; + OverworldSim old(maps_after_route10(), MapPosition{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}, Direction::NORTH, script); + if (const std::string failure = walk_below_martha(old, old_legs, old_rules); !failure.empty()){ + old_wrong++; + logger.log(std::format("Without the dark cave rules, battle on step {}: {}", second, failure)); + } + } + if (!failures.empty()){ + return failures; + } + if (old_wrong == 0){ + return "Without the dark cave rules every walk still ended on (16, 35): the test doesn't show the failure."; + } + return true; +} + +// Rock Tunnel dark, from Winston's tile: below_martha_line, martha_line +// and Martha's battle with one wild battle on each step the walk takes, +// on rows 37 and 38 and on row 35 between the corner (11, 35) and +// (16, 35), seen as it starts and seen late: Martha alone is fought. +UnitTestResult test_b1f_dark_martha_battle_anywhere(Logger&){ + std::string failures; + for (int step = 1; step <= 40; step++){ + for (const bool late : {false, true}){ + SimScript script; + script.dark_maps = ROCK_TUNNEL_DARK; + script.encounters = {SimEncounter{.grass_step = step, .seen_late = late}}; + OverworldSim sim(maps_after_route10(), MapPosition{StoryTable::ROCK_TUNNEL_B1F, {29, 37}}, Direction::NORTH, script); + if (const std::string failure = winston_to_martha(sim); !failure.empty()){ + failures += std::format("{}step {}{}: {}", failures.empty() ? "" : " | ", step, late ? " late" : "", failure); + } + } + } + if (!failures.empty()){ + return failures; + } + return true; +} + // 00.28 whole with a wild battle every 7 to 25 steps on the tunnel's floor, // the gaps drawn from each of these seeds: the row ends where it should. UnitTestResult test_rock_tunnel_frequent_battles(Logger&){ @@ -358,6 +440,7 @@ UnitTestResult test_rock_tunnel_game_odds(Logger& logger){ SimScript script; script.encounter_rates = ROCK_TUNNEL_ENCOUNTER_RATES; script.encounter_seed = seed; + script.dark_maps = ROCK_TUNNEL_DARK; OverworldSim sim = row_start_sim("00.28", script); const UnitTestResult result = row_checked(sim, "00.28", {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); const bool passed = result.result == UnitTestResult::PASSED; @@ -463,6 +546,8 @@ const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { {.input = {"00.28 a wild battle every 7 to 25 steps, 12 seeds", test_rock_tunnel_frequent_battles}, .expectation = {}}, {.input = {"00.28 wild battles at the game's odds, 30 seeds", test_rock_tunnel_game_odds}, .expectation = {}}, {.input = {"00.28 B1F a wild battle into row 38, then a step short: Martha still sees the player", test_b1f_martha_after_row38_battle}, .expectation = {}}, + {.input = {"00.28 B1F dark, the 13:35 legs: a battle at the end of the walk back along row 38", test_b1f_dark_row38_battle_old_legs}, .expectation = {}}, + {.input = {"00.28 B1F dark, a wild battle on any step to Martha's line", test_b1f_dark_martha_battle_anywhere}, .expectation = {}}, {.input = {"00.29 Route 10 south, the ledge, Lavender Town's Pokemon Center", test_lavender_row}, .expectation = {}}, {.input = {"00.29 a tile off after each open leg", test_lavender_tile_off}, .expectation = {}}, {.input = {"00.28 and 00.29 save on Route 10 and in Lavender Town", test_save_places}, .expectation = {}}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.cpp index 85edb74b36..de1f0072c7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.cpp @@ -4,6 +4,7 @@ * */ +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" #include "PokemonFRLG_AutoStoryScreenEvents.h" namespace PokemonAutomation{ @@ -26,6 +27,10 @@ ScreenEvents FrlgScreenObserver::observe(const VideoSnapshot& frame, WallClock){ return {}; } const ImageViewRGB32& screen = *frame.frame; + if (OverworldWalkSteps::dark_cave_screen(screen)){ + // Its black below passes for a battle's text box. + return {}; + } ScreenEvents out = Pokemon::fade_events(Pokemon::fade_of_screen(screen)); if (Pokemon::fade_in(out) != Pokemon::WarpFade::NONE){ // A uniform screen has no box or menu to read. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.h index d6403a765f..63cfa22307 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents.h @@ -25,6 +25,8 @@ namespace PokemonFRLG{ // - CHOICE_BOX: a Yes/No or answer box beside it (SelectionDialogDetector); // - BATTLE_STARTED: the battle menu or a battle's text box; // - FADE_BLACK / FADE_WHITE: fade_of_screen(). +// Nothing on a dark cave's overworld (dark_cave_screen()), whose black +// the battle text box detector reads as a box. // Not reported yet, as FRLG has no detector for them here: the map-name // popup (LOCATION_BANNER), PLAYER_CAN_MOVE, SCREEN_STILL, the interact // prompt (FRLG has none) and a battle's end. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents_Tests.cpp index 7f8182bccc..4ed0f3d1a5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryScreenEvents_Tests.cpp @@ -29,6 +29,9 @@ struct ScreenEventsInput{ std::string name; // The frame's colour; 0 for no frame. uint32_t color; + // The colour of a square in the middle of the frame, around the + // player; 0 for none. + uint32_t middle = 0; }; struct ScreenEventsExpectation{ @@ -52,6 +55,13 @@ class Test_AutoStoryScreenEvents : public UnitTest{ if (m_case.input.color != 0){ ImageRGB32 image(1920, 1080); image.fill(m_case.input.color); + if (m_case.input.middle != 0){ + for (size_t y = 380; y < 700; y++){ + for (size_t x = 800; x < 1120; x++){ + image.pixel(x, y) = m_case.input.middle; + } + } + } frame = VideoSnapshot(std::move(image), WallClock()); } FrlgScreenObserver observer; @@ -82,6 +92,12 @@ const ScreenEventsCase SCREEN_EVENTS_CASES[] = { .input = {"a white screen is a white fade only", 0xffffffff}, .expectation = {{ScreenEvent::FADE_WHITE}}, }, + { + // Rock Tunnel without Flash (console B, 2026-11-09, 00.28): its + // black read as a battle's text box before each step. + .input = {"a dark cave, black but for a lit middle, shows nothing", 0xff000000, 0xff8c7c64}, + .expectation = {{}}, + }, }; void add_tests_AutoStoryScreenEvents(UnitTestDatabase& database){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp index 9613070677..9ba3bf882d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableLavender.cpp @@ -72,7 +72,10 @@ StoryCheckpoint rock_tunnel(){ running(legs("winston_line_end", {{WEST, 5}, {SOUTH, 10}, {NORTH, 1}, {WEST, 1}}, "west to the wall (33, 28), down column 33 to the wall (33, 38), up to (33, 37) and west to (32, 37)")), into_line("winston_line", WEST, "along row 37 onto (29, 37), the end of Winston's line: he walks down to (29, 36)"), battle("pokemaniac_winston", at(ROCK_TUNNEL_B1F, 29, 37, NORTH), false, "Slowpoke and Slowpoke"), - running(legs("below_martha_line", {{WEST, 16}, {NORTH, 2}, {EAST, 6}, {WEST, 3}}, "west along row 37 to (13, 37), up to the wall (13, 35), east to the wall (19, 35), west to (16, 35)")), + // The corner (11, 35), wall north and rock west, finds the player + // from any of (11..14, 35) after a battle; east to the wall (19, + // 35) did not (console B, 2026-11-09 13:35: found 5 tiles off). + running(legs("below_martha_line", {{WEST, 16}, {NORTH, 2}, {WEST, 2}, {EAST, 5}}, "west along row 37 to (13, 37), up to the wall (13, 35), west to the corner (11, 35), east to (16, 35)")), into_line("martha_line", NORTH, "up column 16 onto (16, 33), beside Martha; from (17, 35), a tile short, up column 17 onto (17, 33), the end of her line"), battle("picnicker_martha", at(ROCK_TUNNEL_B1F, 16, 33, WEST), false, "Oddish and Bulbasaur"), running(legs("below_allen_line", {{EAST, 1}, {NORTH, 5}, {EAST, 12}, {NORTH, 9}, {EAST, 11}, {NORTH, 2}, {WEST, 2}, {NORTH, 5}}, "east to the wall (17, 33), up column 17 to the wall (17, 28), east to the wall (29, 28), up column 29 past Steve's line to the wall (29, 19), east to (40, 19), up to row 17, west to the wall (38, 17) and up to the wall (38, 12)")), From 79bfa9735f518579d47d60698549da158f7472f3 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 14:07:04 -0800 Subject: [PATCH 18/24] FRLG 00.28: dark-cave stop rule shared in the reanchor code; code health Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_OverworldReanchor.cpp | 4 ++ .../Overworld/PokemonFRLG_OverworldReanchor.h | 8 ++++ .../PokemonFRLG_OverworldRecoveryWalk.cpp | 37 +++++++++--------- .../Overworld/PokemonFRLG_OverworldSim.cpp | 38 ++++--------------- .../Overworld/PokemonFRLG_OverworldSim.h | 13 +++++-- 5 files changed, 46 insertions(+), 54 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index f5328d5980..cca489347c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -323,6 +323,10 @@ WalkedTiles whole_leg(const RouteStep& step){ return WalkedTiles{.min = 0, .max = step.tiles}; } +WalkedTiles stop_tiles(const RouteStep& step, WalkedTiles timed, bool dark){ + return dark ? whole_leg(step) : timed; +} + std::string to_string(const StopCandidate& candidate){ std::string text = to_string(candidate.player); if (candidate.trainer){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index 28530d8c11..ae962e95b7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -58,6 +58,14 @@ WalkedTiles walked_near(const RouteStep& step, int estimate, Milliseconds tile_t std::vector with_warp_slack(std::vector route); // Anywhere on `step`, for a stop the timing doesn't place. WalkedTiles whole_leg(const RouteStep& step); +// Where a stop on `step` leaves the player, from `timed`, the tiles its +// timing gives: anywhere on the leg (whole_leg()) if it started on a dark +// cave's screen (OverworldWalkSteps::dark_cave_screen()). There the timing +// has no basis: the screen is black before any battle's wipe, and a +// battle at the end of "east 12" on Rock Tunnel B1F was placed 0 to 3 +// tiles in (console B, 2026-11-09, 00.28). The player, "found again" at +// a wall from those, was 5 tiles off. +WalkedTiles stop_tiles(const RouteStep& step, WalkedTiles timed, bool dark); // A trainer who saw the player and walked up to them: which of `map`'s // objects, and the tile next to the player they stand on afterwards. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 68c63608af..9200c0e02b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -56,22 +56,21 @@ struct StepWalk{ bool dark = false; }; -// Where a stop on `step` leaves the player, from `timed`, the timing's -// tiles: anywhere on the leg if the leg started in a dark cave. There the -// timing has no basis: the screen is black before any battle's wipe, and -// a battle at the end of "east 12" on Rock Tunnel B1F was placed 0 to 3 -// tiles in (console B, 2026-11-09, 00.28); the player was found again at -// a wall 5 tiles off. -WalkedTiles stop_tiles(ConsoleHandle& console, const RouteStep& step, bool dark, WalkedTiles timed){ - if (!dark){ - return timed; +// stop_tiles(), logged when the leg started in a dark cave. +WalkedTiles logged_stop_tiles(ConsoleHandle& console, const RouteStep& step, bool dark, WalkedTiles timed){ + if (dark){ + console.log(std::format( + "Route: {} started in a dark cave: the stop's timing ({} to {} tiles in) isn't trusted.", + to_string(step), timed.min, timed.max + ), COLOR_ORANGE); } - const WalkedTiles out = whole_leg(step); - console.log(std::format( - "Route: {} started in a dark cave: the stop's timing ({} to {} tiles in) isn't trusted; {} to {} tiles in.", - to_string(step), timed.min, timed.max, out.min, out.max - ), COLOR_ORANGE); - return out; + return stop_tiles(step, timed, dark); +} + +// Whether the screen shows a dark cave's overworld (dark_cave_screen()). +bool on_dark_cave(ConsoleHandle& console){ + const VideoSnapshot screen = console.video().snapshot(); + return screen && dark_cave_screen(*screen.frame); } // One step of a route, for a player facing `facing`. @@ -93,9 +92,7 @@ StepWalk walk_step( console.log(note); } context.wait_for_all_requests(); - if (VideoSnapshot before = console.video().snapshot()){ - out.dark = dark_cave_screen(*before.frame); - } + out.dark = on_dark_cave(console); out.hold_start = current_time(); BlackScreenOverWatcher arrival(COLOR_RED); Pokemon::WatchAlong fade(arrival); @@ -104,7 +101,7 @@ StepWalk walk_step( leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG, warp_hold_watchers(step, fade) ); if (result.stopped){ - out.stopped = stop_tiles(console, step, out.dark, walked_near(step, result.tiles, tile_time(step, speed))); + out.stopped = logged_stop_tiles(console, step, out.dark, walked_near(step, result.tiles, tile_time(step, speed))); return out; } if (step.warps){ @@ -212,7 +209,7 @@ StepOutcome walk_and_watch( } std::optional placed = recorder.place(console, step, tile_time(step, speed), walked.hold_start); if (walked.dark && step.action == RouteAction::WALK && !step.to_wall){ - placed = stop_tiles(console, step, true, placed.value_or(whole_leg(step))); + placed = logged_stop_tiles(console, step, true, placed.value_or(whole_leg(step))); } return StepOutcome{.end = StepEnd::LATE, .walked = placed}; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 29f586f31b..9d65da8a27 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -166,6 +166,7 @@ void OverworldSim::update_view(){ m_state.position = MapPosition{map().name, m_player.position}; m_state.black_below = map().connections.empty() && map().tiles.height <= SMALL_ROOM_ROWS && m_player.position.y >= map().tiles.height - BLACK_BELOW_ROWS; + m_state.dark = m_script.dark_maps.contains(map().name); } void OverworldSim::schedule(Milliseconds at, SimScreen screen, bool unfreeze){ @@ -601,10 +602,6 @@ std::optional OverworldSim::watch(const std::vector& watche return watch_until(watchers, m_now, m_now + duration); } -bool OverworldSim::dark() const{ - return m_script.dark_maps.contains(map().name); -} - SimBattleEnd OverworldSim::handle_battle(SimBattleChoice choice){ if (!m_battle_pending){ violation("fought a battle that isn't there, on " + std::string(sim_screen_name(m_state.screen)) + "."); @@ -841,7 +838,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, calls.log = [&](const std::string& message){ m_log.push_back("Leg: " + message); }; calls.fail = [&](const std::string& message){ fail(message); }; m_leg_start = m_sim.state().time; - m_leg_dark = m_sim.dark(); + m_leg_dark = m_sim.state().dark; const Pokemon::LegResult result = Pokemon::walk_leg_with(leg, calls, MAX_INTERRUPTIONS_PER_LEG); if (result.stopped){ return dark_stop(step, walked_near(step, result.tiles, TILE_TIME)); @@ -890,13 +887,7 @@ StepOutcome OverworldSimWalker::walk(const RouteStep& step, std::optional OverworldSimWalker::late_stop(const RouteStep& step){ - if (step.to_wall){ - return std::nullopt; - } - if (m_leg_dark){ - return dark_stop(step, whole_leg(step)); - } - if (!m_rules.place_late_battle){ + if (step.to_wall || (!m_leg_dark && !m_rules.place_late_battle)){ return std::nullopt; } const WallClock start = sim_wall_clock(m_leg_start); @@ -904,30 +895,15 @@ std::optional OverworldSimWalker::late_stop(const RouteStep& step){ step, TILE_TIME, screen_event_log(m_sim.state(), m_leg_start), start, sim_wall_clock(m_sim.state().time) ); m_log.push_back(late_stop_line(step, stop, start)); - return stop.walked; -} - -// stop_tiles(): anywhere on `step` if it started in a dark cave. Without -// SimWalkerRules::whole_leg_in_dark, there the dark screen placed the stop -// at the hold's start. -WalkedTiles OverworldSimWalker::dark_stop(const RouteStep& step, WalkedTiles timed){ - if (!m_leg_dark){ - return timed; - } - const WalkedTiles out = m_rules.whole_leg_in_dark ? whole_leg(step) : walked_near(step, 0, TILE_TIME); - m_log.push_back(std::format("Route: {} started in a dark cave: {} to {} tiles in.", to_string(step), out.min, out.max)); - return out; + return m_leg_dark ? std::optional(dark_stop(step, whole_leg(step))) : stop.walked; } // Whether the screen shows a battle on its way: its wipe's black, or its -// text box before the HP boxes; without SimWalkerRules:: -// dark_cave_not_battle, also a dark cave's overworld. +// text box before the HP boxes; without dark_cave_not_battle, a dark cave. bool OverworldSimWalker::battle_on_its_way() const{ const SimScreen screen = m_sim.state().screen; - if (!m_rules.dark_cave_not_battle && screen == SimScreen::OVERWORLD && m_sim.dark()){ - return true; - } - return screen == SimScreen::BATTLE_WIPE || screen == SimScreen::BATTLE_OPENING; + const bool dark_misread = !m_rules.dark_cave_not_battle && screen == SimScreen::OVERWORLD && m_sim.state().dark; + return dark_misread || screen == SimScreen::BATTLE_WIPE || screen == SimScreen::BATTLE_OPENING; } // clear_unseen_battle(): the battle's text box or wipe seen after the diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index d141fa8f63..c84653ded1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -155,6 +155,8 @@ struct SimState{ // Whether black shows below the map: the player stands near the bottom // of a map with nothing joined to it (a small room or a gate). bool black_below = false; + // Whether the player's map is one of SimScript::dark_maps. + bool dark = false; int steps = 0; int warps = 0; int wild_battles = 0; @@ -209,8 +211,6 @@ class OverworldSim{ OverworldSim(std::vector maps, MapPosition start, Direction facing, SimScript script = {}); const SimState& state() const{ return m_state; } - // Whether the player's map is one of SimScript::dark_maps. - bool dark() const; // Holds `direction` for `duration` then lets go for `release`, while // `watchers` watch until `watch` (at least the hold and release) from @@ -461,7 +461,14 @@ class OverworldSimWalker{ }; StepOutcome walk(const RouteStep& step, std::optional facing); std::optional late_stop(const RouteStep& step); - WalkedTiles dark_stop(const RouteStep& step, WalkedTiles timed); + // stop_tiles(); without SimWalkerRules::whole_leg_in_dark, the dark + // screen places the stop at the hold's start. + WalkedTiles dark_stop(const RouteStep& step, WalkedTiles timed) const{ + if (m_leg_dark && !m_rules.whole_leg_in_dark){ + return walked_near(step, 0, MovementMechanics::TILE_TIME); + } + return stop_tiles(step, timed, m_leg_dark); + } std::optional walk_step(const RouteStep& step, std::optional facing); bool after_warp(const RouteStep& step, int warps_before, Milliseconds watched_from); bool fade_over_since(Milliseconds from) const; From 2b8b70e95a72abe67c0d66fb4b4edd0c1d986182 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 15:20:21 -0800 Subject: [PATCH 19/24] FRLG: red tests for a battle won in a dark cave Console B, 2026-11-09, 00.28 hiker_lenny in Rock Tunnel without Flash: the battle was won, and the dark overworld after it read as the battle's text box with no HP box. The loop held B for an evolution three times for 20 s, then failed with "no battle screen and no START menu for 60 s after the battle". The sim now plays the battle loop's end after each trainer's battle (battle_end_on_sim(), on EvolutionHold and BattleEndWatch), with the dark cave's overworld when the map is dark. New: a decisions test, an 00.28 dark sim test, and a DARK_CAVE fault sweep. All fail until the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_OverworldSim.cpp | 10 ++- .../Overworld/PokemonFRLG_OverworldSim.h | 5 ++ .../AutoStory/PokemonFRLG_AutoStoryBattle.cpp | 8 +-- .../PokemonFRLG_AutoStoryBattleDecisions.h | 7 ++ .../PokemonFRLG_AutoStoryFaultSweep_Tests.cpp | 46 +++++++++++++ .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 10 +++ .../PokemonFRLG_AutoStoryParty_Tests.cpp | 40 +++++++++++ .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 66 ++++++++++++++++++- 8 files changed, 183 insertions(+), 9 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 9d65da8a27..3d7bcfdbd6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -736,8 +736,14 @@ void OverworldSimWalker::clear(const Interrupt& interrupt){ } return; } - if (m_sim.close_message() && m_rules.fight_trainer_after_message){ - m_sim.handle_battle(SimBattleChoice::FIGHT); + if (!m_sim.close_message() || !m_rules.fight_trainer_after_message){ + return; + } + m_sim.handle_battle(SimBattleChoice::FIGHT); + if (m_rules.trainer_battle_end){ + if (const std::string failure = m_rules.trainer_battle_end(m_sim); !failure.empty()){ + fail(failure); + } } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index c84653ded1..0e95c34368 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -28,6 +28,7 @@ #ifndef PokemonAutomation_PokemonFRLG_OverworldSim_H #define PokemonAutomation_PokemonFRLG_OverworldSim_H +#include #include #include #include @@ -415,6 +416,10 @@ struct SimWalkerRules{ // and the player was found again at a wall 5 tiles off (console B, // 2026-11-09, 00.28). bool whole_leg_in_dark = true; + // The end of a trainer's battle the walker fought, as the story's + // battle loop takes it on the screen the sim shows after it: its + // failure, or empty. Not set: the battle just ends. + std::function trainer_battle_end = nullptr; }; // OverworldWalkSteps::turn_to() on `sim`, which reads the facing on the diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 9d5e93b652..47c39e1037 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -62,9 +62,6 @@ constexpr Milliseconds OVERWORLD_CHECK = 3000ms; // No battle menu or HP box this long after a battle was seen: there was // none (console B, 2026-11-01, Viridian Forest's north gate mat). constexpr Milliseconds NOT_A_BATTLE = 15000ms; -// An evolution's animation, with no presses: as long as exit_wild_battle() -// (PokemonFRLG_Navigation.cpp) waits for one. -constexpr Milliseconds EVOLUTION = 20000ms; // Text and the move learn prompts after a battle, on the overworld. constexpr Milliseconds AFTER_BATTLE_TEXT = 60000ms; // The move list to fade in after "Delete a move?", and its cursor to move. @@ -427,11 +424,12 @@ class BattleLoop{ m_last_press = now; } - // Nothing pressed while the screen may be an evolution, up to EVOLUTION. + // Nothing pressed while the screen may be an evolution, up to + // EvolutionHold::MAX_HOLD. void wait_out_evolution(){ const WallClock start = current_time(); bool holds = true; - while (holds && current_time() - start < EVOLUTION){ + while (holds && current_time() - start < EvolutionHold::MAX_HOLD){ pbf_wait(m_context, POLL); m_context.wait_for_all_requests(); VideoSnapshot screen = m_console.video().snapshot(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h index 5fb5f5a6cc..f08ed2442b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h @@ -87,6 +87,9 @@ struct BattleTextScreen{ bool arrow = false; // A YES/NO box. bool yes_no = false; + // The overworld of a cave dark without Flash (dark_cave_screen()), + // whose black reads as the text box. + bool dark_cave = false; }; // Answer to an in-battle prompt dialogue. @@ -193,6 +196,10 @@ class BattleDecisions{ // battle loop's B presses). class EvolutionHold{ public: + // The longest one hold lasts: as long as exit_wild_battle() + // (PokemonFRLG_Navigation.cpp) waits for an evolution. + static constexpr Milliseconds MAX_HOLD = std::chrono::milliseconds(20000); + explicit EvolutionHold(PartyMember& lead); // The loop took a turn: only one that attacked can end the battle in a diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp index 12ac3a535b..58d2d9e891 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp @@ -13,6 +13,10 @@ * land encounters, over GAME_ODDS_SEEDS seeds (00.28's Rock Tunnel). * - LATE_FADE: every warp's fade ends LATE_FADE later than the sim's * usual (00.27's warp to Cerulean City). + * - DARK_CAVE: the caves without Flash dark (DARK_MAPS), so each + * battle there ends on the dark cave's overworld, with no wild + * battles and with them at the game's odds (00.28 hiker_lenny, lab-B + * 2026-11-09). * * Not swept here: a missed fade, which needs a knob in OverworldSim; a * battle's opening at a step's start, which needs one in OverworldStepSim; @@ -60,6 +64,7 @@ enum class FaultClass{ SCENE_END_POSE, WILD_BATTLES, LATE_FADE, + DARK_CAVE, // Not swept yet: the measured movement table's lost taps. LOST_TAP, }; @@ -71,6 +76,8 @@ const char* fault_class_name(FaultClass fault){ return "wild battles at the game's odds"; case FaultClass::LATE_FADE: return "late warp fade"; + case FaultClass::DARK_CAVE: + return "dark cave without Flash"; case FaultClass::LOST_TAP: return "lost tap"; } @@ -96,6 +103,11 @@ const std::map STORY_ENCOUNTER_RATES{ // (Mt. Moon B2F, console B, 2026-11-03). const std::set WILD_FLOOR_MAPS{"MtMoon_1F", "MtMoon_B1F", "MtMoon_B2F"}; constexpr unsigned GAME_ODDS_SEEDS = 30; +// The caves the story walks dark: no Flash before Lavender Town. +const std::set DARK_MAPS{"RockTunnel_1F", "RockTunnel_B1F"}; +// Seeds of wild battles at the game's odds in the dark, after a run +// with none. +constexpr unsigned DARK_CAVE_SEEDS = 10; // How much later than SimTiming's a late fade ends: the new map shows // this long after the usual. constexpr Milliseconds LATE_FADE = 1500ms; @@ -333,6 +345,37 @@ UnitTestResult sweep_late_fade(Logger& logger, const StoryCheckpoint& row, const return sweep_result(logger, FaultClass::LATE_FADE, row.id, 1, failures); } +// Row `row` with DARK_MAPS dark: once with no wild battles, then with +// them at the game's odds from DARK_CAVE_SEEDS seeds. Each trainer's +// battle there ends on the dark overworld (battle_end_on_sim() in +// PokemonFRLG_AutoStoryRowSim.cpp); wild ones are run from, after which +// nothing is held for an evolution. A row that walks no dark cave is +// not swept. +UnitTestResult sweep_dark_cave(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + const bool walks_dark = DARK_MAPS.contains(clean.end.map) || std::any_of(clean.before.begin(), clean.before.end(), [](const auto& before){ + return DARK_MAPS.contains(before.second.map); + }); + if (!walks_dark){ + logger.log(std::format("Fault sweep {}: not swept, the row walks no dark cave.", row.id)); + return true; + } + std::vector failures; + for (unsigned seed = 0; seed <= DARK_CAVE_SEEDS; seed++){ + SimScript script; + script.dark_maps = DARK_MAPS; + if (seed > 0){ + script.encounter_rates = STORY_ENCOUNTER_RATES; + script.encounter_seed = seed; + script.wild_floor_maps = WILD_FLOOR_MAPS; + } + const std::string stopped = differs(run_row(row, script, {}, clean.scripted), clean); + if (!stopped.empty()){ + failures.push_back({"", seed == 0 ? std::string("no wild battles") : std::format("seed {}", seed), stopped}); + } + } + return sweep_result(logger, FaultClass::DARK_CAVE, row.id, DARK_CAVE_SEEDS + 1, failures); +} + // A fault class swept over row `row` (empty in FAULT_SWEEP_CASES: each // row with a start). struct FaultSweepInput{ @@ -396,6 +439,8 @@ class Test_AutoStoryFaultSweep : public UnitTest{ return sweep_wild_battles(logger, m_row, clean); case FaultClass::LATE_FADE: return sweep_late_fade(logger, m_row, clean); + case FaultClass::DARK_CAVE: + return sweep_dark_cave(logger, m_row, clean); case FaultClass::LOST_TAP: break; } @@ -413,6 +458,7 @@ const FaultSweepCase FAULT_SWEEP_CASES[] = { {.input = {.fault = FaultClass::SCENE_END_POSE}, .expectation = {}}, {.input = {.fault = FaultClass::WILD_BATTLES}, .expectation = {}}, {.input = {.fault = FaultClass::LATE_FADE}, .expectation = {}}, + {.input = {.fault = FaultClass::DARK_CAVE}, .expectation = {}}, }; void add_tests_AutoStoryFaultSweep(UnitTestDatabase& database){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index 2262b6a2cf..73b5f010be 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -245,6 +245,15 @@ UnitTestResult test_b1f_martha_after_row38_battle(Logger&){ // Rock Tunnel has no Flash: both floors are dark. const std::set ROCK_TUNNEL_DARK{StoryTable::ROCK_TUNNEL_1F, StoryTable::ROCK_TUNNEL_B1F}; +// Regression: console B, 2026-11-09, 00.28 hiker_lenny: won, and the dark +// overworld after it was taken for an evolution until fight_battle() gave +// up. 00.28 dark: every trainer's battle ends on the dark cave's overworld. +UnitTestResult test_rock_tunnel_dark_battle_ends(Logger&){ + SimScript script; + script.dark_maps = ROCK_TUNNEL_DARK; + return row_on_sim("00.28", script, {ROCK_TUNNEL_TRAINERS}, MapPosition{StoryTable::ROUTE10, {8, 58}}); +} + // below_martha_line as `legs` from Winston's tile (29, 37) on `sim`, by the // walker with `rules`: the failure, or empty if it ended on (16, 35). std::string walk_below_martha(OverworldSim& sim, const std::vector& legs, const SimWalkerRules& rules){ @@ -542,6 +551,7 @@ UnitTestResult test_save_places(Logger&){ const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { {.input = {"00.28 the tree, Rock Tunnel's nine trainers, out of its south mouth", test_rock_tunnel_row}, .expectation = {}}, {.input = {"00.28 a wild battle mid-leg in the tunnel", test_rock_tunnel_wild_battles}, .expectation = {}}, + {.input = {"00.28 dark: each trainer's battle ends on the dark cave's overworld", test_rock_tunnel_dark_battle_ends}, .expectation = {}}, {.input = {"00.28 a tile off after each open leg", test_rock_tunnel_tile_off}, .expectation = {}}, {.input = {"00.28 B1F row 37 with a wild battle every 10 to 14 steps", test_b1f_row37_battles_every_12}, .expectation = {}}, {.input = {"00.28 a wild battle every 7 to 25 steps, 12 seeds", test_rock_tunnel_frequent_battles}, .expectation = {}}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp index 0219b7b703..8694afd9e4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp @@ -616,6 +616,45 @@ UnitTestResult test_run_then_overworld(Logger&){ return "START never looked for the overworld in the dark cave."; } +// Regression: console B, 2026-11-09, 00.28 hiker_lenny in Rock Tunnel +// without Flash: the battle won with a move, then the dark cave's +// overworld, black but for the lit circle on the player, read as the +// text box with no HP box. The loop held B for an evolution three times +// for 20 s, and gave up: "no battle screen and no START menu for 60 s +// after the battle". A dark cave's overworld is no evolution: START looks +// for the overworld within seconds of the last HP box. +UnitTestResult test_trainer_win_then_dark_cave(Logger&){ + const BattleTextScreen defeated{.text_box = true, .hp_box = true, .arrow = true, .yes_no = false}; + const BattleTextScreen dark_cave{.text_box = true, .hp_box = false, .arrow = false, .yes_no = false, .dark_cave = true}; + std::vector polls{{.ms = 0, .screen = defeated}}; + for (int ms = 300; ms <= 1500; ms += 300){ + polls.push_back({.ms = ms, .screen = defeated}); + } + for (int ms = 1800; ms <= 60000; ms += 300){ + polls.push_back({.ms = ms, .screen = dark_cave}); + } + + Supplies supplies; + EvolutionHold hold(supplies.lead); + BattleEndWatch watch; + const WallClock start = current_time(); + hold.turn_taken(TurnChoice::MOVE); + for (const LoopPoll& poll : polls){ + const WallClock now = start + std::chrono::milliseconds(poll.ms); + if (hold.holds(poll.screen)){ + return std::format("Held B for an evolution at {} ms, on the dark cave's overworld.", poll.ms); + } + watch.seen(poll.screen.hp_box || poll.screen.yes_no ? BattleSeen::BATTLE : BattleSeen::NONE, now); + if (watch.check_overworld(now)){ + if (poll.ms > 5000){ + return std::format("START first looked for the overworld at {} ms.", poll.ms); + } + return true; + } + } + return "START never looked for the overworld in the dark cave."; +} + // START only once the battle is gone for a while, and then not at every // poll; A and B blind for at most 30 s since the last battle menu. UnitTestResult test_end_watch_timing(Logger&){ @@ -923,6 +962,7 @@ const AutoStoryPartyTestCase AUTO_STORY_PARTY_CASES[] = { {.input = {.name = "summary members: fewer moves, unread PP, strongest attack", .body = test_summary_members}, .expectation = {.description = "handles party members with partial move data"},}, {.input = {.name = "no B during an evolution", .body = test_no_b_during_evolution}, .expectation = {.description = "prevents cancelling evolution sequence"},}, {.input = {.name = "RUN, then a dark cave: no evolution wait, START soon", .body = test_run_then_overworld}, .expectation = {.description = "handles post-battle overworld transition in dark cave"},}, + {.input = {.name = "a trainer beaten, then a dark cave without Flash: no evolution wait, START soon", .body = test_trainer_win_then_dark_cave}, .expectation = {.description = "takes a dark cave's overworld after a win for no evolution"},}, {.input = {.name = "battle end: START after the battle is gone, A and B for 30 s at most", .body = test_end_watch_timing}, .expectation = {.description = "verifies timing constraints on battle end loop"},}, {.input = {.name = "attacks by matchup: Bite on the rival's Bulbasaur and Misty", .body = test_attack_by_matchup}, .expectation = {.description = "selects optimal attack move based on type matchup"},}, {.input = {.name = "attacks against an unknown foe: the plan's order", .body = test_attack_unknown_foe}, .expectation = {.description = "falls back to default move preference order against unknown foe"},}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index a739d0b9e4..c39aac361c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -14,6 +14,7 @@ #include "Common/Cpp/Exceptions.h" #include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryBattleDecisions.h" #include "PokemonFRLG_AutoStoryRowSim.h" namespace PokemonAutomation{ @@ -378,6 +379,62 @@ Pokemon::StoryAction OverworldStepSim::action(StoryActionId id){ }); } +namespace{ + +// fight_battle()'s end on the overworld the sim shows after a battle won +// with a move: the battle loop's polls replayed through its EvolutionHold +// and BattleEndWatch, as BattleLoop::step() takes them. The overworld of a +// dark cave reads as the battle's text box with no HP box (console B, +// 2026-11-09, 00.28 hiker_lenny: three 20 s holds for an evolution, then +// "no battle screen and no START menu for 60 s after the battle"). Empty +// once START would look for the overworld, else the loop's failure. +std::string battle_end_on_sim(const OverworldSim& sim){ + constexpr Milliseconds POLL(300); + // The battle as the loop saw it: a Potion turn's bag, where START + // looked for the overworld and found none (as hiker_lenny's turns 2, + // 4 and 5), then the last turn's text up to the win. + constexpr Milliseconds BAG(4000); + constexpr Milliseconds LAST_TEXT(10000); + // The loop gives up on the overworld after this (BattleEndWatch's + // NO_OVERWORLD); a little longer, so its failure shows. + constexpr Milliseconds GIVE_UP(65000); + const WallClock start{}; + Supplies supplies; + EvolutionHold hold(supplies.lead); + BattleEndWatch watch; + watch.seen(BattleSeen::MENU, start); + Milliseconds at(0); + for (; at <= BAG; at += POLL){ + watch.seen(BattleSeen::NONE, start + at); + if (watch.check_overworld(start + at)){ + break; + } + } + hold.turn_taken(TurnChoice::MOVE); + for (; at <= LAST_TEXT; at += POLL){ + watch.seen(BattleSeen::BATTLE, start + at); + } + const bool dark = sim.state().dark; + const BattleTextScreen overworld{.text_box = dark, .hp_box = false, .arrow = false, .yes_no = false, .dark_cave = dark}; + for (; at <= LAST_TEXT + GIVE_UP; at += POLL){ + if (watch.overworld_lost(start + at)){ + return "fight_battle(): no battle screen and no START menu for 60 s after the battle."; + } + if (hold.holds(overworld)){ + hold.held(EvolutionHold::MAX_HOLD, false); + at += EvolutionHold::MAX_HOLD; + continue; + } + watch.seen(BattleSeen::NONE, start + at); + if (watch.check_overworld(start + at)){ + return ""; + } + } + return "fight_battle(): START never looked for the overworld."; +} + +} // namespace + // A ROUTE or LEGS by the sim's walker, fighting a trainer who sees the // player on the way. std::string OverworldStepSim::walk(const StoryStep& step){ @@ -387,7 +444,9 @@ std::string OverworldStepSim::walk(const StoryStep& step){ return ""; } const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = MapPosition{m_at.map, m_at.tile}, .label = step.name}; - const std::string failure = route ? OverworldSimWalker(m_sim).run(*route, planning) : "no legs."; + SimWalkerRules rules; + rules.trainer_battle_end = battle_end_on_sim; + const std::string failure = route ? OverworldSimWalker(m_sim, rules).run(*route, planning) : "no legs."; m_at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : m_at; return failure; } @@ -443,11 +502,14 @@ std::string OverworldStepSim::cut(const StoryStep& step){ } // A battle from the trainer's message, which must be up, ending where the -// table says. +// table says, the battle loop's end played on the overworld after it. std::string OverworldStepSim::battle(const StoryStep& step){ if (!m_sim.close_message() || m_sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ return "no_battle."; } + if (const std::string failure = battle_end_on_sim(m_sim); !failure.empty()){ + return failure; + } m_at = *story_pose(*step.pose_after); if (!(m_sim.state().position == MapPosition{m_at.map, m_at.tile})){ return "expected " + to_string(MapPosition{m_at.map, m_at.tile}) + "."; From 710e5de9ba1ae8bef94d772f19e9b1429f4b2fa0 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 15:23:49 -0800 Subject: [PATCH 20/24] FRLG: a dark cave's overworld ends the battle loop's evolution wait After a battle won in Rock Tunnel without Flash, the dark overworld read as the battle's text box with no HP box, which EvolutionHold took for "is evolving!". The loop now reads a dark cave's screen (dark_cave_screen()) as no battle screen and holds nothing on it, so START looks for the overworld within seconds, as after any battle. An evolution's scene keeps its text box in the bottom corners, which a dark cave's are not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp | 7 +++++++ .../AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp | 2 +- .../AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 47c39e1037..6d3cb3d36a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -22,6 +22,7 @@ #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_PartyMenuDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_AutoStoryActions.h" @@ -107,6 +108,11 @@ BattleScreen read_battle_screen(const ImageViewRGB32& screen, bool confirmed){ if (battle_yes_no_shows(screen)){ return BattleScreen::LEARN; } + // A dark cave's overworld after the battle: its black reads as the + // text box (console B, 2026-11-09, Rock Tunnel). + if (OverworldWalkSteps::dark_cave_screen(screen)){ + return BattleScreen::NONE; + } if (!confirmed && !BattleScreenDetector(COLOR_RED).detect(screen)){ return BattleScreen::NONE; } @@ -125,6 +131,7 @@ BattleTextScreen read_text_screen(const ImageViewRGB32& screen){ .hp_box = BattleScreenDetector(COLOR_RED).detect(screen), .arrow = AdvanceBattleDialogDetector(COLOR_RED).detect(screen), .yes_no = battle_yes_no_shows(screen) || yes_no_cursor(screen).has_value(), + .dark_cave = OverworldWalkSteps::dark_cave_screen(screen), }; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp index 57fc597e03..d8d8ed4fe3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp @@ -509,7 +509,7 @@ void EvolutionHold::turn_taken(TurnChoice choice){ } bool EvolutionHold::holds(const BattleTextScreen& screen) const{ - return m_attacked && m_held < EVOLUTION_HOLDS && screen.text_box && !screen.hp_box && !screen.arrow && !screen.yes_no; + return m_attacked && m_held < EVOLUTION_HOLDS && screen.text_box && !screen.hp_box && !screen.arrow && !screen.yes_no && !screen.dark_cave; } void EvolutionHold::held(Milliseconds held, bool lead_fainted){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h index f08ed2442b..6d63a5ecf6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h @@ -208,7 +208,9 @@ class EvolutionHold{ // Whether `screen` may be an evolution: after a turn that attacked (not // after RUN: console B, 2026-11-03, a minute without B after "Got away // safely!"), the text box with no HP box and no arrow or YES/NO, as - // "What? is evolving!" shows through the animation. Nothing + // "What? is evolving!" shows through the animation; not a + // dark cave's overworld, whose black reads as the text box (console B, + // 2026-11-09, 00.28 hiker_lenny: three holds, then a stop). Nothing // is pressed while it shows, for at most EVOLUTION_HOLDS in all in a // battle, in case it is something else. bool holds(const BattleTextScreen& screen) const; From db14a253f04cb42e0adfd27ddff3c780dd3ba19d Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 15:26:14 -0800 Subject: [PATCH 21/24] FRLG: keep the battle loop and the sim walker within the file limit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_OverworldSim.cpp | 7 ++----- .../Overworld/PokemonFRLG_OverworldSim.h | 6 ++++-- .../AutoStory/PokemonFRLG_AutoStoryBattle.cpp | 13 ++++--------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 3d7bcfdbd6..f2e590e288 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -736,11 +736,8 @@ void OverworldSimWalker::clear(const Interrupt& interrupt){ } return; } - if (!m_sim.close_message() || !m_rules.fight_trainer_after_message){ - return; - } - m_sim.handle_battle(SimBattleChoice::FIGHT); - if (m_rules.trainer_battle_end){ + if (m_sim.close_message() && m_rules.fight_trainer_after_message){ + m_sim.handle_battle(SimBattleChoice::FIGHT); if (const std::string failure = m_rules.trainer_battle_end(m_sim); !failure.empty()){ fail(failure); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 0e95c34368..0db86f8980 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -418,8 +418,10 @@ struct SimWalkerRules{ bool whole_leg_in_dark = true; // The end of a trainer's battle the walker fought, as the story's // battle loop takes it on the screen the sim shows after it: its - // failure, or empty. Not set: the battle just ends. - std::function trainer_battle_end = nullptr; + // failure, or empty. By default the battle just ends. + std::function trainer_battle_end = [](const OverworldSim&){ + return std::string(); + }; }; // OverworldWalkSteps::turn_to() on `sim`, which reads the facing on the diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 6d3cb3d36a..e01867804f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -100,7 +100,8 @@ const char* screen_name(BattleScreen screen){ } // Until `confirmed`, the battle text box alone counts for nothing: it also -// reads on the black below a small indoor map. +// reads on the black below a small indoor map. Nor ever on a dark cave's +// overworld after the battle (console B, 2026-11-09, Rock Tunnel). BattleScreen read_battle_screen(const ImageViewRGB32& screen, bool confirmed){ if (BattleMenuDetector(COLOR_RED).detect(screen)){ return BattleScreen::MENU; @@ -108,12 +109,7 @@ BattleScreen read_battle_screen(const ImageViewRGB32& screen, bool confirmed){ if (battle_yes_no_shows(screen)){ return BattleScreen::LEARN; } - // A dark cave's overworld after the battle: its black reads as the - // text box (console B, 2026-11-09, Rock Tunnel). - if (OverworldWalkSteps::dark_cave_screen(screen)){ - return BattleScreen::NONE; - } - if (!confirmed && !BattleScreenDetector(COLOR_RED).detect(screen)){ + if ((!confirmed && !BattleScreenDetector(COLOR_RED).detect(screen)) || OverworldWalkSteps::dark_cave_screen(screen)){ return BattleScreen::NONE; } if (AdvanceBattleDialogDetector(COLOR_RED).detect(screen)){ @@ -431,8 +427,7 @@ class BattleLoop{ m_last_press = now; } - // Nothing pressed while the screen may be an evolution, up to - // EvolutionHold::MAX_HOLD. + // Nothing pressed while the screen may be an evolution, up to MAX_HOLD. void wait_out_evolution(){ const WallClock start = current_time(); bool holds = true; From 413f1cba686f5d070d056eaee42c74dd3e0e490f Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 15:58:39 -0800 Subject: [PATCH 22/24] FRLG AutoStory: wait out a battle's exit before the next tap in a trainer's line Console B, 2026-11-09 15:50, 00.28 winston_line: a wild battle on the second of three taps into Winston's line was run from, and the third tap, sent as the battle's black ended, was lost; the step failed no_trainer. After a wild battle run from mid-line (and in the capture pacing), the next tap now waits until the overworld has shown nothing for AFTER_BATTLE_QUIET (wait_for_quiet_overworld() over the shared ScreenLook/ScreenSettle). The sim ignores taps for BATTLE_EXIT_LOCKOUT after a battle, and the fault sweep gains LOST_TAP: a wild battle on each tile of each trainer's line before its last. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_MovementMechanics.h | 12 +++ .../Overworld/PokemonFRLG_OverworldSim.cpp | 10 +-- .../Overworld/PokemonFRLG_OverworldSim.h | 8 +- .../PokemonFRLG_AutoStoryActions.cpp | 13 ++++ .../AutoStory/PokemonFRLG_AutoStoryActions.h | 10 +++ .../PokemonFRLG_AutoStoryCapture.cpp | 3 + .../PokemonFRLG_AutoStoryFaultSweep_Tests.cpp | 76 +++++++++++++++++-- .../PokemonFRLG_AutoStoryLavender_Tests.cpp | 28 +++++++ .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 18 +++++ .../PokemonFRLG_AutoStoryStepInterrupts.cpp | 23 ++++++ .../PokemonFRLG_AutoStoryStepInterrupts.h | 14 ++++ 11 files changed, 204 insertions(+), 11 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h index 9fc6df8df6..f7062858bc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h @@ -21,6 +21,9 @@ * tap's start is ignored. * - Taps with no release between them are one hold: after a turn, 3 of * them took 0 or 1 steps. + * After a battle (console B, 2026-11-09 15:50, 00.28 winston_line): a + * step tap sent as flee_battle() returned, on the black's end, was lost; + * one more tap by hand stepped. * */ @@ -55,6 +58,10 @@ inline constexpr Milliseconds LONGEST_TAP(133); inline constexpr Milliseconds STEP_TAP_LOCKOUT(620); // The shortest step tap spacing, start to start, measured to work. inline constexpr Milliseconds STEP_TAP_SPACING_WORKED(666); +// After a battle's black ends, the overworld fades in and the game ignores +// the D-pad this long. Not measured: pokefirered's fade from black is 16 +// frames, then the field takes input; a tap sent at once was lost. +inline constexpr Milliseconds BATTLE_EXIT_LOCKOUT(500); // The walker's presses. @@ -77,9 +84,14 @@ inline constexpr Milliseconds STEP_TAP(66); // STEP_TAP_RELEASE after it, past the lockout. inline constexpr Milliseconds STEP_TAP_RELEASE(700); +// How long the overworld must show nothing (no box, fade or battle) after +// a battle before the next tap: twice the lockout. +inline constexpr Milliseconds AFTER_BATTLE_QUIET(1000); + static_assert(TURN_TAP <= LONGEST_TAP && FACE_HOLD <= LONGEST_TAP && STEP_TAP <= LONGEST_TAP); static_assert(STEP_TAP + STEP_TAP_RELEASE > STEP_TAP_SPACING_WORKED); static_assert(STEP_TAP_SPACING_WORKED > STEP_TAP_LOCKOUT); +static_assert(AFTER_BATTLE_QUIET > BATTLE_EXIT_LOCKOUT); } // namespace MovementMechanics diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index f2e590e288..e8625e13b3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -505,7 +505,7 @@ void OverworldSim::stuck_hold(Direction direction){ }else if (m_battle_pending){ violation(std::string("the D-pad held ") + direction_name(direction) + " into a battle."); }else{ - note(std::string("the D-pad held ") + direction_name(direction) + " under " + sim_screen_name(m_state.screen) + "."); + note(std::string("the D-pad held ") + direction_name(direction) + " under " + sim_screen_name(m_state.screen) + (m_frozen ? "." : ", in the lockout after a battle.")); } } @@ -532,7 +532,7 @@ std::optional OverworldSim::hold( void OverworldSim::tap(Direction direction, Milliseconds duration, Milliseconds release){ const Milliseconds start = m_now; - if (m_state.screen != SimScreen::OVERWORLD || m_frozen){ + if (m_state.screen != SimScreen::OVERWORLD || m_frozen || start < m_battle_lockout_end){ stuck_hold(direction); m_last_tap.reset(); }else if (m_last_tap && m_last_tap->direction == direction && m_last_tap->end == start){ @@ -620,14 +620,14 @@ SimBattleEnd OverworldSim::handle_battle(SimBattleChoice choice){ show(SimScreen::OVERWORLD, m_now); m_frozen = false; m_battle_pending = false; - if (!m_trainer){ - m_state.in_wild_battles += m_now - m_wild_battle_from; - } + m_battle_lockout_end = m_now + m_script.timing.battle_exit_lockout; if (m_trainer){ MapObject& trainer = m_maps[m_map].objects[*m_trainer]; trainer.trainer.reset(); m_state.trainers_beaten.push_back(trainer.name); m_trainer.reset(); + }else{ + m_state.in_wild_battles += m_now - m_wild_battle_from; } note(std::string(choice == SimBattleChoice::RUN ? "ran from" : "won") + " the battle."); return SimBattleEnd::ENDED; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 0db86f8980..21038720d4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -99,6 +99,8 @@ struct SimTiming{ Milliseconds fade{1000}; // from the step's end to the new map Milliseconds trainer_notice{1000}; // "!" over the trainer, before they walk up Milliseconds trainer_battle{2000}; // after the trainer's message closes + // From a battle's end on, the game ignores taps this long. + Milliseconds battle_exit_lockout = MovementMechanics::BATTLE_EXIT_LOCKOUT; }; // What a test sets up beyond the maps. @@ -225,7 +227,9 @@ class OverworldSim{ // LONGEST_TAP), then `release`, as the console measured it: a player // facing another way only turns; one facing that way steps once, // unless the tap starts inside the lockout after the last step tap - // (STEP_TAP_LOCKOUT), which the game ignores. A tap that starts as + // (STEP_TAP_LOCKOUT), which the game ignores, as it does any tap + // inside the lockout after a battle (BATTLE_EXIT_LOCKOUT); holds + // aren't held up by that lockout yet. A tap that starts as // the last one ends, with no release between, holds it on. A turn may // be dropped by fault injection (SimScript::dropped_turn_taps). void tap( @@ -330,6 +334,8 @@ class OverworldSim{ std::optional m_last_tap; // When the last tap that stepped started. std::optional m_last_step_tap; + // The end of the lockout after the last battle. + Milliseconds m_battle_lockout_end{0}; Milliseconds m_wild_battle_from{0}; int m_holds = 0; SimState m_state; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp index 72386fb022..94a86456c2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp @@ -33,6 +33,7 @@ #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_NamingScreen.h" #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_StartMenuNavigation.h" #include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryStepInterrupts.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -598,6 +599,17 @@ std::optional step_until_seen(int max_tiles, const std::function& s return std::nullopt; } +void wait_out_battle_exit(ConsoleHandle& console, ProControllerContext& context){ + context.wait_for_all_requests(); + ConsoleScreenLook eyes(console, context); + const Pokemon::ScreenSettle settle = wait_for_quiet_overworld( + [&eyes](bool fresh){ return eyes.look(fresh); }, + MovementMechanics::AFTER_BATTLE_QUIET, BATTLE_EXIT_TIMEOUT + ); + const bool quiet = quiet_overworld(settle, MovementMechanics::AFTER_BATTLE_QUIET); + console.log("Story: after the battle, " + Pokemon::describe(settle), quiet ? COLOR_BLACK : COLOR_RED); +} + std::optional step_into_line( ConsoleHandle& console, ProControllerContext& context, Direction direction, std::optional facing, int max_tiles, Milliseconds watch @@ -615,6 +627,7 @@ std::optional step_into_line( if (seen == 1){ console.log("Story: a wild battle on the step into a trainer's line; running from it, then stepping on.", COLOR_ORANGE); flee_battle(console, context); + wait_out_battle_exit(console, context); return false; } return seen == 0; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h index efbd5b8679..07e37e35c9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h @@ -148,6 +148,16 @@ inline constexpr Milliseconds TRAINER_LINE_WATCH(2500); // 2026-11-09). inline constexpr Milliseconds WILD_BATTLE_IN_LINE_WATCH(5000); +// The longest wait_out_battle_exit() waits for the overworld. +inline constexpr Milliseconds BATTLE_EXIT_TIMEOUT(10000); + +// After a battle run from or won: waits until the overworld has shown +// nothing (wait_for_quiet_overworld()) for MovementMechanics:: +// AFTER_BATTLE_QUIET, at most BATTLE_EXIT_TIMEOUT, and logs how the screen +// ended. A tap sent as the battle's black ends is lost (console B, +// 2026-11-09, 00.28 winston_line). +void wait_out_battle_exit(ConsoleHandle& console, ProControllerContext& context); + // How long a step into the line of a trainer who looks around waits for // them to turn to the player (TRAINER_WAIT_VARIANT). inline constexpr Milliseconds TRAINER_WAIT_WATCH(30000); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp index c99038983f..f292e12fd2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp @@ -557,6 +557,9 @@ EncounterSeen ConsoleCaptureActions::read_encounter(){ bool ConsoleCaptureActions::flee(){ try{ flee_battle(m_console, m_context); + // The pacing counts its taps: one lost to the battle's exit + // would put it a tile off. + wait_out_battle_exit(m_console, m_context); return true; }catch (OperationFailedException& e){ m_console.log("Capture: " + e.message(), COLOR_RED); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp index 58d2d9e891..bd2a0112bb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp @@ -17,11 +17,15 @@ * battle there ends on the dark cave's overworld, with no wild * battles and with them at the game's odds (00.28 hiker_lenny, lab-B * 2026-11-09). + * - LOST_TAP: a wild battle on each tile of each step into a trainer's + * line before its last, so the next tap comes as the overworld comes + * back, inside the lockout after a battle (00.28 winston_line, console + * B, 2026-11-09 15:50). The step alone, from where the clean run took + * it, on its map made a wild floor. * * Not swept here: a missed fade, which needs a knob in OverworldSim; a * battle's opening at a step's start, which needs one in OverworldStepSim; - * and lost taps from the input lockout (FaultClass::LOST_TAP), for the - * measured movement table. + * and the step tap lockout between two taps, which step_once() waits out. * * The sim plays walks, steps, cuts and trainers' battles; after a step it * does not play, the player is put on the table's pose. Each row runs @@ -65,7 +69,6 @@ enum class FaultClass{ WILD_BATTLES, LATE_FADE, DARK_CAVE, - // Not swept yet: the measured movement table's lost taps. LOST_TAP, }; const char* fault_class_name(FaultClass fault){ @@ -79,7 +82,7 @@ const char* fault_class_name(FaultClass fault){ case FaultClass::DARK_CAVE: return "dark cave without Flash"; case FaultClass::LOST_TAP: - return "lost tap"; + return "lost tap after a battle in a trainer's line"; } return "?"; } @@ -147,6 +150,7 @@ struct RowRun{ std::set scripted; // Where the player stood before each step, by index. std::map before; + std::map facing_before; MapPosition end; Milliseconds time{0}; int steps = 0; @@ -187,6 +191,7 @@ RowRun run_row( for (size_t c = 0; c < row.steps.size() && out.failure.empty(); c++){ const StoryStep& step = row.steps[c]; out.before.emplace(c, sim->state().position); + out.facing_before.emplace(c, sim->state().facing); out.failure = row_step_on_sim(*sim, at, step, row.id); const bool let_pass = unscripted(out.failure) && (!scripted || scripted->contains(c)); if (let_pass){ @@ -376,6 +381,66 @@ UnitTestResult sweep_dark_cave(Logger& logger, const StoryCheckpoint& row, const return sweep_result(logger, FaultClass::DARK_CAVE, row.id, DARK_CAVE_SEEDS + 1, failures); } +// How many tiles row `row`'s step `c`, into a trainer's line, steps onto +// before its last, in the clean run `clean`. +int line_tiles_before_last(const StoryCheckpoint& row, const RowRun& clean, size_t c){ + const auto from = clean.before.find(c); + const auto next = clean.before.find(c + 1); + if (from == clean.before.end()){ + return 0; + } + const MapPosition last = next == clean.before.end() ? clean.end : next->second; + TilePos tile = from->second.point.next(row.steps[c].direction); + int out = 0; + for (; out + 1 < TRAINER_LINE_TILES && last.map == from->second.map && !(tile == last.point); out++){ + tile = tile.next(row.steps[c].direction); + } + return out; +} + +// Row `row`'s step `c`, into a trainer's line, alone from where the clean +// run `clean` took it, with a wild battle on its `battle_step`th step: why +// it fails or ends off the clean run's tile, or "". +std::string line_with_battle(const StoryCheckpoint& row, const RowRun& clean, size_t c, int battle_step){ + const MapPosition from = clean.before.at(c); + const auto next = clean.before.find(c + 1); + const MapPosition last = next == clean.before.end() ? clean.end : next->second; + SimScript script; + script.wild_floor_maps = {from.map}; + script.encounters = {SimEncounter{.grass_step = battle_step}}; + OverworldSim sim(sweep_maps(), from, clean.facing_before.at(c), script); + StoryPose at{from.map, from.point, clean.facing_before.at(c)}; + const std::string failure = row_step_on_sim(sim, at, row.steps[c], row.id); + if (!failure.empty()){ + return failure; + } + if (!(sim.state().position == last)){ + return std::format("ended on {}, not {}.", to_string(sim.state().position), to_string(last)); + } + return ""; +} + +// Row `row` with a wild battle on each tile of each step into a trainer's +// line before its last, one at a time: the tap after it must not be lost. +UnitTestResult sweep_lost_tap(Logger& logger, const StoryCheckpoint& row, const RowRun& clean){ + std::vector failures; + size_t variants = 0; + for (size_t c = 0; c < row.steps.size(); c++){ + const StoryStep& step = row.steps[c]; + if (step.kind != StoryStepKind::STEP || step.variant != TRAINER_LINE_VARIANT || clean.scripted.contains(c)){ + continue; + } + const int tiles = line_tiles_before_last(row, clean, c); + for (int battle_step = 1; battle_step <= tiles; battle_step++){ + variants++; + if (const std::string stopped = line_with_battle(row, clean, c, battle_step); !stopped.empty()){ + failures.push_back({step.name, std::format("a wild battle on step {}", battle_step), stopped}); + } + } + } + return sweep_result(logger, FaultClass::LOST_TAP, row.id, variants, failures); +} + // A fault class swept over row `row` (empty in FAULT_SWEEP_CASES: each // row with a start). struct FaultSweepInput{ @@ -442,7 +507,7 @@ class Test_AutoStoryFaultSweep : public UnitTest{ case FaultClass::DARK_CAVE: return sweep_dark_cave(logger, m_row, clean); case FaultClass::LOST_TAP: - break; + return sweep_lost_tap(logger, m_row, clean); } return true; } @@ -459,6 +524,7 @@ const FaultSweepCase FAULT_SWEEP_CASES[] = { {.input = {.fault = FaultClass::WILD_BATTLES}, .expectation = {}}, {.input = {.fault = FaultClass::LATE_FADE}, .expectation = {}}, {.input = {.fault = FaultClass::DARK_CAVE}, .expectation = {}}, + {.input = {.fault = FaultClass::LOST_TAP}, .expectation = {}}, }; void add_tests_AutoStoryFaultSweep(UnitTestDatabase& database){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp index 73b5f010be..28a8a12bb1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryLavender_Tests.cpp @@ -242,6 +242,33 @@ UnitTestResult test_b1f_martha_after_row38_battle(Logger&){ return true; } +// Regression: console B, 2026-11-09 15:50, 00.28 winston_line, dark: from +// (32, 37) west into Winston's line, a wild battle on the 2nd tap's tile +// (30, 37), run from; the 3rd tap, sent as the battle's black ended, was +// lost, and the step failed no_trainer. After the battle the next tap +// waits for the overworld (wait_out_battle_exit()): Winston sees the +// player on (29, 37). +UnitTestResult test_winston_line_battle_on_tap_2(Logger&){ + const StoryCheckpoint& row = checkpoint("00.28"); + SimScript script; + script.dark_maps = {StoryTable::ROCK_TUNNEL_1F, StoryTable::ROCK_TUNNEL_B1F}; + script.wild_floor_maps = {StoryTable::ROCK_TUNNEL_B1F}; + script.encounters = {SimEncounter{.grass_step = 2}}; + StoryPose at{StoryTable::ROCK_TUNNEL_B1F, {32, 37}, Direction::WEST}; + OverworldSim sim(maps_after_route10(), MapPosition{at.map, at.tile}, Direction::WEST, script); + const std::string failure = row_step_on_sim(sim, at, row.steps[step_index(row, "winston_line")], row.id); + const auto lost = std::find_if(sim.state().log.begin(), sim.state().log.end(), [](const std::string& line){ + return line.find("in the lockout after a battle") != std::string::npos; + }); + if (!failure.empty() || lost != sim.state().log.end() || sim.state().wild_battles != 1){ + return std::format( + "winston_line: ended on {}, {} wild battle(s), {}; {}", to_string(sim.state().position), + sim.state().wild_battles, lost == sim.state().log.end() ? "no tap lost" : *lost, failure + ); + } + return true; +} + // Rock Tunnel has no Flash: both floors are dark. const std::set ROCK_TUNNEL_DARK{StoryTable::ROCK_TUNNEL_1F, StoryTable::ROCK_TUNNEL_B1F}; @@ -559,6 +586,7 @@ const AutoStoryLavenderCase AUTO_STORY_LAVENDER_CASES[] = { {.input = {"00.28 B1F a wild battle into row 38, then a step short: Martha still sees the player", test_b1f_martha_after_row38_battle}, .expectation = {}}, {.input = {"00.28 B1F dark, the 13:35 legs: a battle at the end of the walk back along row 38", test_b1f_dark_row38_battle_old_legs}, .expectation = {}}, {.input = {"00.28 B1F dark, a wild battle on any step to Martha's line", test_b1f_dark_martha_battle_anywhere}, .expectation = {}}, + {.input = {"00.28 B1F dark, winston_line: a wild battle on the 2nd tap, the 3rd after it", test_winston_line_battle_on_tap_2}, .expectation = {}}, {.input = {"00.29 Route 10 south, the ledge, Lavender Town's Pokemon Center", test_lavender_row}, .expectation = {}}, {.input = {"00.29 a tile off after each open leg", test_lavender_tile_off}, .expectation = {}}, {.input = {"00.28 and 00.29 save on Route 10 and in Lavender Town", test_save_places}, .expectation = {}}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index c39aac361c..ff86b55359 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -433,6 +433,23 @@ std::string battle_end_on_sim(const OverworldSim& sim){ return "fight_battle(): START never looked for the overworld."; } +// wait_out_battle_exit() on `sim`: each look shows the sim's screens for +// BETWEEN_STEPS_LOOK. +void sim_wait_out_battle_exit(OverworldSim& sim){ + Milliseconds from = sim.state().time; + const auto look = [&sim, &from](bool fresh){ + if (fresh){ + from = sim.state().time; + } + sim.watch({}, BETWEEN_STEPS_LOOK); + return Pokemon::ScreenLook{ + .log = screen_event_log(sim.state(), from), + .window = {.start = sim_wall_clock(from), .end = sim_wall_clock(sim.state().time)}, + }; + }; + wait_for_quiet_overworld(look, MovementMechanics::AFTER_BATTLE_QUIET, BATTLE_EXIT_TIMEOUT); +} + } // namespace // A ROUTE or LEGS by the sim's walker, fighting a trainer who sees the @@ -469,6 +486,7 @@ std::string OverworldStepSim::step_ahead(const StoryStep& step){ ); if (shown && shown->watcher == 1){ m_sim.handle_battle(SimBattleChoice::RUN); + sim_wait_out_battle_exit(m_sim); return false; } return shown.has_value(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.cpp index e8774ec4af..129905cbbd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.cpp @@ -98,6 +98,29 @@ Pokemon::ScreenLook ConsoleScreenLook::look(bool fresh){ } +bool quiet_overworld(const Pokemon::ScreenSettle& settle, Milliseconds quiet){ + return settle.seen && settle.showing == Pokemon::ScreenEvents{} && settle.quiet >= quiet; +} + +Pokemon::ScreenSettle wait_for_quiet_overworld( + const Pokemon::ScreenLookFunction& look, Milliseconds quiet, Milliseconds timeout +){ + Pokemon::ScreenLook seen = look(true); + Pokemon::ScreenSettle settle = Pokemon::judge_settle(seen); + // Each look takes about BETWEEN_STEPS_LOOK; the cap also ends a look + // that takes no time. + const size_t max_looks = (size_t)(timeout / BETWEEN_STEPS_LOOK) + 1; + for (size_t looks = 1; looks < max_looks && !quiet_overworld(settle, quiet); looks++){ + if (seen.window.end - seen.window.start >= timeout){ + break; + } + seen = look(false); + settle = Pokemon::judge_settle(seen); + } + return settle; +} + + void InterruptLogGuard::on_interrupt(const Pokemon::StoryStep& step, const Pokemon::InterruptCheck& check){ const bool cleared = check.outcome.succeeded(); m_logger.log(std::format( diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.h index d1d3e5d32a..454ad88000 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepInterrupts.h @@ -94,6 +94,20 @@ class ConsoleScreenLook{ WallClock m_start; }; +// Whether a look ended on FRLG's overworld at rest: nothing showing (no +// box, fade or battle; FrlgScreenObserver reports no PLAYER_CAN_MOVE, so +// Pokemon::is_settled() never holds) for at least `quiet`. +bool quiet_overworld(const Pokemon::ScreenSettle& settle, Milliseconds quiet); + +// Looks with `look`, fresh and then on from it, until it ends on +// quiet_overworld() for `quiet`, or has looked for `timeout`. Returns how +// the last look ended. After a battle, the next tap waits for this +// (MovementMechanics::AFTER_BATTLE_QUIET): one sent at its black's end +// is lost (BATTLE_EXIT_LOCKOUT). +Pokemon::ScreenSettle wait_for_quiet_overworld( + const Pokemon::ScreenLookFunction& look, Milliseconds quiet, Milliseconds timeout +); + // The console's guard: a failed step fails it, as before; each interrupt // found between steps is logged, cleared or not, and how the screen looked // before every step. From 0a1ab58aeff94295f0b28ebd96fb183d650adca2 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 18:35:31 -0800 Subject: [PATCH 23/24] FRLG maps: adopt PokemonSimulation e52be04; spin, hole and button glyphs The FRLG map resource lock moves to PokemonSimulation e52be04 (dayfine/PokemonSimulation#83): all 152 maps regenerated, every pret warp and connection linked, elevation grids back on 14 water maps, Vermilion Gym's beams an overlay. Arduino still reads runtime/, the effective maps. The map glyph table gains the tile legend's u d l r o H B (spin tiles, the spin stop, fall holes and Strength switches), each a TileKind of its own; no loaded map uses them yet. MAP_NAMES gains Route 8, the east-west Underground Path and Route 7 to Celadon City for 00.30. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Pokemon/Navigation/Pokemon_MapData.cpp | 7 +++++ .../Pokemon/Navigation/Pokemon_MapData.h | 7 +++++ .../Navigation/Pokemon_MapData_Tests.cpp | 27 ++++++++++++++++++- .../Overworld/PokemonFRLG_OverworldMaps.cpp | 8 ++++++ devtools/maps/map-resources.lock.json | 4 +-- 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.cpp index 087680cb09..1e0b488e6a 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.cpp @@ -36,6 +36,13 @@ constexpr std::pair GLYPHS[] = { {'2', TileKind::CLOSED_SOUTH}, {'4', TileKind::CLOSED_WEST}, {'6', TileKind::CLOSED_EAST}, + {'u', TileKind::SPIN_NORTH}, + {'d', TileKind::SPIN_SOUTH}, + {'l', TileKind::SPIN_WEST}, + {'r', TileKind::SPIN_EAST}, + {'o', TileKind::STOP_SPIN}, + {'H', TileKind::FALL_HOLE}, + {'B', TileKind::STRENGTH_BUTTON}, }; constexpr std::pair WARP_ENTRIES[] = { diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.h index a5adcea1b9..bb860467c0 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData.h @@ -69,6 +69,13 @@ enum class TileKind : uint8_t{ CLOSED_SOUTH, // '2' not entered from it, nor left through it (the CLOSED_WEST, // '4' number pad's arrows) CLOSED_EAST, // '6' + SPIN_NORTH, // 'u' open; stepping on it slides the player north + SPIN_SOUTH, // 'd' (Rocket Hideout and Viridian Gym spin tiles) + SPIN_WEST, // 'l' + SPIN_EAST, // 'r' + STOP_SPIN, // 'o' open; where a spin's slide stops + FALL_HOLE, // 'H' open; a hole that drops the player a floor + STRENGTH_BUTTON,// 'B' open; Victory Road's switch for a boulder }; // The tile a glyph stands for, or nothing if it isn't one. std::optional tile_from_glyph(char glyph); diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData_Tests.cpp index 554ec84c3e..e745d28bd8 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData_Tests.cpp +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapData_Tests.cpp @@ -181,7 +181,7 @@ UnitTestResult test_lists_may_be_left_out(){ } UnitTestResult test_glyphs_round_trip(){ - for (char glyph : std::string("#.~wv^<>DS 8246")){ + for (char glyph : std::string("#.~wv^<>DS 8246udlroHB")){ const std::optional tile = tile_from_glyph(glyph); if (!tile || glyph_of(*tile) != glyph){ return std::string("Glyph '") + glyph + "' does not round-trip."; @@ -193,6 +193,27 @@ UnitTestResult test_glyphs_round_trip(){ return true; } +// PokemonSimulation's tile-legend.json glyphs for forced movement, holes and +// boulder switches, in a map's rows. +UnitTestResult test_spin_hole_and_button_glyphs_parse(){ + const std::string json = R"({ + "name": "SpinRoom", + "source": {"decomp_file": "f", "revision": "r", "date": "d"}, + "tiles": ["udlroHB"] +})"; + const MapData map = parse_map(parse_json(json), "SpinRoom.json"); + constexpr TileKind EXPECTED[] = { + TileKind::SPIN_NORTH, TileKind::SPIN_SOUTH, TileKind::SPIN_WEST, TileKind::SPIN_EAST, + TileKind::STOP_SPIN, TileKind::FALL_HOLE, TileKind::STRENGTH_BUTTON, + }; + for (int x = 0; x < (int)std::size(EXPECTED); x++){ + if (map.tiles.at({x, 0}) != EXPECTED[x]){ + return std::format("Tile ({}, 0) read '{}'.", x, glyph_of(map.tiles.at({x, 0}))); + } + } + return true; +} + struct BadMap{ const char* name; std::string json; @@ -484,6 +505,10 @@ const MapDataTestCase MAP_DATA_CASES[] = { .input = {.name = "glyphs round-trip", .test = test_glyphs_round_trip}, .expectation = {.description = "converts between tile characters and enum kinds"}, }, + { + .input = {.name = "spin, hole and button glyphs", .test = test_spin_hole_and_button_glyphs_parse}, + .expectation = {.description = "parses u d l r o H B as the tile legend's spin, stop, hole and switch tiles"}, + }, { .input = {.name = "each error class", .test = test_each_error_class}, .expectation = {.description = "rejects invalid JSON syntax and malformed map schemas"}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp index 35934b78c3..4d83c0db45 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -83,6 +83,14 @@ const char* const MAP_NAMES[] = { "RockTunnel_B1F", "LavenderTown", "LavenderTown_PokemonCenter_1F", + // West from Lavender: Route 8, the Underground Path under Saffron and + // Route 7 to Celadon City. + "Route8", + "UndergroundPath_EastEntrance", + "UndergroundPath_EastWestTunnel", + "UndergroundPath_WestEntrance", + "Route7", + "CeladonCity", "CeruleanCave_1F", "CeruleanCave_2F", "CeruleanCave_B1F", diff --git a/devtools/maps/map-resources.lock.json b/devtools/maps/map-resources.lock.json index b608cb31ff..1f1e400006 100644 --- a/devtools/maps/map-resources.lock.json +++ b/devtools/maps/map-resources.lock.json @@ -3,9 +3,9 @@ "repository": "dayfine/PokemonSimulation", "games": { "frlg": { - "revision": "6c2f5c85534186cd9d52af4f844b9847cfd0f33a", + "revision": "e52be04ed7bb007c7219c7d2c96963cf053e0000", "manifest_path": "data/maps/runtime-manifest.json", - "manifest_sha256": "ebcb98381d508de0fb7fa256605b22a754cf83b7f054c83dd954fb019420d096" + "manifest_sha256": "1ccdf3c4e5b8abc90f13e80b6dc8fa429c20f641a018e0e11368930f16165d10" } } } From 6aa5a9bc02bc267b29162566a8b981d7cba6cc5a Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 18:58:11 -0800 Subject: [PATCH 24/24] FRLG replay: refresh the Parcel fixture for PokemonSimulation bdca47c The map lock moved to e52be04's regenerated maps, but the copied Parcel replay binding still pinned five of them at 158b007, so StoryReplay failed with "embedded map hash mismatch". PokemonSimulation #85 repins that binding; copy it, update the binding/candidate hashes and fixture revision in pin.json and CI, and move the map lock to the same commit (identical manifest). The regenerated route candidate has the same actions; only its binding hash moves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .github/workflows/cpp-ci-serial-programs-base.yml | 2 +- .../Programs/AutoStory/ReplayData/binding.json | 12 ++++++------ .../Programs/AutoStory/ReplayData/pin.json | 6 +++--- .../AutoStory/ReplayData/route-candidate.json | 2 +- devtools/maps/map-resources.lock.json | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/cpp-ci-serial-programs-base.yml b/.github/workflows/cpp-ci-serial-programs-base.yml index 1601ddaa9a..48e45d1d1c 100644 --- a/.github/workflows/cpp-ci-serial-programs-base.yml +++ b/.github/workflows/cpp-ci-serial-programs-base.yml @@ -106,7 +106,7 @@ jobs: uses: actions/checkout@v7 with: repository: dayfine/PokemonSimulation - ref: 9cb12c52f5f055299e32c2a7cf43e7103c101e3e + ref: bdca47c5045797be1d2a1f0931ad6a8f15ab3225 path: StoryReplaySource - name: Verify replay preview bytes and independently regenerate its route input diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json index b53d5e1259..067fa631d3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json @@ -5,13 +5,13 @@ "sha256": "8b0c04f2e1507aee0f0927a8d022b8b431036431e95f8893aa7a2383360f8504", "source_revision": "b170b7d2103e887ded825c37fdbdc19fd654fca8" }, - "map_revision": "158b007ecd14efb5776ebdadc530c1a807ef3541", + "map_revision": "e52be04ed7bb007c7219c7d2c96963cf053e0000", "maps": { - "PalletTown_ProfessorOaksLab": "1dedda88f1238eccd6610cbff79c1e5d3cd21b516637b792654746e02b2cdbc8", - "PalletTown": "0dc7446f568097b36a9479954f01afe546cb64c82bb0efa91da0cb66ad950f9f", - "Route1": "78bf43c8bdf7a0c5dcb22880261626abc36d068c0dfbbc242056e12401c44033", - "ViridianCity": "6a671a0a0b06f78f566b89646f6b0f00c91c240a36300dff861f970455f99909", - "ViridianCity_Mart": "1b2b23f8f9a1b1fbe266f12bdac96cdf649d974b057e84bf9cba6befbaa15b3b" + "PalletTown_ProfessorOaksLab": "53d1c2b022cfadd2463fe405a60fb179562b2cc487ab4c5202f63cbc758d5109", + "PalletTown": "92ebe424c07773ba087b8fc8d2b8251876dd3b97b9aea47a1fee8d36abd6ab0c", + "Route1": "26ba91c2ecfdf0af8ccbdca9f21a522b68f93565594386c09af1011485de7781", + "ViridianCity": "c18f797d9fe5edf8fceb86f5e439778b44f78cf4397cd6b566f3ff7246c2d8cb", + "ViridianCity_Mart": "5e14587080521d433626347fa37024eb1ee36b4fe07a164e19e72714b1b755e8" }, "capabilities": { "below_mart_door": {"capability": "frlg-runtime-route/1", "parameters": {}}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json index deeca459f8..1289d24a91 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json @@ -1,12 +1,12 @@ { "format": "pokesim-native-replay-preview/1", "repository": "dayfine/PokemonSimulation", - "revision": "9cb12c52f5f055299e32c2a7cf43e7103c101e3e", + "revision": "bdca47c5045797be1d2a1f0931ad6a8f15ab3225", "status": "preview-unmerged-PR-73", "files": { "binding": { "path": "data/story/parcel-pilot/binding.json", - "sha256": "5de39f53f63a9fd6319b4afd2fa85d0b0c468da7e94161fe4ce22bb939bb846e" + "sha256": "ce91aba96599f443910dc76a985b97a45b8a76bcbbb1c9824cd247a627d946f4" }, "cases": { "path": "data/story/parcel-pilot/cases.json", @@ -18,7 +18,7 @@ } }, "candidate": { - "sha256": "1eeb9d6ba22362ae1854335aa914ed4e3cabad64403628beb430fe8822cca847", + "sha256": "78219878fedc2c531a4b0faabbe215193ebc7e842dee0237ff52d296a6d614ac", "producer": "checkpoint_replay.run actual route actions; expectations are not read" } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json index c253702c48..2ce8d3ce63 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json @@ -1,6 +1,6 @@ { "format": "pokesim-route-candidate/1", - "binding_sha256": "5de39f53f63a9fd6319b4afd2fa85d0b0c468da7e94161fe4ce22bb939bb846e", + "binding_sha256": "ce91aba96599f443910dc76a985b97a45b8a76bcbbb1c9824cd247a627d946f4", "checkpoint": "00.05", "routes": { "below_mart_door": [ diff --git a/devtools/maps/map-resources.lock.json b/devtools/maps/map-resources.lock.json index 1f1e400006..a446a338aa 100644 --- a/devtools/maps/map-resources.lock.json +++ b/devtools/maps/map-resources.lock.json @@ -3,7 +3,7 @@ "repository": "dayfine/PokemonSimulation", "games": { "frlg": { - "revision": "e52be04ed7bb007c7219c7d2c96963cf053e0000", + "revision": "bdca47c5045797be1d2a1f0931ad6a8f15ab3225", "manifest_path": "data/maps/runtime-manifest.json", "manifest_sha256": "1ccdf3c4e5b8abc90f13e80b6dc8fa429c20f641a018e0e11368930f16165d10" }