From 174f751484991213452767ea0f834e134843151f Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 14:49:56 -0800 Subject: [PATCH 01/21] FRLG AutoStory sim tests: a row's steps on the sim in a file of their own walked_route() and row_step_on_sim() move unchanged out of the simulation tests, which are near the length limit, so the next stage's row tests can use them from a file of their own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 76 +++++++++++++++++++ .../AutoStory/PokemonFRLG_AutoStoryRowSim.h | 44 +++++++++++ .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 55 +------------- SerialPrograms/cmake/SourceFiles.cmake | 2 + 4 files changed, 123 insertions(+), 54 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp new file mode 100644 index 0000000000..2c9cf83899 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -0,0 +1,76 @@ +/* AutoStory Row Sim + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryRowSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The route the program walks for `step`, a ROUTE or LEGS, from `at`: +// planned, or the legs laid out. +std::optional> walked_route(const StoryPose& at, const StoryStep& step){ + const MapPosition start{at.map, at.tile}; + if (step.kind == StoryStepKind::LEGS){ + return fixed_route(story_maps(), start, step.legs); + } + const std::optional target = story_pose(*step.target); + const RouteGoal goal{{target->map, target->tile}, target->facing}; + return plan_overworld_route(story_maps(), start, goal, default_route_costs(), at.facing); +} + +// Runs `step` of row `id` on `sim` as the program does: routes and legs +// by the sim's walker from `at`, where the table says the player stands, +// fighting a trainer who sees the player on the way; a step one tap at a +// time, or for a step into a trainer's line by step_until_seen(), each +// watched for the trainer; a battle from the trainer's message, which +// must be up. Moves `at` to where the table says the step ends. Returns +// the failure, or empty. +std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id){ + const std::string where = id + "/" + step.name + ": "; + const auto on = [&sim]{ return " On " + to_string(sim.state().position) + "."; }; + if (step.kind == StoryStepKind::LEGS || step.kind == StoryStepKind::ROUTE){ + const std::optional> route = walked_route(at, step); + const RoutePlanning planning{.maps = &story_maps(), .start = MapPosition{at.map, at.tile}, .label = step.name}; + const std::string failure = route ? OverworldSimWalker(sim).run(*route, planning) : "no legs."; + at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : at; + return failure.empty() ? "" : where + failure + on(); + } + if (step.kind == StoryStepKind::STEP){ + const auto tap = [&sim, &step]{ + if (sim.state().facing != step.direction){ + sim.tap(step.direction); + } + sim.tap(step.direction); + }; + const auto seen = [&sim]{ + return sim.watch({SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, TRAINER_LINE_WATCH).has_value(); + }; + const int tiles = step.variant == TRAINER_LINE_VARIANT ? TRAINER_LINE_TILES : 1; + return step_until_seen(tiles, tap, seen) ? "" : where + "no trainer saw the player." + on(); + } + if (step.kind == StoryStepKind::BATTLE){ + if (!sim.close_message() || sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ + return where + "no_battle." + on(); + } + at = *story_pose(*step.pose_after); + if (!(sim.state().position == MapPosition{at.map, at.tile})){ + return where + "expected " + to_string(MapPosition{at.map, at.tile}) + "." + on(); + } + } + return ""; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h new file mode 100644 index 0000000000..4b3be720d0 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h @@ -0,0 +1,44 @@ +/* AutoStory Row Sim + * + * From: https://github.com/PokemonAutomation/ + * + * A story row's steps on the OverworldSim, for the simulation tests of + * each stage's rows: the walks as the program plans or lays them out, + * the steps into trainers' lines and their battles. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryRowSim_H +#define PokemonAutomation_PokemonFRLG_AutoStoryRowSim_H + +#include +#include +#include +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryEvents.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The route the program walks for `step`, a ROUTE or LEGS, from `at`: +// planned, or the legs laid out. +std::optional> walked_route(const StoryPose& at, const StoryStep& step); + +// Runs `step` of row `id` on `sim` as the program does: routes and legs +// by the sim's walker from `at`, where the table says the player stands, +// fighting a trainer who sees the player on the way; a step one tap at a +// time, or for a step into a trainer's line by step_until_seen(), each +// watched for the trainer; a battle from the trainer's message, which +// must be up. Moves `at` to where the table says the step ends. Returns +// the failure, or empty. +std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id); + + + +} +} +} +#endif 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 c53027b24b..17916919f9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -32,6 +32,7 @@ #include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryPaceSim.h" +#include "PokemonFRLG_AutoStoryRowSim.h" #include "PokemonFRLG_AutoStoryTable.h" namespace PokemonAutomation{ @@ -419,18 +420,6 @@ UnitTestResult test_gives_up_after_attempts(){ return true; } -// The route the program walks for `step`, a ROUTE or LEGS, from `at`: -// planned, or the legs laid out. -std::optional> walked_route(const StoryPose& at, const StoryStep& step){ - const MapPosition start{at.map, at.tile}; - if (step.kind == StoryStepKind::LEGS){ - return fixed_route(story_maps(), start, step.legs); - } - const std::optional target = story_pose(*step.target); - const RouteGoal goal{{target->map, target->tile}, target->facing}; - return plan_overworld_route(story_maps(), start, goal, default_route_costs(), at.facing); -} - // Walks `step` of 00.14 on `sim` from `at`, where the table says the // player stands, and moves `at` to where it says the step ends. Returns // the failure, or empty. @@ -497,48 +486,6 @@ UnitTestResult test_cerulean_entry_short(){ return true; } -// Runs `step` of row `id` on `sim` as the program does: routes and legs -// by the sim's walker from `at`, where the table says the player stands, -// fighting a trainer who sees the player on the way; a step one tap at a -// time, or for a step into a trainer's line by step_until_seen(), each -// watched for the trainer; a battle from the trainer's message, which -// must be up. Moves `at` to where the table says the step ends. Returns -// the failure, or empty. -std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id){ - const std::string where = id + "/" + step.name + ": "; - const auto on = [&sim]{ return " On " + to_string(sim.state().position) + "."; }; - if (step.kind == StoryStepKind::LEGS || step.kind == StoryStepKind::ROUTE){ - const std::optional> route = walked_route(at, step); - const RoutePlanning planning{.maps = &story_maps(), .start = MapPosition{at.map, at.tile}, .label = step.name}; - const std::string failure = route ? OverworldSimWalker(sim).run(*route, planning) : "no legs."; - at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : at; - return failure.empty() ? "" : where + failure + on(); - } - if (step.kind == StoryStepKind::STEP){ - const auto tap = [&sim, &step]{ - if (sim.state().facing != step.direction){ - sim.tap(step.direction); - } - sim.tap(step.direction); - }; - const auto seen = [&sim]{ - return sim.watch({SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, TRAINER_LINE_WATCH).has_value(); - }; - const int tiles = step.variant == TRAINER_LINE_VARIANT ? TRAINER_LINE_TILES : 1; - return step_until_seen(tiles, tap, seen) ? "" : where + "no trainer saw the player." + on(); - } - if (step.kind == StoryStepKind::BATTLE){ - if (!sim.close_message() || sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ - return where + "no_battle." + on(); - } - at = *story_pose(*step.pose_after); - if (!(sim.state().position == MapPosition{at.map, at.tile})){ - return where + "expected " + to_string(MapPosition{at.map, at.tile}) + "." + on(); - } - } - return ""; -} - // Console B, 2026-11-03 (row 00.16), twice: the open leg north from // Cerulean City onto Nugget Bridge ended on (11, 33), a tile short of // (11, 32), and the one step onto Cale's row reached only (11, 32), out diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 78f1daf405..13a962f0bf 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1896,6 +1896,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp From 4c1eebf1218fb8cda83a5a9e7ccbe26e3396b4be Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 15:07:54 -0800 Subject: [PATCH 02/21] FRLG AutoStory 00.21 and 00.22: Route 6 to Vermilion, the S.S. Anne 00.21 goes from Cerulean's Pokemon Center through the burgled house, fights the Rocket in its yard (TM28), then down Route 5, through the Underground Path and Route 6 to Vermilion's Pokemon Center. On Route 6 it walks down column 15 through the grass, on legs to walls, out of Keigo's and Elijah's lines, and fights Jeff, who stands across the only way south. 00.22 shows the S.S. Ticket at the harbor, goes aboard, fights the rival below the Captain's door, gets HM01 from the Captain and walks off the ship as it sails, back to the Center. The maps: the burgled house, Route 5, the Underground Path, Route 6, Vermilion City and its Center, and the S.S. Anne's exterior, 1F and 2F corridors and Captain's office. The generator now reads a tileset's attributes path from metatiles.h (gTileset_SSAnne's folder is ss_anne), and a "new_game" flag set hides the S.S. Anne's rival, who stands on the Captain's door until his scene. Story routes no longer plan Surf: the harbor's water was the shorter way to the pier. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Menus/PokemonFRLG_SaveCardReader.cpp | 1 + .../Menus/PokemonFRLG_SaveCardReader.h | 4 +- .../Overworld/Maps/CeruleanCity.json | 21 +- .../Overworld/Maps/CeruleanCity_House2.json | 76 ++++++ .../PokemonFRLG/Overworld/Maps/Route5.json | 86 +++++++ .../PokemonFRLG/Overworld/Maps/Route6.json | 176 +++++++++++++ .../Overworld/Maps/SSAnne_1F_Corridor.json | 148 +++++++++++ .../Overworld/Maps/SSAnne_2F_Corridor.json | 108 ++++++++ .../Overworld/Maps/SSAnne_CaptainsOffice.json | 42 +++ .../Overworld/Maps/SSAnne_Exterior.json | 129 ++++++++++ .../Maps/UndergroundPath_NorthEntrance.json | 64 +++++ .../UndergroundPath_NorthSouthTunnel.json | 96 +++++++ .../Maps/UndergroundPath_SouthEntrance.json | 64 +++++ .../Overworld/Maps/VermilionCity.json | 240 ++++++++++++++++++ .../Maps/VermilionCity_PokemonCenter_1F.json | 125 +++++++++ .../Overworld/PokemonFRLG_OverworldMaps.cpp | 15 ++ .../Overworld/PokemonFRLG_OverworldMaps.h | 6 +- .../PokemonFRLG_OverworldMaps_Tests.cpp | 52 ++++ .../Overworld/PokemonFRLG_OverworldRoute.cpp | 2 +- .../Overworld/PokemonFRLG_OverworldRoute.h | 2 +- .../PokemonFRLG/PokemonFRLG_Tests.cpp | 1 + .../PokemonFRLG_AutoStoryBattleData.cpp | 5 + .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 23 +- .../AutoStory/PokemonFRLG_AutoStoryEvents.h | 14 +- .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 4 +- .../PokemonFRLG_AutoStoryRunPlan_Tests.cpp | 8 +- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 2 +- .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 88 ++++++- .../AutoStory/PokemonFRLG_AutoStoryTable.h | 9 +- .../PokemonFRLG_AutoStoryVermilion_Tests.cpp | 212 ++++++++++++++++ .../AutoStory/PokemonFRLG_AutoStory_Tests.cpp | 22 +- SerialPrograms/cmake/SourceFiles.cmake | 1 + devtools/maps/frlg_maps.py | 26 +- devtools/maps/tests/test_frlg_maps.py | 21 ++ 34 files changed, 1851 insertions(+), 42 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_House2.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route5.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route6.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_1F_Corridor.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_2F_Corridor.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_CaptainsOffice.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_Exterior.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthEntrance.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthSouthTunnel.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_SouthEntrance.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_PokemonCenter_1F.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilion_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp index fd2db94734..f1affe3765 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp @@ -73,6 +73,7 @@ class PlaceNameMatcher : public OCR::SmallDictionaryMatcher{ {"cerulean-city", "Cerulean City"}, {"route-24", "Route 24"}, {"route-25", "Route 25"}, + {"vermilion-city", "Vermilion City"}, }){ JsonArray names; names.push_back(name); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h index d165670409..0b19e50624 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h @@ -33,8 +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-24", "route-25"; empty if it can't be read, as in a language the -// reader has no names for. +// "route-24", "route-25", "vermilion-city"; empty if it can't be read, as +// in a language the reader has no names for. std::string read_save_card_location(Logger& logger, Language language, const ImageViewRGB32& screen); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json index 51f0256ec9..9334a3aa83 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json @@ -90,10 +90,12 @@ "000000000000000333303333333303333000000000000000" ], "points": { + "warp_1": [30, 11], "warp_3": [22, 19], "warp_4": [31, 21], "warp_6": [29, 28], - "warp_7": [1, 12] + "warp_7": [1, 12], + "warp_9": [31, 8] }, "warps": [ { @@ -104,7 +106,9 @@ { "x": 30, "y": 11, - "enter": "walk_into" + "enter": "walk_into", + "to_map": "CeruleanCity_House2", + "to_point": "warp_1" }, { "x": 15, @@ -152,12 +156,16 @@ { "x": 31, "y": 8, - "enter": "walk_south" + "enter": "walk_south", + "to_map": "CeruleanCity_House2", + "to_point": "warp_3" }, { "x": 31, "y": 9, - "enter": "walk_into" + "enter": "walk_into", + "to_map": "CeruleanCity_House2", + "to_point": "warp_3" }, { "x": 14, @@ -272,6 +280,11 @@ "to_map": "Route24", "offset": 12 }, + { + "side": "south", + "to_map": "Route5", + "offset": 0 + }, { "side": "west", "to_map": "Route4", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_House2.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_House2.json new file mode 100644 index 0000000000..2abad6df50 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_House2.json @@ -0,0 +1,76 @@ +{ + "name": "CeruleanCity_House2", + "source": { + "decomp_file": "data/maps/CeruleanCity_House2/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "##########", + "####.#####", + "........##", + ".........#", + "...##.....", + "...##.....", + "..........", + "#.......##", + "##########" + ], + "points": { + "warp_1": [3, 7], + "warp_3": [4, 1] + }, + "warps": [ + { + "x": 2, + "y": 7, + "enter": "walk_into", + "to_map": "CeruleanCity", + "to_point": "warp_1" + }, + { + "x": 3, + "y": 7, + "enter": "walk_south", + "to_map": "CeruleanCity", + "to_point": "warp_1" + }, + { + "x": 4, + "y": 7, + "enter": "walk_into", + "to_map": "CeruleanCity", + "to_point": "warp_1" + }, + { + "x": 4, + "y": 1, + "enter": "cave_entrance", + "to_map": "CeruleanCity", + "to_point": "warp_9" + } + ], + "objects": [ + { + "name": "hiker", + "kind": "npc", + "x": 1, + "y": 2 + }, + { + "name": "lass", + "kind": "npc", + "x": 7, + "y": 6, + "wander": { + "x0": 6, + "y0": 5, + "x1": 8, + "y1": 7 + } + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route5.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route5.json new file mode 100644 index 0000000000..1b4d929c1d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route5.json @@ -0,0 +1,86 @@ +{ + "name": "Route5", + "source": { + "decomp_file": "data/maps/Route5/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#vvvvvvvv#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#vvvvvvvv#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#vvvvvvvv#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#vvvvvvvv#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#.#####..#....###############", + "###############....#.#####..#....###############", + "###############....#.#####..#....###############", + "###############....#.##D##..#....###############", + "###############....#........#....###############", + "###############....vvvvvvvvvv....###############", + "###############..................###############", + "###############...............##################", + "###############...............##################", + "###############...............#D################", + "###############.......#....#....S###############", + "###############.......######.....###############", + "################################################", + "##############........######......##############", + "################################################", + "################################################", + "########################..######################", + "#######################....#####################" + ], + "points": { + "warp_0": [31, 31] + }, + "warps": [ + { + "x": 31, + "y": 31, + "enter": "walk_into", + "to_map": "UndergroundPath_NorthEntrance", + "to_point": "warp_1" + }, + { + "x": 23, + "y": 25, + "enter": "walk_into" + }, + { + "x": 24, + "y": 32, + "enter": "walk_south" + }, + { + "x": 25, + "y": 32, + "enter": "walk_south" + } + ], + "objects": [], + "flag_sets": {}, + "connections": [ + { + "side": "north", + "to_map": "CeruleanCity", + "offset": 0 + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route6.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route6.json new file mode 100644 index 0000000000..4ce8f0cda2 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route6.json @@ -0,0 +1,176 @@ +{ + "name": "Route6", + "source": { + "decomp_file": "data/maps/Route6/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "########################", + "########################", + "########################", + "########################", + "########################", + "##.......###..###.....##", + "##.......#......#.....##", + "##vvv.vvvv......vvv.vv##", + "##....................##", + "##....................##", + "##....................##", + "##vvvvvvv.vvvvvvvv######", + "##................######", + "##................#D####", + "##....................##", + "##~~~~...~~~~~~......S##", + "##~~~~...~~~~~~~~~~~~~##", + "##~~~~...~~~~~~~~~~~~~##", + "##~~~~...~~~~~~~~~~~~~##", + "##~~~~..............~~##", + "##~~~~..............~~##", + "##~~~~..............~~##", + "##~~~~~~~~~~~~~~~...~~##", + "##wwwwwwwwwwwww~~...~~##", + "##wwwwwwwwwwwww~~...~~##", + "##wwwwwwwwwwwww~~...~~##", + "##wwwwwwwwwwwww~~...~~##", + "##..................~~##", + "##..................~~##", + "##..................~~##", + "#######......~~~~#######", + "##....#......~~~~#....##", + "#######......~~~~#######", + "#######......~~~~#######", + "##########...###########", + "######...#...#....######", + "########.#...###########", + "########.#...###########", + "wwwwwwwww#...###########", + "wwwwwwwww#...###########" + ], + "elevation": [ + "000000000000000000000000", + "000000000000000000000000", + "000000000000000000000000", + "000000000000000000000000", + "000000000000000000000000", + "003333333000330003333300", + "003333333033333303333300", + "000003000033333300030000", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "000000000300000000000000", + "003333333333333333000000", + "003333333333333333000000", + "003333333333333333333300", + "003333333333333333333000", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "001111111111111333333300", + "001111111111111333333300", + "001111111111111333333300", + "001111111111111333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "000000033333333330000000", + "003333033333333330333300", + "000000033333333330000000", + "000000033333333330000000", + "000000000033300000000000", + "000000333033303333000000", + "000000003033300000000000", + "000000003033300000000000", + "111111111033300000000000", + "000000001033300000000000" + ], + "points": { + "warp_0": [19, 13] + }, + "warps": [ + { + "x": 19, + "y": 13, + "enter": "walk_into", + "to_map": "UndergroundPath_SouthEntrance", + "to_point": "warp_1" + }, + { + "x": 12, + "y": 5, + "enter": "cave_entrance" + }, + { + "x": 13, + "y": 5, + "enter": "cave_entrance" + } + ], + "objects": [ + { + "name": "bug_catcher", + "kind": "npc", + "x": 3, + "y": 16, + "facing": "east", + "trainer": { + "sight": 5 + } + }, + { + "name": "camper", + "kind": "npc", + "x": 12, + "y": 21 + }, + { + "name": "picnicker", + "kind": "npc", + "x": 13, + "y": 21 + }, + { + "name": "bug_catcher", + "kind": "npc", + "x": 20, + "y": 25, + "facing": "west", + "trainer": { + "sight": 3 + } + }, + { + "name": "picnicker", + "kind": "npc", + "x": 13, + "y": 32, + "facing": "west", + "trainer": { + "sight": 3 + } + }, + { + "name": "camper", + "kind": "npc", + "x": 13, + "y": 33, + "facing": "west", + "trainer": { + "sight": 3 + } + } + ], + "flag_sets": {}, + "connections": [ + { + "side": "south", + "to_map": "VermilionCity", + "offset": -12 + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_1F_Corridor.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_1F_Corridor.json new file mode 100644 index 0000000000..ef4b147d64 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_1F_Corridor.json @@ -0,0 +1,148 @@ +{ + "name": "SSAnne_1F_Corridor", + "source": { + "decomp_file": "data/maps/SSAnne_1F_Corridor/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + ".................#####.........", + ".................##D##.........", + ".................#...#.........", + ".................#...#.........", + ".................#...#.........", + ".................#...#.........", + "#.################...##########", + "##################...##########", + "###...........................#", + "###...........................#", + "#.............................#", + "#...######################....#", + "#...######################....#", + "#...######################....#", + "#...######################....#", + "#...######################....#", + "#...########################..#", + "#...########################..#", + "#...########################..#", + ".###.##########################", + "###############################" + ], + "elevation": [ + "0000000000000000000000000000000", + "0000000000000000000000000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0003333333333333333333333333330", + "0003333333333333333333333333330", + "0333333333333333333333333333330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000000330", + "0333000000000000000000000000330", + "0000000000000000000000000000000", + "0000000000000000000000000000000" + ], + "points": { + "warp_0": [3, 8], + "warp_2": [19, 1], + "warp_3": [20, 0] + }, + "warps": [ + { + "x": 3, + "y": 8, + "enter": "walk_west", + "to_map": "SSAnne_2F_Corridor", + "to_point": "warp_0" + }, + { + "x": 2, + "y": 18, + "enter": "walk_south" + }, + { + "x": 19, + "y": 1, + "enter": "walk_into", + "to_map": "SSAnne_Exterior", + "to_point": "warp_2" + }, + { + "x": 20, + "y": 0, + "enter": "walk_into", + "to_map": "SSAnne_Exterior", + "to_point": "warp_3" + }, + { + "x": 3, + "y": 20, + "enter": "walk_into" + }, + { + "x": 28, + "y": 17, + "enter": "walk_west" + }, + { + "x": 5, + "y": 10, + "enter": "walk_south" + }, + { + "x": 8, + "y": 10, + "enter": "walk_south" + }, + { + "x": 11, + "y": 10, + "enter": "walk_south" + }, + { + "x": 14, + "y": 10, + "enter": "walk_south" + }, + { + "x": 17, + "y": 10, + "enter": "walk_south" + }, + { + "x": 23, + "y": 10, + "enter": "walk_south" + }, + { + "x": 20, + "y": 10, + "enter": "walk_south" + } + ], + "objects": [ + { + "name": "sailor", + "kind": "npc", + "x": 18, + "y": 8 + }, + { + "name": "worker_m", + "kind": "npc", + "x": 12, + "y": 9 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_2F_Corridor.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_2F_Corridor.json new file mode 100644 index 0000000000..9f74e4ce17 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_2F_Corridor.json @@ -0,0 +1,108 @@ +{ + "name": "SSAnne_2F_Corridor", + "source": { + "decomp_file": "data/maps/SSAnne_2F_Corridor/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "##################################", + "##################################", + "#..###########################.###", + "#..###########################.###", + "#....#########################...#", + "#....#########################...#", + "#....#########################...#", + "#....#########################...#", + "#....#######################.....#", + "#....#######################.....#", + "#....#D###D###D###D###D###D#.....#", + "###..............................#", + "###..............................#", + "###..............................#", + "#................................#", + "##################################" + ], + "points": { + "warp_0": [2, 2], + "warp_2": [30, 2] + }, + "warps": [ + { + "x": 2, + "y": 2, + "enter": "walk_east", + "to_map": "SSAnne_1F_Corridor", + "to_point": "warp_0" + }, + { + "x": 3, + "y": 12, + "enter": "walk_west" + }, + { + "x": 30, + "y": 2, + "enter": "walk_east", + "to_map": "SSAnne_CaptainsOffice", + "to_point": "warp_0" + }, + { + "x": 6, + "y": 10, + "enter": "walk_into" + }, + { + "x": 10, + "y": 10, + "enter": "walk_into" + }, + { + "x": 14, + "y": 10, + "enter": "walk_into" + }, + { + "x": 18, + "y": 10, + "enter": "walk_into" + }, + { + "x": 22, + "y": 10, + "enter": "walk_into" + }, + { + "x": 26, + "y": 10, + "enter": "walk_into" + } + ], + "objects": [ + { + "name": "blue", + "kind": "npc", + "x": 30, + "y": 2, + "hide_flag": "FLAG_HIDE_SS_ANNE_RIVAL" + }, + { + "name": "sailor", + "kind": "npc", + "x": 2, + "y": 7, + "wander": { + "x0": 2, + "y0": 5, + "x1": 2, + "y1": 9 + } + } + ], + "flag_sets": { + "new_game": [ + "FLAG_HIDE_SS_ANNE_RIVAL" + ] + }, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_CaptainsOffice.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_CaptainsOffice.json new file mode 100644 index 0000000000..03a230a27e --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_CaptainsOffice.json @@ -0,0 +1,42 @@ +{ + "name": "SSAnne_CaptainsOffice", + "source": { + "decomp_file": "data/maps/SSAnne_CaptainsOffice/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "#########", + "#########", + "#......##", + "####.#..#", + "####....#", + "#.......#", + "###.....#", + "###.....#", + "###.....#", + "#########" + ], + "points": { + "warp_0": [3, 7] + }, + "warps": [ + { + "x": 3, + "y": 7, + "enter": "walk_west", + "to_map": "SSAnne_2F_Corridor", + "to_point": "warp_2" + } + ], + "objects": [ + { + "name": "captain", + "kind": "npc", + "x": 5, + "y": 4 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_Exterior.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_Exterior.json new file mode 100644 index 0000000000..20658451d2 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_Exterior.json @@ -0,0 +1,129 @@ +{ + "name": "SSAnne_Exterior", + "source": { + "decomp_file": "data/maps/SSAnne_Exterior/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "wwwwwwwwww##wwwwwwwwwwwwwwwwww#####wwwwwwwwww#########################", + "wwwwwwwwww##wwwwwwwwwwwwwwwwww#####wwwwwwwwww#########################", + "wwww##wwwwwwwwwwwwwwwwwwwwwwww#####wwwwwwwwww##........###.###########", + "wwww##wwwwwwwwwwwwww####wwwwww#####wwwwwwwww###........###.###########", + "##wwwwwwww##ww##ww######www##w#####w##wwww#####............###########", + "##wwwww##w##########www#########.#######ww##wwwwwwwwwwwww..###########", + "wwww##w####w##ww##wwwww####ww##...##ww####wwwwwwwwwwwwwww..###########", + "wwww####w##wwwwwwwwwwwwwwwwwwww...wwwwww##wwwwwwwwwwwwwww..###########", + "www##w##wwwwwwwwwwwwwwwwwwwwwww...wwwwwwwwwwwwwwwwwwwwwww..###########", + "w####wwwwwwwwwwwwwwwwwwwwwwwwww...wwwwwwwwwwwwwwwwwwwwwww..###########", + "w##wwwwwwwwwwwwwwwwwwww##wwwwww#.#wwwwww##wwwwwwwwwwwwwww..###########", + "ww##wwwwwwwwwwwwwwwww####ww#####.###ww######wwwwwwwwwwwww..###########", + "ww##wwwwwwwwwwwwwwwww##ww#######.#######ww##wwwwwwwwwwwww..###########", + "##wwwwwwwwwwwwwwwwww##www##www##.##w##wwwwww##wwwwwwwwwww..###########", + "##wwwwwwwwwwwwwwww####wwwwwwww##.##wwwwwwwww##wwwwwwwwwww..###########", + "w##wwwwwwwwwwwwwww##wwwwwwwwww######wwwwwwwww##wwwwwwwwww..###########", + "w##wwwwwwwwwwwwww##wwwwwwwwwwwwwwwwwwwwwwwwww##ww##wwwwww..###########", + "ww##wwwwwwwwww##w##wwwwwwwwwwwwwwwwwwwwwwwwwwww####wwwwww..###########", + "ww##w##wwwww######wwwwwwwwwwwwwwwwwwwwwwwwwwwww##w##wwwww..###########", + "www####w##ww##ww##wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##wwwww..###########", + "www##w######wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##www..###########", + "wwwwww##ww##wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##www..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##wwww..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww####ww..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##ww..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##www..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##www..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##ww..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww####..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww#################", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww###############", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww################" + ], + "elevation": [ + "1111111111001111111111111111110000011111111110000000000000000000000000", + "1111111111001111111111111111110000011111111110000000000000000000000011", + "1111001111111111111111111111110000011111111110033333333000300000000000", + "1111001111111111111100001111110000011111111100033333333000300000000000", + "0011111111001100110000001110010000010011110000033333333333300000000000", + "0011111001000000000011100000000030000000110011111111111113300000000000", + "1111001000010011001111100001100333001100001111111111111113300000000011", + "1111000010011111111111111111111333111111001111111111111113300000000000", + "1110010011111111111111111111111333111111111111111111111113300000000000", + "1000011111111111111111111111111333111111111111111111111113300000000000", + "1001111111111111111111100111111030111111001111111111111113300000000000", + "1100111111111111111110000110000030001100000011111111111113300000000000", + "1100111111111111111110011000000030000000110011111111111113300000000000", + "0011111111111111111100111001111030110011111100111111111113300000000000", + "0011111111111111110000111110110030011011111100111111111113300000000000", + "1001111111111111110011111011110000011110111110011111111113300000000000", + "1001111111111111100111101111111111111111110110011001111113300000000000", + "1100111111111100100111011111111111111111111101100001111113300000000000", + "1100100111110000001110111111111111111111111111100100111113300000000000", + "1110000100110011001111111111111111111111111111011100111113300000000000", + "1110010000001111111011111111111111111111111111101111001113300000000000", + "1111110011001111101111111111111111111111111111110111001113300000000000", + "1111111111111111011111111111111111111111111111111110011113300000000000", + "1111111111111011111111111111111111111111111111111010000113300000000000", + "1111111111111111111111111111111111111111111111111111100113300000000000", + "1111111111111111111111111111111111111111111111111101001113300000000000", + "1111111111111111111111111111111111111111111111111110001113300000000000", + "1111111111111111111111111111111111111111111111111111100113300000000000", + "1111111111111111111111111111111111111111111111111111100003300000000000", + "1111111111111111111111111111111111111111111111111111000000000000000000", + "1111111111111111111111111111111111111111111111111111110000000000000000", + "1111111111111111111111111111111111111111111111111111110000000000000000" + ], + "points": { + "warp_1": [32, 5], + "warp_2": [32, 14], + "warp_3": [33, 15] + }, + "warps": [ + { + "x": 31, + "y": 5, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_0" + }, + { + "x": 32, + "y": 5, + "enter": "walk_north", + "to_map": "VermilionCity", + "to_point": "warp_1" + }, + { + "x": 32, + "y": 14, + "enter": "walk_south", + "to_map": "SSAnne_1F_Corridor", + "to_point": "warp_2" + }, + { + "x": 33, + "y": 15, + "enter": "walk_into", + "to_map": "SSAnne_1F_Corridor", + "to_point": "warp_3" + }, + { + "x": 33, + "y": 5, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_2" + } + ], + "objects": [ + { + "name": "ss_anne", + "kind": "npc", + "x": 30, + "y": 16, + "hide_flag": "FLAG_HIDE_SS_ANNE" + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthEntrance.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthEntrance.json new file mode 100644 index 0000000000..7ae2377b26 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthEntrance.json @@ -0,0 +1,64 @@ +{ + "name": "UndergroundPath_NorthEntrance", + "source": { + "decomp_file": "data/maps/UndergroundPath_NorthEntrance/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "#############", + "#############", + "#...........#", + "..##.##..##..", + "..#..##...#..", + "..#..##...#..", + "..##.....##..", + ".............", + "#...........#", + "#############" + ], + "points": { + "warp_1": [6, 8], + "warp_3": [7, 4] + }, + "warps": [ + { + "x": 5, + "y": 8, + "enter": "walk_into", + "to_map": "Route5", + "to_point": "warp_0" + }, + { + "x": 6, + "y": 8, + "enter": "walk_south", + "to_map": "Route5", + "to_point": "warp_0" + }, + { + "x": 7, + "y": 8, + "enter": "walk_into", + "to_map": "Route5", + "to_point": "warp_0" + }, + { + "x": 7, + "y": 4, + "enter": "walk_west", + "to_map": "UndergroundPath_NorthSouthTunnel", + "to_point": "warp_0" + } + ], + "objects": [ + { + "name": "little_girl", + "kind": "npc", + "x": 5, + "y": 6 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthSouthTunnel.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthSouthTunnel.json new file mode 100644 index 0000000000..72752cc45a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthSouthTunnel.json @@ -0,0 +1,96 @@ +{ + "name": "UndergroundPath_NorthSouthTunnel", + "source": { + "decomp_file": "data/maps/UndergroundPath_NorthSouthTunnel/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "########", + "########", + "#....###", + "#....###", + "#....###", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "###....#", + "###....#", + "###....#", + "########" + ], + "points": { + "warp_0": [4, 3], + "warp_1": [3, 60] + }, + "warps": [ + { + "x": 4, + "y": 3, + "enter": "walk_east", + "to_map": "UndergroundPath_NorthEntrance", + "to_point": "warp_3" + }, + { + "x": 3, + "y": 60, + "enter": "walk_west", + "to_map": "UndergroundPath_SouthEntrance", + "to_point": "warp_3" + } + ], + "objects": [], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_SouthEntrance.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_SouthEntrance.json new file mode 100644 index 0000000000..000f7e215e --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_SouthEntrance.json @@ -0,0 +1,64 @@ +{ + "name": "UndergroundPath_SouthEntrance", + "source": { + "decomp_file": "data/maps/UndergroundPath_SouthEntrance/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "#############", + "#############", + "#...........#", + "..##.##..##..", + "..#..##...#..", + "..#..##...#..", + "..##.....##..", + ".............", + "#...........#", + "#############" + ], + "points": { + "warp_1": [6, 8], + "warp_3": [7, 4] + }, + "warps": [ + { + "x": 5, + "y": 8, + "enter": "walk_into", + "to_map": "Route6", + "to_point": "warp_0" + }, + { + "x": 6, + "y": 8, + "enter": "walk_south", + "to_map": "Route6", + "to_point": "warp_0" + }, + { + "x": 7, + "y": 8, + "enter": "walk_into", + "to_map": "Route6", + "to_point": "warp_0" + }, + { + "x": 7, + "y": 4, + "enter": "walk_west", + "to_map": "UndergroundPath_NorthSouthTunnel", + "to_point": "warp_1" + } + ], + "objects": [ + { + "name": "woman_2", + "kind": "npc", + "x": 5, + "y": 6 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json new file mode 100644 index 0000000000..ca7736267c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json @@ -0,0 +1,240 @@ +{ + "name": "VermilionCity", + "source": { + "decomp_file": "data/maps/VermilionCity/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "wwwwwwwwwwwwwwwwwwwww#...#######################", + "wwwwwwwwwwwwwwwwwwwww#...#######################", + "wwwwww####wwwwwwwwwww#...#######################", + "wwww######www#####www#...#######################", + "wwww##ww##############...#######################", + "www##www##############...#######################", + "www##www#D###SSD######...########S##############", + "ww##wwww.................................#######", + "ww##wwww.................................#######", + "w##wwwww.................................#######", + "w##wwwwwwwwwww...........................#######", + "w##wwwwwwwwwww...........................#######", + "w##wwwwwwwwwwwwwwwww.....................#######", + "w##wwwwwwwwwwwwwwwww.....................#######", + "w##wwwwwwww.....wwww.....................#######", + "ww##wwww...#####..####.....####..........#######", + "ww##wwww...#####..####.....####..........##.....", + "www##www..S#D###..#D##.....SSD###############S##", + "www##www..........................S.............", + "ww##wwww........................................", + "ww##wwww........................................", + "ww##wwww.......................www.....wwwwwwwww", + "ww##wwww...#######.........####www.....wwwwwwwww", + "www##www...#######..wwww...####www64.64wwwwwwwww", + "www##www##S########.wwww...#D##www.....ww#######", + "www##www.....#D#....wwww.......wwww...www#######", + "www##www............wwwwwwwwwwwwwww...www#######", + "ww##wwww............wwwwwwwwwwwwwww...www#######", + "ww##wwww............ww................www#######", + "ww##wwwwwwwwwwwwwwwwww................www##wwwww", + "ww##wwwwwwwwwwwwwwwwww................www##wwwww", + "www##wwwwwwwwwwwwwwwww...wwwwwwwwwwwwwwww##wwwww", + "www##wwwwwwwwwwwwwwwww...wwwwwwwwwwwwwwww##wwwww", + "wwwww##wwwwwwwwwwwww##...####wwwwwwwwwww##wwwwww", + "wwwww##wwwwwwwww######...########wwww##w##wwwwww", + "wwwwww##wwwwww######ww###wwww####wwww##w##wwwwww", + "wwwwww####wwww##wwwwww###wwwwwwww##wwwww##wwwwww", + "wwwwwwww######wwwwwwww.#.wwwwwwww########wwwwwww", + "wwwwwwwwww####wwwwwwww.#.wwwwwwwwww######wwwwwww", + "wwwwwwwwwwwwwwwwwwwwww.#.wwwwwwwwwwwwwwwwwwwwwww" + ], + "elevation": [ + "000000000000000000001033300000000000000000000000", + "000000000000000000001033300000000000000000000000", + "000001000011111111111033300000000000000000000000", + "000100000011100000111033300000000000000000000000", + "000100110000000000000033300000000000000000000000", + "011001110000000000000033300000000000000000000000", + "011001110000000000000033300000000000000000000000", + "010011113333333333333333333333333333333330000000", + "010011113333333333333333333333333333333330000000", + "000111113333333333333333333333333333333330000000", + "000111111111113333333333333333333333333330000000", + "000111111111113333333333333333333333333330000000", + "000111111111111111113333333333333333333330000000", + "000111111111111111113333333333333333333330000000", + "000111111111111111113333333333333333333330000000", + "010011113330000033000033333000033333333330000000", + "010011113330000033000033333000033333333330033333", + "001001113300000033000033333000000000000000000000", + "001001113333333333333333333333333303333333333333", + "000011113333333333333333333333333333333333333333", + "000011113333333333333333333333333333333333333333", + "000011113333333333333333333333311133333111111111", + "000011113330000000333333333000011133333111111111", + "001001113330000000331111333000011133333111111111", + "001001110000000000031111333000011133333110000000", + "001001113333300033331111333333311113331110000000", + "011001113333333333331111111111111113331110000000", + "010011113333333333331111111111111113331110000000", + "010011113333333333331133333333333333331110000000", + "010011111111111111111133333333333333331110011111", + "010011111111111111111133333333333333331110011000", + "011001111111111111111133311111111111111110010000", + "001001111111111111111133311111111111111110010000", + "001110011111111111110033300001111111111100110000", + "000110011111111100000033300000000111100100110000", + "000011001111110000000000000110000111100100110000", + "000001000011110011111100011111111001111100100000", + "000001110000001110001100011000001000000001100000", + "000000001100001000000100010000000010000001000000", + "000000000000000000000100010000000000000000000000" + ], + "points": { + "warp_0": [22, 34], + "warp_1": [23, 34], + "warp_2": [24, 34], + "warp_4": [15, 6] + }, + "warps": [ + { + "x": 22, + "y": 34, + "enter": "walk_south", + "to_map": "SSAnne_Exterior", + "to_point": "warp_1" + }, + { + "x": 23, + "y": 34, + "enter": "walk_south", + "to_map": "SSAnne_Exterior", + "to_point": "warp_1" + }, + { + "x": 24, + "y": 34, + "enter": "walk_south", + "to_map": "SSAnne_Exterior", + "to_point": "warp_1" + }, + { + "x": 9, + "y": 6, + "enter": "walk_into" + }, + { + "x": 15, + "y": 6, + "enter": "walk_into", + "to_map": "VermilionCity_PokemonCenter_1F", + "to_point": "warp_1" + }, + { + "x": 12, + "y": 17, + "enter": "walk_into" + }, + { + "x": 19, + "y": 17, + "enter": "walk_into" + }, + { + "x": 29, + "y": 17, + "enter": "walk_into" + }, + { + "x": 28, + "y": 24, + "enter": "walk_into" + }, + { + "x": 14, + "y": 25, + "enter": "walk_into" + } + ], + "objects": [ + { + "name": "woman_1", + "kind": "npc", + "x": 22, + "y": 11, + "wander": { + "x0": 18, + "y0": 11, + "x1": 26, + "y1": 11 + } + }, + { + "name": "old_man_1", + "kind": "npc", + "x": 17, + "y": 9 + }, + { + "name": "old_man_2", + "kind": "npc", + "x": 36, + "y": 10 + }, + { + "name": "machop", + "kind": "npc", + "x": 35, + "y": 11, + "wander": { + "x0": 33, + "y0": 9, + "x1": 37, + "y1": 13 + } + }, + { + "name": "sailor", + "kind": "npc", + "x": 31, + "y": 30, + "wander": { + "x0": 27, + "y0": 30, + "x1": 35, + "y1": 30 + } + }, + { + "name": "sailor", + "kind": "npc", + "x": 24, + "y": 33 + }, + { + "name": "cut_tree", + "kind": "npc", + "x": 19, + "y": 24, + "hide_flag": "FLAG_TEMP_12" + }, + { + "name": "scientist", + "kind": "npc", + "x": 25, + "y": 7, + "hide_flag": "FLAG_HIDE_VERMILION_CITY_OAKS_AIDE" + } + ], + "flag_sets": { + "new_game": [ + "FLAG_HIDE_VERMILION_CITY_OAKS_AIDE" + ] + }, + "connections": [ + { + "side": "north", + "to_map": "Route6", + "offset": 12 + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_PokemonCenter_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_PokemonCenter_1F.json new file mode 100644 index 0000000000..5e53a3ba51 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_PokemonCenter_1F.json @@ -0,0 +1,125 @@ +{ + "name": "VermilionCity_PokemonCenter_1F", + "source": { + "decomp_file": "data/maps/VermilionCity_PokemonCenter_1F/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "#.#############", + "###############", + "....###..##....", + "....#######....", + "...............", + "...............", + "#..........##..", + "...........##..", + "...............", + "###############" + ], + "elevation": [ + "030000000000000", + "000000000000000", + "333300033003333", + "333300000003333", + "333333333333333", + "330333333333333", + "044033333330033", + "330333333330033", + "333333333333333", + "000000000000000" + ], + "points": { + "nurse_front": [7, 4], + "warp_1": [7, 8] + }, + "warps": [ + { + "x": 6, + "y": 8, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_4" + }, + { + "x": 7, + "y": 8, + "enter": "walk_south", + "to_map": "VermilionCity", + "to_point": "warp_4" + }, + { + "x": 8, + "y": 8, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_4" + }, + { + "x": 1, + "y": 6, + "enter": "step_on" + } + ], + "objects": [ + { + "name": "nurse", + "kind": "npc", + "x": 7, + "y": 2 + }, + { + "name": "man", + "kind": "npc", + "x": 4, + "y": 8, + "wander": { + "x0": 3, + "y0": 8, + "x1": 5, + "y1": 8 + } + }, + { + "name": "hiker", + "kind": "npc", + "x": 12, + "y": 5 + }, + { + "name": "youngster", + "kind": "npc", + "x": 14, + "y": 4, + "wander": { + "x0": 14, + "y0": 3, + "x1": 14, + "y1": 5 + } + }, + { + "name": "cooltrainer_f", + "kind": "npc", + "x": 6, + "y": 4 + }, + { + "name": "0", + "kind": "npc", + "x": 2, + "y": 1, + "hide_flag": "FLAG_HIDE_FAME_CHECKER_LT_SURGE_JOURNAL" + }, + { + "name": "0", + "kind": "npc", + "x": 3, + "y": 1, + "hide_flag": "FLAG_HIDE_FAME_CHECKER_LT_SURGE_JOURNAL" + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp index c859069ceb..f6c27b6cb9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -54,6 +54,21 @@ const char* const MAP_NAMES[] = { "CeruleanCity_Gym", "Route25", "Route25_SeaCottage", + // The burgled house, the way south out of Cerulean; Route 5, the + // Underground Path and Route 6 to Vermilion City, and the S.S. + // Anne's way to the Captain. + "CeruleanCity_House2", + "Route5", + "UndergroundPath_NorthEntrance", + "UndergroundPath_NorthSouthTunnel", + "UndergroundPath_SouthEntrance", + "Route6", + "VermilionCity", + "VermilionCity_PokemonCenter_1F", + "SSAnne_Exterior", + "SSAnne_1F_Corridor", + "SSAnne_2F_Corridor", + "SSAnne_CaptainsOffice", "CeruleanCave_1F", "CeruleanCave_2F", "CeruleanCave_B1F", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h index 76a83fa212..6300eacedf 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h @@ -6,8 +6,10 @@ * new game in Pallet Town through Viridian City, Route 2 and Viridian * Forest to Brock's gym in Pewter City, Route 3, Mt. Moon and Route 4; * and Cerulean City, its Pokemon Center, Mart and Gym, Route 24 (Nugget - * Bridge) and Route 25 to Bill's Sea Cottage, and the three floors of - * Cerulean Cave, where Mewtwo waits on B1F. + * Bridge) and Route 25 to Bill's Sea Cottage; the burgled house, Route 5, + * the Underground Path and Route 6 to Vermilion City and its Pokemon + * Center, and the S.S. Anne's corridors to the Captain's office; and the + * three floors of Cerulean Cave, where Mewtwo waits on B1F. * * Each map is a JSON file, Maps/.json next to this file, generated by * devtools/maps/frlg_maps.py and built into the program by CMake diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp index 3777318e59..c16fb91f76 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp @@ -347,6 +347,57 @@ UnitTestResult test_cerulean_maps(){ return true; } +// Cerulean's burgled house, Route 5, the Underground Path, Route 6, +// Vermilion City and the S.S. Anne load and join: from Cerulean's Pokemon +// Center a route plans through the house, its back yard and the +// Underground Path to Vermilion's nurse, and from there onto +// the ship and up to the Captain. The rival a new game hides +// (FLAG_HIDE_SS_ANNE_RIVAL, flag set "new_game") stands on the Captain's +// door until then: with him there, no route reaches the Captain. With +// Surf's cost below 0, no route surfs. +UnitTestResult test_vermilion_maps(){ + const std::vector all = overworld_maps(); + for (const char* name : { + "CeruleanCity_House2", "Route5", "UndergroundPath_NorthEntrance", "UndergroundPath_NorthSouthTunnel", "UndergroundPath_SouthEntrance", + "Route6", "VermilionCity", "VermilionCity_PokemonCenter_1F", + "SSAnne_Exterior", "SSAnne_1F_Corridor", "SSAnne_2F_Corridor", "SSAnne_CaptainsOffice", + }){ + if (built_in(all, name) == nullptr){ + return std::string("Missing map ") + name + "."; + } + } + const std::set new_game = map_flag_set(all, "new_game"); + if (new_game.count("FLAG_HIDE_SS_ANNE_RIVAL") == 0){ + return "Expected FLAG_HIDE_SS_ANNE_RIVAL in flag set new_game."; + } + const std::vector maps = overworld_maps(new_game); + const MapPosition nurse{"VermilionCity_PokemonCenter_1F", map_point(maps, "VermilionCity_PokemonCenter_1F", "nurse_front")}; + if (nurse.point != TilePos{7, 4}){ + return "Unexpected nurse_front in Vermilion's Pokemon Center."; + } + if (!plans_from_cerulean_nurse(maps, RouteGoal{nurse, Direction::NORTH})){ + return "No route from Cerulean's Pokemon Center through the burgled house and the Underground Path to Vermilion's."; + } + const RouteGoal captain{MapPosition{"SSAnne_CaptainsOffice", {5, 5}}, Direction::NORTH}; + RouteCosts walking = default_route_costs(); + walking[RouteCost::SURF] = -1; + const std::optional> aboard = plan_overworld_route(maps, nurse, captain, walking, Direction::NORTH); + if (!aboard){ + return "No route from Vermilion's Pokemon Center onto the S.S. Anne to the Captain."; + } + // The harbor's water (20..23, 23..27) is the shorter way south; a + // player without Surf walks round it. + for (const RouteStep& step : *aboard){ + if (step.action == RouteAction::SURF){ + return "Expected the way aboard on land, not " + to_string(step) + "."; + } + } + if (plan_overworld_route(all, nurse, captain, default_route_costs(), Direction::NORTH)){ + return "Expected the rival on the Captain's door to block the way while shown."; + } + return true; +} + // A planned step walks where its map doesn't let the player run, and only // there: out of Pewter's Pokemon Center to the Gym. UnitTestResult test_walk_only_indoors(){ @@ -549,6 +600,7 @@ void add_tests_OverworldMaps(UnitTestDatabase& database){ database.add("Mt. Moon joins Route 4's cave mouths", test_mt_moon_maps); database.add("Mt. Moon's floor closed on one side is wild", test_mt_moon_closed_floor_wild); database.add("Cerulean's Mart and Gym, Route 25 and Bill's Sea Cottage", test_cerulean_maps); + database.add("Route 5 and 6 to Vermilion City and the S.S. Anne", test_vermilion_maps); database.add("walks where running isn't allowed", test_walk_only_indoors); database.add("Route 4's Pokemon Center escalator", test_route4_center_escalator); database.add("trainer sight", test_trainer_sight); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index be6898561c..d1a5e6b1fe 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -288,7 +288,7 @@ class StepModel{ std::optional> press(const State& state, Direction direction) const{ const std::optional transition = PokemonFRLG::press(m_maps, state, direction); - if (!transition){ + if (!transition || (transition->action == RouteAction::SURF && route_cost(m_costs, RouteCost::SURF) < 0)){ return std::nullopt; } Pokemon::Step step; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index 9a29b82acb..de4bdb7194 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -84,7 +84,7 @@ enum class RouteCost{ TILE, TURN, // extra for a leg in a new direction WARP, // the fade in and out - SURF, // the dialog and the animation + SURF, // the dialog and the animation; below 0, never GRASS_TILE, // extra for tall grass, where wild Pokemon appear // Extra for a leg that ends where the player could keep walking: it has // to be timed, and if a message or a battle stops it part-way, where diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index d3730de6f9..907427c843 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -741,6 +741,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_PlayerFacingReader(database); add_tests_AutoStory(database); add_tests_AutoStorySimulation(database); + add_tests_AutoStoryVermilion(database); add_tests_AutoStoryPaceSim(database); add_tests_AutoStoryItems(database); add_tests_AutoStoryRngLoop(database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index 1eb072ab07..b290531691 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -60,6 +60,11 @@ constexpr StoryTrainer STORY_TRAINERS[] = { {.map = "Route25", .battle = "picnicker_kelsey", .trainer = "TRAINER_PICNICKER_KELSEY"}, {.map = "Route25", .battle = "hiker_nob", .trainer = "TRAINER_HIKER_NOB"}, {.map = "Route25", .battle = "lass_haley", .trainer = "TRAINER_LASS_HALEY"}, + // The burgled house's Rocket (33, 6), CeruleanCity_EventScript_GruntTrigger. + {.map = "CeruleanCity", .battle = "rocket_grunt", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_5"}, + // Route 6's Jeff (13, 33), across the only way south. + {.map = "Route6", .battle = "camper_jeff", .trainer = "TRAINER_CAMPER_JEFF"}, + {.map = "SSAnne_2F_Corridor", .battle = "rival_ss_anne", .trainer = "TRAINER_RIVAL_SS_ANNE_BULBASAUR"}, }; 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 e0db491cfa..221ff5dcaa 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -131,7 +131,7 @@ Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step){ // Planning lets the walker recover a stopped open leg: it plans back to // the leg's end, then walks the rest as given. From a set of tiles // the start isn't known: the walker reads the facing. - const RoutePlanning planning{.maps = &story_maps(), .costs = default_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; + const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; walk_overworld_route_with_recovery(env.console, env.context, *route, planning, facing, interrupts, move_speed(step)); log_arrival(env, step.name, target->map, target->tile); const std::optional end_facing = route->empty() ? facing : std::optional(route->back().facing_after); @@ -155,7 +155,7 @@ Outcome walk_legs(StoryEnv& env, RunState& state, const StoryStep& step){ const OverworldInterruptSet interrupts = story_interrupts(env.supplies, step.fight_wild, env.checks, env.language); // Planning lets the walker recover a stopped open leg: it plans back to // the leg's end, then walks the rest as given. - const RoutePlanning planning{.maps = &story_maps(), .costs = default_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; + const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; walk_overworld_route_with_recovery(env.console, env.context, *route, planning, facing, interrupts, move_speed(step)); log_arrival(env, step.name, route->back().end.map, route->back().end.point); return Pokemon::outcome_ok(to_pose(StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after})); @@ -324,8 +324,22 @@ std::optional> viridian_mart_orders(const std::vector& story_maps(){ - static const std::vector maps = overworld_maps(map_flag_set(overworld_maps(), "after_hall_of_fame")); + 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; } @@ -358,6 +372,7 @@ std::string save_card_place(const std::string& map){ {"CeruleanCity", "cerulean-city"}, {"Route24", "route-24"}, {"Route25", "route-25"}, + {"VermilionCity", "vermilion-city"}, }; for (const auto& [prefix, place] : PLACES){ if (map == prefix || map.rfind(prefix + "_", 0) == 0){ @@ -441,7 +456,7 @@ std::optional> plan_story_route( if (!holds->empty()){ facing = holds->back().facing_after; } - std::optional> route = plan_overworld_route(maps, from, goal, default_route_costs(), facing); + std::optional> route = plan_overworld_route(maps, from, goal, story_route_costs(), facing); if (route){ route->insert(route->begin(), holds->begin(), holds->end()); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index 3d3d4de3f5..6503b90a5a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -137,8 +137,13 @@ StoryRunPlan plan_story_run( // The maps the story plans on: every map to Route 4's Pokemon Center, with // the objects the game hides after the Hall of Fame removed, as the lab's -// frlg_route does. +// frlg_route does, and those a new game hides until their scene (the +// S.S. Anne's rival on the Captain's door). const std::vector& story_maps(); +// default_route_costs() without Surf, which the story hasn't got yet: +// routes stay on land, even where water is the shorter way (Vermilion +// City's harbor). +const RouteCosts& story_route_costs(); Pokemon::Pose to_pose(const StoryPose& pose); // `pose` as a StoryPose: nothing unless it is on one tile. @@ -166,9 +171,10 @@ std::optional> fixed_route( // other step, or a `patch` not on one tile. Pokemon::Pose paced_pose(const Pokemon::Pose& patch, const StoryStep& step); -// A ROUTE's legs from `pose` to `goal`: from one tile, as planned; from -// a set of tiles (paced_pose()), first the holds to walls that bring them -// all to one tile (holds_to_one_tile()), then the route from there. +// A ROUTE's legs from `pose` to `goal`, on story_route_costs(): from +// one tile, as planned; from a set of tiles (paced_pose()), first the +// holds to walls that bring them all to one tile (holds_to_one_tile()), +// then the route from there. // Nothing if either can't be planned. std::optional> plan_story_route( const std::vector& maps, const Pokemon::Pose& pose, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index 2c9cf83899..0efe00a8f1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -24,7 +24,7 @@ std::optional> walked_route(const StoryPose& at, const St } const std::optional target = story_pose(*step.target); const RouteGoal goal{{target->map, target->tile}, target->facing}; - return plan_overworld_route(story_maps(), start, goal, default_route_costs(), at.facing); + return plan_overworld_route(story_maps(), start, goal, story_route_costs(), at.facing); } // Runs `step` of row `id` on `sim` as the program does: routes and legs @@ -39,7 +39,7 @@ std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& s const auto on = [&sim]{ return " On " + to_string(sim.state().position) + "."; }; if (step.kind == StoryStepKind::LEGS || step.kind == StoryStepKind::ROUTE){ const std::optional> route = walked_route(at, step); - const RoutePlanning planning{.maps = &story_maps(), .start = MapPosition{at.map, at.tile}, .label = step.name}; + const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = MapPosition{at.map, at.tile}, .label = step.name}; const std::string failure = route ? OverworldSimWalker(sim).run(*route, planning) : "no legs."; at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : at; return failure.empty() ? "" : where + failure + on(); 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 55f5a80b10..95a4b5fc31 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -118,7 +118,7 @@ class Test_RunPlan : public UnitTest{ }; void add_tests_RunPlan(UnitTestDatabase& database){ - const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"; + 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"; const RunPlanScenario scenarios[] = { {.name = "handoffs off: the story as before", .first = "00.01", .expected_ids = TODAY}, {.name = "handoffs off: a range", .first = "00.02", .last = "00.04", .expected_ids = "00.02 00.03 00.04"}, @@ -171,8 +171,8 @@ void add_tests_GiftRunPlan(UnitTestDatabase& database){ .expected_ids = "00.11 00.12", .expected_stop = StoryHandoff::GIFT_RNG, }, - {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11", .expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"}, - {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"}, + {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11", .expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22"}, + {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22"}, { .name = "Gift RNG: started at 00.12b, from the save beside the salesman", .first = "00.12b", @@ -187,7 +187,7 @@ void add_tests_GiftRunPlan(UnitTestDatabase& database){ .expected_ids = "", .expected_stop = StoryHandoff::GIFT_RNG, }, - {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}, .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"}, + {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}, .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22"}, { .name = "Starter RNG first, with both on", .first = "00.03", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp index 17916919f9..b24bb5c605 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -243,7 +243,7 @@ class FakeGame : public Pokemon::StoryStepExecutor{ const std::optional target = story_pose(*step.target); const RouteGoal goal{{target->map, target->tile}, target->facing}; const std::optional> route = plan_overworld_route( - story_maps(), at.position, goal, default_route_costs(), at.facing + story_maps(), at.position, goal, story_route_costs(), at.facing ); if (!route){ return Pokemon::outcome_failed("no_route", to_string(at.position) + " to " + to_string(goal.position)); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 1e60d17d1c..eea06e665d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -47,6 +47,12 @@ const char* const CERULEAN_GYM = "CeruleanCity_Gym"; const char* const ROUTE24 = "Route24"; const char* const ROUTE25 = "Route25"; const char* const SEA_COTTAGE = "Route25_SeaCottage"; +const char* const ROUTE6 = "Route6"; +const char* const VERMILION = "VermilionCity"; +const char* const VERMILION_CENTER = "VermilionCity_PokemonCenter_1F"; +const char* const SS_ANNE_1F = "SSAnne_1F_Corridor"; +const char* const SS_ANNE_2F = "SSAnne_2F_Corridor"; +const char* const CAPTAINS_OFFICE = "SSAnne_CaptainsOffice"; Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt){ return to_pose(StoryPose{map, {x, y}, facing}); @@ -875,6 +881,86 @@ StoryCheckpoint oddish(){ }); } +// Cerulean's only way south without Cut (CeruleanCity map.json, +// scripts.inc) is through the burgled house (30, 11), whose door the +// policeman leaves once the S.S. TICKET is had, out of the hole in its +// back wall into the yard (31, 8). The Rocket there (33, 6) stops the +// player on (33, 5) or (33, 7) (CeruleanCity_EventScript_GruntTrigger*): +// from (32, 7), the step east onto (33, 7) turns the player to him; +// Machop and Drowzee, Lv17, then TM28 Dig, and he is gone. Then east +// round the yard, down to the city's south edge by the path along its +// east side (39..40, 20..31), down Route 5's east side to the +// Underground Path's north house (31, 31), through its tunnel and out of +// the south house onto Route 6, below its door (19, 14). Route 6: +// - Bug Catcher Keigo (3, 16) faces east, sight 5, over the middle path +// (4..8, 16); Bug Catcher Elijah (20, 25) faces west, sight 3, over +// the east path (17..19, 25). The way goes down column 15, through the +// grass to the wall (15, 33), out of both lines; the open leg west to +// it, a tile short, leaves the player on column 16, out of them too. +// - Camper Ricky and Picnicker Nancy (12..13, 21) battle only when +// talked to. +// - Picnicker Isabelle (13, 32) and Camper Jeff (13, 33) face west, sight +// 3, over (10..12, 32) and (10..12, 33); the only way south, (10..12, +// 34), is from Jeff's row. Up to (15, 31), west to the wall (7, 31), +// down to the wall (7, 33), east: Jeff is fought on the step onto +// (10, 33) and walks up to (11, 33). Isabelle's row is kept off. +// Then down into Vermilion City (offset 12) to its Pokemon Center. +StoryCheckpoint to_vermilion(){ + return row("00.21", "The Cerulean Rocket, Route 5, the Underground Path and Route 6 to Vermilion's Pokemon Center: Jeff", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("west_of_rocket", at(CERULEAN, 32, 7, EAST), "through the burgled house and out of the hole in its back wall, west of the Rocket (33, 6)")), + step_into("rocket_trigger", EAST, "onto (33, 7), below the Rocket"), + battle("rocket_grunt", at(CERULEAN, 33, 7, NORTH), false, "Machop and Drowzee, Lv17; TM28 Dig"), + running(route("route6_column15", at(ROUTE6, 15, 14), "round the yard and down Cerulean's east side, Route 5 into the Underground Path, through it onto Route 6, west to column 15")), + running(legs("round_keigo_and_elijah", { + {SOUTH, 19}, {NORTH, 1}, {NORTH, 1}, {WEST, 8}, {SOUTH, 2}, {EAST, 2}, + }, "down column 15 to the wall (15, 33); up to (15, 31), west to the wall (7, 31), down to the wall (7, 33), east to (9, 33)")), + into_line("jeff_line", EAST, "onto (10, 33), in Jeff's line"), + battle("camper_jeff", at(ROUTE6, 10, 33, EAST), false, "Spearow and Raticate, Lv16; Jeff stops at (11, 33)"), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "down Route 6's south path into Vermilion City, to the Pokemon Center")), + heal("heal_vermilion"), + }); +} + +// The S.S. Anne (VermilionCity, SSAnne_* scripts.inc). The step onto +// (23, 33) (VermilionCity_EventScript_CheckTicket) has the sailor (24, 33) +// ask for a ticket; the S.S. TICKET (00.18) is shown and the player turns +// east to him. Then onto the pier (23, 34), along the gangway, through +// 1F, by legs off its cabins' doormats on row 10, which planned routes +// cross, and up its stairs (3, 8) to 2F. The step onto (30, 6), below the +// Captain's door (30, 2) (SSAnne_2F_Corridor_EventScript_RivalTriggerLeft), +// brings the rival out and down to (30, 5); the player keeps facing +// north, and after the battle he walks round the player and off. For a +// player who took Squirtle: Pidgeotto Lv19, Raticate Lv16, Kadabra Lv18, +// Ivysaur Lv20. The ship's trainers are in its cabins and on its deck, +// off the way. The Captain (5, 4) faces up; talked to from (5, 5) he is +// rubbed on the back and gives HM01 (VAR_MAP_SCENE_VERMILION_CITY 1). +// Back out through 1F's door (19, 1): on the gangway the ship sails +// (SSAnne_Exterior_ExitSSAnne), the player is put on (23, 34) and walked +// up to (23, 32) (VermilionCity_EventScript_ExitSSAnne). Cut is taught +// later, to the Oddish. +StoryCheckpoint ss_anne(){ + return row("00.22", "The S.S. Anne: the rival, HM01 Cut from the Captain, back to Vermilion's Pokemon Center", at(VERMILION_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("above_ticket_check", at(VERMILION, 23, 32, SOUTH), "out of the Center down to the harbor, above the ticket check (23, 33)")), + step_into("ticket_check", SOUTH, "onto (23, 33): \"Do you have a ticket?\""), + scene("show_ss_ticket", at(VERMILION, 23, 33, EAST), "the S.S. TICKET; the player turns to the sailor"), + running(route("aboard", at(SS_ANNE_1F, 19, 2, SOUTH), "onto the pier, along the gangway and in by 1F's door, a step below it")), + running(legs("past_the_cabins", {{SOUTH, 8}, {NORTH, 1}, {WEST, 6}, {NORTH, 1}}, "to the wall (19, 10), up a step, west to the worker (12, 9), up a step to (13, 8): off the cabins' mats on row 10")), + running(route("below_rival_trigger", at(SS_ANNE_2F, 30, 7, NORTH), "west along 1F, up the stairs (3, 8) and along 2F below the Captain's door")), + step_into("rival_trigger", NORTH, "onto (30, 6): the rival comes out of the Captain's door"), + battle("rival_ss_anne", at(SS_ANNE_2F, 30, 6, NORTH), false, "Pidgeotto, Raticate, Kadabra and Ivysaur"), + running(route("behind_captain", at(CAPTAINS_OFFICE, 5, 5, NORTH), "into the Captain's office, behind the Captain (5, 4)")), + talk("captain", "\"Ooargh... I feel hideous... Urrp! Seasick...\""), + scene("hm01_cut", at(CAPTAINS_OFFICE, 5, 5, NORTH), "the player rubs his back; HM01, and what Cut does"), + running(route("off_2f", at(SS_ANNE_1F, 13, 8), "back along 2F, down the stairs and east along 1F's row 8")), + running(legs("round_the_sailor", {{EAST, 4}, {SOUTH, 1}, {EAST, 2}}, "to the sailor (18, 8), down a step, east to (19, 9): off the cabins' mats on row 10")), + running(route("below_ship_door", at(SS_ANNE_1F, 19, 2, NORTH), "up below the door to the gangway")), + step_into("off_the_ship", NORTH, "out onto the gangway"), + scene("ss_anne_sails", at(VERMILION, 23, 32, NORTH), "the ship sails; the player is walked off the pier to (23, 32)"), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "back to the Pokemon Center")), + heal("heal_vermilion"), + }); +} + } @@ -883,7 +969,7 @@ const std::vector& story_checkpoints(){ new_game(), after_sid_helper(), leave_house(), oaks_trigger(), to_starter_ball(), starter(), parcel(), pokedex(), shopping(), forest(), brock(), running_shoes(), route3(), magikarp_salesman(), after_gift_rng(), magikarp_to_box(), mt_moon(), route4_to_cerulean(), cerulean_gym(), misty(), cerulean_grind(), cerulean_rival(), nugget_bridge(), - route25(), bill(), back_to_cerulean(), oddish(), + route25(), bill(), back_to_cerulean(), oddish(), to_vermilion(), ss_anne(), }; 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 153a4f1e8b..4fad9c41d3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h @@ -4,8 +4,9 @@ * * FireRed from a new game to the Boulder Badge, the Running Shoes, Route * 3 to the Magikarp salesman in Route 4's Pokemon Center, through Mt. - * Moon to Route 4 east of it, and on to Cerulean City, Misty, its rival - * and Nugget Bridge, as story checkpoints 00.01 to 00.16, each saved at its + * Moon to Route 4 east of it, and on to Cerulean City, Misty, its rival, + * Nugget Bridge and Bill, then Route 6 to Vermilion City and the S.S. + * Anne's HM01, as story checkpoints 00.01 to 00.22, each saved at its * end. After 00.10 every route and leg runs. Taken from a by-hand run on * console B (2026-11-01) and the pokefirered map scripts. * @@ -23,7 +24,7 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -// Checkpoints 00.01 to 00.16, in order, with 00.01b, the way back in after +// Checkpoints 00.01 to 00.22, in order, with 00.01b, the way back in after // SID Helper, after 00.01, 00.04 split in two at Squirtle's ball for // Starter RNG: 00.04 and 00.04b, 00.12b, the way back in after Gift RNG, // after 00.12, 00.12c, the Magikarp put in BOX 1, after it, 00.14b and @@ -38,6 +39,8 @@ void add_tests_AutoStory(UnitTestDatabase& database); void add_tests_AutoStoryRunPlan(UnitTestDatabase& database); // The table run end to end against a fake game (PokemonFRLG_AutoStorySimulation_Tests.cpp). void add_tests_AutoStorySimulation(UnitTestDatabase& database); +// Rows 00.21 and 00.22 on the overworld sim (PokemonFRLG_AutoStoryVermilion_Tests.cpp). +void add_tests_AutoStoryVermilion(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilion_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilion_Tests.cpp new file mode 100644 index 0000000000..ff6b16394b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilion_Tests.cpp @@ -0,0 +1,212 @@ +/* AutoStory Vermilion Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Rows 00.21 (Cerulean to Vermilion City) and 00.22 (the S.S. Anne) on + * the overworld sim: their walks reach their ends, and meet only the + * trainers the table fights. + * + */ + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTable.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class Test_AutoStoryVermilion : public UnitTest{ +public: + Test_AutoStoryVermilion(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStory - Simulation: " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + std::function m_body; +}; + +const StoryCheckpoint* checkpoint(const std::string& id){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [&](const StoryCheckpoint& data){ return data.id == id; }); + return row == rows.end() ? nullptr : &*row; +} + +std::string beaten_list(const OverworldSim& sim){ + std::string text; + for (const std::string& name : sim.state().trainers_beaten){ + text += " " + name; + } + return text.empty() ? " none" : text; +} + +size_t step_index(const StoryCheckpoint& row, const std::string& name){ + const auto step = std::find_if(row.steps.begin(), row.steps.end(), [&](const StoryStep& data){ return data.name == name; }); + if (step == row.steps.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No step " + row.id + "/" + name + "."); + } + return step - row.steps.begin(); +} + +// Runs row 00.21's steps from `first` to its end on `sim` from `at`, and +// checks that only Jeff ("camper") was fought and the walk ended at +// Vermilion's nurse. +std::string run_to_vermilion(OverworldSim& sim, StoryPose at, const StoryCheckpoint& row, size_t first){ + for (size_t c = first; c < row.steps.size(); c++){ + const std::string failure = row_step_on_sim(sim, at, row.steps[c], row.id); + if (!failure.empty()){ + return failure; + } + } + if (sim.state().trainers_beaten != std::vector{"camper"} || !sim.state().violations.empty()){ + return "00.21: expected Jeff alone beaten and no violation; beat" + beaten_list(sim) + "."; + } + if (!(sim.state().position == MapPosition{"VermilionCity_PokemonCenter_1F", {7, 4}})){ + return "00.21: expected to end at Vermilion's nurse, not " + to_string(sim.state().position) + "."; + } + return ""; +} + +// 00.21 on the sim, from Cerulean's Pokemon Center through the burgled +// house to (32, 7), west of the Rocket, meeting no one; his trigger and +// battle aren't the sim's, so from where his battle leaves the player: +// Route 5, the Underground Path and Route 6, down column 15 out of +// Keigo's and Elijah's lines, round Isabelle's row to Jeff, who is fought +// on the step onto (10, 33), and on to Vermilion's nurse. Then from the +// walk onto Route 6's column 15 ended a tile short, on column 16: the +// same. +UnitTestResult test_route6_jeff_only(){ + const StoryCheckpoint* row = checkpoint("00.21"); + if (row == nullptr){ + return "No checkpoint 00.21."; + } + StoryPose at = *story_pose(*row->start); + OverworldSim house(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + const size_t rocket = step_index(*row, "rocket_trigger"); + for (size_t c = 0; c < rocket; c++){ + if (const std::string failure = row_step_on_sim(house, at, row->steps[c], row->id); !failure.empty()){ + return failure; + } + } + if (!(house.state().position == MapPosition{"CeruleanCity", {32, 7}}) || !house.state().trainers_beaten.empty()){ + return "00.21: expected to reach (32, 7) west of the Rocket, meeting no one, not " + to_string(house.state().position) + "."; + } + const size_t battle = step_index(*row, "rocket_grunt"); + const StoryPose after_rocket = *story_pose(*row->steps[battle].pose_after); + OverworldSim whole(story_maps(), MapPosition{after_rocket.map, after_rocket.tile}, *after_rocket.facing); + if (const std::string failure = run_to_vermilion(whole, after_rocket, *row, battle + 1); !failure.empty()){ + return "From the Rocket: " + failure; + } + const size_t walk = step_index(*row, "route6_column15"); + const StoryPose column = *story_pose(*row->steps[walk].target); + OverworldSim sim(story_maps(), MapPosition{column.map, {column.tile.x + 1, column.tile.y}}, Direction::WEST); + if (const std::string failure = run_to_vermilion(sim, column, *row, walk + 1); !failure.empty()){ + return "A tile short: " + failure; + } + return true; +} + +// Whether `route` from `start` steps onto any of `tiles` of `map`. +bool steps_onto(const std::vector& route, MapPosition start, const std::string& map, const std::vector& tiles){ + for (const RouteStep& step : route){ + const std::vector passed = step.action == RouteAction::WALK ? leg_tiles(story_maps(), start, step) : std::vector{}; + for (size_t c = 1; c < passed.size(); c++){ + if (passed[c].map == map && std::find(tiles.begin(), tiles.end(), passed[c].point) != tiles.end()){ + return true; + } + } + start = step.end; + } + return false; +} + +// Where the table says the player stands after `step` from `at`. +StoryPose pose_after(const StoryPose& at, const StoryStep& step){ + if (step.pose_after){ + return *story_pose(*step.pose_after); + } + if (step.kind == StoryStepKind::STEP){ + return StoryPose{at.map, at.tile.next(step.direction), step.direction}; + } + return at; +} + +// 00.22's walks on the sim, each from where the table says it starts: to +// the harbor short of the ticket check, aboard and along 2F short of the +// rival's trigger row (30..32, 6), into the Captain's office behind him, +// back to 1F's door, and from the pier to the nurse. None meets a +// trainer: the ship's are in its cabins and on its deck. +UnitTestResult test_ss_anne_walks(){ + const StoryCheckpoint* row = checkpoint("00.22"); + if (row == nullptr){ + return "No checkpoint 00.22."; + } + StoryPose at = *story_pose(*row->start); + bool before_rival = true; + for (const StoryStep& step : row->steps){ + if (step.kind != StoryStepKind::ROUTE && step.kind != StoryStepKind::LEGS){ + before_rival &= step.kind != StoryStepKind::BATTLE; + at = pose_after(at, step); + continue; + } + const std::optional> route = walked_route(at, step); + if (!route || route->empty()){ + return "00.22/" + step.name + ": no route."; + } + const MapPosition start{at.map, at.tile}; + if (steps_onto(*route, start, "VermilionCity", {{22, 33}, {23, 33}}) + || (before_rival && steps_onto(*route, start, "SSAnne_2F_Corridor", {{30, 6}, {31, 6}, {32, 6}})) + ){ + return "00.22/" + step.name + ": crosses a trigger before its step."; + } + OverworldSim sim(story_maps(), start, *at.facing); + if (const std::string failure = row_step_on_sim(sim, at, step, row->id); !failure.empty()){ + return failure; + } + if (!(sim.state().position == MapPosition{at.map, at.tile})){ + return "00.22/" + step.name + ": ended on " + to_string(sim.state().position) + ", not " + to_string(MapPosition{at.map, at.tile}) + "."; + } + if (!sim.state().trainers_beaten.empty() || !sim.state().violations.empty()){ + return "00.22/" + step.name + ": a trainer's battle or a violation on the way."; + } + } + if (!(MapPosition{at.map, at.tile} == MapPosition{"VermilionCity_PokemonCenter_1F", {7, 4}})){ + return "00.22: expected to end at Vermilion's nurse."; + } + return true; +} + +} + + +void add_tests_AutoStoryVermilion(UnitTestDatabase& database){ + database.add("00.21 to Vermilion, only Jeff on Route 6, the walk onto column 15 whole or a tile short", test_route6_jeff_only); + database.add("00.22 the S.S. Anne's walks, short of the ticket check and the rival's trigger", test_ss_anne_walks); +} + + + +} +} +} 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 5cf14a080e..2aeb13ae28 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -217,8 +217,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 27 || checkpoints[0].start){ - return "Expected 27 checkpoints, the first from a new game."; + if (checkpoints.size() != 29 || checkpoints[0].start){ + return "Expected 29 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -423,7 +423,7 @@ std::vector legs_walked(const StoryPose& pose, const StoryStep& step) const MapData* map = find_map_named(pose.map); const std::optional facing = pose.facing ? pose.facing : map ? wall_beside(*map, pose.tile) : std::nullopt; const StoryPose target = one_tile(*step.target); - route = plan_overworld_route(story_maps(), start, RouteGoal{{target.map, target.tile}, target.facing}, default_route_costs(), facing); + route = plan_overworld_route(story_maps(), start, RouteGoal{{target.map, target.tile}, target.facing}, story_route_costs(), facing); } return route ? *route : std::vector{}; } @@ -521,6 +521,12 @@ std::string tile_problem(const MapPosition& tile, const std::set legs = legs_walked(pose, step); + return step.kind == StoryStepKind::LEGS && std::all_of(legs.begin(), legs.end(), [](const RouteStep& leg){ return !leg.wild || leg.to_wall || leg.tiles == 1; }); +} + // Moves `pose` past `step`, as check_checkpoint() does. UnitTestResult advance(StoryPose& pose, const StoryStep& step){ if (step.kind == StoryStepKind::ROUTE){ @@ -552,7 +558,7 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set{}){ const StoryPose end = to_patch ? one_tile(*step.target) : StoryPose{}; const bool patch = to_patch && tile.map == end.map && tile.point == end.tile; - if (const std::string problem = tile_problem(tile, beaten); !problem.empty() && !(patch && problem.starts_with("in the grass"))){ + if (const std::string problem = tile_problem(tile, beaten); !problem.empty() && !((patch || grass_held(pose, step)) && problem.starts_with("in the grass"))){ return where + problem; } } @@ -579,9 +585,9 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set), from the + file src/data/tilesets/metatiles.h names for gMetatileAttributes_: + folder names don't always follow the symbol (gTileset_SSAnne is ss_anne).""" + attributes = symbol.replace('gTileset_', 'gMetatileAttributes_') + match = re.search( + r'\b' + attributes + r'\[\]\s*=\s*INCBIN_U32\("([^"]+)"\)', + decomp.read('src/data/tilesets/metatiles.h').decode(), + ) + if not match: + sys.exit(f'No {attributes} in src/data/tilesets/metatiles.h.') + data = decomp.read(match.group(1)) return [struct.unpack_from(' Date: Tue, 3 Nov 2026 15:30:15 -0800 Subject: [PATCH 03/21] FRLG: image test for WhiteDialogDetector on the S.S. Anne's white hull The 1F corridor frame, with no dialog on screen, reads as a white dialog today. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Dialogs/PokemonFRLG_DialogDetector.cpp | 38 +++++++++++++++++++ .../Dialogs/PokemonFRLG_DialogDetector.h | 1 + .../PokemonFRLG/PokemonFRLG_Tests.cpp | 1 + 3 files changed, 40 insertions(+) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp index c87bebb817..266cd59a01 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp @@ -127,6 +127,44 @@ bool SelectionDialogDetector::detect(const ImageViewRGB32& screen){ return false; } +class Test_WhiteDialogDetector : public ImageUnitTest{ +public: + + Test_WhiteDialogDetector( + const std::string& image, + bool expected + ) + : ImageUnitTest("PokemonFRLG::WhiteDialogDetector - " + image, {image}) + , m_expected(expected) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + WhiteDialogDetector detector(COLOR_RED); + const ImageRGB32& image = images[0]; + return detector.detect(image) == m_expected; + }; + +private: + bool m_expected; +}; + +void add_tests_WhiteDialogDetector(UnitTestDatabase& database){ + // The S.S. Anne's white hull fills the dialog area, with no frame around it. + database.add("PokemonFRLG/WhiteDialogDetector/SSAnne1F_Corridor_False.png", false); + database.add("PokemonFRLG/WhiteDialogDetector/SSAnne1F_CorridorWorker_False.png", false); + // Real dialogs, from the other dialog detectors' folders. + database.add("PokemonFRLG/AdvanceWhiteDialogDetector/English-Bulba_True.png", true); + database.add("PokemonFRLG/AdvanceWhiteDialogDetector/Ger-KennyExile-Sq_True.png", true); + database.add("PokemonFRLG/AdvanceWhiteDialogDetector/MacOS-not-ili-Char_True.png", true); + database.add("PokemonFRLG/AdvanceWhiteDialogDetector/Spanish-AlejaKaiser-Char_True.png", true); + database.add("PokemonFRLG/Dialog/white_dialog_jap-112434221413.png", true); + database.add("PokemonFRLG/Dialog/white_dialog_red_text_spa-205200755327.png", true); + database.add("PokemonFRLG/SelectionDialogDetector/English-Bulba-720p_True.png", true); +} + class Test_AdvanceWhiteDialogDetector : public ImageUnitTest{ public: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h index b9041ac1a1..90778d5be0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h @@ -134,6 +134,7 @@ class SelectionDialogWatcher : public DetectorToFinder{ {} }; +void add_tests_WhiteDialogDetector(UnitTestDatabase& database); void add_tests_AdvanceWhiteDialogDetector(UnitTestDatabase& database); void add_tests_SelectionDialogDetector(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 907427c843..9a18a9925b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -705,6 +705,7 @@ static void add_tests_AutoStoryReaders(UnitTestDatabase& database){ } void add_tests(UnitTestDatabase& database){ + add_tests_WhiteDialogDetector(database); add_tests_AdvanceWhiteDialogDetector(database); add_tests_BoxCellOccupiedDetector(database); add_tests_BoxCellReader(database); From 7fd42c226c002b5cf98de098ca392c695c1cd3e9 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 15:32:27 -0800 Subject: [PATCH 04/21] FRLG: WhiteDialogDetector checks the dialog's blue frame The detector looked only at three white strips inside the dialog box, so a large white area with no box, like the S.S. Anne's hull below the 1F corridor, read as a dialog and stopped the walker's leg. It now also requires the strip on the box's top frame not to be bright white. All real dialog images have a light blue frame there (sum of RGB 600-690); the hull is 765. Group the naming screen's test suites so add_tests() stays under 50 lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Dialogs/PokemonFRLG_DialogDetector.cpp | 8 ++++++++ .../Inference/Dialogs/PokemonFRLG_DialogDetector.h | 1 + .../VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp | 13 +++++++++---- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp index 266cd59a01..28e0f4e1f3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp @@ -28,12 +28,14 @@ WhiteDialogDetector::WhiteDialogDetector(Color color) : m_right_box(0.923385, 0.748077, 0.00615385, 0.204577) , m_top_box(0.0704615, 0.741846, 0.859077, 0.00623077) , m_bottom_box(0.0716923, 0.943308, 0.851692, 0.00934615) + , m_frame_box(0.0704615, 0.7288, 0.859077, 0.0048) {} void WhiteDialogDetector::make_overlays(VideoOverlaySet& items) const{ const BoxOption& GAME_BOX = GameSettings::instance().GAME_BOX; items.add(COLOR_RED, GAME_BOX.inner_to_outer(m_right_box)); items.add(COLOR_RED, GAME_BOX.inner_to_outer(m_top_box)); items.add(COLOR_RED, GAME_BOX.inner_to_outer(m_bottom_box)); + items.add(COLOR_RED, GAME_BOX.inner_to_outer(m_frame_box)); } bool WhiteDialogDetector::detect(const ImageViewRGB32& screen){ ImageViewRGB32 game_screen = extract_box_reference(screen, GameSettings::instance().GAME_BOX); @@ -41,9 +43,15 @@ bool WhiteDialogDetector::detect(const ImageViewRGB32& screen){ ImageViewRGB32 right_image = extract_box_reference(game_screen, m_right_box); ImageViewRGB32 top_image = extract_box_reference(game_screen, m_top_box); ImageViewRGB32 bottom_image = extract_box_reference(game_screen, m_bottom_box); + ImageViewRGB32 frame_image = extract_box_reference(game_screen, m_frame_box); + // The dialog's top frame is light blue, about (165, 214, 230). A white area with no + // frame, like the S.S. Anne's hull, is pure white there. The default is_white() would + // accept the light blue too, so this asks for a brighter white than any frame color. + const bool frame_is_white = is_white(frame_image, 720); if (is_white(right_image) && is_white(top_image) && is_white(bottom_image) + && !frame_is_white ){ return true; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h index 90778d5be0..0b1f37e299 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h @@ -73,6 +73,7 @@ class WhiteDialogDetector : public StaticScreenDetector{ ImageFloatBox m_right_box; ImageFloatBox m_top_box; ImageFloatBox m_bottom_box; + ImageFloatBox m_frame_box; }; class WhiteDialogWatcher : public DetectorToFinder{ public: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 9a18a9925b..92fd74c777 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -704,6 +704,14 @@ static void add_tests_AutoStoryReaders(UnitTestDatabase& database){ add_tests_TeachScreenReader(database); } +// The naming screen's cursor, plans, keys and text. +static void add_tests_Naming(UnitTestDatabase& database){ + add_tests_NamingCursor(database); + add_tests_NamingPlan(database); + add_tests_NamingKeys(database); + add_tests_NamingText(database); +} + void add_tests(UnitTestDatabase& database){ add_tests_WhiteDialogDetector(database); add_tests_AdvanceWhiteDialogDetector(database); @@ -749,10 +757,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStoryCapture(database); add_tests_AutoStoryPcStorage(database); add_tests_AutoStoryAbilities(database); - add_tests_NamingCursor(database); - add_tests_NamingPlan(database); - add_tests_NamingKeys(database); - add_tests_NamingText(database); + add_tests_Naming(database); add_tests_RngManipulation(database); } From ea22fd8fc8a1f9eb5fd2de307bccba537b1843a9 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 15:59:44 -0800 Subject: [PATCH 05/21] FRLG: the win-chance sim plays the battle loop's Antidote, logs its losses The S.S. Anne rival came out at 40% for a Lv27 Wartortle with 15 Potions. The sim's math matches the decomp; the battles are lost to the Ivysaur, seeded and poisoned, where Bite (15-18 of its 56 HP) can't outpace Leech Seed's 9 HP a turn back. The sim left out the in-battle Antidote the battle loop uses (once, after two poisoned turns): it now plays it, and the refusal log names the foe and the lead's state in the battles lost. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_AutoStoryBattleDecisions.cpp | 4 +- .../PokemonFRLG_AutoStoryBattleSim.h | 6 ++ .../PokemonFRLG_AutoStoryBattleSim_Tests.cpp | 39 ++++++++++++ .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 6 +- .../AutoStory/PokemonFRLG_AutoStoryGrind.cpp | 2 +- .../PokemonFRLG_AutoStoryWinChance.cpp | 62 ++++++++++++++++++- .../PokemonFRLG_AutoStoryWinChance.h | 27 +++++++- 7 files changed, 135 insertions(+), 11 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp index f8bdc2957a..cfa9dfd572 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp @@ -24,8 +24,6 @@ constexpr size_t PARTY_SLOTS = 6; constexpr int MIN_POTIONS_TO_FIGHT = 2; // RUN this many times ("Can't escape!") before fighting instead. constexpr int RUN_TRIES = 4; -// PSN read at this many turns in a row before an Antidote. -constexpr int PSN_READS = 2; // An evolution screen held this long was an evolution, not a text box // between two others. constexpr Milliseconds EVOLUTION_SEEN = std::chrono::milliseconds(5000); @@ -251,7 +249,7 @@ TurnAction BattleDecisions::lead_turn(const PartyStatus& status){ if (items.choice != TurnChoice::MOVE){ return TurnAction{.choice = items.choice, .heal_reason = items.reason}; } - if (!m_items_off && m_psn_reads >= PSN_READS && !m_antidote_used && m_supplies.antidotes > 0){ + if (!m_items_off && m_psn_reads >= PSN_TURNS_BEFORE_ANTIDOTE && !m_antidote_used && m_supplies.antidotes > 0){ m_antidote_used = true; return TurnAction{.choice = TurnChoice::ANTIDOTE, .heal_reason = items.reason}; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h index dad7dd6207..99a91486b0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h @@ -70,10 +70,16 @@ inline constexpr uint16_t POTION_HP = 20; // must be rarer than 1 in 5. inline constexpr double POTION_COVER_PERCENTILE = 0.80; +// An Antidote is used, once a battle, on the turn the lead is read +// poisoned this many turns in a row, if no Potion or Awakening comes +// first. +inline constexpr int PSN_TURNS_BEFORE_ANTIDOTE = 2; + // The lead's items in the bag that the sim plays. struct SimItems{ int potions = 0; int awakenings = 0; + int antidotes = 0; }; enum class SimStatus{ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp index 3528e4f67d..2744920774 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp @@ -51,6 +51,7 @@ class Test_AutoStoryBattleSim : public UnitTest{ constexpr std::string_view RIVAL = "TRAINER_RIVAL_CERULEAN_BULBASAUR"; constexpr std::string_view MISTY = "TRAINER_LEADER_MISTY"; +constexpr std::string_view SS_ANNE_RIVAL = "TRAINER_RIVAL_SS_ANNE_BULBASAUR"; std::string stats_text(const Stats& s){ return std::format("{}/{}/{}/{}/{}/{}", s.hp, s.attack, s.defense, s.sp_attack, s.sp_defense, s.speed); @@ -644,6 +645,43 @@ UnitTestResult test_rival_potions_raise_chance(){ } +// 00.22's lead: Wartortle Lv27 at 78 HP, its other stats at OWN_GENES. +PartyMember ss_anne_lead(){ + Stats stats = combatant("wartortle", 27, OWN_GENES)->stats; + stats.hp = 78; + std::vector moves; + for (const char* move : {"bite", "water-pulse", "bubble", "withdraw"}){ + moves.push_back(SummaryMove{.move = move, .pp = max_pp(move), .max_pp = max_pp(move)}); + } + return member_from_summary("wartortle", 27, moves, stats); +} + +UnitTestResult test_ss_anne_rival(){ + const SimTrainer rival = *sim_trainer(SS_ANNE_RIVAL); + const SimBattler own = *own_battler(ss_anne_lead()); + const WinEstimate base = estimate_win_chance(own, SimItems{.potions = 15}, rival); + const WinEstimate antidote = estimate_win_chance(own, SimItems{.potions = 15, .antidotes = 1}, rival); + SimBattler mega_kick = own; + mega_kick.moves[2] = SimMove{.move = "mega-kick", .pp = 5}; + const WinEstimate kicked = estimate_win_chance(mega_kick, SimItems{.potions = 15}, rival); + if (base.chance < 0.25 || base.chance > 0.45 || antidote.chance < base.chance + 0.03 || kicked.chance < base.chance + 0.15){ + return std::format( + "Lv27, 15 Potions: expected 25-45%, an Antidote 3 points more and Mega Kick 15 more, got {:.1f}%, {:.1f}%, {:.1f}%.", + 100 * base.chance, 100 * antidote.chance, 100 * kicked.chance + ); + } + const auto& by_foe = base.losses.by_foe; + const size_t to_ivysaur = by_foe.contains("ivysaur") ? by_foe.at("ivysaur") : 0; + if (2 * to_ivysaur < base.battles - base.wins || 2 * base.losses.seeded < base.battles - base.wins){ + return "Expected most losses to the Ivysaur and seeded, got \"" + loss_text(base) + "\"."; + } + const std::string text = loss_text(base); + if (!text.starts_with(std::format("lost {}: ivysaur {}", base.battles - base.wins, to_ivysaur)) || text.find("; seeded ") == std::string::npos){ + return "Expected the loss text to lead with the Ivysaur and name the seeded, got \"" + text + "\"."; + } + return true; +} + void add_tests_AutoStoryBattleSim(UnitTestDatabase& database){ database.add("Gen 3 stats and damage worked by hand", test_hand_numbers); database.add("the Elite Four Farmer's Starmie picks OHKO at Sp. Atk 302", test_elite_four_table); @@ -663,6 +701,7 @@ void add_tests_AutoStoryBattleSim(UnitTestDatabase& database){ database.add("Cerulean rival: a Potion against the Bulbasaur", test_rival_bulbasaur_potion); database.add("Cerulean rival: 15 Potions win over half", test_rival_potions_raise_chance); database.add("the foe's next-turn damage and move chances", test_next_turn_damage); + database.add("S.S. Anne rival: lost to the Ivysaur, seeded", test_ss_anne_rival); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 221ff5dcaa..93dadeadb1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -191,14 +191,14 @@ std::optional refuse_battle(StoryEnv& env, const StoryStep& step, s if (step.may_lose || supplies.risk != BattleRisk::HIGH){ return std::nullopt; } - const SimItems items{.potions = supplies.potions, .awakenings = supplies.awakenings}; + const SimItems items{.potions = supplies.potions, .awakenings = supplies.awakenings, .antidotes = supplies.antidotes}; const std::optional estimate = story_win_chance(supplies.lead, items, supplies.hp_fill, trainer); if (!estimate){ return std::nullopt; } env.console.log(std::format( - "Story: {} against {}: the sim wins {} of {} ({:.0f}%) with {}.", - step.name, trainer, estimate->wins, estimate->battles, 100 * estimate->chance, estimate->inputs + "Story: {} against {}: the sim wins {} of {} ({:.0f}%) with {}; {}.", + step.name, trainer, estimate->wins, estimate->battles, 100 * estimate->chance, estimate->inputs, loss_text(*estimate) )); return win_chance_refusal(*estimate); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp index f7d8eabf4f..bbb4d363f8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp @@ -355,7 +355,7 @@ bool ConsoleGrindActions::needs_heal(){ std::optional ConsoleGrindActions::win_chance(const MemberInfo& member, std::string_view trainer){ m_supplies.lead = lead_as_read(member, m_supplies.lead); - return healed_win_chance(m_supplies.lead, SimItems{.potions = m_supplies.potions, .awakenings = m_supplies.awakenings}, trainer); + return healed_win_chance(m_supplies.lead, SimItems{.potions = m_supplies.potions, .awakenings = m_supplies.awakenings, .antidotes = m_supplies.antidotes}, trainer); } bool ConsoleGrindActions::heal_trip(){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp index 9a9b421dcc..ebe60b5db4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "PokemonFRLG_AutoStoryWinChance.h" namespace PokemonAutomation{ @@ -105,6 +107,18 @@ class SimBattle{ return false; } + // After a lost battle: the foe out and the lead's state. + void tally_loss(LossTally& tally){ + tally.by_foe[foe().species]++; + tally.seeded += m_own.seeded ? 1 : 0; + tally.poisoned += m_own.status == SimStatus::POISON ? 1 : 0; + tally.asleep += m_own.status == SimStatus::SLEEP ? 1 : 0; + tally.paralyzed += m_own.status == SimStatus::PARALYSIS ? 1 : 0; + tally.accuracy_down += stage(m_own, Stage::ACCURACY) < 0 ? 1 : 0; + tally.no_potions += m_items.potions == 0 ? 1 : 0; + tally.turn_cap += m_own.hp > 0 ? 1 : 0; + } + private: SimBattler& foe(){ return m_trainer.party[m_out]; @@ -146,7 +160,9 @@ class SimBattle{ void play_turn(){ const size_t left = foes_left(); + m_psn_turns = m_own.status == SimStatus::POISON ? m_psn_turns + 1 : 0; const OwnTurn own = own_turn(m_own, m_items, foe(), m_trainer, left); + const bool antidote = !own.potion && !own.awakening && antidote_now(); const std::optional item = ai_item_choice(foe(), m_trainer.items, left); const std::optional foe_slot = item ? std::nullopt : foe_move(); if (own.potion){ @@ -158,17 +174,28 @@ class SimBattle{ m_own.sleep = 0; m_items.awakenings--; } + if (antidote){ + m_own.status = SimStatus::NONE; + m_items.antidotes--; + m_antidote_used = true; + } if (item){ gain_hp(foe(), ai_item_hp(m_trainer.items[*item])); m_trainer.items[*item].clear(); } - const bool item_used = own.potion || own.awakening; + const bool item_used = own.potion || own.awakening || antidote; const std::string own_move = item_used ? "" : own.slot ? m_own.moves[*own.slot].move : "struggle"; const std::string foe_used = item ? "" : foe_slot ? foe().moves[*foe_slot].move : "struggle"; play_moves(own_move, own.slot, foe_used, foe_slot); end_of_turn(); } + // As the battle loop: once a battle, PSN_TURNS_BEFORE_ANTIDOTE turns + // poisoned in a row. + bool antidote_now() const{ + return m_items.antidotes > 0 && !m_antidote_used && m_psn_turns >= PSN_TURNS_BEFORE_ANTIDOTE; + } + bool own_first(const std::string& own_move, const std::string& foe_used){ const int own_priority = data_of(own_move).priority; const int foe_priority = data_of(foe_used).priority; @@ -339,6 +366,8 @@ class SimBattle{ std::mt19937& m_rng; size_t m_out = 0; bool m_flinched = false; + int m_psn_turns = 0; + bool m_antidote_used = false; }; } @@ -349,12 +378,38 @@ WinEstimate estimate_win_chance(const SimBattler& own, const SimItems& items, co WinEstimate estimate{.battles = battles, .wins = 0, .chance = 0}; for (size_t i = 0; i < battles; i++){ SimBattle battle(own, items, trainer, rng); - estimate.wins += battle.won() ? 1 : 0; + if (battle.won()){ + estimate.wins++; + }else{ + battle.tally_loss(estimate.losses); + } } estimate.chance = battles == 0 ? 0 : static_cast(estimate.wins) / battles; return estimate; } +std::string loss_text(const WinEstimate& estimate){ + const LossTally& t = estimate.losses; + std::string text = std::format("lost {}:", estimate.battles - estimate.wins); + std::vector> foes(t.by_foe.begin(), t.by_foe.end()); + std::ranges::sort(foes, [](const auto& a, const auto& b){ return a.second > b.second; }); + for (size_t i = 0; i < foes.size(); i++){ + text += std::format("{} {} {}", i == 0 ? "" : ",", foes[i].first, foes[i].second); + } + const std::pair states[] = { + {"seeded", t.seeded}, {"poisoned", t.poisoned}, {"asleep", t.asleep}, {"paralyzed", t.paralyzed}, + {"accuracy down", t.accuracy_down}, {"no Potions", t.no_potions}, {"turn cap", t.turn_cap}, + }; + const char* separator = ";"; + for (const auto& [name, count] : states){ + if (count > 0){ + text += std::format("{} {} {}", separator, name, count); + separator = ","; + } + } + return text; +} + std::optional story_win_chance(const PartyMember& lead, const SimItems& items, double hp_share, std::string_view trainer){ const std::optional own = own_battler(lead, hp_share); const std::optional sim = sim_trainer(trainer); @@ -372,6 +427,9 @@ std::optional story_win_chance(const PartyMember& lead, const SimIt if (items.awakenings > 0){ estimate.inputs += std::format(", {} Awakenings", items.awakenings); } + if (items.antidotes > 0){ + estimate.inputs += std::format(", {} Antidotes", items.antidotes); + } return estimate; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h index 9553ceac5a..17d85d2337 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h @@ -10,8 +10,10 @@ * AI's ties. Too low a chance stops the story before the fight, for a * GRIND or the nurse first. * - * Each turn: the Potion or Awakening and the AI's item first, then the moves by - * priority and speed (a quarter with PARALYSIS). Sleep (2 to 5 on the + * Each turn: the Potion, Awakening or Antidote (as the battle loop: once a + * battle, after PSN_TURNS_BEFORE_ANTIDOTE turns poisoned) and the AI's + * item first, then the moves by priority and speed (a quarter with + * PARALYSIS). Sleep (2 to 5 on the * game's counter), poison (1/8 HP a turn), paralysis (1/4 no move), Leech * Seed (1/8 a turn to the other side), flinch, one- and two-stage stat * changes, multi-hit moves (2 to 5 as the game draws them), drain (half) @@ -26,6 +28,7 @@ #include #include +#include #include #include #include @@ -36,6 +39,21 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ +// How the battles were lost: the foe out when the lead fainted, and the +// lead's state then. A battle can count under more than one state. +struct LossTally{ + std::map by_foe = {}; + size_t seeded = 0; + size_t poisoned = 0; + size_t asleep = 0; + size_t paralyzed = 0; + // The lead's accuracy below 0. + size_t accuracy_down = 0; + size_t no_potions = 0; + // Still going at the turn cap. + size_t turn_cap = 0; +}; + struct WinEstimate{ size_t battles = 0; size_t wins = 0; @@ -44,8 +62,13 @@ struct WinEstimate{ // The lead and items the battles were played with, for the log: // "wartortle Lv20 60/60 HP: bite 25/25, ...; 5 Potions, 2 Awakenings". std::string inputs = {}; + LossTally losses = {}; }; +// The losses for the log: "lost 604: ivysaur 410, kadabra 150; seeded +// 380, poisoned 300, asleep 120, accuracy down 200, no Potions 50". +std::string loss_text(const WinEstimate& estimate); + // `own` with `items` against `trainer`, `battles` times, from a fixed // `seed` so the same inputs give the same estimate. WinEstimate estimate_win_chance(const SimBattler& own, const SimItems& items, const SimTrainer& trainer, size_t battles = 1000, uint32_t seed = 1); From 2fe142e173aab7b2e0706e07bd742c4faf1162ec Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 16:30:31 -0800 Subject: [PATCH 06/21] FRLG battle loop: real YES/NOs only, Disable over "no PP", the sim's Potions Console B, m68 (the S.S. Anne rival): the send-out flash on the ship's white floor read as the move prompt's YES/NO, and the decisions took the first for "Delete a move to make room for PROTECT?" (Lv31): NO, and the second for "Stop learning?": YES. The lead was then figured at Lv31 (78 -> 88 max HP). A battle YES/NO now needs the box's slate frame, and a YES/NO with no move the lead can have reached is NO, changing nothing; "Stop learning?" is due only within the turn. "no PP left" read 37 s after the last move zeroed that move's PP: it now counts only soon after the choice, and the first read is taken as a Disable (Kadabra's), the second as no PP. At BattleRisk::HIGH the loop's Potion is the win-chance sim's own_turn(), with the lead's poison, the chance a foe's Leech Seed hit it, and the foe's HP from its bar. own_turn() also tops up before a knockout when the foe's turn is small and leaves nothing lasting, and a stronger foe is to come (task #77 step 1); neutral on the S.S. Anne rival (31.4% -> 31.7%). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_BattleHpBarReader.cpp | 28 +++- .../Inference/PokemonFRLG_BattleHpBarReader.h | 12 +- .../PokemonFRLG_BattleScreenDetector.cpp | 46 ++++++ .../PokemonFRLG_BattleScreenDetector.h | 9 ++ .../AutoStory/PokemonFRLG_AutoStoryBattle.cpp | 18 +-- .../AutoStory/PokemonFRLG_AutoStoryBattle.h | 6 + .../PokemonFRLG_AutoStoryBattleDecisions.cpp | 141 +++++++++++++++--- .../PokemonFRLG_AutoStoryBattleDecisions.h | 36 ++++- .../PokemonFRLG_AutoStoryBattleSim.cpp | 107 ++++++++++--- .../PokemonFRLG_AutoStoryBattleSim.h | 37 ++++- .../PokemonFRLG_AutoStoryBattleSim_Tests.cpp | 107 +++++++++++++ .../PokemonFRLG_AutoStoryFoeTracker.h | 3 + .../PokemonFRLG_AutoStoryItems_Tests.cpp | 62 ++++++++ .../PokemonFRLG_AutoStoryParty_Tests.cpp | 52 +++++++ .../PokemonFRLG_AutoStoryWinChance.cpp | 13 +- 15 files changed, 605 insertions(+), 72 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.cpp index 0d2c517a28..d9e2913314 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.cpp @@ -9,6 +9,7 @@ #include #include #include "CommonFramework/ImageTools/ImageBoxes.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_BattleHpBarReader.h" namespace PokemonAutomation{ @@ -26,6 +27,10 @@ namespace{ // row and one lighter one, whose pixels were counted, read 17/55 HP as // 15% of the bar, and as 2% with the box a pixel lower. const ImageFloatBox HP_BAR_BOX(174.0 / 240, 90.0 / 160, 48.0 / 240, 5.0 / 160); +// The opponent's bar, in its box top left: 48 pixels from x 52, its +// lighter rows at y 33 to 35 (console B, 2026-11-03, the S.S. Anne rival's +// Pidgeotto at full HP and Raticate at 7 of 48 pixels). +const ImageFloatBox OPPONENT_HP_BAR_BOX(52.0 / 240, 32.0 / 160, 48.0 / 240, 5.0 / 160); // A column needs this many pixels of a fill color to count as filled: a // lighter row is about 6 pixels tall at 1080p. @@ -52,11 +57,8 @@ std::optional fill_color(uint32_t pixel){ return std::nullopt; } -} - - -HpBarReading read_player_hp_bar(const ImageViewRGB32& game){ - const ImageViewRGB32 bar = extract_box_reference(game, HP_BAR_BOX); +HpBarReading read_hp_bar(const ImageViewRGB32& game, const ImageFloatBox& box){ + const ImageViewRGB32 bar = extract_box_reference(game, box); size_t counts[3] = {}; size_t filled = 0; for (size_t x = 0; x < bar.width(); x++){ @@ -80,6 +82,22 @@ HpBarReading read_player_hp_bar(const ImageViewRGB32& game){ return reading; } +} + + +HpBarReading read_player_hp_bar(const ImageViewRGB32& game){ + return read_hp_bar(game, HP_BAR_BOX); +} + +HpBarReading read_opponent_hp_bar(const ImageViewRGB32& game){ + return read_hp_bar(game, OPPONENT_HP_BAR_BOX); +} + +std::optional read_opponent_hp_fill(const ImageViewRGB32& screen){ + const HpBarReading bar = read_opponent_hp_bar(extract_box_reference(screen, GameSettings::instance().GAME_BOX)); + return bar.seen ? std::optional(bar.fill) : std::nullopt; +} + } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h index 0a34d8677e..4b8446b362 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h @@ -2,14 +2,15 @@ * * From: https://github.com/PokemonAutomation/ * - * The HP bar in the player's box on a battle screen: its color and how - * much of it is filled. + * The HP bars on a battle screen, the player's and the opponent's: their + * color and how much of them is filled. * */ #ifndef PokemonAutomation_PokemonFRLG_BattleHpBarReader_H #define PokemonAutomation_PokemonFRLG_BattleHpBarReader_H +#include #include "CommonFramework/ImageTypes/ImageViewRGB32.h" namespace PokemonAutomation{ @@ -35,6 +36,13 @@ struct HpBarReading{ // `game`: the game's screen, the Game Box of the video. HpBarReading read_player_hp_bar(const ImageViewRGB32& game); +// The opponent's HP bar, in its box top left, the same way. +HpBarReading read_opponent_hp_bar(const ImageViewRGB32& game); + +// That bar's filled share on `screen` (the whole video, its Game Box +// taken from the settings), none if no fill shows. +std::optional read_opponent_hp_fill(const ImageViewRGB32& screen); + } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.cpp index a3ac1c9106..9922abcfa6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.cpp @@ -4,6 +4,10 @@ * */ +#include +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_BattleScreenDetector.h" namespace PokemonAutomation{ @@ -30,6 +34,48 @@ bool BattleScreenDetector::detect(const ImageViewRGB32& screen){ } +namespace{ + +// On the 240x160 screen: the battle YES/NO box's left frame is 2 pixels of +// slate blue at x 187-188, from y 66 to 109 (console B, 2026-11-03, +// "Delete a move to make room for BITE?"). This box takes 3 pixels either +// side of it, over the rows clear of its corners. +const ImageFloatBox YES_NO_FRAME_BOX(184.0 / 240, 70.0 / 160, 8.0 / 240, 34.0 / 160); +// The rows with frame in them, of the box's: nearly all of them. +constexpr double FRAME_ROWS = 0.9; +// Pixels of frame a row needs: about a third of a game pixel. +constexpr size_t FRAME_PIXELS = 2; + +// The frame's slate blue, measured (120, 115, 145) with its lighter edge +// (147, 155, 218); not the empty HP bar's dark gray (75, 74, 87), the +// white or the HP box's cream. +bool frame_color(uint32_t pixel){ + const int r = (pixel >> 16) & 0xff; + const int g = (pixel >> 8) & 0xff; + const int b = pixel & 0xff; + return r >= 95 && r <= 170 && g >= 90 && g <= 175 && b >= 120 && b <= 240 && b >= r + 15; +} + +} + +bool battle_yes_no_shows(const ImageViewRGB32& screen){ + if (!BattleLearnDialogDetector(COLOR_RED).detect(screen)){ + return false; + } + const ImageViewRGB32 game = extract_box_reference(screen, GameSettings::instance().GAME_BOX); + const ImageViewRGB32 frame = extract_box_reference(game, YES_NO_FRAME_BOX); + size_t rows = 0; + for (size_t y = 0; y < frame.height(); y++){ + size_t pixels = 0; + for (size_t x = 0; x < frame.width(); x++){ + pixels += frame_color(frame.pixel(x, y)) ? 1 : 0; + } + rows += pixels >= FRAME_PIXELS ? 1 : 0; + } + return frame.height() > 0 && rows >= FRAME_ROWS * frame.height(); +} + + } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h index ac4257c707..bcd738d45a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h @@ -47,6 +47,15 @@ class BattleScreenWatcher : public DetectorToFinder{ }; +// A YES/NO box in a battle (a move to learn): BattleLearnDialogDetector, +// and the box's slate-blue left frame. The detector alone reads white +// where the box would be and the teal text box, which a foe's send-out +// flash shows on the S.S. Anne's white floor (console B, 2026-11-03, m68: +// "RIVAL GREEN sent out RATICATE!" and "...KADABRA!" taken for a move +// prompt, NO at one and YES at the other). +bool battle_yes_no_shows(const ImageViewRGB32& screen); + + } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 77d7330881..31be4d097c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -107,7 +107,7 @@ BattleScreen read_battle_screen(const ImageViewRGB32& screen, bool confirmed){ if (BattleMenuDetector(COLOR_RED).detect(screen)){ return BattleScreen::MENU; } - if (BattleLearnDialogDetector(COLOR_RED).detect(screen)){ + if (battle_yes_no_shows(screen)){ return BattleScreen::LEARN; } if (!confirmed && !BattleScreenDetector(COLOR_RED).detect(screen)){ @@ -127,7 +127,7 @@ BattleTextScreen read_text_screen(const ImageViewRGB32& screen){ .text_box = BattleDialogDetector(COLOR_RED).detect(screen), .hp_box = BattleScreenDetector(COLOR_RED).detect(screen), .arrow = AdvanceBattleDialogDetector(COLOR_RED).detect(screen), - .yes_no = BattleLearnDialogDetector(COLOR_RED).detect(screen) || yes_no_cursor(screen).has_value(), + .yes_no = battle_yes_no_shows(screen) || yes_no_cursor(screen).has_value(), }; } @@ -167,8 +167,7 @@ bool bag_row_is( // From the battle menu: `item`, in `row` (from 0) of the Items pocket, on // the starter. The bag opens on the pocket used last; Items is the first. -// Returns whether it was used; if the row holds something else (THOROUGH), -// backs out to the battle menu. Not run on the console yet. +// Returns whether it was used; else (THOROUGH) backs out to the menu. bool use_item( ConsoleHandle& console, ProControllerContext& context, int row, const std::string& item, StoryChecks checks, Language language @@ -207,8 +206,7 @@ void run_away(ProControllerContext& context){ pbf_press_button(context, BUTTON_A, 160ms, 500ms); } -// FIGHT, then the move in `slot` from the top left. The cursor doesn't -// move onto an empty slot. +// FIGHT, then the move in `slot` from the top left; the cursor stays off empty slots. void use_move(ProControllerContext& context, size_t slot){ tap(context, DPAD_LEFT); tap(context, DPAD_UP); @@ -283,12 +281,12 @@ bool forget_move(ConsoleHandle& console, ProControllerContext& context, size_t s return true; } -// The party list in battle opens on the lead: down to `slot`, then A, and -// SHIFT, the top of its menu. TurnResult item_result(bool used){ return used ? TurnResult::DONE : TurnResult::NOT_DONE; } +// The party list in battle opens on the lead: down to `slot`, then A, and +// SHIFT, the top of its menu. void send_member(ProControllerContext& context, size_t slot){ for (size_t c = 0; c < slot; c++){ pbf_press_dpad(context, DPAD_DOWN, 160ms, 400ms); @@ -392,7 +390,7 @@ class BattleLoop{ if (!m_confirmed){ return false; } - if (m_move_chosen && BattleOutOfPpDetector(COLOR_RED).detect(screen)){ + if (m_move_chosen && m_refusal.no_pp_due(now) && BattleOutOfPpDetector(COLOR_RED).detect(screen)){ m_console.log("Battle: no PP left for that move; choosing again.", COLOR_ORANGE); m_decisions.turn_done(TurnChoice::MOVE, TurnResult::NOT_DONE); m_move_chosen = false; @@ -586,6 +584,8 @@ class BattleLoop{ if (!m_foes.species().empty()){ m_foes.at_menu(read_trainer_foe(m_console.logger(), m_language, screen, m_foes.species())); m_supplies.foe_out = m_foes.out(); + m_supplies.foes_fainted = m_foes.fainted(); + m_supplies.foe_hp_fill = read_opponent_hp_fill(screen); } if (m_refusal.refused(current_time())){ m_console.log("Battle: the move list came back without a turn (Disable?); choosing another move.", COLOR_ORANGE); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h index 81b2a67341..9375bc9698 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h @@ -25,6 +25,7 @@ #include #include +#include #include #include #include "Common/Cpp/TestRunners/UnitTestDatabase.h" @@ -94,6 +95,11 @@ struct Supplies{ // The slot of the trainer's party out, as the battle loop tracks it // (TrainerFoeTracker): the lead's attacks are chosen against it. std::optional foe_out = {}; + // Its HP bar's filled share at the last battle menu, none if not read: + // its HP for the lead's attacks and Potions. Full while none. + std::optional foe_hp_fill = {}; + // The slots of that party seen fainted (TrainerFoeTracker): not to come. + std::set foes_fainted = {}; // The wild Pokemon of the battle going on, as read at its first battle // menu (read_wild_foes()): the lead's attacks are chosen against it. // Empty when it wasn't read; trainer battles never use it. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp index cfa9dfd572..f1c69acc79 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp @@ -5,6 +5,7 @@ */ #include +#include #include #include #include @@ -48,15 +49,51 @@ constexpr Milliseconds REFUSED_WITHIN = std::chrono::milliseconds(7000); constexpr size_t LOW_ATTACK_PP = 10; // The move prompts a turn can bring: two moves offered, NO then YES each. constexpr int MOVE_PROMPTS = 4; - -// The trainer's Pokemon out, at full HP (its damage taken isn't read), +// The levels the lead can gain in a battle, for the moves it can be +// offered: a story battle gives one or two. +constexpr uint8_t MAX_LEVELS_IN_BATTLE = 3; +// The lead is taken as seeded once the chance that a foe's Leech Seed hit +// it is over this. +constexpr double SEEDED_CHANCE = 0.5; + +SimStatus sim_status(StatusCondition status){ + switch (status){ + case StatusCondition::SLEEP: + return SimStatus::SLEEP; + case StatusCondition::POISON: + return SimStatus::POISON; + case StatusCondition::PARALYSIS: + return SimStatus::PARALYSIS; + default: + return SimStatus::NONE; + } +} + +// The trainer's Pokemon out, with the HP its bar shows (full if not read), // when the battle loop knows which. std::optional foe_out(const Supplies& supplies){ if (supplies.trainer.empty() || !supplies.foe_out){ return std::nullopt; } const std::vector party = trainer_mons(supplies.trainer); - return *supplies.foe_out < party.size() ? trainer_battler(party[*supplies.foe_out]) : std::nullopt; + std::optional foe = *supplies.foe_out < party.size() ? trainer_battler(party[*supplies.foe_out]) : std::nullopt; + if (foe && supplies.foe_hp_fill){ + const double hp = std::round(*supplies.foe_hp_fill * foe->body.stats.hp); + foe->hp = static_cast(std::clamp(hp, 1, foe->body.stats.hp)); + } + return foe; +} + +// The trainer's Pokemon still to come: not out and not seen fainted, at +// full HP. +std::vector foes_to_come(const Supplies& supplies, const SimTrainer& trainer){ + std::vector to_come; + for (size_t slot = 0; slot < trainer.party.size(); slot++){ + if (slot != supplies.foe_out && !supplies.foes_fainted.contains(slot)){ + to_come.push_back(trainer.party[slot]); + } + } + return to_come; } // A trainer's battle: against the foe out, the best expected damage at @@ -112,7 +149,7 @@ std::string no_potion_reason(const Supplies& supplies, const PartyStatus& status if (supplies.potions <= 0){ return "none left"; } - const bool asked = supplies.risk == BattleRisk::HIGH ? status.hp_red : status.hp_red || status.hp_yellow; + const bool asked = supplies.risk == BattleRisk::HIGH ? c.max_hit >= c.hp : status.hp_red || status.hp_yellow; if (!asked){ return std::format("HP {} and the next hit can't faint the lead", bar_name(status)); } @@ -133,27 +170,26 @@ std::string move_chances_text(const PotionCase& c){ return text.empty() ? "none" : text; } -// Against a known foe: a Potion at BattleRisk::HIGH when its next turn -// could faint the lead (potion_now(), the HP from the bar's share) or the -// bar is red, at LOW whenever the bar isn't green; either only if -// potion_pays(). Else, asleep, an Awakening if awakening_now(). +std::string potion_numbers(const PotionCase& c, bool seeded){ + return std::format( + "{}/{} HP{}, the foe's next turn {} HP or less {:.0f}% of the time (mean {:.0f}, up to {}; {}), {} of them before the lead's attacks faint it", + c.hp, c.max_hp, seeded ? " (taken as seeded)" : "", c.likely_hit, 100 * POTION_COVER_PERCENTILE, + c.expected_hit, c.max_hit, move_chances_text(c), c.hits_to_ko + ); +} + +// Against a known foe at BattleRisk::LOW: a Potion whenever the bar isn't +// green, if potion_pays(). Else, asleep, an Awakening if awakening_now(). ItemTurn known_foe_items( const Supplies& supplies, const PartyStatus& status, int awakenings, const SimBattler& own, const PotionCase& c ){ const bool hurt = status.hp_red || status.hp_yellow; - const std::string numbers = std::format( - "{}/{} HP, the foe's next turn {} HP or less {:.0f}% of the time (mean {:.0f}, up to {}; {}), {} of them before the lead's attacks faint it", - c.hp, c.max_hp, c.likely_hit, 100 * POTION_COVER_PERCENTILE, c.expected_hit, c.max_hit, move_chances_text(c), c.hits_to_ko - ); + const std::string numbers = potion_numbers(c, own.seeded); if (awakening_now(c, own, awakenings)){ return {TurnChoice::AWAKENING, "an Awakening: asleep, and the lead outlasts the turn it costs; " + numbers}; } - const bool high = supplies.risk == BattleRisk::HIGH; - if (high && potion_now(c, supplies.potions)){ - return {TurnChoice::POTION, "a Potion: the next turn could faint the lead, and it covers that turn; " + numbers}; - } - if (supplies.potions > 0 && (high ? status.hp_red : hurt) && potion_pays(c)){ + if (supplies.potions > 0 && hurt && potion_pays(c)){ return {TurnChoice::POTION, "a Potion: it covers the next turn, or outlasts the foe; " + numbers}; } if (!hurt && own.status != SimStatus::SLEEP){ @@ -162,15 +198,46 @@ ItemTurn known_foe_items( return {TurnChoice::MOVE, "no Potion: " + no_potion_reason(supplies, status, c) + "; " + numbers}; } -// The item this turn, from the bar and the badge as read. -ItemTurn item_turn(const Supplies& supplies, const PartyStatus& status, double seen_hit, int awakenings){ +// Against a known foe at BattleRisk::HIGH: the win-chance sim's own_turn(), +// so the chance it gives is the one the loop plays for. +ItemTurn sim_items( + const Supplies& supplies, const PartyStatus& status, int awakenings, + const SimBattler& own, const SimBattler& foe, const SimTrainer& trainer, double seen_hit +){ + const std::vector to_come = foes_to_come(supplies, trainer); + const SimItems items{.potions = supplies.potions, .awakenings = awakenings, .antidotes = supplies.antidotes}; + const OwnTurn turn = own_turn(own, items, foe, trainer, to_come.size() + 1, to_come, seen_hit); + const PotionCase c = potion_case(own, foe, trainer, seen_hit); + const std::string numbers = potion_numbers(c, own.seeded); + if (turn.awakening){ + return {TurnChoice::AWAKENING, "an Awakening: asleep, and the lead outlasts the turn it costs; " + numbers}; + } + if (turn.top_up){ + return {TurnChoice::POTION, "a Potion: topping up before the knockout, a stronger foe to come; " + numbers}; + } + if (turn.potion){ + return {TurnChoice::POTION, "a Potion: the next turn could faint the lead, and it covers that turn; " + numbers}; + } + if (!status.hp_red && !status.hp_yellow && own.status != SimStatus::SLEEP){ + return {}; + } + return {TurnChoice::MOVE, "no Potion: " + no_potion_reason(supplies, status, c) + "; " + numbers}; +} + +// The item this turn, from the bar and the badge as read, and the chance +// the lead is seeded. +ItemTurn item_turn(const Supplies& supplies, const PartyStatus& status, double seen_hit, int awakenings, bool seeded){ const std::optional foe = foe_out(supplies); std::optional own = own_battler(supplies.lead, status.hp_seen ? status.hp_fill : 1.0); const std::optional trainer = sim_trainer(supplies.trainer); if (!foe || !own || !trainer){ return unknown_foe_items(supplies, status, seen_hit, awakenings); } - own->status = status.status == StatusCondition::SLEEP ? SimStatus::SLEEP : SimStatus::NONE; + own->status = sim_status(status.status); + own->seeded = seeded; + if (supplies.risk == BattleRisk::HIGH){ + return sim_items(supplies, status, awakenings, *own, *foe, *trainer, seen_hit); + } return known_foe_items(supplies, status, awakenings, *own, potion_case(*own, *foe, *trainer, seen_hit)); } @@ -206,11 +273,16 @@ bool nurse_before_battle(const Supplies& supplies){ BattleDecisions::BattleDecisions(Supplies& supplies, BattleKind kind) : m_supplies(supplies) , m_kind(kind) -{} +{ + if (supplies.lead.level > 0){ + m_level_cap = static_cast(std::min(battle_level(supplies.lead) + MAX_LEVELS_IN_BATTLE, 255)); + } +} TurnAction BattleDecisions::turn(const PartyStatus& status){ m_turns++; m_move_prompts = 0; + m_declined_delete = false; m_before_forget.reset(); count_down_refused(); m_last_move_slot.reset(); @@ -243,9 +315,11 @@ TurnAction BattleDecisions::lead_turn(const PartyStatus& status){ return TurnAction{.choice = TurnChoice::RUN}; } see_hit(status); + see_seed_chance(); // Awakenings are kept for trainers: a wild battle with a status is run from. const int awakenings = m_kind == BattleKind::TRAINER ? m_supplies.awakenings : 0; - const ItemTurn items = m_items_off ? ItemTurn{} : item_turn(m_supplies, status, m_seen_hit, awakenings); + const bool seeded = 1 - m_unseeded > SEEDED_CHANCE; + const ItemTurn items = m_items_off ? ItemTurn{} : item_turn(m_supplies, status, m_seen_hit, awakenings, seeded); if (items.choice != TurnChoice::MOVE){ return TurnAction{.choice = items.choice, .heal_reason = items.reason}; } @@ -258,6 +332,17 @@ TurnAction BattleDecisions::lead_turn(const PartyStatus& status){ return action; } +void BattleDecisions::see_seed_chance(){ + m_unseeded *= 1 - m_seed_pending; + m_seed_pending = 0; + const std::optional foe = foe_out(m_supplies); + const std::optional own = own_battler(m_supplies.lead); + const std::optional trainer = sim_trainer(m_supplies.trainer); + if (foe && own && trainer){ + m_seed_pending = seed_chance(*trainer, *foe, *own); + } +} + void BattleDecisions::see_hit(const PartyStatus& status){ if (m_supplies.foe_out != m_hit_by){ m_seen_hit = 0; @@ -323,8 +408,10 @@ void BattleDecisions::turn_done(TurnChoice choice, TurnResult result){ } const bool done = result == TurnResult::DONE; if (!done && choice == TurnChoice::MOVE){ - if (m_last_move_slot){ + if (m_last_move_slot && !m_no_pp_read.insert(*m_last_move_slot).second){ no_pp_left(m_supplies.lead, *m_last_move_slot); + }else{ + move_refused(); } return; } @@ -379,6 +466,10 @@ PromptAnswer BattleDecisions::prompt(bool lead_box_gone){ } PartyMember learning = m_supplies.lead; const LearnDecision decision = learn_move(learning); + if (!decision.move || (m_level_cap && battle_level(learning) > *m_level_cap)){ + // No move the lead can have reached: not a move prompt. + return PromptAnswer{.answer = YesNo::NO}; + } if (decision.forget_slot){ m_before_forget = m_supplies.lead; return PromptAnswer{.answer = YesNo::YES, .forget_slot = decision.forget_slot, .learned = std::move(learning)}; @@ -456,6 +547,10 @@ void MoveRefusalWatch::no_battle_screen(){ m_list_back = m_chosen.has_value(); } +bool MoveRefusalWatch::no_pp_due(WallClock now) const{ + return m_chosen && now - *m_chosen < REFUSED_WITHIN; +} + bool MoveRefusalWatch::refused(WallClock now){ const bool refused = m_chosen && m_list_back && now - *m_chosen < REFUSED_WITHIN; m_chosen.reset(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h index 5db4217cb5..b574e72cfc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h @@ -20,6 +20,7 @@ #define PokemonAutomation_PokemonFRLG_AutoStoryBattleDecisions_H #include +#include #include #include #include @@ -55,7 +56,10 @@ struct TurnAction{ enum class TurnResult{ DONE, // An item not used (another item in its row), or a move the game said - // has no PP left. + // has no PP left. The first time for a move it is taken as turned + // down (REFUSED): Kadabra's Disable, or a misread (console B, + // 2026-11-03, m68: "no PP left" read after the battle's last turn); the + // second time its PP is 0. NOT_DONE, // The game turned the move down although it has PP left: a foe's // Disable (console B, 2026-11-03, Mt. Moon: Miguel's Grimer disabled @@ -106,11 +110,15 @@ class BattleDecisions{ // A YES/NO in or after the battle. With the lead's box gone and the // opponent's there, it is "Use next POKEMON?"; else the move prompts: - // "Delete a move to make room?", then "Stop learning?" after a NO, or - // after YES and a move list closed with nothing forgotten. Past - // MOVE_PROMPTS since the last turn, a plain YES, which ends them - // either way (console B, 2026-11-03: NO and YES out of step with the - // game went round for 75 s). + // "Delete a move to make room?", then "Stop learning?" after a NO (in + // the same turn), or after YES and a move list closed with nothing + // forgotten. Past MOVE_PROMPTS since the last turn, a plain YES, which + // ends them either way (console B, 2026-11-03: NO and YES out of step + // with the game went round for 75 s). With no move the lead can have + // reached to offer (none left, or one more than MAX_LEVELS_IN_BATTLE + // above its level read before the battle), it is no move prompt, such + // as the trainer's "Will change POKEMON?": NO, and the lead + // is left as it was. PromptAnswer prompt(bool lead_box_gone); // The party list opened with the lead fainted: the slot to send in, @@ -130,6 +138,8 @@ class BattleDecisions{ void see_hit(const PartyStatus& status); void count_down_refused(); void move_refused(); + // The chance the foe's last turn seeded the lead taken in. + void see_seed_chance(); Supplies& m_supplies; const BattleKind m_kind; @@ -158,6 +168,16 @@ class BattleDecisions{ std::optional m_hit_by; // The slots the game turned down, and the turns until they are tried again. std::map m_refused; + // The slots "no PP left" was read for once. + std::set m_no_pp_read; + // The highest level a move offered can be for, from the lead's level + // read before the battle; none if it wasn't read. + std::optional m_level_cap; + // The chance the lead isn't seeded, from the foes' Leech Seed chances + // at each turn (the game doesn't show it), and the chance at the turn + // just chosen, taken in at the next one. + double m_unseeded = 1.0; + double m_seed_pending = 0; // The members sent in after the lead fainted. std::set m_sent; }; @@ -231,6 +251,10 @@ class BattleEndWatch{ class MoveRefusalWatch{ public: void move_chosen(WallClock now); + // Whether a "no PP left" read at `now` counts: no later than a + // refusal's menu would come (console B, 2026-11-03, m68: one read 37 s + // after the battle's last move, as the battle ended). + bool no_pp_due(WallClock now) const; // No battle screen read while the battle goes on. void no_battle_screen(); // At the battle menu: true if the move chosen before it was turned down. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp index 9ac8679470..83712be4c0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp @@ -25,6 +25,11 @@ constexpr int NOT_STRONGEST = 1; constexpr int MAX_STAGE = 6; // More turns than this to faint a foe counts as never. constexpr size_t TURNS_CAP = 30; +// A top-up gives the foe a turn: not when that turn lowers the lead's +// stages or gives it a status this often (the S.S. Anne rival's Pidgeotto, +// Sand-Attack; Raticate, Tail Whip: topping up against them lost more +// battles to the Ivysaur than it won). +constexpr double MAX_TOP_UP_HARM = 0.15; // The hits a multi-hit move is taken to land when sizing a foe's hit. constexpr uint32_t MULTI_HITS = 3; constexpr uint32_t CRIT_CHANCE = 16; @@ -688,6 +693,7 @@ PotionCase potion_case(const SimBattler& own, const SimBattler& foe, const SimTr .expected_hit = std::max(next.mean(), seen_hit), .likely_hit = std::max(next.percentile(POTION_COVER_PERCENTILE), seen), .max_hit = std::max(next.most(), seen), + .turns_to_ko = turns, .hits_to_ko = hits, .move_chances = std::move(chances), }; @@ -718,33 +724,98 @@ bool awakening_now(const PotionCase& c, const SimBattler& own, int awakenings){ return c.max_hit > 0 && own.hp > c.likely_hit; } +double seed_chance(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& own){ + const std::vector chances = ai_move_chances(trainer, foe, own); + for (size_t slot = 0; slot < foe.moves.size(); slot++){ + const MoveData& move = data_of(foe.moves[slot].move); + if (foe.moves[slot].move == "leech-seed" && !own.seeded){ + return chances[slot] * moves_this_turn(foe) * hit_percent(foe, own, move) / 100.0; + } + } + return 0; +} + +namespace{ + +// The chance the foe's turn (`c`) leaves the lead worse off for the rest of +// the battle: a stat stage down, a status or Leech Seed, by a move that does +// no damage. +double lasting_harm_chance(const PotionCase& c){ + double chance = 0; + for (const auto& [move, picked] : c.move_chances){ + const MoveData& data = data_of(move); + const std::string_view effect = data.effect; + const bool lasting = effect.ends_with("-down") || effect == "poison" || effect == "toxic" + || effect == "sleep" || effect == "paralyze" || effect == "leech-seed"; + chance += data.power == 0 && lasting ? picked : 0; + } + return chance; +} + +} + +bool top_up_now( + const PotionCase& c, const SimBattler& own, const SimTrainer& trainer, + const std::vector& to_come, int potions +){ + const double heal = std::min(POTION_HP, c.max_hp - c.hp); + if (potions <= 0 || c.turns_to_ko != 1 || 2 * c.expected_hit > POTION_HP || heal - c.expected_hit < POTION_HP / 4.0){ + return false; + } + if (lasting_harm_chance(c) > MAX_TOP_UP_HARM){ + return false; + } + return std::ranges::any_of(to_come, [&](const SimBattler& next){ + return next.hp > 0 && next_turn_damage(trainer, next, own).mean() > c.expected_hit; + }); +} + +namespace{ + +// own_turn()'s planned move when it surely faints `foe` first. +bool sure_first_ko( + const SimBattler& own, const SimBattler& foe, const std::vector& items, + size_t foes_left, std::optional slot +){ + if (!slot || own.status == SimStatus::SLEEP){ + return false; + } + const MoveData& move = data_of(own.moves[*slot].move); + const bool foe_priority = std::ranges::any_of(foe.moves, [](const SimMove& known){ + return known.pp > 0 && data_of(known.move).priority > 0; + }); + const bool first = speed_of(own) > speed_of(foe) && !foe_priority && move.priority >= 0; + const bool sure = move.accuracy == 0 || move.accuracy >= 100; + return first && sure && lowest_roll(own, foe, move) >= foe.hp && !ai_item_choice(foe, items, foes_left); +} + +} + OwnTurn own_turn( const SimBattler& own, const SimItems& own_items, const SimBattler& foe, - const SimTrainer& trainer, size_t foes_left + const SimTrainer& trainer, size_t foes_left, + const std::vector& to_come, double seen_hit ){ - const std::vector& items = trainer.items; - const std::optional slot = planned_attack_slot(own, foe, items, foes_left); - if (slot && own.status != SimStatus::SLEEP){ - const MoveData& move = data_of(own.moves[*slot].move); - const bool foe_priority = std::ranges::any_of(foe.moves, [](const SimMove& known){ - return known.pp > 0 && data_of(known.move).priority > 0; - }); - const bool first = speed_of(own) > speed_of(foe) && !foe_priority && move.priority >= 0; - const bool sure = move.accuracy == 0 || move.accuracy >= 100; - if (first && sure && lowest_roll(own, foe, move) >= foe.hp && !ai_item_choice(foe, items, foes_left)){ - return OwnTurn{.potion = false, .slot = slot}; - } - } + const std::optional slot = planned_attack_slot(own, foe, trainer.items, foes_left); const bool may_heal = own_items.potions > 0 && own.hp < own.body.stats.hp; const bool may_wake = own_items.awakenings > 0 && own.status == SimStatus::SLEEP; - if (!may_heal && !may_wake){ + std::optional c; + if (may_heal && !to_come.empty() && own.status != SimStatus::SLEEP){ + c = potion_case(own, foe, trainer, seen_hit); + if (top_up_now(*c, own, trainer, to_come, own_items.potions)){ + return OwnTurn{.potion = true, .slot = std::nullopt, .awakening = false, .top_up = true}; + } + } + if (sure_first_ko(own, foe, trainer.items, foes_left, slot) || (!may_heal && !may_wake)){ return OwnTurn{.potion = false, .slot = slot}; } - const PotionCase c = potion_case(own, foe, trainer); - if (awakening_now(c, own, own_items.awakenings)){ + if (!c){ + c = potion_case(own, foe, trainer, seen_hit); + } + if (awakening_now(*c, own, own_items.awakenings)){ return OwnTurn{.potion = false, .slot = std::nullopt, .awakening = true}; } - if (potion_now(c, own_items.potions)){ + if (potion_now(*c, own_items.potions)){ return OwnTurn{.potion = true, .slot = std::nullopt}; } return OwnTurn{.potion = false, .slot = slot}; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h index 99a91486b0..8ad58572ca 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h @@ -177,6 +177,10 @@ struct NextTurnDamage{ }; NextTurnDamage next_turn_damage(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& own); +// The chance `foe`'s next turn seeds `own`: the AI's chance of Leech Seed +// times its accuracy. 0 if it has none, or `own` is seeded or Grass. +double seed_chance(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& own); + // The party slot the AI sends out after `fainted`, of the ones still up. size_t ai_next_foe(const std::vector& party, size_t fainted, const SimBattler& lead); @@ -207,8 +211,9 @@ struct PotionCase{ double expected_hit = 0; uint32_t likely_hit = 0; uint32_t max_hit = 0; - // The foe's hits the lead takes before its attacks faint the foe, if - // it attacks from now on. + // The lead's attacks that faint the foe at the mean, and the foe's hits + // the lead takes before they do, if it attacks from now on. + size_t turns_to_ko = 0; size_t hits_to_ko = 0; // The foe's moves and ai_move_chances(), for the log. std::vector> move_chances = {}; @@ -240,6 +245,17 @@ bool potion_now(const PotionCase& c, int potions); // rules say. bool awakening_now(const PotionCase& c, const SimBattler& own, int awakenings); +// Task #77's top-up before the knockout, with `potions` left: the lead's +// next attack faints the foe out at the mean, whose turn takes at most +// half a Potion on average (`c`), a Potion heals at least a quarter of its +// HP more than that, and a foe in `to_come` hits harder on average +// (next_turn_damage()). Each such turn costs a small hit and heals more, +// so the lead meets the stronger foe near full. +bool top_up_now( + const PotionCase& c, const SimBattler& own, const SimTrainer& trainer, + const std::vector& to_come, int potions +); + // The lead's turn at BattleRisk::HIGH. struct OwnTurn{ bool potion = false; @@ -247,15 +263,20 @@ struct OwnTurn{ // damage. std::optional slot = std::nullopt; bool awakening = false; + // The Potion is top_up_now()'s. + bool top_up = false; }; -// planned_attack_slot()'s move when it surely faints `foe` first (the -// lead is awake and faster, the lowest roll faints it, it never misses and -// the AI won't heal first), else a Potion if potion_now(), else an -// Awakening if awakening_now(), else that move. `trainer`: its AI and the -// items it has left. +// A Potion if top_up_now() (`to_come`: the trainer's Pokemon not out and +// not fainted), else planned_attack_slot()'s move when it surely faints +// `foe` first (the lead is awake and faster, the lowest roll faints it, it +// never misses and the AI won't heal first), else a Potion if +// potion_now(), else an Awakening if awakening_now(), else that move. +// `trainer`: its AI and the items it has left. `seen_hit`: as +// potion_case() takes it. OwnTurn own_turn( const SimBattler& own, const SimItems& own_items, const SimBattler& foe, - const SimTrainer& trainer, size_t foes_left + const SimTrainer& trainer, size_t foes_left, + const std::vector& to_come = {}, double seen_hit = 0 ); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp index 2744920774..2fcef59a91 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp @@ -10,6 +10,7 @@ */ #include +#include #include #include #include @@ -682,6 +683,109 @@ UnitTestResult test_ss_anne_rival(){ return true; } +// Regression, console B 2026-11-03, m68, the S.S. Anne rival: a YES/NO +// read at "RIVAL GREEN sent out RATICATE!" (turn 5) and "...KADABRA!" +// (turn 7), with no move to learn. The first was taken for "Delete a move +// to make room for PROTECT?" (Lv31): NO, and "Stop learning?" due, which +// the second got: YES. From then on the lead was figured at Lv31: "34/88 +// HP" for 31/78. Neither is a move prompt: NO, the lead as it was. +UnitTestResult test_ss_anne_send_out_prompts(){ + Supplies supplies; + supplies.lead = ss_anne_lead(); + supplies.trainer = std::string(SS_ANNE_RIVAL); + supplies.foes = trainer_party(SS_ANNE_RIVAL); + supplies.risk = BattleRisk::HIGH; + const PartyMember before = supplies.lead; + BattleDecisions decisions(supplies, BattleKind::TRAINER); + const PartyStatus green{.hp_seen = true, .hp_yellow = false, .hp_red = false, .hp_fill = 1.0, .status = StatusCondition::NONE}; + for (int turn = 1; turn <= 7; turn++){ + decisions.turn(green); + if (turn != 5 && turn != 7){ + continue; + } + const PromptAnswer answer = decisions.prompt(false); + if (answer.answer != YesNo::NO || answer.forget_slot){ + return std::format("Turn {}: expected NO to a YES/NO with no move to learn.", turn); + } + } + if (battle_level(supplies.lead) != 27 || supplies.lead.moves_through_level != before.moves_through_level){ + return std::format("Expected the lead still at Lv27, got Lv{}.", battle_level(supplies.lead)); + } + const uint16_t max_hp = own_battler(supplies.lead)->body.stats.hp; + return max_hp == 78 ? UnitTestResult(true) : UnitTestResult(std::format("Expected 78 max HP, as the screen shows, got {}.", max_hp)); +} + +// The battle loop's Potion against a known foe at BattleRisk::HIGH is the +// win-chance sim's (own_turn()), poisoned or not, so the chance the sim +// gives is the one the loop plays for. Before, the loop left out the +// poison's eighth a turn, and used a Potion at red HP the sim wouldn't. +// Whether the loop and the sim agree on a Potion against the rival's +// `slot`, out first, at `percent` of the lead's HP with `status`. +UnitTestResult loop_potion_is_sim(size_t slot, StatusCondition status, int percent){ + const SimTrainer rival = *sim_trainer(SS_ANNE_RIVAL); + Supplies supplies; + supplies.lead = ss_anne_lead(); + supplies.trainer = std::string(SS_ANNE_RIVAL); + supplies.foes = trainer_party(SS_ANNE_RIVAL); + supplies.risk = BattleRisk::HIGH; + supplies.potions = 15; + supplies.foe_out = slot; + const double fill = percent / 100.0; + const PartyStatus read{.hp_seen = true, .hp_yellow = fill <= 0.5 && fill > 0.2, .hp_red = fill <= 0.2, .hp_fill = fill, .status = status}; + BattleDecisions decisions(supplies, BattleKind::TRAINER); + const bool loop = decisions.turn(read).choice == TurnChoice::POTION; + SimBattler own = *own_battler(ss_anne_lead(), fill); + own.status = status == StatusCondition::POISON ? SimStatus::POISON : SimStatus::NONE; + std::vector to_come = rival.party; + to_come.erase(to_come.begin() + static_cast(slot)); + const bool sim = own_turn(own, SimItems{.potions = 15}, rival.party[slot], rival, rival.party.size(), to_come).potion; + if (loop == sim){ + return true; + } + return std::format( + "{} at {}% HP, {}: the loop {} a Potion, the sim {}.", rival.party[slot].species, percent, + status_name(status), loop ? "uses" : "doesn't use", sim ? "does" : "doesn't" + ); +} + +UnitTestResult test_loop_potion_is_sim(){ + for (size_t slot : {size_t(1), size_t(3)}){ + for (StatusCondition status : {StatusCondition::NONE, StatusCondition::POISON}){ + for (int percent = 5; percent < 100; percent += 5){ + const UnitTestResult result = loop_potion_is_sim(slot, status, percent); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + } + } + return true; +} + +// Task #77, the top-up: against the S.S. Anne rival's Kadabra, which the +// lead's next attack faints and whose turn takes little (Teleport, +// Disable), a Potion at 45/78 HP while the Ivysaur is to come; not with +// nothing to come, nor near full HP. +UnitTestResult test_top_up(){ + const SimTrainer rival = *sim_trainer(SS_ANNE_RIVAL); + const SimBattler& kadabra = rival.party[2]; + const std::vector to_come{rival.party[3]}; + SimBattler own = *own_battler(ss_anne_lead()); + own.hp = 45; + const PotionCase c = potion_case(own, kadabra, rival); + if (!own_turn(own, SimItems{.potions = 15}, kadabra, rival, 2, to_come).top_up){ + return std::format("45/78 HP against the Kadabra (mean hit {:.1f}, {} turns to faint it): expected a top-up.", c.expected_hit, c.turns_to_ko); + } + if (own_turn(own, SimItems{.potions = 15}, kadabra, rival, 1).potion){ + return "Expected no top-up with no foe to come."; + } + own.hp = 70; + if (own_turn(own, SimItems{.potions = 15}, kadabra, rival, 2, to_come).potion){ + return "Expected no top-up at 70/78 HP."; + } + return true; +} + void add_tests_AutoStoryBattleSim(UnitTestDatabase& database){ database.add("Gen 3 stats and damage worked by hand", test_hand_numbers); database.add("the Elite Four Farmer's Starmie picks OHKO at Sp. Atk 302", test_elite_four_table); @@ -702,6 +806,9 @@ void add_tests_AutoStoryBattleSim(UnitTestDatabase& database){ database.add("Cerulean rival: 15 Potions win over half", test_rival_potions_raise_chance); database.add("the foe's next-turn damage and move chances", test_next_turn_damage); database.add("S.S. Anne rival: lost to the Ivysaur, seeded", test_ss_anne_rival); + database.add("S.S. Anne rival: the send-outs' YES/NO is NO, the lead kept at Lv27", test_ss_anne_send_out_prompts); + database.add("the battle loop's Potion is the sim's, poisoned or not", test_loop_potion_is_sim); + database.add("a top-up before the knockout, a stronger foe to come", test_top_up); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.h index 2dc37a8b90..6b699b58ad 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.h @@ -53,6 +53,9 @@ class TrainerFoeTracker{ // The party slot out; none when the trainer isn't known. std::optional out() const; + // The party slots seen fainted. + const std::set& fainted() const{ return m_fainted; } + private: std::vector m_party; std::vector m_species; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp index 5bb4224fba..37d6b31da7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp @@ -26,6 +26,8 @@ #include "CommonFramework/Language.h" #include "Tests/ImageUnitTest.h" #include "Tests/TestUtils.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" @@ -357,6 +359,52 @@ class Test_BattleShows : public ImageUnitTest{ std::optional m_expected; }; +// battle_yes_no_shows() on console B's captures named +// _.png. +class Test_BattleYesNo : public ImageUnitTest{ +public: + Test_BattleYesNo(const std::string& image) + : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + bool expected = false; + const std::vector words = parse_words(Filesystem::Path(image_paths()[0]).stem().string()); + if (words.empty() || !parse_bool(words.back(), expected)){ + return "Error: filename must be _."; + } + return battle_yes_no_shows(images[0]) == expected; + } +}; + +// read_opponent_hp_fill() on console B's captures named +// _FoeHp_of48.png: the bar's filled share within a pixel. +class Test_FoeHp : public ImageUnitTest{ +public: + Test_FoeHp(const std::string& image) + : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + const std::vector words = parse_words(Filesystem::Path(image_paths()[0]).stem().string()); + int pixels = -1; + if (words.empty() || words.back().find("of48") == std::string::npos || !parse_int(words.back().substr(0, words.back().find("of48")), pixels)){ + return "Error: filename must be _FoeHp_of48."; + } + const std::optional fill = read_opponent_hp_fill(images[0]); + if (!fill || std::abs(*fill - pixels / 48.0) > 1.0 / 48){ + return std::format("Expected {} of 48 pixels, got {:.1f}.", pixels, fill ? 48 * *fill : -1.0); + } + return true; + } +}; + // read_party_status() on console B's captures named // _Hp_of[_].png: the bar's color, // and its colored share within a pixel of the 48-pixel bar of hp / max hp. @@ -428,6 +476,20 @@ void add_tests_AutoStoryItems(UnitTestDatabase& database){ }){ database.add(std::string("PokemonFRLG/AutoStoryBattle/") + image); } + // The YES/NO to "Delete a move to make room for BITE?" (Miguel), and the + // S.S. Anne rival's and Misty's send-outs, which the move prompt's + // detector alone took for one (console B, 2026-11-03). + for (const char* image : { + "Miguel_DeleteMoveForBite_YesNo_True.png", + "SSAnneRival_SentOutRaticate_YesNo_False.png", + "SSAnneRival_SentOutKadabra_YesNo_False.png", + "Misty_SentOutStarmie_YesNo_False.png", + }){ + database.add(std::string("PokemonFRLG/AutoStoryBattle/") + image); + } + // The S.S. Anne rival's Pidgeotto at full HP, its Raticate low. + database.add("PokemonFRLG/AutoStoryBattle/SSAnneRival_Pidgeotto_FoeHp_48of48.png"); + database.add("PokemonFRLG/AutoStoryBattle/SSAnneRival_Raticate_FoeHp_7of48.png"); // The battle's choices, registered here to keep add_tests() within its size. add_tests_AutoStoryParty(database); } 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 4e55d3a6e6..86a68373c9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp @@ -710,6 +710,56 @@ UnitTestResult test_disabled_move(Logger&){ return true; } +// Regression, console B 2026-11-03, m68, the S.S. Anne rival: "no PP left +// for that move" read 37 s after the battle's last move, as it ended, and +// the story took the move's PP as 0. Read once, a move is left alone as a +// Disable (Kadabra's) would have it, its PP kept; read again for the same +// move, its PP is 0. +UnitTestResult test_no_pp_read_once(Logger&){ + Supplies supplies; + supplies.lead = squirtle_before_miguel(); + supplies.foes = {"grimer", "voltorb", "koffing"}; + BattleDecisions decisions(supplies, BattleKind::TRAINER); + if (decisions.turn(GREEN).slot != TOP_LEFT_MOVE){ + return "Expected Water Gun (slot 0) at turn 1."; + } + decisions.turn_done(TurnChoice::MOVE, TurnResult::NOT_DONE); + if (supplies.lead.moves[TOP_LEFT_MOVE].pp != 25){ + return std::format("Expected Water Gun's 25 PP kept after one \"no PP\" read, got {}.", supplies.lead.moves[TOP_LEFT_MOVE].pp); + } + for (int c = 0; c < 5; c++){ + if (decisions.turn(GREEN).slot == TOP_LEFT_MOVE){ + return std::format("Expected Water Gun left alone at turn {} after the read, as a Disable.", c + 1); + } + } + if (decisions.turn(GREEN).slot != TOP_LEFT_MOVE){ + return "Expected Water Gun again once a Disable would be over."; + } + decisions.turn_done(TurnChoice::MOVE, TurnResult::NOT_DONE); + if (supplies.lead.moves[TOP_LEFT_MOVE].pp != 0){ + return std::format("Expected Water Gun at 0 PP after a second read, got {}.", supplies.lead.moves[TOP_LEFT_MOVE].pp); + } + return true; +} + +// "no PP left" counts only as soon after the choice as a refusal's menu +// comes: m68's read 37 s after the battle's last move doesn't. +UnitTestResult test_no_pp_due(Logger&){ + const WallClock start = current_time(); + MoveRefusalWatch watch; + if (watch.no_pp_due(start)){ + return "Expected no \"no PP\" read counted with no move chosen."; + } + watch.move_chosen(start); + if (!watch.no_pp_due(start + 4000ms)){ + return "Expected a \"no PP\" read 4 s after the choice counted."; + } + if (watch.no_pp_due(start + 37000ms)){ + return "Expected a \"no PP\" read 37 s after the choice not counted."; + } + return true; +} + // The loop's view of a move turned down (console B, 2026-11-03): the move // list back (no battle screen) and the battle menu 5.7 s after the choice. // Not a turn (8.3 s and up, with no move list), nor a menu long after one @@ -834,6 +884,8 @@ void add_tests_AutoStoryParty(UnitTestDatabase& database){ database.add("a YES/NO with no move list after it changes no move", test_yes_no_without_move_list); database.add("a disabled move: left alone while Disable lasts, PP back", test_disabled_move); database.add("a move turned down: the move list, then the menu sooner than a turn", test_move_refusal_watch); + database.add("\"no PP\" read once: left alone as a Disable; twice: PP 0", test_no_pp_read_once); + database.add("\"no PP\" counts only soon after the move chosen", test_no_pp_due); database.add("the move list closed with nothing forgotten: YES to \"Stop learning?\"", test_move_list_closed); database.add("move prompts after a turn: a plain YES after the first few", test_move_prompts_bounded); database.add("the nurse before a battle for HP not green with no Potion", test_nurse_before_battle); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp index ebe60b5db4..8ed43490e2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp @@ -130,6 +130,17 @@ class SimBattle{ })); } + // The trainer's Pokemon not out and not fainted. + std::vector to_come() const{ + std::vector rest; + for (size_t slot = 0; slot < m_trainer.party.size(); slot++){ + if (slot != m_out && m_trainer.party[slot].hp > 0){ + rest.push_back(m_trainer.party[slot]); + } + } + return rest; + } + bool send_next(){ if (foes_left() == 0){ return false; @@ -161,7 +172,7 @@ class SimBattle{ void play_turn(){ const size_t left = foes_left(); m_psn_turns = m_own.status == SimStatus::POISON ? m_psn_turns + 1 : 0; - const OwnTurn own = own_turn(m_own, m_items, foe(), m_trainer, left); + const OwnTurn own = own_turn(m_own, m_items, foe(), m_trainer, left, to_come()); const bool antidote = !own.potion && !own.awakening && antidote_now(); const std::optional item = ai_item_choice(foe(), m_trainer.items, left); const std::optional foe_slot = item ? std::nullopt : foe_move(); From 6f17c46d71907c5c699c921f4487379cc8bb45ab Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 16:42:33 -0800 Subject: [PATCH 07/21] FRLG AutoStory 00.23-00.25: Cut, Vermilion Gym's trash cans, Lt. Surge 00.23 teaches HM01 Cut to the Oddish and TM28 Dig in place of Withdraw, cuts Vermilion's gym tree (a new CUT_TREE GAME step), fights Dwayne, Baily and Tucker, and searches the trash cans: two pages to a can's message mean the first switch (or a reset after it), one page the second (or trash), so the search counts pages, guesses one can beside the first and starts over from where it stands after a reset. Dwayne and Baily look around, so each is met by a step onto the second tile of his line and a wait (TRAINER_WAIT_VARIANT). 00.24 grinds on Route 6 until the sim beats Surge 50% of the time, heals, and saves in front of him; 00.25 fights him and heals. The gym map writes the beams off, as the story finds them after the switches; the sim learns to cut trees, which grow back on a warp. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Overworld/Maps/VermilionCity.json | 7 +- .../Overworld/Maps/VermilionCity_Gym.json | 137 ++++++ .../Overworld/PokemonFRLG_OverworldMaps.cpp | 2 + .../PokemonFRLG_OverworldMaps_Tests.cpp | 52 +++ .../Overworld/PokemonFRLG_OverworldSim.cpp | 24 ++ .../Overworld/PokemonFRLG_OverworldSim.h | 8 + .../PokemonFRLG/PokemonFRLG_Tests.cpp | 3 + .../PokemonFRLG_AutoStoryActions.cpp | 28 +- .../AutoStory/PokemonFRLG_AutoStoryActions.h | 18 +- .../PokemonFRLG_AutoStoryBattleData.cpp | 5 + .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 91 +++- .../AutoStory/PokemonFRLG_AutoStoryEvents.h | 22 + .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 33 +- .../AutoStory/PokemonFRLG_AutoStoryRowSim.h | 4 +- .../PokemonFRLG_AutoStoryRunPlan_Tests.cpp | 8 +- .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 183 +------- .../AutoStory/PokemonFRLG_AutoStoryTable.h | 10 +- .../PokemonFRLG_AutoStoryTableSteps.cpp | 158 +++++++ .../PokemonFRLG_AutoStoryTableSteps.h | 127 ++++++ ...PokemonFRLG_AutoStoryTableVermilionGym.cpp | 142 +++++++ .../PokemonFRLG_AutoStoryTrashCans.cpp | 154 +++++++ .../PokemonFRLG_AutoStoryTrashCans.h | 137 ++++++ .../PokemonFRLG_AutoStoryTrashCans_Tests.cpp | 268 ++++++++++++ ...okemonFRLG_AutoStoryVermilionGym_Tests.cpp | 400 ++++++++++++++++++ .../AutoStory/PokemonFRLG_AutoStory_Tests.cpp | 38 +- SerialPrograms/cmake/SourceFiles.cmake | 7 + devtools/maps/frlg_maps.py | 13 + 27 files changed, 1858 insertions(+), 221 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_Gym.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans_Tests.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json index ca7736267c..3e6026ea80 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json @@ -93,7 +93,8 @@ "warp_0": [22, 34], "warp_1": [23, 34], "warp_2": [24, 34], - "warp_4": [15, 6] + "warp_4": [15, 6], + "warp_9": [14, 25] }, "warps": [ { @@ -152,7 +153,9 @@ { "x": 14, "y": 25, - "enter": "walk_into" + "enter": "walk_into", + "to_map": "VermilionCity_Gym", + "to_point": "warp_1" } ], "objects": [ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_Gym.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_Gym.json new file mode 100644 index 0000000000..2ed0d496e1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_Gym.json @@ -0,0 +1,137 @@ +{ + "name": "VermilionCity_Gym", + "source": { + "decomp_file": "data/maps/VermilionCity_Gym/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "###########", + "###########", + "#..#...#..#", + "#.........#", + "#.........#", + "#.........#", + "####...####", + "####...####", + "...........", + "...........", + ".#.#.#.#.#.", + "...........", + ".#.#.#.#.#.", + "...........", + ".#.#.#.#.#.", + "...........", + "...........", + "...S...S...", + "...........", + "...........", + "###########" + ], + "elevation": [ + "00000000000", + "00000000000", + "03303330330", + "03333333330", + "03333333330", + "03333333330", + "00000000000", + "00000000000", + "33333333333", + "33333333333", + "30303030303", + "33333333333", + "30303030303", + "33333333333", + "30303030303", + "33333333333", + "33333333333", + "33303330333", + "33333333333", + "33333333333", + "00000000000" + ], + "points": { + "warp_1": [5, 19] + }, + "warps": [ + { + "x": 4, + "y": 19, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_9" + }, + { + "x": 5, + "y": 19, + "enter": "walk_south", + "to_map": "VermilionCity", + "to_point": "warp_9" + }, + { + "x": 6, + "y": 19, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_9" + } + ], + "objects": [ + { + "name": "lt_surge", + "kind": "npc", + "x": 5, + "y": 2 + }, + { + "name": "balding_man", + "kind": "npc", + "x": 2, + "y": 11, + "trainer": { + "sight": 3, + "looks": [ + "north", + "east", + "south", + "west" + ] + } + }, + { + "name": "sailor", + "kind": "npc", + "x": 8, + "y": 13, + "trainer": { + "sight": 3, + "looks": [ + "north", + "east", + "south", + "west" + ] + } + }, + { + "name": "gym_guy", + "kind": "npc", + "x": 4, + "y": 17 + }, + { + "name": "gentleman", + "kind": "npc", + "x": 7, + "y": 8, + "facing": "west", + "trainer": { + "sight": 3 + } + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp index f6c27b6cb9..c5d00a8bbc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -69,6 +69,8 @@ const char* const MAP_NAMES[] = { "SSAnne_1F_Corridor", "SSAnne_2F_Corridor", "SSAnne_CaptainsOffice", + // Vermilion Gym, past the tree Cut fells. + "VermilionCity_Gym", "CeruleanCave_1F", "CeruleanCave_2F", "CeruleanCave_B1F", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp index c16fb91f76..cd1965ed83 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp @@ -4,6 +4,7 @@ * */ +#include #include #include #include @@ -398,6 +399,56 @@ UnitTestResult test_vermilion_maps(){ return true; } +// Vermilion Gym loads and joins the city by its door (14, 25), behind the +// tree (19, 24), an object Cut removes: from the Center no route on land +// reaches the gym, from the tree's tile one does. The beams on rows 6 and +// 7 are written off (OPENED_TILES), so a route reaches Lt. Surge (5, 2); +// the cans on rows 10, 12 and 14 are walls. Dwayne (8, 13) and Baily +// (2, 11) look every way, sight 3; Tucker (7, 8) west. +UnitTestResult test_vermilion_gym_map(){ + const std::vector maps = overworld_maps(map_flag_set(overworld_maps(), "new_game")); + const MapData* gym = built_in(maps, "VermilionCity_Gym"); + if (gym == nullptr){ + return "Missing map VermilionCity_Gym."; + } + RouteCosts walking = default_route_costs(); + walking[RouteCost::SURF] = -1; + const MapPosition nurse{"VermilionCity_PokemonCenter_1F", {7, 4}}; + const RouteGoal door{MapPosition{"VermilionCity_Gym", {5, 18}}, std::nullopt}; + if (plan_overworld_route(maps, nurse, door, walking, Direction::NORTH)){ + return "Expected the tree (19, 24) to shut the gym off from the Center."; + } + if (!plan_overworld_route(maps, MapPosition{"VermilionCity", {19, 24}}, door, walking, Direction::SOUTH)){ + return "No route from the tree's tile into the gym."; + } + if (!plan_overworld_route(maps, MapPosition{"VermilionCity_Gym", {5, 8}}, RouteGoal{MapPosition{"VermilionCity_Gym", {5, 3}}, Direction::NORTH}, walking, Direction::NORTH)){ + return "Expected a route through the beams, off, to Lt. Surge."; + } + for (int can : {1, 3, 5, 7, 9}){ + for (int row : {10, 12, 14}){ + if (tile_at(*gym, TilePos{can, row}) != TileKind::WALL){ + return std::format("Expected a can at ({}, {}).", can, row); + } + } + } + struct Seen{ + const char* name; + TilePos tile; + bool seen; + }; + for (const Seen& check : std::initializer_list{ + {"sailor", {8, 15}, true}, {"sailor", {7, 15}, false}, {"sailor", {5, 13}, true}, {"sailor", {4, 13}, false}, + {"balding_man", {2, 13}, true}, {"balding_man", {3, 13}, false}, {"balding_man", {5, 11}, true}, {"balding_man", {6, 11}, false}, + {"gentleman", {4, 8}, true}, {"gentleman", {5, 9}, false}, {"gentleman", {8, 8}, false}, + }){ + const auto object = std::find_if(gym->objects.begin(), gym->objects.end(), [&](const MapObject& o){ return o.name == check.name; }); + if (object == gym->objects.end() || trainer_may_see(*gym, *object, check.tile) != check.seen){ + return std::format("{} {} ({}, {}).", check.name, check.seen ? "should see" : "shouldn't see", check.tile.x, check.tile.y); + } + } + return true; +} + // A planned step walks where its map doesn't let the player run, and only // there: out of Pewter's Pokemon Center to the Gym. UnitTestResult test_walk_only_indoors(){ @@ -601,6 +652,7 @@ void add_tests_OverworldMaps(UnitTestDatabase& database){ database.add("Mt. Moon's floor closed on one side is wild", test_mt_moon_closed_floor_wild); database.add("Cerulean's Mart and Gym, Route 25 and Bill's Sea Cottage", test_cerulean_maps); database.add("Route 5 and 6 to Vermilion City and the S.S. Anne", test_vermilion_maps); + database.add("Vermilion Gym: behind the tree, beams, cans and trainers' sight", test_vermilion_gym_map); database.add("walks where running isn't allowed", test_walk_only_indoors); database.add("Route 4's Pokemon Center escalator", test_route4_center_escalator); database.add("trainer sight", test_trainer_sight); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 113099a1fb..25e4dbaf30 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -53,6 +53,8 @@ constexpr Milliseconds TAP_TIME(340); constexpr Milliseconds NO_BATTLE_WAIT(15000); constexpr Milliseconds BATTLE_TIME(10000); constexpr Milliseconds MESSAGE_CLOSE(1000); +// The questions, " used CUT!" and the tree falling. +constexpr Milliseconds CUT_TIME(4000); // Rows of black below a small room, seen from this near its bottom. constexpr int BLACK_BELOW_ROWS = 5; constexpr int SMALL_ROOM_ROWS = 20; @@ -301,6 +303,10 @@ bool OverworldSim::after_step(const Step& step, Milliseconds pressed){ update_view(); if (step.warped){ m_state.warps++; + for (auto& [index, tree] : m_cut_trees){ + m_maps[index].objects.push_back(tree); + } + m_cut_trees.clear(); m_frozen = true; schedule(pressed + timing.tile, SimScreen::FADE, false); schedule(pressed + timing.tile + timing.fade, SimScreen::OVERWORLD, true); @@ -478,6 +484,24 @@ bool OverworldSim::close_message(){ return true; } +bool OverworldSim::cut_tree(){ + std::vector& objects = m_maps[m_map].objects; + const TilePos ahead = m_player.position.next(m_state.facing); + for (size_t c = 0; c < objects.size(); c++){ + if (objects[c].name == "cut_tree" && objects[c].x == ahead.x && objects[c].y == ahead.y + && m_state.screen == SimScreen::OVERWORLD && !m_frozen + ){ + m_cut_trees.emplace_back(m_map, objects[c]); + objects.erase(objects.begin() + c); + advance_to(m_now + CUT_TIME); + note("cut the tree on " + to_string(MapPosition{map().name, ahead}) + "."); + return true; + } + } + violation("no tree to cut " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); + return false; +} + // diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 45cf81ce46..4645b9d931 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include "Common/Cpp/Time.h" #include "Common/Cpp/TestRunners/UnitTestDatabase.h" @@ -177,6 +178,11 @@ class OverworldSim{ // battle follows it. bool close_message(); + // Cuts the tree the player faces (an object named "cut_tree") with + // YES to the game's question: it is gone until the player next warps, + // as its hide flag is a FLAG_TEMP. Returns whether there was one. + bool cut_tree(); + private: // A screen that shows at `at`; `unfreeze`: the player can move again. struct Pending{ @@ -227,6 +233,8 @@ class OverworldSim{ std::optional m_trainer_speaking; // The trainer whose battle is on screen or about to show. std::optional m_trainer; + // The trees cut since the last warp, and their maps. + std::vector> m_cut_trees; int m_grass_steps = 0; int m_holds = 0; SimState m_state; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 92fd74c777..6e350b7d43 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -51,6 +51,7 @@ #include "Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryTable.h" +#include "Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h" #include "Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.h" #include "Programs/RngManipulation/PokemonFRLG_RngPlanInputs_Tests.h" #include "Programs/RngManipulation/PokemonFRLG_RngPlanLog_Tests.h" @@ -751,6 +752,8 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStory(database); add_tests_AutoStorySimulation(database); add_tests_AutoStoryVermilion(database); + add_tests_AutoStoryVermilionGym(database); + add_tests_AutoStoryTrashCans(database); add_tests_AutoStoryPaceSim(database); add_tests_AutoStoryItems(database); add_tests_AutoStoryRngLoop(database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp index 586e5a01c1..152a4d550c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp @@ -555,6 +555,30 @@ void answer_yes_no(ConsoleHandle& console, ProControllerContext& context, bool y ); } +std::optional talk_and_count_pages(ConsoleHandle& console, ProControllerContext& context){ + WhiteDialogWatcher opened(COLOR_RED); + const int shown = run_until( + console, context, + [](ProControllerContext& context){ + pbf_press_button(context, BUTTON_A, 160ms, 3000ms); + }, + {opened} + ); + if (shown < 0){ + return std::nullopt; + } + // Fast text prints a page in well under a second; an A before it + // ends would only finish it. + pbf_wait(context, 1500ms); + pbf_press_button(context, BUTTON_A, 160ms, 1200ms); + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + const size_t pages = screen && WhiteDialogDetector(COLOR_RED).detect(screen) ? 2 : 1; + console.log(std::format("Message: {} page(s).", pages)); + mash_until_overworld(console, context, std::chrono::seconds(20)); + return pages; +} + void step_once(ProControllerContext& context, Direction direction, std::optional facing){ if (facing != direction){ pbf_press_dpad(context, dpad(direction), TURN_TAP, TURN_SETTLE); @@ -576,7 +600,7 @@ std::optional step_until_seen(int max_tiles, const std::function& s std::optional step_into_line( ConsoleHandle& console, ProControllerContext& context, - Direction direction, std::optional facing, int max_tiles + Direction direction, std::optional facing, int max_tiles, Milliseconds watch ){ return step_until_seen( max_tiles, @@ -587,7 +611,7 @@ std::optional step_into_line( [&]{ WhiteDialogWatcher message(COLOR_RED); BattleScreenWatcher battle(COLOR_RED); - return wait_until(console, context, TRAINER_LINE_WATCH, {message, battle}) >= 0; + return wait_until(console, context, watch, {message, battle}) >= 0; } ); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h index e555294448..f90125dfdd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h @@ -107,6 +107,13 @@ void mash_until_overworld(ConsoleHandle& console, ProControllerContext& context, // answers NO. Throws OperationFailedException after `timeout`. void answer_yes_no(ConsoleHandle& console, ProControllerContext& context, bool yes, Button advance, Milliseconds timeout); +// From the overworld, facing something that answers A with a message (a +// sign, Vermilion Gym's trash cans): A, then how many pages the message +// has, one or two, then on to the overworld. Language-free: one A after +// the first page has printed either closes the message or shows its +// second page. Nothing if no message showed. +std::optional talk_and_count_pages(ConsoleHandle& console, ProControllerContext& context); + // 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. @@ -125,12 +132,17 @@ std::optional step_until_seen(int max_tiles, const std::function& s // with no tiles to walk up (PokemonFRLG_OverworldRecoveryWalk.cpp). inline constexpr Milliseconds TRAINER_LINE_WATCH(2500); +// 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); + // From the overworld: step_until_seen() `direction`, turning first -// unless `facing` is `direction`, each step watched TRAINER_LINE_WATCH -// for a trainer's message or battle. +// unless `facing` is `direction`, each step watched `watch` for a +// trainer's message or battle. std::optional step_into_line( ConsoleHandle& console, ProControllerContext& context, - Pokemon::Direction direction, std::optional facing, int max_tiles + Pokemon::Direction direction, std::optional facing, int max_tiles, + Milliseconds watch = TRAINER_LINE_WATCH ); // From the overworld, turns to face `direction` without stepping: the diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index b290531691..6dc31f2431 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -65,6 +65,11 @@ constexpr StoryTrainer STORY_TRAINERS[] = { // Route 6's Jeff (13, 33), across the only way south. {.map = "Route6", .battle = "camper_jeff", .trainer = "TRAINER_CAMPER_JEFF"}, {.map = "SSAnne_2F_Corridor", .battle = "rival_ss_anne", .trainer = "TRAINER_RIVAL_SS_ANNE_BULBASAUR"}, + // Vermilion Gym's three, before the trash cans, and Lt. Surge. + {.map = "VermilionCity_Gym", .battle = "sailor_dwayne", .trainer = "TRAINER_SAILOR_DWAYNE"}, + {.map = "VermilionCity_Gym", .battle = "engineer_baily", .trainer = "TRAINER_ENGINEER_BAILY"}, + {.map = "VermilionCity_Gym", .battle = "gentleman_tucker", .trainer = "TRAINER_GENTLEMAN_TUCKER"}, + {.map = "VermilionCity_Gym", .battle = "lt_surge", .trainer = "TRAINER_LEADER_LT_SURGE"}, }; 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 93dadeadb1..5aba80e94d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -23,6 +23,7 @@ #include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStorySummary.h" #include "PokemonFRLG_AutoStoryTeachScreens.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" #include "PokemonFRLG_AutoStoryWinChance.h" namespace PokemonAutomation{ @@ -100,7 +101,9 @@ std::optional route_start(const Pokemon::Pose& pose){ return MapPosition{pose.map, tiles[0]}; } -Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step){ +// `maps`: where the route is planned; the story's but where a step knows +// better (the trash cans' walks, past trainers who have walked up). +Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step, const std::vector& maps = story_maps()){ const std::vector tiles = Pokemon::tiles_of(state.pose); const bool known = state.pose.certainty == Pokemon::PoseCertainty::TILE || (state.pose.certainty == Pokemon::PoseCertainty::SET && tiles.size() > 1); @@ -120,7 +123,7 @@ Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step){ return Pokemon::outcome_failed("bad_target", step.target ? Pokemon::to_string(*step.target) : "none"); } const RouteGoal goal{{target->map, target->tile}, target->facing}; - const std::optional> route = plan_story_route(story_maps(), state.pose, facing, goal); + const std::optional> route = plan_story_route(maps, state.pose, facing, goal); if (!route){ return Pokemon::outcome_failed("no_route", Pokemon::to_string(state.pose) + " to " + to_string(goal.position)); } @@ -131,7 +134,7 @@ Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step){ // Planning lets the walker recover a stopped open leg: it plans back to // the leg's end, then walks the rest as given. From a set of tiles // the start isn't known: the walker reads the facing. - const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; + const RoutePlanning planning{.maps = &maps, .costs = story_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; walk_overworld_route_with_recovery(env.console, env.context, *route, planning, facing, interrupts, move_speed(step)); log_arrival(env, step.name, target->map, target->tile); const std::optional end_facing = route->empty() ? facing : std::optional(route->back().facing_after); @@ -170,6 +173,10 @@ Outcome take_step(StoryEnv& env, RunState& state, const StoryStep& step){ } env.console.log(std::format("Story: {}: a trainer saw the player after {} tile(s).", step.name, *stepped)); tiles_stepped = *stepped; + }else if (step.variant == TRAINER_WAIT_VARIANT){ + if (!step_into_line(env.console, env.context, step.direction, facing_of(state.pose), 1, TRAINER_WAIT_WATCH)){ + 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)); } @@ -615,6 +622,78 @@ std::optional run_abilities(StoryEnv& env, RunState& state, const Story return run_ability_game_step(env.console.logger(), &env.console, step, abilities); } +// CUT_TREE_VARIANT: the tree `step.direction` of the player, cut, and a +// step onto its tile. +Outcome cut_tree(StoryEnv& env, RunState& state, const StoryStep& step){ + const std::optional here = story_pose(state.pose); + if (!here){ + return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); + } + 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); + } + 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); + return Pokemon::outcome_ok(to_pose(StoryPose{here->map, here->tile.next(step.direction), step.direction})); +} + +// Vermilion Gym's trash cans on the console: a walk to each can's stand +// (a ROUTE planned on trash_can_maps()), A, and its pages counted. +class ConsoleTrashCans{ +public: + ConsoleTrashCans(StoryEnv& env, RunState& state, const StoryStep& step) + : m_env(env), m_state(state), m_step(step) + {} + + CanPages check(int can){ + const TrashCanStand stand = trash_can_stand(can); + const std::optional here = story_pose(m_state.pose); + const Pokemon::Pose target = to_pose(StoryPose{m_state.pose.map, stand.tile, stand.facing}); + if (!here || !(here->tile == stand.tile) || here->facing != stand.facing){ + StoryStep walk = m_step; + walk.kind = StoryStepKind::ROUTE; + walk.name = m_step.name + "_can" + std::to_string(can); + walk.variant.clear(); + walk.target = target; + static const std::vector MAPS = trash_can_maps(story_maps()); + const Outcome outcome = walk_route(m_env, m_state, walk, MAPS); + if (!outcome.succeeded()){ + return CanPages::FAILED; + } + if (outcome.pose){ + m_state.pose = *outcome.pose; + } + } + const std::optional pages = talk_and_count_pages(m_env.console, m_env.context); + if (!pages){ + return CanPages::FAILED; + } + m_env.console.log(std::format("Story: {}: can {}, {} page(s).", m_step.name, can, *pages)); + return *pages == 1 ? CanPages::ONE : CanPages::TWO; + } + +private: + StoryEnv& m_env; + RunState& m_state; + const StoryStep& m_step; +}; + +// TRASH_CANS_VARIANT: the search for Vermilion Gym's switches. +Outcome open_gym_door(StoryEnv& env, RunState& state, const StoryStep& step){ + if (!story_pose(state.pose)){ + return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); + } + ConsoleTrashCans cans(env, state, step); + const TrashCanResult result = run_trash_cans(env.console, [&](int can){ return cans.check(can); }); + if (result.end != TrashCanEnd::OPENED){ + return Pokemon::outcome_failed(std::string("trash_cans_") + trash_can_end_name(result.end), std::format("{} checks", result.checks)); + } + return Pokemon::outcome_ok(state.pose); +} + } Outcome StoryExecutor::game(RunState& state, const StoryStep& step){ @@ -626,6 +705,12 @@ Outcome StoryExecutor::game(RunState& state, const StoryStep& step){ leave_gift_summary(m_env.console, m_env.context); return Pokemon::outcome_ok(); } + if (step.variant == CUT_TREE_VARIANT){ + return cut_tree(m_env, state, step); + } + if (step.variant == TRASH_CANS_VARIANT){ + return open_gym_door(m_env, state, step); + } const Pokemon::Pose patch = state.pose; ConsoleCaptureActions capture(m_env.console, m_env.context, m_env.language, [&]{ pace_trainer_battle(m_env, state, step, patch); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index 6503b90a5a..0089b577ab 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -63,6 +63,28 @@ inline const std::string RUN_VARIANT = "run"; inline const std::string TRAINER_LINE_VARIANT = "trainer_line"; inline constexpr int TRAINER_LINE_TILES = 3; +// The variant of a STEP into the line of a trainer who looks around +// (MOVEMENT_TYPE_LOOK_AROUND): one tile, then a wait of up to +// TRAINER_WAIT_WATCH for the trainer to turn to the player. Indoors, where +// the player can't run, such a trainer turns a random way every 32 to 128 +// frames (pokefirered event_object_movement.c, MovementType_LookAround; +// it turns to the player only when the player runs), so they face the +// player within 30 s all but about 1 time in 500; and the game checks +// their sight every frame (ProcessPlayerFieldInput()), not only after a +// step. Stepped into from outside their sight onto the second tile of +// their line, the player is always seen there, and the trainer walks up +// one tile. +inline const std::string TRAINER_WAIT_VARIANT = "trainer_wait"; + +// The GAME step that cuts the tree the player faces, `direction` +// (pokefirered data/scripts/field_moves.inc, EventScript_CutTree): A, +// "This tree looks like it can be CUT down! Would you like to CUT it?" +// YES, " used CUT!", the tree falls; then a step onto its tile. With +// no Pokemon that knows Cut there is no YES/NO, and the step fails. The +// tree grows back when the player leaves the map (its hide flag is a +// FLAG_TEMP), so the walk back cuts it again. +inline const std::string CUT_TREE_VARIANT = "cut_tree"; + // The GAME step that closes the Trainer Card SID Helper leaves open. inline const std::string CLOSE_TRAINER_CARD = "close_trainer_card"; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index 0efe00a8f1..aafc6eb001 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -27,18 +27,43 @@ std::optional> walked_route(const StoryPose& at, const St return plan_overworld_route(story_maps(), start, goal, story_route_costs(), at.facing); } +namespace{ + +// A CUT_TREE_VARIANT step: turned to the tree, cut, and a step onto its +// tile. Moves `at` there; returns the failure, or empty. +std::string cut_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step){ + if (sim.state().facing != step.direction){ + sim.tap(step.direction); + } + if (!sim.cut_tree()){ + return "no tree to cut."; + } + sim.tap(step.direction); + at = StoryPose{at.map, at.tile.next(step.direction), step.direction}; + if (!(sim.state().position == MapPosition{at.map, at.tile})){ + return "expected to step onto the tree's tile " + to_string(MapPosition{at.map, at.tile}) + "."; + } + return ""; +} + +} + // Runs `step` of row `id` on `sim` as the program does: routes and legs // by the sim's walker from `at`, where the table says the player stands, // fighting a trainer who sees the player on the way; a step one tap at a // time, or for a step into a trainer's line by step_until_seen(), each // watched for the trainer; a battle from the trainer's message, which -// must be up. Moves `at` to where the table says the step ends. Returns -// the failure, or empty. +// must be up; a cut tree cut and stepped onto. Moves `at` to where the +// table says the step ends. Returns the failure, or empty. std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id){ const std::string where = id + "/" + step.name + ": "; const auto on = [&sim]{ return " On " + to_string(sim.state().position) + "."; }; if (step.kind == StoryStepKind::LEGS || step.kind == StoryStepKind::ROUTE){ const std::optional> route = walked_route(at, step); + if (route && route->empty()){ + // Already there. + return ""; + } const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = MapPosition{at.map, at.tile}, .label = step.name}; const std::string failure = route ? OverworldSimWalker(sim).run(*route, planning) : "no legs."; at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : at; @@ -57,6 +82,10 @@ std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& s const int tiles = step.variant == TRAINER_LINE_VARIANT ? TRAINER_LINE_TILES : 1; return step_until_seen(tiles, tap, seen) ? "" : where + "no trainer saw the player." + on(); } + if (step.kind == StoryStepKind::GAME && step.variant == CUT_TREE_VARIANT){ + const std::string failure = cut_on_sim(sim, at, step); + return failure.empty() ? "" : where + failure + on(); + } if (step.kind == StoryStepKind::BATTLE){ if (!sim.close_message() || sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ return where + "no_battle." + on(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h index 4b3be720d0..3ffb4cbcf7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h @@ -32,8 +32,8 @@ std::optional> walked_route(const StoryPose& at, const St // fighting a trainer who sees the player on the way; a step one tap at a // time, or for a step into a trainer's line by step_until_seen(), each // watched for the trainer; a battle from the trainer's message, which -// must be up. Moves `at` to where the table says the step ends. Returns -// the failure, or empty. +// must be up; a cut tree cut and stepped onto. Moves `at` to where the +// table says the step ends. Returns the failure, or empty. std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id); 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 95a4b5fc31..3c0fa43f5a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -118,7 +118,7 @@ class Test_RunPlan : public UnitTest{ }; void add_tests_RunPlan(UnitTestDatabase& database){ - const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22"; + 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"; const RunPlanScenario scenarios[] = { {.name = "handoffs off: the story as before", .first = "00.01", .expected_ids = TODAY}, {.name = "handoffs off: a range", .first = "00.02", .last = "00.04", .expected_ids = "00.02 00.03 00.04"}, @@ -171,8 +171,8 @@ void add_tests_GiftRunPlan(UnitTestDatabase& database){ .expected_ids = "00.11 00.12", .expected_stop = StoryHandoff::GIFT_RNG, }, - {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11", .expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22"}, - {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22"}, + {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11", .expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25"}, + {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25"}, { .name = "Gift RNG: started at 00.12b, from the save beside the salesman", .first = "00.12b", @@ -187,7 +187,7 @@ void add_tests_GiftRunPlan(UnitTestDatabase& database){ .expected_ids = "", .expected_stop = StoryHandoff::GIFT_RNG, }, - {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}, .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22"}, + {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}, .expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25"}, { .name = "Starter RNG first, with both on", .first = "00.03", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index eea06e665d..273924e2d8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -10,6 +10,7 @@ #include "PokemonFRLG_AutoStoryMart.h" #include "PokemonFRLG_AutoStoryPcStorage.h" #include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" #include "PokemonFRLG_AutoStoryTeach.h" namespace PokemonAutomation{ @@ -19,186 +20,7 @@ namespace PokemonFRLG{ namespace{ -constexpr Direction NORTH = Direction::NORTH; -constexpr Direction SOUTH = Direction::SOUTH; -constexpr Direction EAST = Direction::EAST; -constexpr Direction WEST = Direction::WEST; - -const char* const BEDROOM = "PalletTown_PlayersHouse_2F"; -const char* const PALLET = "PalletTown"; -const char* const LAB = "PalletTown_ProfessorOaksLab"; -const char* const VIRIDIAN = "ViridianCity"; -const char* const VIRIDIAN_MART = "ViridianCity_Mart"; -const char* const VIRIDIAN_CENTER = "ViridianCity_PokemonCenter_1F"; -const char* const FOREST = "ViridianForest"; -const char* const FOREST_GATE = "Route2_ViridianForest_NorthEntrance"; -const char* const PEWTER = "PewterCity"; -const char* const PEWTER_CENTER = "PewterCity_PokemonCenter_1F"; -const char* const GYM = "PewterCity_Gym"; -const char* const ROUTE3 = "Route3"; -const char* const ROUTE4 = "Route4"; -const char* const ROUTE4_CENTER = "Route4_PokemonCenter_1F"; -const char* const MT_MOON_1F = "MtMoon_1F"; -const char* const MT_MOON_B2F = "MtMoon_B2F"; -const char* const CERULEAN = "CeruleanCity"; -const char* const CERULEAN_CENTER = "CeruleanCity_PokemonCenter_1F"; -const char* const CERULEAN_MART = "CeruleanCity_Mart"; -const char* const CERULEAN_GYM = "CeruleanCity_Gym"; -const char* const ROUTE24 = "Route24"; -const char* const ROUTE25 = "Route25"; -const char* const SEA_COTTAGE = "Route25_SeaCottage"; -const char* const ROUTE6 = "Route6"; -const char* const VERMILION = "VermilionCity"; -const char* const VERMILION_CENTER = "VermilionCity_PokemonCenter_1F"; -const char* const SS_ANNE_1F = "SSAnne_1F_Corridor"; -const char* const SS_ANNE_2F = "SSAnne_2F_Corridor"; -const char* const CAPTAINS_OFFICE = "SSAnne_CaptainsOffice"; - -Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt){ - return to_pose(StoryPose{map, {x, y}, facing}); -} - -StoryCheckpoint row( - std::string id, std::string title, std::optional start, - Supplies supplies, StartMenu menu, std::vector steps -){ - StoryCheckpoint checkpoint; - checkpoint.id = std::move(id); - checkpoint.title = std::move(title); - checkpoint.start = std::move(start); - checkpoint.steps = std::move(steps); - checkpoint.supplies = supplies; - checkpoint.menu = menu; - return checkpoint; -} - -// At the rows after Brock: no items counted, the starter with the moves -// the story gave it by Lv13. A run that loads a save reads the moves off -// the lead's summary instead (read_lead_moves()), so a save whose moves -// differ from these (console B, 2026-11-02: Water Gun where Tackle was) -// is fought with the moves it has. -Supplies after_brock(){ - Supplies supplies; - supplies.lead = story_lead_after_brock(); - return supplies; -} - -// What a walk does about wild Pokemon: runs, or fights them for EXP. -enum class WildBattles{ - RUN, - FIGHT, -}; - -StoryStep make(StoryStepKind kind, std::string name, std::string why){ - StoryStep step; - step.kind = kind; - step.name = std::move(name); - step.why = std::move(why); - return step; -} - -StoryStep route(std::string name, Pokemon::Pose target, std::string why, WildBattles wild = WildBattles::RUN){ - StoryStep step = make(StoryStepKind::ROUTE, std::move(name), std::move(why)); - step.target = std::move(target); - step.fight_wild = wild == WildBattles::FIGHT; - return step; -} - -StoryStep step_into(std::string name, Direction direction, std::string why){ - StoryStep step = make(StoryStepKind::STEP, std::move(name), std::move(why)); - step.direction = direction; - return step; -} - -// A step the way a ledge faces, from the tile before it: the player hops -// it and lands on `landing`, two tiles on. Planned routes never hop a -// ledge (they hold into walls, and a held ledge hops), so where the only -// way on is down a ledge the table takes it as this one step. -// A step `direction` into a trainer's line, a tile at a time until the -// trainer sees the player (TRAINER_LINE_VARIANT): the battle after it -// starts whether the walk before ended on its tile or a tile short. -StoryStep into_line(std::string name, Direction direction, std::string why){ - StoryStep step = step_into(std::move(name), direction, std::move(why)); - step.variant = TRAINER_LINE_VARIANT; - return step; -} - -StoryStep hop(std::string name, Direction direction, Pokemon::Pose landing, std::string why){ - StoryStep step = step_into(std::move(name), direction, std::move(why)); - step.pose_after = std::move(landing); - return step; -} - -StoryStep answer(std::string name, bool yes, std::string why){ - StoryStep step = make(StoryStepKind::ANSWER, std::move(name), std::move(why)); - step.yes = yes; - return step; -} - -StoryStep scene(std::string name, std::optional pose_after, std::string why){ - StoryStep step = make(StoryStepKind::MASH, std::move(name), std::move(why)); - step.pose_after = std::move(pose_after); - return step; -} - -StoryStep battle(std::string name, Pokemon::Pose pose_after, bool may_lose, std::string why){ - StoryStep step = make(StoryStepKind::BATTLE, std::move(name), std::move(why)); - step.pose_after = std::move(pose_after); - step.may_lose = may_lose; - return step; -} - -StoryStep legs(std::string name, std::vector legs, std::string why, WildBattles wild = WildBattles::RUN){ - StoryStep step = make(StoryStepKind::LEGS, std::move(name), std::move(why)); - step.legs = std::move(legs); - step.fight_wild = wild == WildBattles::FIGHT; - return step; -} - -// `step`, a ROUTE or LEGS, run with B held: from the Running Shoes on. -// Indoors, where the game doesn't let the player run, it walks. -StoryStep running(StoryStep step){ - step.variant = RUN_VARIANT; - return step; -} - -StoryStep new_game_intro(Pokemon::Pose pose_after, std::string why){ - StoryStep step = make(StoryStepKind::NEW_GAME, "intro", std::move(why)); - step.pose_after = std::move(pose_after); - return step; -} - -StoryStep talk(std::string name, std::string why){ - return make(StoryStepKind::TALK, std::move(name), std::move(why)); -} - -StoryStep ready(std::string name, Pokemon::Pose nurse, std::string why){ - StoryStep step = make(StoryStepKind::READY, std::move(name), std::move(why)); - step.target = std::move(nurse); - return step; -} - -StoryStep heal(std::string name){ - return make(StoryStepKind::HEAL, std::move(name), "full HP"); -} - -StoryStep shop(std::string name, std::vector items, std::string why){ - StoryStep step = make(StoryStepKind::SHOP, std::move(name), std::move(why)); - step.items = std::move(items); - return step; -} - - -StoryStep game_step(std::string variant, Pokemon::Pose pose_after, std::string why){ - StoryStep step = make(StoryStepKind::GAME, variant, std::move(why)); - step.variant = std::move(variant); - step.pose_after = std::move(pose_after); - return step; -} - -StoryStep options_step(){ - return make(StoryStepKind::OPTIONS, "options", "fast text; no switch prompt between a trainer's Pokemon"); -} +using namespace StoryTable; StoryCheckpoint new_game(){ @@ -970,6 +792,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(), }; 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 4fad9c41d3..9299ace65d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h @@ -5,9 +5,9 @@ * FireRed from a new game to the Boulder Badge, the Running Shoes, Route * 3 to the Magikarp salesman in Route 4's Pokemon Center, through Mt. * Moon to Route 4 east of it, and on to Cerulean City, Misty, its rival, - * Nugget Bridge and Bill, then Route 6 to Vermilion City and the S.S. - * Anne's HM01, as story checkpoints 00.01 to 00.22, each saved at its - * end. After 00.10 every route and leg runs. Taken from a by-hand run on + * Nugget Bridge and Bill, then Route 6 to Vermilion City, the S.S. + * Anne's HM01 and Lt. Surge, as story checkpoints 00.01 to 00.25, each + * saved at its end. After 00.10 every route and leg runs. Taken from a by-hand run on * console B (2026-11-01) and the pokefirered map scripts. * */ @@ -24,7 +24,7 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -// Checkpoints 00.01 to 00.22, in order, with 00.01b, the way back in after +// Checkpoints 00.01 to 00.25, in order, with 00.01b, the way back in after // SID Helper, after 00.01, 00.04 split in two at Squirtle's ball for // Starter RNG: 00.04 and 00.04b, 00.12b, the way back in after Gift RNG, // after 00.12, 00.12c, the Magikarp put in BOX 1, after it, 00.14b and @@ -41,6 +41,8 @@ void add_tests_AutoStoryRunPlan(UnitTestDatabase& database); void add_tests_AutoStorySimulation(UnitTestDatabase& database); // Rows 00.21 and 00.22 on the overworld sim (PokemonFRLG_AutoStoryVermilion_Tests.cpp). 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); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp new file mode 100644 index 0000000000..cb9c7bccba --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp @@ -0,0 +1,158 @@ +/* AutoStory Table Steps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonFRLG_AutoStoryTableSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryTable{ + + +Pokemon::Pose at(const char* map, int x, int y, std::optional facing){ + return to_pose(StoryPose{map, {x, y}, facing}); +} + +StoryCheckpoint row( + std::string id, std::string title, std::optional start, + Supplies supplies, StartMenu menu, std::vector steps +){ + StoryCheckpoint checkpoint; + checkpoint.id = std::move(id); + checkpoint.title = std::move(title); + checkpoint.start = std::move(start); + checkpoint.steps = std::move(steps); + checkpoint.supplies = supplies; + checkpoint.menu = menu; + return checkpoint; +} + +Supplies after_brock(){ + Supplies supplies; + supplies.lead = story_lead_after_brock(); + return supplies; +} + +StoryStep make(StoryStepKind kind, std::string name, std::string why){ + StoryStep step; + step.kind = kind; + step.name = std::move(name); + step.why = std::move(why); + return step; +} + +StoryStep route(std::string name, Pokemon::Pose target, std::string why, WildBattles wild){ + StoryStep step = make(StoryStepKind::ROUTE, std::move(name), std::move(why)); + step.target = std::move(target); + step.fight_wild = wild == WildBattles::FIGHT; + return step; +} + +StoryStep step_into(std::string name, Direction direction, std::string why){ + StoryStep step = make(StoryStepKind::STEP, std::move(name), std::move(why)); + step.direction = direction; + return step; +} + +StoryStep into_line(std::string name, Direction direction, std::string why){ + StoryStep step = step_into(std::move(name), direction, std::move(why)); + step.variant = TRAINER_LINE_VARIANT; + return step; +} + +StoryStep hop(std::string name, Direction direction, Pokemon::Pose landing, std::string why){ + StoryStep step = step_into(std::move(name), direction, std::move(why)); + step.pose_after = std::move(landing); + return step; +} + +StoryStep answer(std::string name, bool yes, std::string why){ + StoryStep step = make(StoryStepKind::ANSWER, std::move(name), std::move(why)); + step.yes = yes; + return step; +} + +StoryStep scene(std::string name, std::optional pose_after, std::string why){ + StoryStep step = make(StoryStepKind::MASH, std::move(name), std::move(why)); + step.pose_after = std::move(pose_after); + return step; +} + +StoryStep battle(std::string name, Pokemon::Pose pose_after, bool may_lose, std::string why){ + StoryStep step = make(StoryStepKind::BATTLE, std::move(name), std::move(why)); + step.pose_after = std::move(pose_after); + step.may_lose = may_lose; + return step; +} + +StoryStep legs(std::string name, std::vector legs, std::string why, WildBattles wild){ + StoryStep step = make(StoryStepKind::LEGS, std::move(name), std::move(why)); + step.legs = std::move(legs); + step.fight_wild = wild == WildBattles::FIGHT; + return step; +} + +StoryStep running(StoryStep step){ + step.variant = RUN_VARIANT; + return step; +} + +StoryStep new_game_intro(Pokemon::Pose pose_after, std::string why){ + StoryStep step = make(StoryStepKind::NEW_GAME, "intro", std::move(why)); + step.pose_after = std::move(pose_after); + return step; +} + +StoryStep talk(std::string name, std::string why){ + return make(StoryStepKind::TALK, std::move(name), std::move(why)); +} + +StoryStep ready(std::string name, Pokemon::Pose nurse, std::string why){ + StoryStep step = make(StoryStepKind::READY, std::move(name), std::move(why)); + step.target = std::move(nurse); + return step; +} + +StoryStep heal(std::string name){ + return make(StoryStepKind::HEAL, std::move(name), "full HP"); +} + +StoryStep shop(std::string name, std::vector items, std::string why){ + StoryStep step = make(StoryStepKind::SHOP, std::move(name), std::move(why)); + step.items = std::move(items); + return step; +} + +StoryStep game_step(std::string variant, Pokemon::Pose pose_after, std::string why){ + StoryStep step = make(StoryStepKind::GAME, variant, std::move(why)); + step.variant = std::move(variant); + step.pose_after = std::move(pose_after); + return step; +} + +StoryStep options_step(){ + return make(StoryStepKind::OPTIONS, "options", "fast text; no switch prompt between a trainer's Pokemon"); +} +StoryStep into_sight(std::string name, Direction direction, std::string why){ + StoryStep step = step_into(std::move(name), direction, std::move(why)); + step.variant = TRAINER_WAIT_VARIANT; + return step; +} + +StoryStep cut_tree(std::string name, Pokemon::Pose tree, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = CUT_TREE_VARIANT; + step.direction = facing_of(tree).value_or(Direction::NORTH); + step.pose_after = std::move(tree); + return step; +} + + + +} +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h new file mode 100644 index 0000000000..626d2ee879 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h @@ -0,0 +1,127 @@ +/* AutoStory Table Steps + * + * From: https://github.com/PokemonAutomation/ + * + * What the story table (PokemonFRLG_AutoStoryTable.h) is written in: the + * maps' names and the functions that make its rows and steps, shared by + * the files its rows are in. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTableSteps_H +#define PokemonAutomation_PokemonFRLG_AutoStoryTableSteps_H + +#include +#include +#include +#include "PokemonFRLG_AutoStoryEvents.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryTable{ + + +inline constexpr Direction NORTH = Direction::NORTH; +inline constexpr Direction SOUTH = Direction::SOUTH; +inline constexpr Direction EAST = Direction::EAST; +inline constexpr Direction WEST = Direction::WEST; + +inline constexpr const char* BEDROOM = "PalletTown_PlayersHouse_2F"; +inline constexpr const char* PALLET = "PalletTown"; +inline constexpr const char* LAB = "PalletTown_ProfessorOaksLab"; +inline constexpr const char* VIRIDIAN = "ViridianCity"; +inline constexpr const char* VIRIDIAN_MART = "ViridianCity_Mart"; +inline constexpr const char* VIRIDIAN_CENTER = "ViridianCity_PokemonCenter_1F"; +inline constexpr const char* FOREST = "ViridianForest"; +inline constexpr const char* FOREST_GATE = "Route2_ViridianForest_NorthEntrance"; +inline constexpr const char* PEWTER = "PewterCity"; +inline constexpr const char* PEWTER_CENTER = "PewterCity_PokemonCenter_1F"; +inline constexpr const char* GYM = "PewterCity_Gym"; +inline constexpr const char* ROUTE3 = "Route3"; +inline constexpr const char* ROUTE4 = "Route4"; +inline constexpr const char* ROUTE4_CENTER = "Route4_PokemonCenter_1F"; +inline constexpr const char* MT_MOON_1F = "MtMoon_1F"; +inline constexpr const char* MT_MOON_B2F = "MtMoon_B2F"; +inline constexpr const char* CERULEAN = "CeruleanCity"; +inline constexpr const char* CERULEAN_CENTER = "CeruleanCity_PokemonCenter_1F"; +inline constexpr const char* CERULEAN_MART = "CeruleanCity_Mart"; +inline constexpr const char* CERULEAN_GYM = "CeruleanCity_Gym"; +inline constexpr const char* ROUTE24 = "Route24"; +inline constexpr const char* ROUTE25 = "Route25"; +inline constexpr const char* SEA_COTTAGE = "Route25_SeaCottage"; +inline constexpr const char* ROUTE6 = "Route6"; +inline constexpr const char* VERMILION = "VermilionCity"; +inline constexpr const char* VERMILION_CENTER = "VermilionCity_PokemonCenter_1F"; +inline constexpr const char* VERMILION_GYM = "VermilionCity_Gym"; +inline constexpr const char* SS_ANNE_1F = "SSAnne_1F_Corridor"; +inline constexpr const char* SS_ANNE_2F = "SSAnne_2F_Corridor"; +inline constexpr const char* CAPTAINS_OFFICE = "SSAnne_CaptainsOffice"; + +Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt); + +StoryCheckpoint row( + std::string id, std::string title, std::optional start, + Supplies supplies, StartMenu menu, std::vector steps +); + +// At the rows after Brock: no items counted, the starter with the moves +// the story gave it by Lv13. A run that loads a save reads the moves off +// the lead's summary instead (read_lead_moves()), so a save whose moves +// differ from these (console B, 2026-11-02: Water Gun where Tackle was) +// is fought with the moves it has. +Supplies after_brock(); + +// What a walk does about wild Pokemon: runs, or fights them for EXP. +enum class WildBattles{ + RUN, + FIGHT, +}; + +StoryStep make(StoryStepKind kind, std::string name, std::string why); +StoryStep route(std::string name, Pokemon::Pose target, std::string why, WildBattles wild = WildBattles::RUN); +StoryStep step_into(std::string name, Direction direction, std::string why); +// A step `direction` into a trainer's line, a tile at a time until the +// trainer sees the player (TRAINER_LINE_VARIANT): the battle after it +// starts whether the walk before ended on its tile or a tile short. +StoryStep into_line(std::string name, Direction direction, std::string why); +// A step `direction` into the line of a trainer who looks around +// (TRAINER_WAIT_VARIANT), then a wait until they turn to the player: the +// battle after it starts on that tile. +StoryStep into_sight(std::string name, Direction direction, std::string why); +// A step the way a ledge faces, from the tile before it: the player hops +// it and lands on `landing`, two tiles on. Planned routes never hop a +// ledge (they hold into walls, and a held ledge hops), so where the only +// way on is down a ledge the table takes it as this one step. +StoryStep hop(std::string name, Direction direction, Pokemon::Pose landing, std::string why); +StoryStep answer(std::string name, bool yes, std::string why); +StoryStep scene(std::string name, std::optional pose_after, std::string why); +StoryStep battle(std::string name, Pokemon::Pose pose_after, bool may_lose, std::string why); +StoryStep legs(std::string name, std::vector legs, std::string why, WildBattles wild = WildBattles::RUN); +// `step`, a ROUTE or LEGS, run with B held: from the Running Shoes on. +// Indoors, where the game doesn't let the player run, it walks. +StoryStep running(StoryStep step); +StoryStep new_game_intro(Pokemon::Pose pose_after, std::string why); +StoryStep talk(std::string name, std::string why); +StoryStep ready(std::string name, Pokemon::Pose nurse, std::string why); +StoryStep heal(std::string name); +StoryStep shop(std::string name, std::vector items, std::string why); +StoryStep game_step(std::string variant, Pokemon::Pose pose_after, std::string why); +StoryStep options_step(); +// Cuts the tree the player faces (CUT_TREE_VARIANT) and steps onto its +// tile, `tree`, facing the way the step goes. +StoryStep cut_tree(std::string name, Pokemon::Pose tree, std::string why); + + +// Rows in files of their own (PokemonFRLG_AutoStoryTableVermilionGym.cpp). +StoryCheckpoint vermilion_gym(); +StoryCheckpoint to_lt_surge(); +StoryCheckpoint lt_surge(); + + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp new file mode 100644 index 0000000000..3e877b956d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp @@ -0,0 +1,142 @@ +/* AutoStory Table: Vermilion Gym + * + * From: https://github.com/PokemonAutomation/ + * + * Rows 00.23 to 00.25: HM01 Cut to the Oddish, the tree before Vermilion + * Gym, its three trainers and its trash cans, then Lt. Surge from a save + * in front of him. + * + * The gym (VermilionCity_Gym map.json, scripts.inc), 11 x 20, entered on + * (5, 19): the cans on rows 10, 12 and 14 at x 1, 3, 5, 7, 9 + * (PokemonFRLG_AutoStoryTrashCans.h), the beams across rows 6 and 7 that + * the two switches turn off, Lt. Surge (5, 2) facing down above them. Its + * trainers, sight 3: + * - Sailor Dwayne (8, 13) and Engineer Baily (2, 11) look around + * (MOVEMENT_TYPE_LOOK_AROUND): between them they see across rows 11 and + * 13 and down columns 2 and 8, so no way from the door to the cans + * keeps out of their sight. Each is fought from the second tile of his + * line, stepped onto from beside it, out of sight, and waited on there + * until he turns to the player (TRAINER_WAIT_VARIANT): Dwayne from + * (8, 15), stepped onto from (7, 15), Baily from (2, 13), from (3, 13). + * Each walks up a tile, Dwayne to (8, 14), Baily to (2, 12), and stays + * there while the player is in the gym. + * - Gentleman Tucker (7, 8) faces west over (4..6, 8): fought on the step + * up from (5, 9) onto (5, 8); he walks up to (6, 8). + * The cans' stands, on rows 9, 11 and 13 above each, are clear of where + * the three stand after their battles. Leaving the gym puts them back + * where the map has them, beaten, and grows the tree back. + * + */ + +#include "PokemonFRLG_AutoStoryBattleData.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" +#include "PokemonFRLG_AutoStoryTeach.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryTable{ + + +namespace{ + +// The search for the switches (PokemonFRLG_AutoStoryTrashCans.h). It +// ends at the last can's stand, which the step's Outcome says. +StoryStep trash_cans(std::string name, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = TRASH_CANS_VARIANT; + return step; +} + +} + + +// HM01 Cut, from the S.S. Anne (00.22), to the Oddish (00.20), second in +// the party: a wild Oddish of Lv12 to 14 knows 2 or 3 moves (Absorb, +// Sweet Scent, Poison Powder at 14), so it has room. TM28 Dig, from the +// Cerulean Rocket (00.21), to the lead in place of Withdraw: Ground, twice +// as strong on Lt. Surge's three, it takes the sim's chance against him +// for a Lv28 Wartortle with 14 Potions from 7% to 45%. Vermilion's tree +// (19, 24) (EventScript_CutTree) is the only way into the gym's yard, +// row 24 being walls but for it; the gym's door is (14, 25). In the gym, +// the trainers as above, then the trash cans until the door opens; out, +// the tree cut again from below, and back to the nurse. +StoryCheckpoint vermilion_gym(){ + const TeachPlan cut{.item = "hm01", .move = "cut", .member = 1}; + const TeachPlan dig{.item = "tm28", .move = "dig", .member = 0, .forget_move = "withdraw"}; + return row("00.23", "Vermilion Gym: Cut and Dig, the tree, Dwayne, Baily and Tucker, the trash cans", at(VERMILION_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + teach_step("teach_cut", "HM01 Cut to the Oddish, second in the party, unless it knows it", cut), + teach_step("teach_dig", "TM28 Dig in place of Withdraw, for Lt. Surge, unless the lead knows it or there is no TM28", dig), + running(route("above_gym_tree", at(VERMILION, 19, 23, SOUTH), "out of the Center, south through the city to above the tree (19, 24)")), + cut_tree("cut_gym_tree", at(VERMILION, 19, 24, SOUTH), "the Oddish cuts the tree; a step onto its tile"), + running(route("gym_door", at(VERMILION_GYM, 5, 19, NORTH), "down into the yard and in by the gym's door (14, 25)")), + running(legs("west_of_dwayne_line", {{NORTH, 4}, {EAST, 2}}, "up column 5 to the can (5, 14), east to (7, 15): out of Dwayne's and Baily's sight")), + into_sight("dwayne_sight", EAST, "onto (8, 15), two below Dwayne (8, 13): he turns to the player and walks up to (8, 14)"), + battle("sailor_dwayne", at(VERMILION_GYM, 8, 15, NORTH), false, "two Pikachu, Lv21"), + running(legs("east_of_baily_line", {{WEST, 4}, {NORTH, 2}, {WEST, 1}}, "west along row 15 to (4, 15), up to (4, 13), west to (3, 13): out of Baily's sight")), + into_sight("baily_sight", WEST, "onto (2, 13), two below Baily (2, 11): he turns to the player and walks up to (2, 12)"), + battle("engineer_baily", at(VERMILION_GYM, 2, 13, NORTH), false, "Voltorb and Magnemite, Lv21"), + running(legs("below_tucker_line", {{EAST, 2}, {NORTH, 4}, {EAST, 1}}, "east to (4, 13), up column 4 past Baily's row to (4, 9), east to (5, 9): below Tucker's line, not on it")), + into_line("tucker_line", NORTH, "onto (5, 8), in Tucker's line: he walks up to (6, 8)"), + battle("gentleman_tucker", at(VERMILION_GYM, 5, 8, EAST), false, "Pikachu, Lv23"), + trash_cans("trash_cans", "the cans until both locks open; a wrong second can resets them"), + running(route("below_gym_tree", at(VERMILION, 19, 25, NORTH), "out of the gym, east to below the tree, grown back")), + cut_tree("cut_tree_out", at(VERMILION, 19, 24, NORTH), "the tree again; a step onto its tile"), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "north through the city to the Pokemon Center")), + heal("heal_vermilion"), + }); +} + +// Wild battles on Route 6 until the sim beats Lt. Surge half the time: +// with Dig, 45% at Lv28, 51% at Lv30, so a Lv29 lead after the gym's +// trainers needs a level or so. The grass nearest the city is Route 6's +// south patch (13..16, 30..33); Picnicker Isabelle (13, 32) faces west +// over (10..12, 32), on the way north from the city, so the walk goes +// along Jeff's row 33 (he is beaten) to the wall (7, 33), up to row 31 +// and east along it onto the patch's west edge, (13, 31), above +// Isabelle, out of her line. Back to the nurse the same way. +// Then the tree, the gym, whose trainers stand where the map has them, +// beaten, and whose beams stay off (FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES): +// up column 5 to below Lt. Surge, and saved there. +StoryCheckpoint to_lt_surge(){ + const GrindPlan for_surge{ + .member = 0, .level = 32, .max_encounters = 150, .pace = EAST, .heal_every = 0, + .trainer = std::string(story_trainer(VERMILION_GYM, "lt_surge").value_or("")), .min_chance = MIN_WIN_CHANCE, + }; + return row("00.24", "Route 6's grass until Lt. Surge is winnable, then through the open beams to him", at(VERMILION_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("route6_south_path", at(ROUTE6, 11, 34, NORTH), "out of the Center, north out of the city onto Route 6's path, below Jeff's row")), + running(legs("round_isabelle_line", {{NORTH, 1}, {WEST, 4}, {NORTH, 2}, {EAST, 5}, {EAST, 1}}, "onto Jeff's row (11, 33), west to the wall (7, 33), up to (7, 31), east along row 31 to (12, 31) and a step onto the grass, (13, 31): out of Isabelle's line")), + grind_step("grind_for_surge", "wild battles until the sim beats Lt. Surge 50% or more, Lv32 at most; the nurse when hurt or low on PP", for_surge, at(VERMILION_CENTER, 7, 4, NORTH)), + running(route("west_on_row_31", at(ROUTE6, 7, 31), "west along row 31, out of the grass, to the wall (7, 31)")), + running(legs("down_to_jeff_row", {{SOUTH, 2}, {EAST, 4}}, "down to the wall (7, 33), east along Jeff's row to (11, 33): out of Isabelle's line")), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "south into the city to the Pokemon Center")), + heal("heal_vermilion"), + running(route("above_gym_tree", at(VERMILION, 19, 23, SOUTH), "out of the Center, south through the city to above the tree (19, 24)")), + cut_tree("cut_gym_tree", at(VERMILION, 19, 24, SOUTH), "the tree; a step onto its tile"), + running(route("below_lt_surge", at(VERMILION_GYM, 5, 3, NORTH), "into the gym, up round the cans and through the beams, off, to below Surge (5, 2)")), + }); +} + +// Lt. Surge (5, 2), talked to from below, from the save in front of him: +// Voltorb Lv21, Pikachu Lv18 and Raichu Lv24, Shock Wave and Thunder +// Wave, a Super Potion and a Full Heal (sParty_LeaderLtSurge); then the +// THUNDERBADGE and TM34, read to the overworld with the battle's text. +// Out, the tree, and the nurse. +StoryCheckpoint lt_surge(){ + return row("00.25", "Vermilion Gym: Lt. Surge, the THUNDERBADGE, then the Center", at(VERMILION_GYM, 5, 3, NORTH), after_brock(), StartMenu::FULL, { + talk("challenge_lt_surge", "\"Hey, kid! What do you think you're doing here?\""), + battle("lt_surge", at(VERMILION_GYM, 5, 3, NORTH), false, "Voltorb, Pikachu and Raichu; THUNDERBADGE and TM34"), + running(route("below_gym_tree", at(VERMILION, 19, 25, NORTH), "down through the beams, out of the gym, east to below the tree, grown back")), + cut_tree("cut_tree_out", at(VERMILION, 19, 24, NORTH), "the tree; a step onto its tile"), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "north through the city to the Pokemon Center")), + heal("heal_vermilion"), + }); +} + + +} +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.cpp new file mode 100644 index 0000000000..3e58973ac9 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.cpp @@ -0,0 +1,154 @@ +/* AutoStory Trash Cans + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "PokemonFRLG_AutoStoryTrashCans.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using Pokemon::Direction; +using Pokemon::TilePos; + + +TilePos trash_can_tile(int can){ + return TilePos{1 + 2 * ((can - 1) % 5), 10 + 2 * ((can - 1) / 5)}; +} + +std::vector trash_can_neighbors(int can){ + const int column = (can - 1) % 5; + std::vector cans; + if (can > 5){ + cans.push_back(can - 5); + } + if (column < 4){ + cans.push_back(can + 1); + } + if (can <= 10){ + cans.push_back(can + 5); + } + if (column > 0){ + cans.push_back(can - 1); + } + return cans; +} + +TrashCanStand trash_can_stand(int can){ + const TilePos tile = trash_can_tile(can); + return TrashCanStand{TilePos{tile.x, tile.y - 1}, Direction::SOUTH}; +} + +const std::vector& trash_can_order(){ + static const std::vector ORDER{1, 2, 3, 4, 5, 10, 9, 8, 7, 6, 11, 12, 13, 14, 15}; + return ORDER; +} + +std::vector trash_can_maps(const std::vector& maps){ + struct Moved{ + const char* name; + TilePos tile; + }; + static constexpr Moved MOVED[] = { + {"sailor", {8, 14}}, + {"balding_man", {2, 12}}, + {"gentleman", {6, 8}}, + }; + std::vector moved = maps; + for (Pokemon::MapData& map : moved){ + if (map.name != "VermilionCity_Gym"){ + continue; + } + for (Pokemon::MapObject& object : map.objects){ + for (const Moved& trainer : MOVED){ + if (object.name == trainer.name){ + object.x = trainer.tile.x; + object.y = trainer.tile.y; + object.trainer.reset(); + } + } + } + } + return moved; +} + +const char* trash_can_end_name(TrashCanEnd end){ + switch (end){ + case TrashCanEnd::OPENED: return "opened"; + case TrashCanEnd::GAVE_UP: return "gave_up"; + case TrashCanEnd::FAILED: return "failed"; + } + return "?"; +} + + +namespace{ + +// The guess beside `first`: the next can in the search order, as the +// player is likely to walk that way anyway, else the first beside it. +int guess_beside(int first){ + const std::vector beside = trash_can_neighbors(first); + const std::vector& order = trash_can_order(); + const size_t at = std::find(order.begin(), order.end(), first) - order.begin(); + for (size_t c = 1; c < order.size(); c++){ + const int next = order[(at + c) % order.size()]; + if (std::find(beside.begin(), beside.end(), next) != beside.end()){ + return next; + } + } + return beside[0]; +} + +} + + +TrashCanResult run_trash_cans(Logger& logger, const CheckCan& check, size_t max_checks){ + TrashCanResult result; + const std::vector& order = trash_can_order(); + size_t next = 0; + std::optional first; + while (result.checks < max_checks){ + const int can = first ? guess_beside(*first) : order[next]; + const CanPages pages = check(can); + result.checks++; + result.last_can = can; + if (pages == CanPages::FAILED){ + logger.log(std::format("Trash cans: checking can {} failed.", can), COLOR_RED); + result.end = TrashCanEnd::FAILED; + return result; + } + if (!first){ + if (pages == CanPages::TWO){ + logger.log(std::format("Trash cans: the first switch is under can {}; the second may be under {} of the cans beside it.", can, trash_can_neighbors(can).size())); + first = can; + }else{ + next = (next + 1) % order.size(); + } + continue; + } + if (pages == CanPages::ONE){ + logger.log(std::format("Trash cans: the second switch is under can {}: the door is open, after {} checks and {} resets.", can, result.checks, result.resets)); + result.end = TrashCanEnd::OPENED; + return result; + } + // Reset: the new first switch is as likely under this can as any. + result.resets++; + logger.log(std::format("Trash cans: not under can {}; the locks reset ({}).", can, result.resets)); + first.reset(); + next = std::find(order.begin(), order.end(), can) - order.begin(); + } + logger.log(std::format("Trash cans: the door is still shut after {} checks.", result.checks), COLOR_RED); + result.end = TrashCanEnd::GAVE_UP; + return result; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h new file mode 100644 index 0000000000..590a6e29e0 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h @@ -0,0 +1,137 @@ +/* AutoStory Trash Cans + * + * From: https://github.com/PokemonAutomation/ + * + * Vermilion Gym's door switches (pokefirered data/maps/VermilionCity_Gym/ + * scripts.inc, src/field_specials.c SetVermilionTrashCans()). Fifteen + * cans stand in three rows of five, numbered 1-5 on row 10, 6-10 on row + * 12 and 11-15 on row 14, at x 1, 3, 5, 7, 9. Each time the player + * enters the gym the first switch goes under a can drawn at random, and + * the second under one of the cans beside it in the rows (left, right, + * above, below), each as likely. A can answers A from any side: + * + * - Before the first switch: "Nope! There's only trash here." (one page), + * or "Hey! There's a switch under the trash! Turn it on!" and "The first + * electric lock opened!" (two pages). + * - After it: "The second electric lock opened! The motorized door + * opened!" (one page): the beams across rows 6 and 7 go off for good + * (FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES). Or "Nope! There's only + * trash here." and "Hey! The electric locks were reset!" (two pages): + * both switches are drawn again, the first anywhere. The can that had + * the first switch counts as a wrong one. + * + * So in either state the number of pages tells the two answers apart, + * with no text read. Leaving the gym draws the switches again too; a + * battle doesn't (the first switch is a FLAG_TEMP, kept until the map + * changes). + * + * The search: the cans in a snake along the rows until one has two + * pages; then one guess beside it. A wrong guess draws both switches + * again, so the search goes on from the can the player stands at, which + * is as likely as any to have the new first switch. A guess is right + * 1 time in 2 to 4 (about 36% over the 15 cans), so the gym opens after + * about 3 rounds of about 9 cans; TRASH_CAN_MAX_CHECKS gives up after + * about 20 rounds (left 1 time in 10000). + * + * The decisions take their game step as a CheckCan, so they run in tests + * against a fake gym; on the console the step walks to the can + * and counts its pages (PokemonFRLG_AutoStoryEvents.cpp). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTrashCans_H +#define PokemonAutomation_PokemonFRLG_AutoStoryTrashCans_H + +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "Pokemon/Navigation/Pokemon_MapData.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The GAME step that opens Vermilion Gym's door. +inline const std::string TRASH_CANS_VARIANT = "trash_cans"; + +inline constexpr int TRASH_CAN_COUNT = 15; +inline constexpr size_t TRASH_CAN_MAX_CHECKS = 200; + +// Can `can`'s tile (1-15). +Pokemon::TilePos trash_can_tile(int can); + +// The cans the second switch may be under when the first is under `can` +// (SetVermilionTrashCans()'s cases). +std::vector trash_can_neighbors(int can); + +// Where the player stands to check `can`, facing it: the row above it. +// Rows 9, 11 and 13 run the gym's width, clear of the trainers where +// their battles leave them (PokemonFRLG_AutoStoryTableVermilionGym.cpp). +struct TrashCanStand{ + Pokemon::TilePos tile; + Pokemon::Direction facing = Pokemon::Direction::SOUTH; +}; +TrashCanStand trash_can_stand(int can); + +// `maps` with Vermilion Gym's trainers where their battles in 00.23 leave +// them, beaten (PokemonFRLG_AutoStoryTableVermilionGym.cpp): Sailor +// Dwayne on (8, 14), Engineer Baily on (2, 12), Gentleman Tucker on +// (6, 8). The search plans its walks between the cans on these, not on +// the maps' tiles for them, which are free once they have walked up. +std::vector trash_can_maps(const std::vector& maps); + +// The order the search checks the cans: along row 10 east, row 12 west, +// row 14 east. +const std::vector& trash_can_order(); + + +// What a can's message came to. +enum class CanPages{ + ONE, + TWO, + // The walk to the can or its message failed. + FAILED, +}; + +// The game step the search takes, injected so its decisions run in +// tests: from the overworld in the gym, to `can`'s stand, A on the can, +// its message to the overworld. +using CheckCan = std::function; + +enum class TrashCanEnd{ + // The second lock opened. + OPENED, + // TRASH_CAN_MAX_CHECKS checks without it. + GAVE_UP, + FAILED, +}; +const char* trash_can_end_name(TrashCanEnd end); + +struct TrashCanResult{ + TrashCanEnd end = TrashCanEnd::FAILED; + size_t checks = 0; + // Wrong guesses: the locks reset. + size_t resets = 0; + // The can checked last, where the player stands. + int last_can = 0; +}; + +// Searches the cans as above until the second lock opens, from the +// overworld in the gym with neither switch found since the player came +// in. +TrashCanResult run_trash_cans(Logger& logger, const CheckCan& check, size_t max_checks = TRASH_CAN_MAX_CHECKS); + + +void add_tests_AutoStoryTrashCans(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans_Tests.cpp new file mode 100644 index 0000000000..c9cc0da050 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans_Tests.cpp @@ -0,0 +1,268 @@ +/* AutoStory Trash Cans Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The trash can search against a fake Vermilion Gym that draws its + * switches as SetVermilionTrashCans() does and answers each can with the + * pages its message has (PokemonFRLG_AutoStoryTrashCans.h). + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +class Test_AutoStoryTrashCans : public UnitTest{ +public: + Test_AutoStoryTrashCans(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStoryTrashCans - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_body(); + } + +private: + std::function m_body; +}; + +using Switches = std::pair; + +// SetVermilionTrashCans()'s second switch for each first one, as its +// cases add to the first: 1 +1 or +5; 2-4 +1, +5 or -1; 5 +5 or -1; 6 -5, +// +1 or +5; 7-9 -5, +1, +5 or -1; 10 -5, +5 or -1; 11 -5 or +1; 12-14 -5, +// +1 or -1; 15 -5 or -1. +std::vector decomp_offsets(int first){ + switch (first){ + case 1: return {+1, +5}; + case 2: case 3: case 4: return {+1, +5, -1}; + case 5: return {+5, -1}; + case 6: return {-5, +1, +5}; + case 7: case 8: case 9: return {-5, +1, +5, -1}; + case 10: return {-5, +5, -1}; + case 11: return {-5, +1}; + case 12: case 13: case 14: return {-5, +1, -1}; + default: return {-5, -1}; + } +} + +Switches draw(std::mt19937& rng){ + const int first = 1 + static_cast(rng() % 15); + const std::vector offsets = decomp_offsets(first); + return {first, first + offsets[rng() % offsets.size()]}; +} + +// The gym's cans as its scripts answer them. +class FakeGym{ +public: + explicit FakeGym(std::function draw) + : m_draw(std::move(draw)) + , m_switches(m_draw()) + {} + + CanPages check(int can){ + checked.push_back(can); + if (can == fail_at){ + return CanPages::FAILED; + } + if (open){ + return CanPages::ONE; + } + if (!m_first_found){ + m_first_found = can == m_switches.first; + return m_first_found ? CanPages::TWO : CanPages::ONE; + } + if (can == m_switches.second){ + open = true; + return CanPages::ONE; + } + m_first_found = false; + m_switches = m_draw(); + return CanPages::TWO; + } + + std::vector checked; + bool open = false; + int fail_at = 0; + +private: + std::function m_draw; + Switches m_switches; + bool m_first_found = false; +}; + +// Draws `script` in order, then the game's draws. +std::function scripted(std::vector script, uint32_t seed = 1){ + auto rng = std::make_shared(seed); + auto next = std::make_shared(0); + return [script = std::move(script), rng, next]{ + return *next < script.size() ? script[(*next)++] : draw(*rng); + }; +} + +UnitTestResult test_cans_and_neighbors(){ + for (int can = 1; can <= TRASH_CAN_COUNT; can++){ + std::set expected; + for (int offset : decomp_offsets(can)){ + expected.insert(can + offset); + } + const std::vector beside = trash_can_neighbors(can); + if (std::set(beside.begin(), beside.end()) != expected){ + return std::format("Can {}: the cans beside it differ from SetVermilionTrashCans().", can); + } + // Beside: one tile apart on the map but for the can between. + const Pokemon::TilePos tile = trash_can_tile(can); + for (int other : beside){ + const Pokemon::TilePos there = trash_can_tile(other); + if (std::abs(there.x - tile.x) + std::abs(there.y - tile.y) != 2){ + return std::format("Cans {} and {} aren't two tiles apart.", can, other); + } + } + const TrashCanStand stand = trash_can_stand(can); + if (stand.tile.next(stand.facing) != tile){ + return std::format("Can {}'s stand doesn't face it.", can); + } + } + if (!(trash_can_tile(1) == Pokemon::TilePos{1, 10}) || !(trash_can_tile(15) == Pokemon::TilePos{9, 14})){ + return "Cans 1 and 15 aren't at (1, 10) and (9, 14), the gym's TrashCan1 and TrashCan15."; + } + std::vector order = trash_can_order(); + std::sort(order.begin(), order.end()); + for (int can = 1; can <= TRASH_CAN_COUNT; can++){ + if (order[can - 1] != can){ + return "The search order isn't every can once."; + } + } + return true; +} + +// The guess beside the first switch is right: the search ends there. +UnitTestResult test_right_guess(){ + QuietLogger logger; + FakeGym gym(scripted({{3, 4}})); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + const std::vector expected{1, 2, 3, 4}; + if (result.end != TrashCanEnd::OPENED || gym.checked != expected || result.resets != 0 || result.last_can != 4 || !gym.open){ + return std::format("Expected 1, 2, 3 then 4, open, got {} after {} checks.", trash_can_end_name(result.end), result.checks); + } + return true; +} + +// The guess is wrong: the locks reset and both switches are drawn again; +// the search goes on from the can the player stands at, finds the new +// first switch and guesses again. +UnitTestResult test_wrong_guess_resets(){ + QuietLogger logger; + // First under 3, second under 8: the guess, 4, is wrong. Then first + // under 4 (where the player stands), second under 5. + FakeGym gym(scripted({{3, 8}, {4, 5}})); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + const std::vector expected{1, 2, 3, 4, 4, 5}; + if (result.end != TrashCanEnd::OPENED || gym.checked != expected || result.resets != 1){ + std::string text; + for (int can : gym.checked){ + text += " " + std::to_string(can); + } + return std::format("Expected 1 2 3 4 4 5 with a reset, got{} ({}).", text, trash_can_end_name(result.end)); + } + // The new first switch further on: the search walks on from there. + FakeGym further(scripted({{3, 8}, {13, 14}})); + const TrashCanResult on = run_trash_cans(logger, [&](int can){ return further.check(can); }); + const std::vector walked{1, 2, 3, 4, 4, 5, 10, 9, 8, 7, 6, 11, 12, 13, 14}; + if (on.end != TrashCanEnd::OPENED || further.checked != walked){ + return "Expected the search to go on from can 4 to 13, then 14."; + } + return true; +} + +// Every first switch, with every second beside it: the door opens, the +// wrong guesses' draws as the game makes them. +UnitTestResult test_every_pair_opens(){ + QuietLogger logger; + uint32_t seed = 1; + for (int first = 1; first <= TRASH_CAN_COUNT; first++){ + for (int offset : decomp_offsets(first)){ + FakeGym gym(scripted({{first, first + offset}}, seed++)); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + if (result.end != TrashCanEnd::OPENED || !gym.open){ + return std::format("First {}, second {}: {} after {} checks.", first, first + offset, trash_can_end_name(result.end), result.checks); + } + } + } + return true; +} + +// 10000 gyms as the game draws them: the checks it takes, and how often +// TRASH_CAN_MAX_CHECKS isn't enough. +UnitTestResult test_many_gyms(){ + QuietLogger logger; + std::mt19937 rng(7); + size_t total = 0; + size_t gave_up = 0; + size_t most = 0; + constexpr size_t GYMS = 10000; + for (size_t c = 0; c < GYMS; c++){ + FakeGym gym([&rng]{ return draw(rng); }); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + gave_up += result.end != TrashCanEnd::OPENED; + total += result.checks; + most = std::max(most, result.checks); + } + const double mean = static_cast(total) / GYMS; + if (gave_up > 2 || mean < 15 || mean > 35){ + return std::format("Expected about 25 checks a gym and next to no give-ups, got {:.1f} checks, {} give-ups, at most {}.", mean, gave_up, most); + } + return true; +} + +UnitTestResult test_failed_check_stops(){ + QuietLogger logger; + FakeGym gym(scripted({{9, 14}})); + gym.fail_at = 3; + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + if (result.end != TrashCanEnd::FAILED || result.checks != 3 || gym.checked.back() != 3){ + return "Expected the search to stop at the failed check of can 3."; + } + return true; +} + +} + + +void add_tests_AutoStoryTrashCans(UnitTestDatabase& database){ + database.add("cans, the cans beside each and their stands match the gym", test_cans_and_neighbors); + database.add("a right guess opens the door", test_right_guess); + database.add("a wrong guess resets, the search goes on from there", test_wrong_guess_resets); + database.add("every first and second switch opens the door", test_every_pair_opens); + database.add("10000 gyms as the game draws them", test_many_gyms); + database.add("a failed check stops the search", test_failed_check_stops); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp new file mode 100644 index 0000000000..2027c80f6b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -0,0 +1,400 @@ +/* AutoStory Vermilion Gym Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Rows 00.23 to 00.25 (PokemonFRLG_AutoStoryTableVermilionGym.cpp) on the + * overworld sim: the tree cut on the way in and out, the gym's three + * trainers fought where the table says, the trash can search walking + * between the cans clear of where the trainers stand after their battles + * (the switches found at once, and after a wrong can's reset), and Lt. + * Surge reached through the beams. And the sim's chance against Surge. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryDamage.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryParty.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" +#include "PokemonFRLG_AutoStoryWinChance.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +class Test_AutoStoryVermilionGym : public UnitTest{ +public: + Test_AutoStoryVermilionGym(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStory - Simulation: " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + std::function m_body; +}; + +const char* const GYM = StoryTable::VERMILION_GYM; + +const StoryCheckpoint& checkpoint(const std::string& 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, "No checkpoint " + id + "."); + } + return *row; +} + +size_t step_index(const StoryCheckpoint& row, const std::string& name){ + const auto step = std::find_if(row.steps.begin(), row.steps.end(), [&](const StoryStep& data){ return data.name == name; }); + if (step == row.steps.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No step " + row.id + "/" + name + "."); + } + return step - row.steps.begin(); +} + +std::string beaten_list(const OverworldSim& sim){ + std::string text; + for (const std::string& name : sim.state().trainers_beaten){ + text += " " + name; + } + return text.empty() ? " none" : text; +} + +MapData& map_named(std::vector& maps, const std::string& name){ + for (MapData& map : maps){ + if (map.name == name){ + return map; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No map " + name + "."); +} + +// The story's maps with the trainers beaten that rows before 00.24 +// fought: Route 6's Jeff (00.21) and Vermilion Gym's three (00.23). +std::vector maps_after_gym(){ + std::vector maps = story_maps(); + for (MapObject& object : map_named(maps, GYM).objects){ + object.trainer.reset(); + } + for (MapObject& object : map_named(maps, StoryTable::ROUTE6).objects){ + if (object.name == "camper" && object.trainer){ + object.trainer.reset(); + } + } + return maps; +} + +// The walk to `can`'s stand on `sim` as the trash can step plans it, on +// trash_can_maps(): ends there facing the can, or says why not. +std::string walk_to_stand(OverworldSim& sim, StoryPose& at, int can){ + static const std::vector MAPS = trash_can_maps(story_maps()); + const TrashCanStand stand = trash_can_stand(can); + const MapPosition start{at.map, at.tile}; + const std::optional> route = plan_overworld_route( + MAPS, start, RouteGoal{{GYM, stand.tile}, stand.facing}, story_route_costs(), at.facing + ); + if (!route){ + return std::format("no route from {} to can {}.", to_string(start), can); + } + if (!route->empty()){ + const RoutePlanning planning{.maps = &MAPS, .costs = story_route_costs(), .start = start, .label = "can"}; + if (const std::string failure = OverworldSimWalker(sim).run(*route, planning); !failure.empty()){ + return std::format("to can {}: ", can) + failure; + } + } + at = StoryPose{GYM, stand.tile, stand.facing}; + if (!(sim.state().position == MapPosition{GYM, stand.tile}) || sim.state().facing != stand.facing){ + return std::format("not at can {}'s stand facing it: on {}.", can, to_string(sim.state().position)); + } + if (!sim.state().violations.empty()){ + return std::format("to can {}: {}", can, sim.state().violations.back()); + } + return ""; +} + +// A step of a row on the sim: row_step_on_sim(), but Surge's battle, +// which a talk starts, leaves the player where the table says. The TEACH, +// the GRIND and the talks the sim hasn't got pass. +std::string gym_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id){ + if (step.kind == StoryStepKind::BATTLE && step.name == "lt_surge"){ + at = *story_pose(*step.pose_after); + return ""; + } + return row_step_on_sim(sim, at, step, id); +} + +using Switches = std::pair; + +// The gym's cans as its scripts answer them, each checked from its stand, +// walked to on the sim as the program plans it. +class SimGym{ +public: + SimGym(OverworldSim& sim, StoryPose& at, std::vector draws) + : m_sim(sim), m_at(at), m_draws(std::move(draws)) + { + next_draw(); + } + + CanPages check(int can){ + error = walk_to_stand(m_sim, m_at, can); + if (!error.empty()){ + return CanPages::FAILED; + } + if (open){ + return CanPages::ONE; + } + if (!m_first_found){ + m_first_found = can == m_switches.first; + return m_first_found ? CanPages::TWO : CanPages::ONE; + } + if (can == m_switches.second){ + open = true; + return CanPages::ONE; + } + m_first_found = false; + next_draw(); + return CanPages::TWO; + } + + std::string error; + bool open = false; + +private: + void next_draw(){ + // Past the script: one the search's guess finds. + m_switches = m_drawn < m_draws.size() ? m_draws[m_drawn++] : Switches{8, 7}; + } + + OverworldSim& m_sim; + StoryPose& m_at; + std::vector m_draws; + size_t m_drawn = 0; + Switches m_switches; + bool m_first_found = false; +}; + +// Row 00.23 from steps `first` to `last` (exclusive) on `sim`, the trash +// cans drawn as `draws` say. +std::string run_gym_steps(OverworldSim& sim, StoryPose& at, size_t first, size_t last, const std::vector& draws){ + const StoryCheckpoint& row = checkpoint("00.23"); + for (size_t c = first; c < last; c++){ + const StoryStep& step = row.steps[c]; + if (step.variant == TRASH_CANS_VARIANT){ + QuietLogger logger; + SimGym gym(sim, at, draws); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + if (result.end != TrashCanEnd::OPENED || !gym.open){ + return "00.23/trash_cans: " + std::string(trash_can_end_name(result.end)) + " " + gym.error; + } + continue; + } + if (const std::string failure = gym_step_on_sim(sim, at, step, row.id); !failure.empty()){ + return failure; + } + } + return ""; +} + +// 00.23 on the sim from the nurse to the nurse: the tree cut, Dwayne, +// Baily and Tucker fought in that order where the table says and no one +// else, the cans searched, out past the tree grown back. The switches +// found at once (under 3 and 4), and after a wrong guess's reset (under +// 3 and 8, then 13 and 14). +UnitTestResult test_gym_row(){ + const StoryCheckpoint& row = checkpoint("00.23"); + for (const std::vector& draws : {std::vector{{3, 4}}, std::vector{{3, 8}, {13, 14}}}){ + StoryPose at = *story_pose(*row.start); + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + if (const std::string failure = run_gym_steps(sim, at, 0, row.steps.size(), draws); !failure.empty()){ + return failure + " Beat" + beaten_list(sim) + "."; + } + const std::vector expected{"sailor", "balding_man", "gentleman"}; + if (sim.state().trainers_beaten != expected || !sim.state().violations.empty()){ + return "00.23: expected Dwayne, Baily and Tucker beaten and no violation; beat" + beaten_list(sim) + "."; + } + if (!(sim.state().position == MapPosition{StoryTable::VERMILION_CENTER, {7, 4}})){ + return "00.23: expected to end at Vermilion's nurse, not " + to_string(sim.state().position) + "."; + } + } + return true; +} + +// From where Tucker's battle leaves the player, with the three where +// their battles leave them: every can's stand reached from (5, 8) and from +// every other stand, and the way out to the nurse from every stand. +UnitTestResult test_cans_from_everywhere(){ + const StoryCheckpoint& row = checkpoint("00.23"); + StoryPose at = *story_pose(*row.start); + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + const size_t cans = step_index(row, "trash_cans"); + if (const std::string failure = run_gym_steps(sim, at, 0, cans, {}); !failure.empty()){ + return failure; + } + for (int from = 1; from <= TRASH_CAN_COUNT; from++){ + OverworldSim there = sim; + StoryPose here = at; + if (const std::string failure = walk_to_stand(there, here, from); !failure.empty()){ + return "From Tucker's battle: " + failure; + } + for (int to = 1; to <= TRASH_CAN_COUNT; to++){ + OverworldSim walked = there; + StoryPose pose = here; + if (const std::string failure = walk_to_stand(walked, pose, to); !failure.empty()){ + return std::format("From can {}: ", from) + failure; + } + if (walked.state().trainers_beaten.size() != 3){ + return std::format("Can {} to can {}: a battle on the way.", from, to); + } + } + OverworldSim out = there; + if (const std::string failure = run_gym_steps(out, here, cans + 1, row.steps.size(), {}); !failure.empty()){ + return std::format("Out from can {}: ", from) + failure; + } + if (!(out.state().position == MapPosition{StoryTable::VERMILION_CENTER, {7, 4}}) || !out.state().violations.empty()){ + return std::format("Out from can {}: expected the nurse and no violation, on {}.", from, to_string(out.state().position)); + } + } + return true; +} + +// The tree shuts the gym's yard: no way from above it to the door. +UnitTestResult test_tree_shuts_the_yard(){ + const RouteGoal door{{GYM, {5, 19}}, std::nullopt}; + if (plan_overworld_route(story_maps(), MapPosition{StoryTable::VERMILION, {19, 23}}, door, story_route_costs(), Direction::SOUTH)){ + return "Planned a way into the gym past the uncut tree (19, 24)."; + } + if (!plan_overworld_route(story_maps(), MapPosition{StoryTable::VERMILION, {19, 24}}, door, story_route_costs(), Direction::SOUTH)){ + return "No way from the tree's tile to the gym."; + } + // The sim: no step onto the tree; cut, it's gone; a warp grows it back. + OverworldSim sim(story_maps(), MapPosition{StoryTable::VERMILION, {19, 23}}, Direction::SOUTH); + sim.tap(Direction::SOUTH); + if (!(sim.state().position == MapPosition{StoryTable::VERMILION, {19, 23}}) || !sim.cut_tree()){ + return "Expected the tree to stop the player, then be cut."; + } + sim.tap(Direction::SOUTH); + if (!(sim.state().position == MapPosition{StoryTable::VERMILION, {19, 24}})){ + return "Expected a step onto the cut tree's tile."; + } + return true; +} + +// 00.24 and 00.25 on the sim, the gym as 00.23 leaves it: to Route 6's +// grass round Isabelle's line and back, to below Surge through the beams, +// and from him back out past the tree to the nurse, meeting no one. +UnitTestResult test_to_surge_and_out(){ + for (const char* id : {"00.24", "00.25"}){ + const StoryCheckpoint& row = checkpoint(id); + StoryPose at = *story_pose(*row.start); + OverworldSim sim(maps_after_gym(), MapPosition{at.map, at.tile}, *at.facing); + for (const StoryStep& step : row.steps){ + if (const std::string failure = gym_step_on_sim(sim, at, step, row.id); !failure.empty()){ + return failure; + } + } + if (!sim.state().trainers_beaten.empty() || !sim.state().violations.empty()){ + return std::string(id) + ": a battle or a violation on the way."; + } + const MapPosition end = std::string(id) == "00.24" ? MapPosition{GYM, {5, 3}} : MapPosition{StoryTable::VERMILION_CENTER, {7, 4}}; + if (!(sim.state().position == end)){ + return std::string(id) + ": ended on " + to_string(sim.state().position) + ", not " + to_string(end) + "."; + } + } + const StoryPose next = *story_pose(*checkpoint("00.25").start); + if (!(next.tile == TilePos{5, 3}) || next.map != GYM){ + return "00.25 doesn't start where 00.24 ends."; + } + return true; +} + +// The story's lead before Surge: Wartortle at `level` with the S.S. +// Anne's moves (00.22), stats at OWN_GENES. +PartyMember surge_lead(uint8_t level){ + const Stats stats = combatant("wartortle", level, OWN_GENES)->stats; + std::vector moves; + for (const char* move : {"bite", "water-pulse", "bubble", "withdraw"}){ + moves.push_back(SummaryMove{.move = move, .pp = max_pp(move), .max_pp = max_pp(move)}); + } + return member_from_summary("wartortle", level, moves, stats); +} + +// Lt. Surge against the lead with 14 Potions: Raichu's Shock Wave and +// Thunder Wave beat Water Pulse and Bite (7% at Lv28, 14% at Lv30); TM28 +// Dig in place of Withdraw takes it to 45% at Lv28 and about 50% at Lv30, +// the GRIND's gate (00.23, 00.24). The battles still lost are to the +// Raichu. The gym's three trainers are no risk. +UnitTestResult test_surge_chance(){ + const SimTrainer surge = *sim_trainer("TRAINER_LEADER_LT_SURGE"); + const SimItems potions{.potions = 14}; + const auto chance = [&](uint8_t level, bool dig){ + SimBattler own = *own_battler(surge_lead(level)); + if (dig){ + own.moves[3] = SimMove{.move = "dig", .pp = 10}; + } + return estimate_win_chance(own, potions, surge); + }; + const WinEstimate plain = chance(28, false); + const WinEstimate dug = chance(28, true); + const WinEstimate dug30 = chance(30, true); + if (plain.chance > 0.15 || dug.chance < 0.35 || dug.chance > 0.55 || dug30.chance < dug.chance + 0.02){ + return std::format( + "Expected under 15% at Lv28, 35-55% with Dig, more at Lv30; got {:.1f}%, {:.1f}% ({}), {:.1f}%.", + 100 * plain.chance, 100 * dug.chance, loss_text(dug), 100 * dug30.chance + ); + } + const auto& by_foe = dug.losses.by_foe; + if (!by_foe.contains("raichu") || 10 * by_foe.at("raichu") < 9 * (dug.battles - dug.wins)){ + return "Expected the battles lost with Dig lost to the Raichu, got \"" + loss_text(dug) + "\"."; + } + for (const char* trainer : {"TRAINER_SAILOR_DWAYNE", "TRAINER_ENGINEER_BAILY", "TRAINER_GENTLEMAN_TUCKER"}){ + if (estimate_win_chance(*own_battler(surge_lead(28)), potions, *sim_trainer(trainer)).chance < 0.9){ + return std::string("Expected the lead to beat ") + trainer + " 90% of the time or more."; + } + } + return true; +} +} + + +void add_tests_AutoStoryVermilionGym(UnitTestDatabase& database){ + database.add("00.23 the tree shuts the gym's yard until cut", test_tree_shuts_the_yard); + database.add("00.23 Vermilion Gym: Dwayne, Baily and Tucker, the cans at once or after a reset", test_gym_row); + database.add("00.23 every can's stand from Tucker's tile and from every other, and out", test_cans_from_everywhere); + database.add("00.24 and 00.25 through the beams to Lt. Surge and out", test_to_surge_and_out); + database.add("Lt. Surge: Dig over Withdraw, the sim's chance", test_surge_chance); +} + + + +} +} +} 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 2aeb13ae28..d8239ee597 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -217,8 +217,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 29 || checkpoints[0].start){ - return "Expected 29 checkpoints, the first from a new game."; + if (checkpoints.size() != 32 || checkpoints[0].start){ + return "Expected 32 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -369,11 +369,11 @@ bool looks_around(const MapObject& object){ } // The trainer on `map` who would see the player on `tile`, other than the -// `beaten`. Wanderers and those who look around are left out: no route -// can plan around one (see wanderer_stops() and held_past_lookers()). -const MapObject* trainer_seeing(const MapData& map, const TilePos& tile, const std::set& beaten){ +// `beaten`. Wanderers and those who look around are left out (no route can plan round one: wanderer_stops(), +// held_past_lookers()); with `lookers`, only those who look around, met by a wait (TRAINER_WAIT_VARIANT). +const MapObject* trainer_seeing(const MapData& map, const TilePos& tile, const std::set& beaten, bool lookers){ for (const MapObject& object : map.objects){ - if (object.trainer && !object.wander && !looks_around(object) && !beaten.contains(&object) && trainer_may_see(map, object, tile)){ + if (object.trainer && !object.wander && looks_around(object) == lookers && !beaten.contains(&object) && trainer_may_see(map, object, tile)){ return &object; } } @@ -393,7 +393,7 @@ TilePos step_end(const StoryPose& pose, const StoryStep& step, const std::set> wanderer_stops(const StoryPose& pose, co // the player's way, they walk up beside the leg, and the walker holds it // on to its wall after the battle. Or the leg is one tile: the trainer // sees the player when the step is done, on its end. Empty if so, else -// the leg that doesn't. -std::string held_past_lookers(const StoryPose& pose, const StoryStep& step){ +// the leg that doesn't. Trainers in `beaten` see no one. +std::string held_past_lookers(const StoryPose& pose, const StoryStep& step, const std::set& beaten){ MapPosition from{pose.map, pose.tile}; for (const RouteStep& leg : legs_walked(pose, step)){ const MapData* map = find_map_named(from.map); bool crosses = false; - for (const MapPosition& tile : leg.action == RouteAction::WALK ? leg_tiles(story_maps(), from, leg) : std::vector{}){ - for (const MapObject& object : map ? map->objects : std::vector{}){ - crosses |= looks_around(object) && trainer_may_see(*map, object, tile.point); + for (const MapPosition& tile : leg.action == RouteAction::WALK && map ? leg_tiles(story_maps(), from, leg) : std::vector{}){ + for (const MapObject& object : map->objects){ + crosses |= looks_around(object) && !beaten.contains(&object) && trainer_may_see(*map, object, tile.point); } } if (crosses && !leg.to_wall && leg.tiles != 1){ @@ -514,7 +514,7 @@ std::string tile_problem(const MapPosition& tile, const std::setto){ return "onto a warp to a map not loaded at " + to_string(tile); } - const MapObject* trainer = trainer_seeing(*map, tile.point, beaten); + const MapObject* trainer = trainer_seeing(*map, tile.point, beaten, false); if (trainer != nullptr){ return "in the line of the trainer at " + to_string(TilePos{trainer->x, trainer->y}) + " at " + to_string(tile); } @@ -566,11 +566,11 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set 0){ return where + "crosses a wandering trainer's sight where the walk can't tell where they'd stop the player."; } - if (const std::string problem = walks ? held_past_lookers(pose, step) : ""; !problem.empty()){ + if (const std::string problem = walks ? held_past_lookers(pose, step, beaten) : ""; !problem.empty()){ return where + problem; } const MapData* map = find_map_named(pose.map); - const MapObject* trainer = step.kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, step, beaten), beaten) : nullptr; + const MapObject* trainer = step.kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, step, beaten), beaten, step.variant == TRAINER_WAIT_VARIANT) : nullptr; if (trainer != nullptr && (c + 1 == checkpoint.steps.size() || checkpoint.steps[c + 1].kind != StoryStepKind::BATTLE)){ return where + "steps into a trainer's line with no battle after it."; } @@ -587,7 +587,7 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::setstart); for (size_t c = 0; c < steps.size(); c++){ const MapData* map = find_map_named(pose.map); - const MapObject* trainer = steps[c].kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, steps[c], {}), {}) : nullptr; + const MapObject* trainer = steps[c].kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, steps[c], {}), {}, false) : nullptr; if (trainer != nullptr && (c + 1 == steps.size() || steps[c + 1].kind != StoryStepKind::BATTLE)){ return "00.16/" + steps[c].name + ": no battle right after the step."; } diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 218432ad9d..10671ab95a 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1904,15 +1904,22 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilion_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp diff --git a/devtools/maps/frlg_maps.py b/devtools/maps/frlg_maps.py index 6436b660f6..6af5fd6f06 100644 --- a/devtools/maps/frlg_maps.py +++ b/devtools/maps/frlg_maps.py @@ -112,6 +112,17 @@ 'VermilionCity': {'new_game': ['FLAG_HIDE_VERMILION_CITY_OAKS_AIDE']}, } +# Tiles a script changes for good once the story is past it, written as +# the story finds them after: Vermilion Gym's beams across rows 6 and 7 +# go off once both switches are found +# (VermilionCity_Gym_EventScript_SetBeamsOff: (4..6, 6..7) floor, +# FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES). No route of the story's goes +# north of them before that, and one after can't end a leg on them as on +# a wall. +OPENED_TILES = { + 'VermilionCity_Gym': [(x, y) for y in (6, 7) for x in (4, 5, 6)], +} + class Decomp: def __init__(self, commit, cache): @@ -350,6 +361,8 @@ def map_json(decomp, name, sources, layouts, names, loaded, unhandled, land_maps tiles, elevation, entries, wild_edges = grid( decomp, layouts[source['layout']], names, unhandled, signs, source['id'] in land_maps ) + for x, y in OPENED_TILES.get(name, []): + tiles[y] = tiles[y][:x] + '.' + tiles[y][x + 1:] warp_events = source.get('warp_events', []) points = {n: [x, y] for n, (x, y) in POINTS.get(name, {}).items()} for index in sorted(arrivals(sources, loaded)[name]): From ee9fcf1336f19de4f535604cf6118ee843970d06 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 16:45:16 -0800 Subject: [PATCH 08/21] FRLG AutoStory 00.23-00.24: the sim's Surge numbers after the merge Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp | 6 +++--- .../AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp index 3e877b956d..36034fecfc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp @@ -58,7 +58,7 @@ StoryStep trash_cans(std::string name, std::string why){ // Sweet Scent, Poison Powder at 14), so it has room. TM28 Dig, from the // Cerulean Rocket (00.21), to the lead in place of Withdraw: Ground, twice // as strong on Lt. Surge's three, it takes the sim's chance against him -// for a Lv28 Wartortle with 14 Potions from 7% to 45%. Vermilion's tree +// for a Lv28 Wartortle with 14 Potions from 6% to 47%. Vermilion's tree // (19, 24) (EventScript_CutTree) is the only way into the gym's yard, // row 24 being walls but for it; the gym's door is (14, 25). In the gym, // the trainers as above, then the trash cans until the door opens; out, @@ -90,8 +90,8 @@ StoryCheckpoint vermilion_gym(){ } // Wild battles on Route 6 until the sim beats Lt. Surge half the time: -// with Dig, 45% at Lv28, 51% at Lv30, so a Lv29 lead after the gym's -// trainers needs a level or so. The grass nearest the city is Route 6's +// with Dig, 47% at Lv28 and 51% at Lv29, so a lead the gym's trainers +// have brought to Lv29 passes at once, and a Lv28 one after a level. The grass nearest the city is Route 6's // south patch (13..16, 30..33); Picnicker Isabelle (13, 32) faces west // over (10..12, 32), on the way north from the city, so the walk goes // along Jeff's row 33 (he is beaten) to the wall (7, 33), up to row 31 diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp index 2027c80f6b..75cfc582f7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -348,9 +348,9 @@ PartyMember surge_lead(uint8_t level){ } // Lt. Surge against the lead with 14 Potions: Raichu's Shock Wave and -// Thunder Wave beat Water Pulse and Bite (7% at Lv28, 14% at Lv30); TM28 -// Dig in place of Withdraw takes it to 45% at Lv28 and about 50% at Lv30, -// the GRIND's gate (00.23, 00.24). The battles still lost are to the +// Thunder Wave beat Water Pulse and Bite (6% at Lv28); TM28 Dig in place +// of Withdraw takes it to 47% at Lv28 and 51% at Lv29 and Lv30, past the +// GRIND's gate (00.23, 00.24). The battles still lost are to the // Raichu. The gym's three trainers are no risk. UnitTestResult test_surge_chance(){ const SimTrainer surge = *sim_trainer("TRAINER_LEADER_LT_SURGE"); From 43ee40b32900762ccbae9fbc5afe7fe9cacc0377 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 16:56:40 -0800 Subject: [PATCH 09/21] FRLG summary: read the Oddish's moves; an unnamed move keeps its row The KNOWN MOVES reader had no names for the Oddish line's moves, so ABSORB matched nothing, the reader stopped at the top row and 00.23's Cut teach found no moves. Add the Oddish line's level-up moves, and end the moves at the first slot with no type badge instead of the first name not read: an unnamed move now keeps its row with an empty name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Menus/PokemonFRLG_SummaryMovesReader.cpp | 54 ++++++++++++++++--- .../Menus/PokemonFRLG_SummaryMovesReader.h | 4 +- .../PokemonFRLG_AutoStorySummary.cpp | 4 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.cpp index 0fd8d48b8e..41f7d0b1e9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.cpp @@ -35,16 +35,23 @@ namespace{ constexpr double ROW_STEP = 28; constexpr double NAME_TOP = 22; constexpr double PP_TOP = 33; +// A move's type badge, x 123 to 154 and y 22 to 33 on the top row: an +// empty slot shows the pane's light background there instead. +constexpr double BADGE_LEFT = 125; +constexpr double BADGE_TOP = 24; +constexpr double BADGE_WIDTH = 27; +constexpr double BADGE_HEIGHT = 8; // The cursor box's left edge, x 120 to 121, from y 20 to 44 on the top // row: (255, 72, 0). constexpr double CURSOR_EDGE_LEFT = 120; constexpr double CURSOR_EDGE_TOP = 24; constexpr double CURSOR_EDGE_HEIGHT = 16; -// The level-up moves of Squirtle's and Magikarp's lines (pret +// The level-up moves of Squirtle's, Magikarp's and Oddish's lines (pret // pokefirered src/data/pokemon/level_up_learnsets.h), then the TMs' // and HMs' moves not among them (src/data/items.h; Hyper Beam, Protect -// and Rain Dance are above), as the game writes them. +// and Rain Dance are above), as the game writes them +// (src/data/text/move_names.h). constexpr std::pair MOVE_NAMES[] = { {"tackle", "Tackle"}, {"tail-whip", "Tail Whip"}, @@ -64,6 +71,17 @@ constexpr std::pair MOVE_NAMES[] = { {"leer", "Leer"}, {"twister", "Twister"}, {"dragon-dance", "Dragon Dance"}, + {"absorb", "Absorb"}, + {"sweet-scent", "Sweet Scent"}, + {"poison-powder", "PoisonPowder"}, + {"stun-spore", "Stun Spore"}, + {"sleep-powder", "Sleep Powder"}, + {"acid", "Acid"}, + {"moonlight", "Moonlight"}, + {"petal-dance", "Petal Dance"}, + {"aromatherapy", "Aromatherapy"}, + {"mega-drain", "Mega Drain"}, + {"magical-leaf", "Magical Leaf"}, {"hyper-beam", "Hyper Beam"}, {"focus-punch", "Focus Punch"}, {"dragon-claw", "Dragon Claw"}, @@ -172,6 +190,24 @@ bool cursor_red(uint32_t pixel){ return r > 200 && g < 140 && b < 90; } +// Whether row `row` holds a move: its type badge shows, mostly darker +// than the pane's light background. +bool row_has_move(const ImageViewRGB32& screen, size_t row){ + const ImageFloatBox box( + BADGE_LEFT / 240, (BADGE_TOP + ROW_STEP * double(row)) / 160, BADGE_WIDTH / 240, BADGE_HEIGHT / 160 + ); + const ImageViewRGB32 badge = extract_box_reference(game_box(screen), box); + size_t dark = 0; + for (size_t y = 0; y < badge.height(); y++){ + for (size_t x = 0; x < badge.width(); x++){ + const uint32_t pixel = badge.pixel(x, y); + dark += ((pixel >> 16) & 0xff) + ((pixel >> 8) & 0xff) + (pixel & 0xff) < 600 ? 1 : 0; + } + } + // Measured: a third to three quarters of a badge; none of an empty slot. + return dark * 5 > badge.width() * badge.height(); +} + int read_pp(Logger& logger, const ImageViewRGB32& screen, const ImageFloatBox& box){ const int pp = read_digits_waterfill_template(logger, extract_box_reference(game_box(screen), box), DigitTemplateType::StatBox); // A move has at most 64 PP (40 with three PP Ups). @@ -202,12 +238,12 @@ std::string read_move_name(Logger& logger, Language language, const ImageViewRGB std::vector read_summary_moves(Logger& logger, Language language, const ImageViewRGB32& screen){ std::vector moves; for (size_t row = 0; row < SUMMARY_MOVE_ROWS - 1; row++){ - std::string move = read_summary_move_name(logger, language, screen, row); - if (move.empty()){ + // The game packs the moves to the top: the first empty slot ends them. + if (!row_has_move(screen, row)){ break; } moves.push_back(SummaryMove{ - .move = std::move(move), + .move = read_summary_move_name(logger, language, screen, row), .pp = read_pp(logger, screen, summary_move_pp_box(row)), .max_pp = read_pp(logger, screen, summary_move_max_pp_box(row)), }); @@ -247,7 +283,8 @@ namespace{ // A capture of a summary page, named _.png. Its // golden file holds the page shown; on the first page the species and the // level; on KNOWN MOVES, or a move being picked, each move's name, PP and -// full PP, then the cursor's row (from 1) or "none". +// full PP ("unknown" for a move with no name read), then the cursor's +// row (from 1) or "none". class Test_SummaryScreen : public ImageUnitTest{ public: Test_SummaryScreen(const std::string& image) @@ -285,7 +322,7 @@ class Test_SummaryScreen : public ImageUnitTest{ const std::vector known = read_summary_moves(logger, language, screen); for (size_t c = 0; c < known.size(); c++){ labels.push_back(std::format("move {}", c + 1)); - values.push_back(known[c].move); + values.push_back(known[c].move.empty() ? "unknown" : known[c].move); labels.push_back(std::format("move {} PP", c + 1)); values.push_back(std::to_string(known[c].pp)); labels.push_back(std::format("move {} full PP", c + 1)); @@ -311,6 +348,9 @@ void add_tests_SummaryMovesReader(UnitTestDatabase& database){ database.add("PokemonFRLG/SummaryMovesReader/MoveDetail_eng.png"); // The party menu: no summary page. database.add("PokemonFRLG/SummaryMovesReader/PartyMenu_eng.png"); + // FireRed on console B, 2026-11-03: the Oddish caught for Cut, Lv12, with + // ABSORB, SWEET SCENT and two empty slots. + database.add("PokemonFRLG/SummaryMovesReader/Oddish_KnownMoves_eng.png"); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h index dc4202857d..c6a018919b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h @@ -53,6 +53,7 @@ std::string read_summary_move_name(Logger& logger, Language language, const Imag std::string read_move_name(Logger& logger, Language language, const ImageViewRGB32& name); struct SummaryMove{ + // Empty for a move the reader has no name for. std::string move; // -1 if not read. The game draws a move's PP in color once it is down // to half, which the digit templates may not read. @@ -60,7 +61,8 @@ struct SummaryMove{ int max_pp = -1; }; -// The moves from the top, up to the first row whose name can't be read. +// The moves from the top, up to the first empty slot. A move whose name +// isn't read keeps its row, with its name empty. std::vector read_summary_moves(Logger& logger, Language language, const ImageViewRGB32& screen); // The row (0 to 4) the red cursor box is on, if one shows. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp index bfc466a410..f64f9cd819 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp @@ -129,7 +129,9 @@ std::optional read_party_summary( if (read){ std::string moves; for (const SummaryMove& move : read->moves){ - moves += std::format("{}{} {}/{}", moves.empty() ? "" : ", ", move.move, move.pp, move.max_pp); + moves += std::format( + "{}{} {}/{}", moves.empty() ? "" : ", ", move.move.empty() ? "?" : move.move, move.pp, move.max_pp + ); } const std::string stats = read->stats ? std::format( From 7268069e4c1fe4cf133a78a3ba853657cc3524da Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 17:08:49 -0800 Subject: [PATCH 10/21] FRLG teach: red tests for the TM's "Machine set!" scene Console B, 00.23: HM01 to the Oddish (two free slots) stopped on the TM's scene, "Machine set!", which the reader didn't place, and gave up mashing B, which cancels the teach (pokefirered Task_MachineSet). The fake game now runs that scene for TMs and HMs, free slot or forget, and errs on B there; the capture's image test expects tm_scene/machine_set. Only the names are added; the reader and step don't use them yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Menus/PokemonFRLG_TeachScreenReader.cpp | 10 +- .../Menus/PokemonFRLG_TeachScreenReader.h | 13 +- .../PokemonFRLG_AutoStoryTeachScreens.cpp | 1 + ...okemonFRLG_AutoStoryTeachScreens_Tests.cpp | 140 +++++++++++++++++- 4 files changed, 154 insertions(+), 10 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp index 1dd88d6d85..4d2844ad45 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp @@ -271,6 +271,7 @@ const char* teach_screen_name(TeachScreen screen){ case TeachScreen::PARTY_TEXT: return "party_text"; case TeachScreen::PARTY_YES_NO: return "party_yes_no"; case TeachScreen::KNOWN_MOVES: return "known_moves"; + case TeachScreen::TM_SCENE: return "tm_scene"; } return "?"; } @@ -286,6 +287,8 @@ const char* teach_text_name(TeachText text){ case TeachText::STOP_QUESTION: return "stop_question"; case TeachText::DID_NOT_LEARN: return "did_not_learn"; case TeachText::NOT_COMPATIBLE: return "not_compatible"; + case TeachText::MACHINE_SET: return "machine_set"; + case TeachText::LEARNED: return "learned"; } return "?"; } @@ -415,8 +418,8 @@ void add_list( // then: in the bag, its pocket, the key items from the top (KEY ITEMS // only) and the cursor's row (from 1, or "none"); in the TM CASE, its // moves from the top and the cursor's row; in an item menu or YES/NO, the -// cursor's row; over the party menu, the message; on KNOWN MOVES, the five -// rows' moves and the cursor's row. +// cursor's row; over the party menu or in the TM's scene, the message; on +// KNOWN MOVES, the five rows' moves and the cursor's row. class Test_TeachScreen : public ImageUnitTest{ public: Test_TeachScreen(const std::string& image) @@ -467,6 +470,7 @@ class Test_TeachScreen : public ImageUnitTest{ [[fallthrough]]; case TeachScreen::PARTY_PROMPT: case TeachScreen::PARTY_TEXT: + case TeachScreen::TM_SCENE: labels.push_back("text"); values.push_back(teach_text_name(read_teach_text(logger, language, screen))); return; @@ -506,6 +510,8 @@ void add_tests_TeachScreenReader(UnitTestDatabase& database){ "KnownMoves_eng.png", "StopQuestion_eng.png", "DidNotLearn_eng.png", + // HM01 to an Oddish with two free slots (00.23, console B). + "MachineSet_eng.png", }){ database.add(std::string("PokemonFRLG/TeachScreenReader/") + image); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h index 2e1dc393f5..ff9da17243 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h @@ -49,6 +49,13 @@ enum class TeachScreen{ PARTY_YES_NO, // The KNOWN MOVES page with its red cursor: the move to forget. KNOWN_MOVES, + // The TM's scene over the member (pokefirered + // pokemon_special_anim.c), on its blue diamonds: with a free slot, + // right after A on the member; after a forget, "1, 2 and... Poof!", + // " forgot .", "And...". Then "Machine set!", which waits + // for A (B cancels the teach), the disc's wobble, " learned + // !" and the TM CASE. + TM_SCENE, }; const char* teach_screen_name(TeachScreen screen); @@ -94,11 +101,13 @@ enum class TeachText{ STOP_QUESTION, // "Stop trying to teach ?" DID_NOT_LEARN, // " did not learn the move ." NOT_COMPATIBLE, // " and are not compatible." + MACHINE_SET, // "Machine set!", in the TM's scene + LEARNED, // " learned !" }; const char* teach_text_name(TeachText text); -// The message over the party menu, from the prompt's or the message box's -// first two lines. +// The message over the party menu or in the TM's scene, from the prompt's +// or the message box's first two lines. TeachText read_teach_text(Logger& logger, Language language, const ImageViewRGB32& screen); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp index 26eeee569f..c6f5465b6a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp @@ -315,6 +315,7 @@ bool ScreenTeachActions::leave(){ stop_teaching(); break; case TeachScreen::OTHER: + case TeachScreen::TM_SCENE: return m_screens.to_overworld(); } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp index cc27f10a19..36d60a3fd0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp @@ -8,12 +8,20 @@ * tm03 from the TM Case": the console's steps had no readers and stopped * before the bag. * + * A TM or HM taught goes through the TM's scene (pokefirered + * pokemon_special_anim.c, Task_UseTM_NoForget / Task_ForgetMove, then + * Task_MachineSet): "Machine set!" waits for A and B cancels the teach; + * the wobble and " learned !" run on their own. Console B, + * 00.23 (2026-11-03): HM01 to an Oddish with two free slots stopped on + * "Machine set!", read as "other", and gave up. + * */ #include #include #include #include +#include #include #include #include @@ -57,6 +65,17 @@ enum class FakeScreen{ KNOWN_MOVES, // A tutor's last line in the overworld, after its party menu. TUTOR_TEXT, + // The TM's scene. + SCENE, +}; + +// A look at the TM's scene: what it shows, and whether it waits for A; +// the others pass with the look. +struct SceneStep{ + TeachText text = TeachText::UNKNOWN; + bool waits_for_a = false; + // The disc's wobble and zoom: no message box. + bool box = true; }; // The bag, TM CASE and party menu as console B showed them. @@ -80,8 +99,16 @@ struct FakeTeachGame{ FakeScreen after_texts = FakeScreen::OVERWORLD; // The messages after a move is forgotten ("1, 2 and... Poof!", "... // forgot TAIL WHIP.", "And... learned WATER PULSE!") read as "other", - // as on console B (2026-11-03). + // as on console B (2026-11-03): a tutor's over the party menu, a TM's + // whole scene. bool forget_texts_unread = false; + // The TM's scene left to show, and the move it teaches: into + // `scene_slot`, or a free slot. + std::deque scene = {}; + std::string scene_move = {}; + std::optional scene_slot = std::nullopt; + // The TM's scene reads as "other". + bool scene_unread = false; // The TM in use, by row. size_t tm = 0; std::vector presses = {}; @@ -127,10 +154,11 @@ std::string tm_move(const FakeTeachGame& game){ } void use_up(FakeTeachGame& game){ - if (game.tutoring){ + const std::string move = tm_move(game); + // An HM is kept. + if (game.tutoring || is_hm_move(move)){ return; } - const std::string move = tm_move(game); for (FakeTm& tm : game.tms){ if (tm.move == move && tm.count > 0){ tm.count--; @@ -139,17 +167,65 @@ void use_up(FakeTeachGame& game){ } } +// The TM's scene, after the forget's lines if any: "Machine set!", the +// wobble, " learned !", then the TM CASE. +void start_scene(FakeTeachGame& game, std::optional slot){ + game.scene.clear(); + if (slot){ + game.scene.push_back({}); + game.scene.push_back({.waits_for_a = true}); + game.scene.push_back({.waits_for_a = true}); + game.scene.push_back({.waits_for_a = true}); + } + game.scene.push_back({.text = TeachText::MACHINE_SET, .waits_for_a = true}); + game.scene.push_back({.box = false}); + game.scene.push_back({.box = false}); + game.scene.push_back({.text = TeachText::LEARNED}); + game.scene.push_back({.text = TeachText::LEARNED}); + game.scene_move = tm_move(game); + game.scene_slot = slot; + game.scene_unread |= slot && game.forget_texts_unread; + game.screen = FakeScreen::SCENE; +} + +// The scene's step passed: at its end the move is learned. +void next_scene_step(FakeTeachGame& game){ + game.scene.pop_front(); + if (!game.scene.empty()){ + return; + } + if (game.scene_slot){ + game.moves[*game.scene_slot] = game.scene_move; + }else{ + game.moves.push_back(game.scene_move); + } + use_up(game); + game.screen = FakeScreen::TM_CASE; + game.cursor = game.tm; +} + +void press_scene(FakeTeachGame& game, TeachPress press){ + if (press == TeachPress::B){ + game.error = "B cancelled the TM's scene"; + }else if (press == TeachPress::A && game.scene.front().waits_for_a){ + next_scene_step(game); + } +} + void choose_member(FakeTeachGame& game){ const std::string move = tm_move(game); if (!game.compatible.contains(move)){ show_texts(game, {TeachText::NOT_COMPATIBLE}, FakeScreen::PROMPT); return; } + if (game.moves.size() < MOVE_SLOTS && !game.tutoring){ + start_scene(game, std::nullopt); + return; + } if (game.moves.size() < MOVE_SLOTS){ game.moves.push_back(move); - use_up(game); game.cursor = 0; - show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::UNKNOWN}, after_party(game)); + show_texts(game, {TeachText::LEARNED}, after_party(game)); return; } show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::KNOWS_FOUR}, FakeScreen::DELETE_YES_NO); @@ -246,6 +322,10 @@ void press_known_moves(FakeTeachGame& game, TeachPress press){ if (is_hm_move(game.moves[game.cursor])){ return; } + if (!game.tutoring){ + start_scene(game, game.cursor); + return; + } game.moves[game.cursor] = tm_move(game); use_up(game); show_texts(game, {TeachText::UNKNOWN, TeachText::UNKNOWN, TeachText::UNKNOWN}, after_party(game)); @@ -271,6 +351,7 @@ void press_game(FakeTeachGame& game, TeachPress press){ case FakeScreen::DELETE_YES_NO: case FakeScreen::STOP_YES_NO: press_yes_no(game, press); return; case FakeScreen::KNOWN_MOVES: press_known_moves(game, press); return; + case FakeScreen::SCENE: press_scene(game, press); return; case FakeScreen::PROMPT: if (press == TeachPress::A){ choose_member(game); @@ -319,6 +400,13 @@ TeachView view_of(const FakeTeachGame& game){ return {.screen = TeachScreen::PARTY_YES_NO, .cursor = game.cursor, .text = TeachText::STOP_QUESTION}; case FakeScreen::KNOWN_MOVES: return {.screen = TeachScreen::KNOWN_MOVES, .cursor = game.cursor}; + case FakeScreen::SCENE:{ + const SceneStep& step = game.scene.front(); + if (game.scene_unread || !step.box){ + return {}; + } + return {.screen = TeachScreen::TM_SCENE, .text = step.text}; + } } return {}; } @@ -349,7 +437,11 @@ class FakeTeachScreens : public TeachScreens{ return true; } virtual TeachView look() override{ - return view_of(m_game); + const TeachView view = view_of(m_game); + if (m_game.screen == FakeScreen::SCENE && !m_game.scene.front().waits_for_a){ + next_scene_step(m_game); + } + return view; } virtual void press(TeachPress press) override{ m_game.presses.emplace_back(teach_press_name(press)); @@ -396,6 +488,22 @@ void left_on_poke_balls(FakeTeachGame& game){ void three_moves(FakeTeachGame& game){ game.moves = {"bite", "bubble", "withdraw"}; } +void three_moves_scene_unread(FakeTeachGame& game){ + three_moves(game); + game.scene_unread = true; +} +// 00.23 on console B: HM01 to the Oddish (slot 2), ABSORB and SWEET +// SCENT. +void oddish_cut(FakeTeachGame& game){ + game.tms = {{"water-pulse", 1}, {"cut", 1}}; + game.moves = {"absorb", "sweet-scent"}; + game.compatible.insert("cut"); +} +// Next in the row: TM28 DIG over a move of the Wartortle's four. +void wartortle_dig(FakeTeachGame& game){ + game.tms = {{"water-pulse", 1}, {"dig", 1}}; + game.compatible.insert("dig"); +} void no_tm03(FakeTeachGame& game){ game.tms = {{"rock-tomb", 1}}; } @@ -442,6 +550,26 @@ std::vector screens_cases(){ .plan = {.item = "tm03", .move = "water-pulse", .member = 0}, .moves = {"bite", "bubble", "withdraw", "water-pulse"}, }, + { + .name = "a free slot, the TM's scene read as other", + .setup = three_moves_scene_unread, + .plan = {.item = "tm03", .move = "water-pulse", .member = 0}, + .moves = {"bite", "bubble", "withdraw", "water-pulse"}, + }, + { + .name = "console B 00.23: HM01 to the Oddish, two free slots", + .setup = oddish_cut, + .plan = {.item = "hm01", .move = "cut", .member = 1}, + .moves = {"absorb", "sweet-scent", "cut"}, + .tm03 = 1, + }, + { + .name = "TM28 DIG over WITHDRAW", + .setup = wartortle_dig, + .plan = {.item = "tm28", .move = "dig", .member = 0, .forget_move = "withdraw"}, + .moves = {"bite", "tail-whip", "bubble", "dig"}, + .tm03 = 1, + }, { .name = "no TM03 in the TM CASE: skipped, back to the overworld", .setup = no_tm03, From 010db08c0d963d123b9397a667956a2804b605f7 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 17:13:45 -0800 Subject: [PATCH 11/21] FRLG teach: A through the TM's scene, never B The reader places the TM's scene (pale blue diamonds on both sides) and reads "Machine set!" and "learned" in its message box. After A on the member, and after the pick on KNOWN MOVES, the step presses A through the scene until "learned" shows, then waits for the TM CASE; B there would cancel the teach. A scene the reader misses (two looks of "other") gets the same A presses. leave() finishes a scene before backing out. The moves read after the teach still decide. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Menus/PokemonFRLG_TeachScreenReader.cpp | 20 ++++++- .../Menus/PokemonFRLG_TeachScreenReader.h | 5 +- .../AutoStory/PokemonFRLG_AutoStoryTeach.h | 11 ++-- .../PokemonFRLG_AutoStoryTeachScreens.cpp | 54 +++++++++++++++++-- .../PokemonFRLG_AutoStoryTeachScreens.h | 29 ++++++---- ...okemonFRLG_AutoStoryTeachScreens_Tests.cpp | 34 +++++++----- 6 files changed, 116 insertions(+), 37 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp index 4d2844ad45..591b9363e8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp @@ -57,6 +57,12 @@ bool solid(const ImageViewRGB32& screen, const GameBox& box, const FloatPixel& c return euclidean_distance(stats.average, color) < 40 && stats.stddev.sum() < 40; } +// The box is a fine pattern of light colors averaging near `color`. +bool patterned(const ImageViewRGB32& screen, const GameBox& box, const FloatPixel& color){ + const ImageStats stats = image_stats(game_crop(screen, box)); + return euclidean_distance(stats.average, color) < 30 && stats.stddev.sum() < 70; +} + // Measured on the captures (console B, 2026-11-03). Left of the title, the // frame's color tells the screen: the bag's orange, the TM CASE's mint, // the party menu's dark teal. @@ -65,6 +71,11 @@ const FloatPixel BAG_ORANGE(255, 210, 94); const FloatPixel TM_CASE_MINT(193, 234, 207); const FloatPixel PARTY_TEAL(18, 109, 107); const FloatPixel WHITE(255, 255, 255); +// The TM's scene: pale blue diamonds on both sides of the member, around +// (198, 234, 255) with a stddev near 30 for the pattern. +constexpr GameBox SCENE_LEFT{0, 20, 16, 90}; +constexpr GameBox SCENE_RIGHT{224, 20, 16, 90}; +const FloatPixel SCENE_BLUE(198, 234, 255); // The item menu's box, between its rows (OPEN at y 109, REGISTER 125, // CANCEL 141); blue text box or list below it when it is closed. @@ -202,6 +213,8 @@ const NameMatcher& text_matcher(){ {"stop-question", "Stop trying to teach"}, {"did-not-learn", "did not learn the"}, {"not-compatible", "are not compatible"}, + {"machine-set", "Machine set"}, + {"learned", "learned"}, }; return matcher; } @@ -216,6 +229,8 @@ TeachText teach_text(const std::string& slug){ {"stop-question", TeachText::STOP_QUESTION}, {"did-not-learn", TeachText::DID_NOT_LEARN}, {"not-compatible", TeachText::NOT_COMPATIBLE}, + {"machine-set", TeachText::MACHINE_SET}, + {"learned", TeachText::LEARNED}, }; for (const auto& [name, text] : texts){ if (name == slug){ @@ -304,6 +319,9 @@ TeachScreen read_teach_screen(const ImageViewRGB32& screen){ if (solid(screen, CORNER, PARTY_TEAL)){ return read_party_screen(screen); } + if (patterned(screen, SCENE_LEFT, SCENE_BLUE) && patterned(screen, SCENE_RIGHT, SCENE_BLUE)){ + return TeachScreen::TM_SCENE; + } if (read_summary_move_cursor(screen)){ return TeachScreen::KNOWN_MOVES; } @@ -353,7 +371,7 @@ TeachText read_teach_text(Logger& logger, Language language, const ImageViewRGB3 if (kind == TeachScreen::PARTY_PROMPT){ return teach_text(text_matcher().read(logger, language, game_crop(screen, PROMPT_LINE), DARK_TEXT_FILTERS())); } - if (kind != TeachScreen::PARTY_TEXT && kind != TeachScreen::PARTY_YES_NO){ + if (kind != TeachScreen::PARTY_TEXT && kind != TeachScreen::PARTY_YES_NO && kind != TeachScreen::TM_SCENE){ return TeachText::UNKNOWN; } // The phrase either line matches best. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h index ff9da17243..978a17cb2e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h @@ -12,8 +12,9 @@ * Which screen shows is told from the colors of its background, its boxes * and the dark arrow of its cursor, measured on those captures in the * game's 240x160 pixels: the bag's frame is orange, the TM CASE's mint, - * the party menu's dark teal. Names and messages are read by OCR, in - * English only so far: in another language nothing is read. + * the party menu's dark teal, the TM's scene's pale blue diamonds. Names + * and messages are read by OCR, in English only so far: in another + * language nothing is read. * */ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h index c3e94e1872..cd37172a6a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h @@ -10,11 +10,12 @@ * "Teach which POKEMON?", each member ABLE or NOT ABLE. A on a member * that knows the move says so; one NOT ABLE: " and are not * compatible.". - * - " wants to learn the move ."; with fewer than four moves - * it learns. - * - With four: "However, already knows four moves.", "Should a - * move be deleted and replaced with ?" YES/NO. YES opens the - * moves page; A on a move forgets it and it learns. An HM move can't be + * - With fewer than four moves it learns, in the TM's scene ("Machine + * set!", " learned !"). + * - With four: " wants to learn the move .", "However, + * already knows four moves.", "Should a move be deleted and replaced + * with ?" YES/NO. YES opens the moves page; A on a move forgets + * it and it learns, in the same scene. An HM move can't be * forgotten (pokefirered: "HM moves can't be forgotten now.") and the * moves page shows again. B there, then "Stop trying to teach ?" * YES, leaves it unlearned. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp index c6f5465b6a..0e68b8078b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp @@ -171,6 +171,7 @@ TeachPrompt ScreenTeachActions::member_answer(){ bool incompatible = false; bool texts = false; size_t prompts = 0; + size_t unplaced = 0; for (size_t c = 0; c < MAX_LOOKS; c++){ const TeachView view = m_screens.look(); switch (view.screen){ @@ -182,11 +183,18 @@ TeachPrompt ScreenTeachActions::member_answer(){ texts = true; press(TeachPress::A); break; + case TeachScreen::TM_SCENE: + return through_scene() ? TeachPrompt::LEARNED : TeachPrompt::FAILED; case TeachScreen::OTHER: // A tutor's party menu closes onto the overworld once learned. if (m_tutor && texts){ return TeachPrompt::LEARNED; } + // A TM's: the scene, not placed (the first look may be the + // party menu closing). + if (!m_tutor && ++unplaced > 1){ + return through_scene() ? TeachPrompt::LEARNED : TeachPrompt::FAILED; + } break; case TeachScreen::PARTY_YES_NO: return view.text == TeachText::STOP_QUESTION ? TeachPrompt::FAILED : TeachPrompt::DELETE_PROMPT; @@ -210,6 +218,38 @@ TeachPrompt ScreenTeachActions::member_answer(){ return TeachPrompt::FAILED; } +bool ScreenTeachActions::through_scene(){ + bool learned = false; + for (size_t c = 0; c < MAX_LOOKS; c++){ + const TeachView view = m_screens.look(); + switch (view.screen){ + case TeachScreen::TM_SCENE: + case TeachScreen::OTHER: + // The disc's wobble and "learned" run on their own: an A + // then is lost, or opens the TM's menu once the TM CASE is + // back, which leave() closes. + learned |= view.text == TeachText::LEARNED; + if (!learned){ + press(TeachPress::A); + } + break; + case TeachScreen::TM_CASE: + case TeachScreen::ITEM_MENU: + case TeachScreen::BAG: + return true; + default: + m_logger.log(std::format("Teach: {} after the TM's scene: not learned.", teach_screen_name(view.screen)), COLOR_RED); + return false; + } + } + if (learned){ + m_logger.log("Teach: the TM CASE didn't show after \"learned\"; reading the moves will tell.", COLOR_ORANGE); + return true; + } + m_logger.log("Teach: the TM's scene didn't end.", COLOR_RED); + return false; +} + void ScreenTeachActions::answer_to_forget(const TeachView& view){ // YES to delete a move; NO to stop trying, back to the moves. if (view.text == TeachText::STOP_QUESTION){ @@ -254,10 +294,10 @@ ForgetEnd ScreenTeachActions::forget(size_t slot){ if (picked && m_tutor){ return ForgetEnd::LEARNED; } - // After the pick, its messages: B pages through them, once the - // moves page has closed (the first look). - if (picked && ++unplaced > 1){ - press(TeachPress::B); + // A TM's scene; one not placed once the moves page has closed + // (the first look). + if (picked && (view.screen == TeachScreen::TM_SCENE || ++unplaced > 1)){ + return through_scene() ? ForgetEnd::LEARNED : ForgetEnd::FAILED; } break; } @@ -314,8 +354,11 @@ bool ScreenTeachActions::leave(){ case TeachScreen::KNOWN_MOVES: stop_teaching(); break; - case TeachScreen::OTHER: case TeachScreen::TM_SCENE: + // B would cancel it: to its end, then out. + through_scene(); + break; + case TeachScreen::OTHER: return m_screens.to_overworld(); } } @@ -402,6 +445,7 @@ TeachView ConsoleTeachScreens::read_view(const ImageViewRGB32& image, TeachScree case TeachScreen::PARTY_PROMPT: case TeachScreen::PARTY_TEXT: case TeachScreen::PARTY_YES_NO: + case TeachScreen::TM_SCENE: view.text = read_teach_text(logger, m_language, image); break; default: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h index e04de0ddf5..0485ab43e3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h @@ -13,16 +13,21 @@ * WATER PULSE x1", "No39 ROCK TOMB x1", CLOSE). * - A on a TM: USE/GIVE/EXIT, USE on top; A: the party menu, "Teach which * POKEMON?", each member ABLE or NOT ABLE, the cursor on the lead. - * - A on a member: " wants to learn the move ."; with room it - * learns. With four: "However, already knows four moves.", - * "Should a move be deleted and replaced with ?" YES/NO (YES on - * top), YES: "Which move should be forgotten?", then KNOWN MOVES with - * the four moves and the new one fifth, the red cursor on the first. A - * on a move forgets it: "1, 2 and... ... Poof!", " forgot .", - * "And... learned !", which the reader doesn't place (read - * "other"; B pages through them), then the TM CASE's list. B instead: "Stop trying to teach ?" - * YES/NO, YES: " did not learn the move ." and the TM CASE's - * list again, the TM kept. + * - A on a member with room: the TM's scene (pokefirered + * pokemon_special_anim.c), the member on pale blue diamonds: "Machine + * set!", which waits for A (B there cancels the teach), the disc's + * wobble, " learned !", then the TM CASE's list (console B, + * 00.23: HM01 to an Oddish with two free slots). With four: " + * wants to learn the move .", "However, already knows four + * moves.", "Should a move be deleted and replaced with ?" YES/NO + * (YES on top), YES: "Which move should be forgotten?", then KNOWN + * MOVES with the four moves and the new one fifth, the red cursor on + * the first. A on a move forgets it in the TM's scene: "1, 2 and... + * ... Poof!", " forgot .", "And...", each waiting for A, + * then "Machine set!" and the rest as with room. A pages through it + * all; once "learned" shows, no more presses. B instead: "Stop trying + * to teach ?" YES/NO, YES: " did not learn the move + * ." and the TM CASE's list again, the TM kept. * - From the TM CASE's list, B closes it to the bag, and B the bag. * - A move tutor (not seen on console B yet; pokefirered * move_tutors.inc): its lines and two YES/NOs in the overworld's @@ -126,6 +131,10 @@ class ScreenTeachActions : public TeachActions{ ItemOpen open_row(TeachScreen screen, std::string_view name); // After A on a member: what it showed. TeachPrompt member_answer(); + // In the TM's scene: A through its messages, never B, until the TM + // CASE is back. True if it came back or "learned" showed (the moves + // read after decide); false if the party menu came back instead. + bool through_scene(); // On a YES/NO while forgetting: the answer that goes on to the moves. void answer_to_forget(const TeachView& view); void press(TeachPress press); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp index 36d60a3fd0..09c250919f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp @@ -550,6 +550,25 @@ std::vector screens_cases(){ .plan = {.item = "tm03", .move = "water-pulse", .member = 0}, .moves = {"bite", "bubble", "withdraw", "water-pulse"}, }, + { + .name = "no TM03 in the TM CASE: skipped, back to the overworld", + .setup = no_tm03, + .end = TeachEnd::NO_ITEM, + .moves = {"bite", "tail-whip", "bubble", "withdraw"}, + }, + { + .name = "not compatible: given up, back to the overworld", + .setup = nothing_compatible, + .end = TeachEnd::REFUSED, + .moves = {"bite", "tail-whip", "bubble", "withdraw"}, + .tm03 = 1, + }, + }; +} + +// The TM's scene, with a free slot and after a forget, for a TM and an HM. +std::vector scene_screens_cases(){ + return { { .name = "a free slot, the TM's scene read as other", .setup = three_moves_scene_unread, @@ -570,19 +589,6 @@ std::vector screens_cases(){ .moves = {"bite", "tail-whip", "bubble", "dig"}, .tm03 = 1, }, - { - .name = "no TM03 in the TM CASE: skipped, back to the overworld", - .setup = no_tm03, - .end = TeachEnd::NO_ITEM, - .moves = {"bite", "tail-whip", "bubble", "withdraw"}, - }, - { - .name = "not compatible: given up, back to the overworld", - .setup = nothing_compatible, - .end = TeachEnd::REFUSED, - .moves = {"bite", "tail-whip", "bubble", "withdraw"}, - .tm03 = 1, - }, }; } @@ -660,7 +666,7 @@ UnitTestResult check_screens_case(const ScreensCase& item){ } UnitTestResult test_screens(){ - for (const std::vector& cases : {screens_cases(), tutor_screens_cases()}){ + for (const std::vector& cases : {screens_cases(), scene_screens_cases(), tutor_screens_cases()}){ for (const ScreensCase& item : cases){ const UnitTestResult result = check_screens_case(item); if (result.result != UnitTestResult::PASSED){ From b9923327c0616bbcbd0b2801dddf5c92e89fc63d Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 17:29:23 -0800 Subject: [PATCH 12/21] FRLG teach: red tests for "Poof!" read as "learned" and a stuck scene Console B, TM28 Dig over Withdraw: the forget's "1, 2, and... Poof!" read as "learned", the step stopped pressing and waited 5 minutes, leave() starting the scene's wait again on each look. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- ...okemonFRLG_AutoStoryTeachScreens_Tests.cpp | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp index 09c250919f..347876bcc7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp @@ -109,6 +109,12 @@ struct FakeTeachGame{ std::optional scene_slot = std::nullopt; // The TM's scene reads as "other". bool scene_unread = false; + // "1, 2, and... Poof!" reads as "learned", as on console B (TM28 to + // the Wartortle, 2026-11-03). + bool poof_reads_learned = false; + // A doesn't page the scene's boxes: it never ends. + bool scene_stuck = false; + size_t looks = 0; // The TM in use, by row. size_t tm = 0; std::vector presses = {}; @@ -171,9 +177,14 @@ void use_up(FakeTeachGame& game){ // wobble, " learned !", then the TM CASE. void start_scene(FakeTeachGame& game, std::optional slot){ game.scene.clear(); + // After a forget (pokefirered Task_ForgetMove): "1, 2, and... Poof!", + // " forgot .", "And...", each ending in \p: they wait for A. if (slot){ game.scene.push_back({}); - game.scene.push_back({.waits_for_a = true}); + game.scene.push_back({ + .text = game.poof_reads_learned ? TeachText::LEARNED : TeachText::UNKNOWN, + .waits_for_a = true, + }); game.scene.push_back({.waits_for_a = true}); game.scene.push_back({.waits_for_a = true}); } @@ -207,7 +218,7 @@ void next_scene_step(FakeTeachGame& game){ void press_scene(FakeTeachGame& game, TeachPress press){ if (press == TeachPress::B){ game.error = "B cancelled the TM's scene"; - }else if (press == TeachPress::A && game.scene.front().waits_for_a){ + }else if (press == TeachPress::A && game.scene.front().waits_for_a && !game.scene_stuck){ next_scene_step(game); } } @@ -437,6 +448,9 @@ class FakeTeachScreens : public TeachScreens{ return true; } virtual TeachView look() override{ + if (++m_game.looks > 1000){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "more than 1000 looks"); + } const TeachView view = view_of(m_game); if (m_game.screen == FakeScreen::SCENE && !m_game.scene.front().waits_for_a){ next_scene_step(m_game); @@ -507,6 +521,12 @@ void wartortle_dig(FakeTeachGame& game){ void no_tm03(FakeTeachGame& game){ game.tms = {{"rock-tomb", 1}}; } +// Console B, 2026-11-03: the step stopped pressing on "Poof!" read as +// "learned", and waited 5 minutes. +void wartortle_dig_poof_learned(FakeTeachGame& game){ + wartortle_dig(game); + game.poof_reads_learned = true; +} void forget_texts_unread(FakeTeachGame& game){ game.forget_texts_unread = true; } @@ -589,6 +609,13 @@ std::vector scene_screens_cases(){ .moves = {"bite", "tail-whip", "bubble", "dig"}, .tm03 = 1, }, + { + .name = "console B: TM28 DIG over WITHDRAW, \"Poof!\" read as \"learned\"", + .setup = wartortle_dig_poof_learned, + .plan = {.item = "tm28", .move = "dig", .member = 0, .forget_move = "withdraw"}, + .moves = {"bite", "tail-whip", "bubble", "dig"}, + .tm03 = 1, + }, }; } @@ -665,6 +692,30 @@ UnitTestResult check_screens_case(const ScreensCase& item){ return true; } +// A TM's scene that A doesn't page: the step gives up within a few +// scenes' looks, not one per leave() look. +UnitTestResult test_stuck_scene(){ + QuietLogger logger; + FakeTeachGame game; + wartortle_dig(game); + game.scene_stuck = true; + FakeTeachScreens screens(game); + ScreenTeachActions actions(logger, screens); + const MemberReader reader = [&game](size_t) -> std::optional{ + return MemberInfo{.species = "wartortle", .level = 22, .moves = game.moves}; + }; + const TeachPlan plan{.item = "tm28", .move = "dig", .member = 0, .forget_move = "withdraw"}; + const TeachResult result = run_teach(logger, actions, reader, plan); + if (result.end != TeachEnd::FAILED){ + return std::format("a stuck scene ended {} ({}).", teach_end_name(result.end), result.detail); + } + // The scene's looks: once in forget(), once in leave(). + if (game.looks > 150){ + return std::format("a stuck scene took {} looks.", game.looks); + } + return true; +} + UnitTestResult test_screens(){ for (const std::vector& cases : {screens_cases(), scene_screens_cases(), tutor_screens_cases()}){ for (const ScreensCase& item : cases){ @@ -701,6 +752,7 @@ class Test_AutoStoryTeachScreens : public UnitTest{ void add_tests_AutoStoryTeachScreens(UnitTestDatabase& database){ database.add("TM03 through the console's screens", test_screens); + database.add("a TM's scene that doesn't end", test_stuck_scene); } From 212854cfabe0bd9f5c900887b324ca2623fb101f Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 17:30:49 -0800 Subject: [PATCH 13/21] FRLG teach: A on every look of the TM's scene; leave() waits for it once pokefirered: after a forget, "1, 2, and... Poof!", " forgot ." and "And..." end in \p and wait for A; " learned !" runs on its own. Console B read "Poof!" as "learned", so the step stopped pressing; and leave() restarted the scene's wait on each of its looks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_AutoStoryTeachScreens.cpp | 17 ++++++++++++----- .../PokemonFRLG_AutoStoryTeachScreens.h | 8 ++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp index 0e68b8078b..76f4a1f8e0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp @@ -225,13 +225,13 @@ bool ScreenTeachActions::through_scene(){ switch (view.screen){ case TeachScreen::TM_SCENE: case TeachScreen::OTHER: - // The disc's wobble and "learned" run on their own: an A + // A on every look, "learned" too: the forget's "Poof!" has + // read as "learned" (console B, 2026-11-03) and waits for A. + // The wobble and the real "learned" run on their own: an A // then is lost, or opens the TM's menu once the TM CASE is // back, which leave() closes. learned |= view.text == TeachText::LEARNED; - if (!learned){ - press(TeachPress::A); - } + press(TeachPress::A); break; case TeachScreen::TM_CASE: case TeachScreen::ITEM_MENU: @@ -340,6 +340,7 @@ void ScreenTeachActions::dump_screen(std::string_view label){ } bool ScreenTeachActions::leave(){ + bool scene_seen = false; for (size_t c = 0; c < MAX_LOOKS; c++){ const TeachView view = m_screens.look(); switch (view.screen){ @@ -355,7 +356,13 @@ bool ScreenTeachActions::leave(){ stop_teaching(); break; case TeachScreen::TM_SCENE: - // B would cancel it: to its end, then out. + // B would cancel it: to its end, then out. Once: a scene + // that A didn't end is stuck. + if (scene_seen){ + m_logger.log("Teach: the TM's scene is still up.", COLOR_RED); + return false; + } + scene_seen = true; through_scene(); break; case TeachScreen::OTHER: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h index 0485ab43e3..8c6a17641c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h @@ -25,8 +25,8 @@ * the first. A on a move forgets it in the TM's scene: "1, 2 and... * ... Poof!", " forgot .", "And...", each waiting for A, * then "Machine set!" and the rest as with room. A pages through it - * all; once "learned" shows, no more presses. B instead: "Stop trying - * to teach ?" YES/NO, YES: " did not learn the move + * all, until the TM CASE: "Poof!" has read as "learned". B on the + * moves instead: "Stop trying to teach ?" YES/NO, YES: " did not learn the move * ." and the TM CASE's list again, the TM kept. * - From the TM CASE's list, B closes it to the bag, and B the bag. * - A move tutor (not seen on console B yet; pokefirered @@ -131,8 +131,8 @@ class ScreenTeachActions : public TeachActions{ ItemOpen open_row(TeachScreen screen, std::string_view name); // After A on a member: what it showed. TeachPrompt member_answer(); - // In the TM's scene: A through its messages, never B, until the TM - // CASE is back. True if it came back or "learned" showed (the moves + // In the TM's scene: A on each look, never B, until the TM CASE is + // back. True if it came back or "learned" showed (the moves // read after decide); false if the party menu came back instead. bool through_scene(); // On a YES/NO while forgetting: the answer that goes on to the moves. From 00a20770731e048a5f3b03245aa5859e1521f6a2 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 17:50:09 -0800 Subject: [PATCH 14/21] FRLG walk: hold a warp a tile further after an open leg over a connection On console B (row 00.24) the open leg south 14 from Route 6 into Vermilion City, held a tile longer for the stall on a connection, ended a tile further south: the game didn't hold the player up there. West 8 then ran along row 8 and north 1 onto the Pokemon Center's door stopped below it ("no fade in and out"). Until a leg to a wall or a warp places the player again, a warp walked back that way is now held a tile further (RouteStep::warp_slack, with_warp_slack() in run_route()), stopping at the fade. The overworld sim can now leave out the stall (SimScript::connections_stall); the new sim test walks the exact plan both ways. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_OverworldReanchor.cpp | 24 +++++++++++++++++++ .../Overworld/PokemonFRLG_OverworldReanchor.h | 9 +++++++ .../PokemonFRLG_OverworldRecovery.cpp | 2 +- .../PokemonFRLG_OverworldRecoveryWalk.cpp | 2 +- .../Overworld/PokemonFRLG_OverworldRoute.h | 6 +++++ .../Overworld/PokemonFRLG_OverworldSim.cpp | 4 ++-- .../Overworld/PokemonFRLG_OverworldSim.h | 5 ++++ .../PokemonFRLG_OverworldSim_Tests.cpp | 20 ++++++++++++++++ .../Overworld/PokemonFRLG_OverworldWalk.cpp | 2 +- 9 files changed, 69 insertions(+), 5 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index a087f5a38f..670cf0a2e9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -285,6 +285,30 @@ WalkedTiles walked_near(const RouteStep& step, int estimate, Milliseconds tile_t }; } +std::vector with_warp_slack(std::vector route){ + const auto same_axis = [](Direction a, Direction b){ return (int)a % 2 == (int)b % 2; }; + std::optional further; + for (RouteStep& step : route){ + if (step.action != RouteAction::WALK){ + continue; + } + if (step.warps){ + if (further && step.direction != *further && same_axis(step.direction, *further)){ + step.warp_slack = 1; + } + further.reset(); + continue; + } + if (step.to_wall && further && same_axis(step.direction, *further)){ + further.reset(); + } + if (!step.to_wall && !step.connection_steps.empty()){ + further = step.direction; + } + } + return route; +} + WalkedTiles whole_leg(const RouteStep& step){ return WalkedTiles{.min = 0, .max = step.tiles}; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index bbd8fe0993..3501729c12 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -47,6 +47,15 @@ constexpr Milliseconds STOP_TIMING_SLACK(300); // (Pokemon::LegResult::tiles), give or take STOP_TIMING_SLACK at // `tile_time` a tile: a tile either side walking, two running. WalkedTiles walked_near(const RouteStep& step, int estimate, Milliseconds tile_time); +// `route` with RouteStep::warp_slack set. An open leg over a map +// connection is held a tile longer for the game holding the player up +// there, which it doesn't always do: south from Route 6 into Vermilion +// City it didn't, the player ended a tile further south, and the north 1 +// onto the Pokemon Center's door stopped below it (console B, 2026-11-03, +// row 00.24). Until a leg to a wall that way or a warp places the player +// again, they may stand a tile further on; a warp walked back that way is +// held a tile further for it. +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); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index c91375a8a6..f33a021f2b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -211,7 +211,7 @@ class RouteRun{ , m_calls(calls) , m_lines(calls, planning) , m_steps(calls, planning != nullptr ? planning->wall_check : WallCheck::TAP) - , m_plan(route) + , m_plan(with_warp_slack(route)) , m_at(planning != nullptr ? planning->start : std::nullopt) , m_facing(facing) {} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 109dd87b09..ec633a4fd8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -32,7 +32,7 @@ namespace{ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ Pokemon::LegSpec leg; leg.direction = step.direction; - leg.tiles = step.tiles; + leg.tiles = step.tiles + step.warp_slack; leg.to_wall = step.to_wall; leg.tile_time = tile_time(step, speed); leg.stop_on_interrupt = !step.to_wall; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index de4bdb7194..70777e345c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -69,6 +69,12 @@ struct RouteStep{ // by the tiles alone ended a tile short (console B, 2026-11-03, rows // 00.14, 00.16 and 00.20). std::vector connection_steps = {}; + // WALK onto a warp: how many tiles further the player may stand from + // it than planned, which the hold goes on for, stopping at the fade. + // An open leg over a map connection before it may end a tile further + // on, when the game doesn't hold the player up there (console B, + // 2026-11-03, row 00.24). Set by with_warp_slack() when a route runs. + int warp_slack = 0; }; std::string to_string(const RouteStep& step); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 25e4dbaf30..d8f825ffe7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -377,7 +377,7 @@ void OverworldSim::walk_hold(Direction direction, Milliseconds duration, int sho bump(direction, pressed); return; } - if (step->map != m_map && !step->warped){ + if (m_script.connections_stall && step->map != m_map && !step->warped){ // The step onto a connected map waits a tile while the game // loads it: a hold timed by its tiles alone ended a tile short // (console B, 2026-11-03, rows 00.14, 00.16 and 00.20). @@ -638,7 +638,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, const std::vector& ints = interrupts(step); Pokemon::LegSpec leg; leg.direction = step.direction; - leg.tiles = step.tiles; + leg.tiles = step.tiles + step.warp_slack; leg.to_wall = step.to_wall; leg.tile_time = TILE_TIME; leg.stop_on_interrupt = !step.to_wall; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 4645b9d931..0e1436f28b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -108,6 +108,11 @@ struct SimScript{ // don't mark as grass: Mt. Moon B2F's tiles closed on one side ('8') // are cave floor too (console B, 2026-11-03). std::set wild_floor_maps; + // Whether the game holds the player up a tile on a step onto a + // connected map. It did north onto Nugget Bridge (console B, + // 2026-11-03, rows 00.16 and 00.20), not south from Route 6 into + // Vermilion City (row 00.24). + bool connections_stall = true; SimTiming timing; }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index d50094c97d..8756a4fb1c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -912,6 +912,25 @@ UnitTestResult test_nugget_bridge_legs(){ return true; } +// Story row 00.24 from Route 6 to Vermilion City's nurse: the open leg +// south 14 crosses onto Vermilion City and is held a tile longer for the +// game holding the player up there. It didn't: the player ended a tile +// south of (23, 7), the leg west on (15, 8), and the north 1 onto the +// Center's door only reached the tile below it: "no fade in and out" +// (console B, 2026-11-03). Either way the door is reached. +UnitTestResult test_route6_to_vermilion_center(){ + const MapPosition nurse = at("VermilionCity_PokemonCenter_1F", 7, 4); + for (const bool stalls : {true, false}){ + SimCase c{.start = at("Route6", 11, 33), .facing = Direction::EAST, .goal = nurse}; + c.script.connections_stall = stalls; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.warps != 1){ + return std::format("Expected the nurse reached by the Center's door, the crossing {}:", stalls ? "stalled" : "not stalled") + describe(now); + } + } + return true; +} + } @@ -942,6 +961,7 @@ void add_tests_OverworldSim(UnitTestDatabase& database){ database.add("Cerulean Gym: the straight ways meet Luis and Diana", test_cerulean_gym_lines); database.add("Cerulean: the Center to the rival's row and Nugget Bridge", test_cerulean_to_bridge); database.add("Nugget Bridge: each trainer met on the step onto their row", test_nugget_bridge_legs); + database.add("Route 6 into Vermilion's Pokemon Center, the crossing stalled or not", test_route6_to_vermilion_center); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 4c1812e7fe..16b4321d9b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -147,7 +147,7 @@ class RepelWoreOffInterrupt : public MessageInterrupt{ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ - Pokemon::LegSpec leg{step.direction, step.tiles, step.to_wall, tile_time(step, speed)}; + Pokemon::LegSpec leg{step.direction, step.tiles + step.warp_slack, step.to_wall, tile_time(step, speed)}; leg.stalled_steps = step.connection_steps; return leg; } From 027ae80216525e45bdb9be107a07e5eb63280a40 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 17:53:04 -0800 Subject: [PATCH 15/21] FRLG 00.24: check Lt. Surge's win chance in the Center before the grind On console B row 00.24's grind found the chance against Lt. Surge already 79% at Lv29 and fought nothing, but the row had walked out to Route 6's grass and still walked back. A GAME step in the Center now reads the lead first (grind_check_step(), grind_reached_already()); when it passes it sets a run flag that the walk out, the grind and the way back skip_if. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 6 ++++ .../AutoStory/PokemonFRLG_AutoStoryGrind.cpp | 26 ++++++++++++++ .../AutoStory/PokemonFRLG_AutoStoryGrind.h | 17 ++++++++++ .../PokemonFRLG_AutoStoryGrind_Tests.cpp | 25 ++++++++++++++ ...PokemonFRLG_AutoStoryTableVermilionGym.cpp | 23 +++++++++---- ...okemonFRLG_AutoStoryVermilionGym_Tests.cpp | 34 +++++++++++++++++++ 6 files changed, 124 insertions(+), 7 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 5aba80e94d..374d404311 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -619,6 +619,12 @@ std::optional run_abilities(StoryEnv& env, RunState& state, const Story ); ConsoleChoiceActions choice(env.console, env.context); StoryAbilities abilities{console_member_reader(env.console, env.context, env.language), mart, teach, grind, choice}; + if (const std::optional plan = parse_grind_check_variant(step.variant)){ + if (grind_reached_already(env.console.logger(), grind, abilities.members, *plan)){ + state.flags.insert(step.name); + } + return Pokemon::outcome_unchanged(); + } return run_ability_game_step(env.console.logger(), &env.console, step, abilities); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp index bbb4d363f8..4a1e243d0c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp @@ -120,6 +120,22 @@ Pokemon::StoryStep grind_step(std::string name, std::string why, const GrindPlan return step; } +std::optional parse_grind_check_variant(std::string_view variant){ + if (!variant.starts_with(GRIND_CHECK_VARIANT_PREFIX)){ + return std::nullopt; + } + return parse_grind_variant(std::string(GRIND_VARIANT_PREFIX) + std::string(variant.substr(GRIND_CHECK_VARIANT_PREFIX.size()))); +} + +Pokemon::StoryStep grind_check_step(std::string name, std::string why, const GrindPlan& plan){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = std::move(name); + step.why = std::move(why); + step.variant = std::string(GRIND_CHECK_VARIANT_PREFIX) + grind_variant(plan).substr(GRIND_VARIANT_PREFIX.size()); + return step; +} + namespace{ @@ -161,6 +177,10 @@ class GrindRun{ } } } + // The member, read, has what the plan grinds for. Nothing fought. + bool reached_already(){ + return m_plan.member == 0 && read_level() && over() && m_result.end == GrindEnd::REACHED; + } private: GrindResult end(GrindEnd end, std::string detail){ @@ -274,6 +294,12 @@ GrindResult run_grind(Logger& logger, GrindActions& actions, const MemberReader& return result; } +bool grind_reached_already(Logger& logger, GrindActions& actions, const MemberReader& reader, const GrindPlan& plan){ + const bool reached = GrindRun(logger, actions, reader, plan).reached_already(); + logger.log(reached ? "Grind: reached already; no walk out to the patch." : "Grind: not reached yet; out to the patch."); + return reached; +} + std::string grind_message(const GrindPlan& plan, const GrindResult& result){ const std::string level = result.level ? std::to_string(*result.level) : "?"; const std::string chance = plan.trainer.empty() ? "" : std::format( diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h index d197be0478..993398b6d7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h @@ -84,6 +84,17 @@ std::optional parse_grind_variant(std::string_view variant); // The table's GRIND step: `nurse` is where the player faces the nurse. Pokemon::StoryStep grind_step(std::string name, std::string why, const GrindPlan& plan, Pokemon::Pose nurse); +// The prefix of a GAME step's variant that checks a GRIND step's plan +// before the walk out to its patch, e.g. "grind_check slot=1 level=32 +// ...", the rest as grind_variant()'s. +inline constexpr std::string_view GRIND_CHECK_VARIANT_PREFIX = "grind_check "; +std::optional parse_grind_check_variant(std::string_view variant); + +// The table's check before the walk out to a GRIND step's patch: when the +// lead already has what `plan` grinds for, the step sets a run flag named +// `name`, which the walk out, the GRIND step and the way back skip_if. +Pokemon::StoryStep grind_check_step(std::string name, std::string why, const GrindPlan& plan); + enum class GrindFight{ // The battle ended with the overworld back and the lead standing, @@ -150,6 +161,12 @@ struct GrindResult{ // From the overworld in the patch: grinds as `plan` says. GrindResult run_grind(Logger& logger, GrindActions& actions, const MemberReader& reader, const GrindPlan& plan); +// Before the walk out to the patch: whether the member `plan` grinds, as +// read, already has its level, or with a trainer its win chance. Nothing +// is fought. 00.24 walked to Route 6's grass and back at Lv29 with the +// chance against Lt. Surge already 79% (console B, 2026-11-03). +bool grind_reached_already(Logger& logger, GrindActions& actions, const MemberReader& reader, const GrindPlan& plan); + std::string grind_message(const GrindPlan& plan, const GrindResult& result); // The story's lead as `read` shows it (species, level, moves, stats), diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp index 5a80e8671c..fd4a8cee87 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp @@ -457,6 +457,30 @@ UnitTestResult test_grind_variant(){ return true; } +// The check in the Center before the walk out: a lead that already has +// the chance needs no trip (00.24 at Lv29, 79% against Lt. Surge, console +// B, 2026-11-03); one short of it does. Nothing is fought either way. +UnitTestResult test_grind_reached_already(){ + QuietLogger logger; + for (const unsigned level : {16u, 19u}){ + FakePatch patch; + patch.level = level; + winnable_from_19(patch); + const bool reached = grind_reached_already(logger, patch, [&patch](size_t slot){ + return patch.read_member(slot); + }, beat_rival(30)); + if (reached != (level >= 19) || patch.encounters != 0 || patch.heals != 0 || !patch.error.empty()){ + return std::format("Lv{}: reached {}, {} encounters, {} heals {}.", level, reached, patch.encounters, patch.heals, patch.error); + } + } + const Pokemon::StoryStep check = grind_check_step("rival_winnable", "", beat_rival(30)); + const std::optional back = parse_grind_check_variant(check.variant); + if (!back || back->trainer != RIVAL || back->level != 30 || parse_grind_variant(check.variant)){ + return "\"" + check.variant + "\" didn't read back as its plan, or read as a GRIND step."; + } + return true; +} + } @@ -466,6 +490,7 @@ void add_tests_AutoStoryGrind(UnitTestDatabase& database){ }; add("grinding on a fake patch", test_grind); add("the table's variant", test_grind_variant); + add("reached already: no walk out to the patch", test_grind_reached_already); add("the level cap's message", test_grind_cap_message); add("the lead the sim plays is the one read", test_lead_as_read); add("console B's party is fought at Lv22 against the Cerulean rival", test_console_party); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp index 36034fecfc..85c2e7677c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp @@ -50,6 +50,12 @@ StoryStep trash_cans(std::string name, std::string why){ return step; } +// `step`, skipped when the run flag `flag` is set. +StoryStep unless(const char* flag, StoryStep step){ + step.skip_if.emplace_back(flag); + return step; +} + } @@ -97,22 +103,25 @@ StoryCheckpoint vermilion_gym(){ // along Jeff's row 33 (he is beaten) to the wall (7, 33), up to row 31 // and east along it onto the patch's west edge, (13, 31), above // Isabelle, out of her line. Back to the nurse the same way. +// All that is skipped when the lead, read in the Center, passes already. // Then the tree, the gym, whose trainers stand where the map has them, // beaten, and whose beams stay off (FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES): // up column 5 to below Lt. Surge, and saved there. StoryCheckpoint to_lt_surge(){ + constexpr const char* SURGE_WINNABLE = "surge_winnable"; const GrindPlan for_surge{ .member = 0, .level = 32, .max_encounters = 150, .pace = EAST, .heal_every = 0, .trainer = std::string(story_trainer(VERMILION_GYM, "lt_surge").value_or("")), .min_chance = MIN_WIN_CHANCE, }; return row("00.24", "Route 6's grass until Lt. Surge is winnable, then through the open beams to him", at(VERMILION_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { - running(route("route6_south_path", at(ROUTE6, 11, 34, NORTH), "out of the Center, north out of the city onto Route 6's path, below Jeff's row")), - running(legs("round_isabelle_line", {{NORTH, 1}, {WEST, 4}, {NORTH, 2}, {EAST, 5}, {EAST, 1}}, "onto Jeff's row (11, 33), west to the wall (7, 33), up to (7, 31), east along row 31 to (12, 31) and a step onto the grass, (13, 31): out of Isabelle's line")), - grind_step("grind_for_surge", "wild battles until the sim beats Lt. Surge 50% or more, Lv32 at most; the nurse when hurt or low on PP", for_surge, at(VERMILION_CENTER, 7, 4, NORTH)), - running(route("west_on_row_31", at(ROUTE6, 7, 31), "west along row 31, out of the grass, to the wall (7, 31)")), - running(legs("down_to_jeff_row", {{SOUTH, 2}, {EAST, 4}}, "down to the wall (7, 33), east along Jeff's row to (11, 33): out of Isabelle's line")), - running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "south into the city to the Pokemon Center")), - heal("heal_vermilion"), + grind_check_step(SURGE_WINNABLE, "the lead as it is: if the sim beats Lt. Surge 50% or more already, no trip to Route 6", for_surge), + unless(SURGE_WINNABLE, running(route("route6_south_path", at(ROUTE6, 11, 34, NORTH), "out of the Center, north out of the city onto Route 6's path, below Jeff's row"))), + unless(SURGE_WINNABLE, running(legs("round_isabelle_line", {{NORTH, 1}, {WEST, 4}, {NORTH, 2}, {EAST, 5}, {EAST, 1}}, "onto Jeff's row (11, 33), west to the wall (7, 33), up to (7, 31), east along row 31 to (12, 31) and a step onto the grass, (13, 31): out of Isabelle's line"))), + unless(SURGE_WINNABLE, grind_step("grind_for_surge", "wild battles until the sim beats Lt. Surge 50% or more, Lv32 at most; the nurse when hurt or low on PP", for_surge, at(VERMILION_CENTER, 7, 4, NORTH))), + unless(SURGE_WINNABLE, running(route("west_on_row_31", at(ROUTE6, 7, 31), "west along row 31, out of the grass, to the wall (7, 31)"))), + unless(SURGE_WINNABLE, running(legs("down_to_jeff_row", {{SOUTH, 2}, {EAST, 4}}, "down to the wall (7, 33), east along Jeff's row to (11, 33): out of Isabelle's line"))), + unless(SURGE_WINNABLE, running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "south into the city to the Pokemon Center"))), + unless(SURGE_WINNABLE, heal("heal_vermilion")), running(route("above_gym_tree", at(VERMILION, 19, 23, SOUTH), "out of the Center, south through the city to above the tree (19, 24)")), cut_tree("cut_gym_tree", at(VERMILION, 19, 24, SOUTH), "the tree; a step onto its tile"), running(route("below_lt_surge", at(VERMILION_GYM, 5, 3, NORTH), "into the gym, up round the cans and through the beams, off, to below Surge (5, 2)")), diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp index 75cfc582f7..e223dc6859 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -24,6 +24,7 @@ #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_AutoStoryDamage.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStoryParty.h" #include "PokemonFRLG_AutoStoryRowSim.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -382,6 +383,38 @@ UnitTestResult test_surge_chance(){ } return true; } + +// 00.24 with a lead that beats Lt. Surge already: the check in the Center +// comes first, and with its flag set the walk to Route 6's grass, the +// grind and the way back are skipped; the rest goes from the nurse to +// below Surge on the sim. Console B walked out and back at Lv29, 79% +// (2026-11-03). +UnitTestResult test_surge_winnable_skips_grind(){ + const StoryCheckpoint& row = checkpoint("00.24"); + const StoryStep& check = row.steps.front(); + if (!parse_grind_check_variant(check.variant)){ + return "Expected 00.24 to start with the grind's check, not " + check.name + "."; + } + StoryPose at = *story_pose(*row.start); + OverworldSim sim(maps_after_gym(), MapPosition{at.map, at.tile}, *at.facing); + std::vector skipped; + for (const StoryStep& step : row.steps){ + if (step.skip_if == std::vector{check.name}){ + skipped.push_back(step.name); + continue; + } + if (const std::string failure = gym_step_on_sim(sim, at, step, row.id); !failure.empty()){ + return failure; + } + } + const std::vector expected{ + "route6_south_path", "round_isabelle_line", "grind_for_surge", "west_on_row_31", "down_to_jeff_row", "vermilion_nurse", "heal_vermilion", + }; + if (skipped != expected || !(sim.state().position == MapPosition{GYM, {5, 3}})){ + return std::format("Expected the trip to Route 6 skipped and below Surge reached; {} skipped, on {}.", skipped.size(), to_string(sim.state().position)); + } + return true; +} } @@ -391,6 +424,7 @@ void add_tests_AutoStoryVermilionGym(UnitTestDatabase& database){ database.add("00.23 every can's stand from Tucker's tile and from every other, and out", test_cans_from_everywhere); database.add("00.24 and 00.25 through the beams to Lt. Surge and out", test_to_surge_and_out); database.add("Lt. Surge: Dig over Withdraw, the sim's chance", test_surge_chance); + database.add("00.24 with Lt. Surge winnable already: no trip to Route 6", test_surge_winnable_skips_grind); } From d8b375fbe5526f3d4c7f004259c0f5bd8922145c Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 18:22:49 -0800 Subject: [PATCH 16/21] FRLG sim: Double Team's AI score and two-turn moves (Lt. Surge, m73) Console B lost to Lt. Surge's Raichu at a 79% sim chance: the Raichu used Double Team at full HP while each Dig spent a turn under ground. The sim now scores Double Team as pret AI_CV_EvasionUp does, and plays two-turn moves (charge turn, no choice the next, semi-invulnerable for Dig/Fly/ Dive/Bounce); the planner counts a two-turn move at half its damage a turn. m73's lead now wins 58%; 82% at Lv31. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG_AutoStoryBattleSim.cpp | 37 ++++++++++- .../PokemonFRLG_AutoStoryBattleSim.h | 26 ++++++-- ...okemonFRLG_AutoStoryVermilionGym_Tests.cpp | 66 +++++++++++++++++-- .../PokemonFRLG_AutoStoryWinChance.cpp | 43 ++++++++++-- .../PokemonFRLG_AutoStoryWinChance.h | 7 +- 5 files changed, 156 insertions(+), 23 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp index 83712be4c0..95cf148e67 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp @@ -205,6 +205,20 @@ int cv_toxic(const SimBattler& user, const SimBattler& target, std::mt19937& rng return score - (hp_percent(target) > 50 || random_below(rng, 50) ? 0 : 3); } +// AI_CV_EvasionUp, without the target's Toxic, Ingrain and Curse, which +// the sim doesn't play. +int cv_evasion_up(const SimBattler& user, const SimBattler& target, std::mt19937& rng){ + int score = 0; + score += hp_percent(user) >= 90 && !random_below(rng, 100) ? 3 : 0; + score -= level(user, Stage::EVASION) >= 9 && !random_below(rng, 128) ? 1 : 0; + score += target.seeded && !random_below(rng, 70) ? 3 : 0; + if (hp_percent(user) > 70 || level(user, Stage::EVASION) == MAX_STAGE){ + return score; + } + const bool low = hp_percent(user) < 40 || hp_percent(target) < 40; + return score - (low || !random_below(rng, 70) ? 2 : 0); +} + int cv_heal(const SimBattler& user, const SimBattler& target, std::mt19937& rng){ if (hp_percent(user) == 100){ return -3; @@ -230,6 +244,9 @@ int viability(const MoveData& move, const SimBattler& user, const SimBattler& ta if (effect == "accuracy-down"){ return cv_accuracy_down(user, target, rng); } + if (effect == "evasion-up"){ + return cv_evasion_up(user, target, rng); + } if (effect == "speed-down"){ return faster(target, user) ? (random_below(rng, 70) ? 0 : 2) : -3; } @@ -303,9 +320,12 @@ uint32_t lowest_roll(const SimBattler& own, const SimBattler& foe, const MoveDat return damage_range(own.body, foe.body, move, false).min * hits_of(move); } +// At the move's own accuracy: the battle loop doesn't see stages. A +// two-turn move does half its damage a turn. double mean_per_turn(const SimBattler& own, const SimBattler& foe, const MoveData& move){ const double accuracy = move.accuracy == 0 ? 1.0 : move.accuracy / 100.0; - return average_damage(own.body, foe.body, move) * hits_of(move) * accuracy; + const double turns = two_turn(move) ? 2.0 : 1.0; + return average_damage(own.body, foe.body, move) * hits_of(move) * accuracy / turns; } // Turns to faint `foe` with `per_turn` a turn, the AI healing it as @@ -562,6 +582,16 @@ uint32_t crit_odds(const MoveData& move){ return move.effect == "high-critical" ? HIGH_CRIT_CHANCE : CRIT_CHANCE; } +bool two_turn(const MoveData& move){ + const std::string_view effect = move.effect; + return effect == "semi-invulnerable" || effect == "solar-beam" || effect == "razor-wind" + || effect == "skull-bash" || effect == "sky-attack"; +} + +bool semi_invulnerable(const SimBattler& battler){ + return !battler.charging.empty() && data_of(battler.charging).effect == "semi-invulnerable"; +} + double NextTurnDamage::mean() const{ double sum = 0; for (size_t damage = 0; damage < hp_lost.size(); damage++){ @@ -652,7 +682,8 @@ std::optional planned_attack_slot( const bool faints = lowest_roll(own, foe, move) >= foe.hp; const int sure = move.accuracy == 0 ? 100 : move.accuracy; // Fewest turns, then surest, then weakest, then most PP left. - const size_t turns = faints ? 1 : turns_to_faint(foe, items, foes_left, mean_per_turn(own, foe, move)); + const size_t first = two_turn(move) ? 2 : 1; + const size_t turns = faints ? first : turns_to_faint(foe, items, foes_left, mean_per_turn(own, foe, move)); const Rank rank{turns, -(faints ? sure : 0), move.power, -own.moves[slot].pp}; if (!best || rank < best_rank){ best = slot; @@ -784,7 +815,7 @@ bool sure_first_ko( const bool foe_priority = std::ranges::any_of(foe.moves, [](const SimMove& known){ return known.pp > 0 && data_of(known.move).priority > 0; }); - const bool first = speed_of(own) > speed_of(foe) && !foe_priority && move.priority >= 0; + const bool first = speed_of(own) > speed_of(foe) && !foe_priority && move.priority >= 0 && !two_turn(move); const bool sure = move.accuracy == 0 || move.accuracy >= 100; return first && sure && lowest_roll(own, foe, move) >= foe.hp && !ai_item_choice(foe, items, foes_left); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h index 8ad58572ca..f41dfd3520 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h @@ -23,7 +23,7 @@ * Attack) and takes 1 off the damaging moves that aren't its strongest; * CHECK_VIABILITY scores the status moves the early trainers carry * (AI_CV_AttackDown, DefenseDown, AccuracyDown, SpeedDown, Poison, - * Toxic for Leech Seed too, Paralyze, Heal) by HP, speed and stages, + * Toxic for Leech Seed too, Paralyze, Heal, EvasionUp) by HP, speed and stages, * with their random parts. The best score wins, ties at random. The * other scripts, and CHECK_VIABILITY on damaging moves, are left out. * - The AI's items (battle_ai_switch_items.c ShouldUseItem) as the game @@ -106,6 +106,9 @@ struct SimBattler{ // moves on the turn it reaches 0. uint8_t sleep = 0; bool seeded = false; + // The two-turn move (two_turn()) it charged last turn and strikes + // with this turn, "" for none. + std::string charging = {}; }; // The lead with `hp_share` of its HP (1 for full), its moves and PP as @@ -157,6 +160,16 @@ uint32_t hit_percent(const SimBattler& user, const SimBattler& target, const Mov // A move's crit odds: 1 in 16, 1 in 8 for a high-crit move. uint32_t crit_odds(const MoveData& move); +// A move that charges a turn and hits the next: Dig, Fly, Dive, Bounce, +// Solar Beam (no sun in the sim), Razor Wind, Skull Bash, Sky Attack. +bool two_turn(const MoveData& move); + +// Under ground, in the air or under water: charging Dig, Fly, Dive or +// Bounce. pret AccuracyCalcHelper misses every move at it before the +// never-miss check (Shock Wave too); moves on the user itself (Double +// Team) don't check. +bool semi_invulnerable(const SimBattler& battler); + // The HP `own` loses over the foe's next turn, as the AI picks its move // (ai_move_chances()): each damaging move's 16 rolls, crit chance, // accuracy with stages and multi-hit counts; a poison or Leech Seed that @@ -185,13 +198,16 @@ double seed_chance(const SimTrainer& trainer, const SimBattler& foe, const SimBa size_t ai_next_foe(const std::vector& party, size_t fainted, const SimBattler& lead); -// BattleRisk::LOW: the slot with PP left whose mean damage against `foe` -// is highest. None if no move does damage. +// BattleRisk::LOW: the slot with PP left whose mean damage a turn against +// `foe` is highest. None if no move does damage. std::optional best_damage_slot(const SimBattler& own, const SimBattler& foe); // BattleRisk::HIGH: a move that faints `foe` at the lowest roll, the // weakest of them to keep the strong moves' PP; else the one that faints -// it in the fewest turns at the mean roll and accuracy, counting the +// it in the fewest turns at the mean roll and accuracy, a two-turn move +// at half its damage a turn (console B, m73: Dig against Lt. Surge's +// Raichu, the Raichu's Double Team landing while the lead was under +// ground), counting the // heals the AI would use on it from `items` (ai_item_choice()), ties to // the weaker move. None if no move does damage. std::optional planned_attack_slot( @@ -269,7 +285,7 @@ struct OwnTurn{ // A Potion if top_up_now() (`to_come`: the trainer's Pokemon not out and // not fainted), else planned_attack_slot()'s move when it surely faints // `foe` first (the lead is awake and faster, the lowest roll faints it, it -// never misses and the AI won't heal first), else a Potion if +// never misses, takes one turn and the AI won't heal first), else a Potion if // potion_now(), else an Awakening if awakening_now(), else that move. // `trainer`: its AI and the items it has left. `seen_hit`: as // potion_case() takes it. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp index e223dc6859..b32ba7d784 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -348,10 +348,10 @@ PartyMember surge_lead(uint8_t level){ return member_from_summary("wartortle", level, moves, stats); } -// Lt. Surge against the lead with 14 Potions: Raichu's Shock Wave and -// Thunder Wave beat Water Pulse and Bite (6% at Lv28); TM28 Dig in place -// of Withdraw takes it to 47% at Lv28 and 51% at Lv29 and Lv30, past the -// GRIND's gate (00.23, 00.24). The battles still lost are to the +// Lt. Surge against the lead with 14 Potions: Raichu's Shock Wave, +// Thunder Wave and Double Team beat Water Pulse and Bite (3% at Lv28); +// TM28 Dig in place of Withdraw takes it to 27% at Lv28 and 61% at Lv30, +// past the GRIND's gate (00.23, 00.24). The battles still lost are to the // Raichu. The gym's three trainers are no risk. UnitTestResult test_surge_chance(){ const SimTrainer surge = *sim_trainer("TRAINER_LEADER_LT_SURGE"); @@ -366,9 +366,9 @@ UnitTestResult test_surge_chance(){ const WinEstimate plain = chance(28, false); const WinEstimate dug = chance(28, true); const WinEstimate dug30 = chance(30, true); - if (plain.chance > 0.15 || dug.chance < 0.35 || dug.chance > 0.55 || dug30.chance < dug.chance + 0.02){ + if (plain.chance > 0.15 || dug.chance < 0.2 || dug.chance > 0.4 || dug30.chance < 0.5){ return std::format( - "Expected under 15% at Lv28, 35-55% with Dig, more at Lv30; got {:.1f}%, {:.1f}% ({}), {:.1f}%.", + "Expected under 15% at Lv28, 20-40% with Dig, 50% or more at Lv30; got {:.1f}%, {:.1f}% ({}), {:.1f}%.", 100 * plain.chance, 100 * dug.chance, loss_text(dug), 100 * dug30.chance ); } @@ -384,6 +384,58 @@ UnitTestResult test_surge_chance(){ return true; } +// Console B, m73 (2026-11-03): the Raichu stayed at full HP for six +// turns while the Dig missed. pret AI_CV_EvasionUp gives Double Team +3 +// at 90% HP or more, 156 times in 256, over Shock Wave and Thunder Wave +// (100 each); AI_CBM_EvasionUp takes 10 off at +6 evasion. +UnitTestResult test_surge_raichu_double_team(){ + const SimTrainer surge = *sim_trainer("TRAINER_LEADER_LT_SURGE"); + const SimBattler& raichu = surge.party[2]; + const SimBattler lead = *own_battler(surge_lead(29)); + if (raichu.moves[2].move != "double-team"){ + return "Expected the Raichu's third move to be Double Team, got " + raichu.moves[2].move + "."; + } + const double full = ai_move_chances(surge, raichu, lead)[2]; + SimBattler dodging = raichu; + dodging.body.stages[static_cast(Stage::EVASION)] = 6; + const double maxed = ai_move_chances(surge, dodging, lead)[2]; + if (full < 0.65 || maxed > 0){ + return std::format("Expected Double Team 65% of the time or more at full HP, never at +6; got {:.0f}% and {:.0f}%.", 100 * full, 100 * maxed); + } + return true; +} + +// m73's lead as its summary showed it before Lt. Surge, `levels` more. +PartyMember m73_lead(uint8_t levels = 0){ + std::vector moves; + for (const char* move : {"bite", "water-pulse", "bubble", "dig"}){ + moves.push_back(SummaryMove{.move = move, .pp = max_pp(move), .max_pp = max_pp(move)}); + } + PartyMember lead = member_from_summary("wartortle", 29, moves, Stats{83, 44, 59, 51, 64, 49}); + lead.moves_through_level = static_cast(29 + levels); + return lead; +} + +// m73: each Dig took two turns (18 s from menu to menu, Bite 10 s), and +// the Raichu's Double Team lands while the lead is under ground. The sim +// played Dig as one turn and gave Double Team no CHECK_VIABILITY: 79% for +// m73's lead and 14 Potions, lost on the console. Now 58%, all lost to +// the Raichu; at Lv31 the lead outspeeds it (82%). +UnitTestResult test_surge_dig_two_turns(){ + const SimItems potions{.potions = 14}; + const WinEstimate m73 = *story_win_chance(m73_lead(), potions, 1.0, "TRAINER_LEADER_LT_SURGE"); + const WinEstimate lv31 = *story_win_chance(m73_lead(2), potions, 1.0, "TRAINER_LEADER_LT_SURGE"); + const auto& by_foe = m73.losses.by_foe; + const bool raichu = by_foe.contains("raichu") && by_foe.at("raichu") == m73.battles - m73.wins; + if (m73.chance < 0.45 || m73.chance > 0.7 || !raichu || lv31.chance < 0.75){ + return std::format( + "Expected m73's lead at 45-70%, every loss to the Raichu, 75% or more at Lv31; got {:.1f}% ({}), {:.1f}%.", + 100 * m73.chance, loss_text(m73), 100 * lv31.chance + ); + } + return true; +} + // 00.24 with a lead that beats Lt. Surge already: the check in the Center // comes first, and with its flag set the walk to Route 6's grass, the // grind and the way back are skipped; the rest goes from the nurse to @@ -424,6 +476,8 @@ void add_tests_AutoStoryVermilionGym(UnitTestDatabase& database){ database.add("00.23 every can's stand from Tucker's tile and from every other, and out", test_cans_from_everywhere); database.add("00.24 and 00.25 through the beams to Lt. Surge and out", test_to_surge_and_out); database.add("Lt. Surge: Dig over Withdraw, the sim's chance", test_surge_chance); + database.add("Lt. Surge: the Raichu's Double Team at full HP", test_surge_raichu_double_team); + database.add("Lt. Surge: Dig takes two turns, m73's lead under 79%", test_surge_dig_two_turns); database.add("00.24 with Lt. Surge winnable already: no trip to Route 6", test_surge_winnable_skips_grind); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp index 8ed43490e2..9eb7c20595 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp @@ -172,10 +172,13 @@ class SimBattle{ void play_turn(){ const size_t left = foes_left(); m_psn_turns = m_own.status == SimStatus::POISON ? m_psn_turns + 1 : 0; - const OwnTurn own = own_turn(m_own, m_items, foe(), m_trainer, left, to_come()); - const bool antidote = !own.potion && !own.awakening && antidote_now(); - const std::optional item = ai_item_choice(foe(), m_trainer.items, left); - const std::optional foe_slot = item ? std::nullopt : foe_move(); + // A charged two-turn move strikes: no choice this turn. + const bool own_locked = !m_own.charging.empty(); + const bool foe_locked = !foe().charging.empty(); + const OwnTurn own = own_locked ? OwnTurn{} : own_turn(m_own, m_items, foe(), m_trainer, left, to_come()); + const bool antidote = !own_locked && !own.potion && !own.awakening && antidote_now(); + const std::optional item = foe_locked ? std::nullopt : ai_item_choice(foe(), m_trainer.items, left); + const std::optional foe_slot = item || foe_locked ? std::nullopt : foe_move(); if (own.potion){ gain_hp(m_own, POTION_HP); m_items.potions--; @@ -195,8 +198,10 @@ class SimBattle{ m_trainer.items[*item].clear(); } const bool item_used = own.potion || own.awakening || antidote; - const std::string own_move = item_used ? "" : own.slot ? m_own.moves[*own.slot].move : "struggle"; - const std::string foe_used = item ? "" : foe_slot ? foe().moves[*foe_slot].move : "struggle"; + const std::string own_move = own_locked ? m_own.charging + : item_used ? "" : own.slot ? m_own.moves[*own.slot].move : "struggle"; + const std::string foe_used = foe_locked ? foe().charging + : item ? "" : foe_slot ? foe().moves[*foe_slot].move : "struggle"; play_moves(own_move, own.slot, foe_used, foe_slot); end_of_turn(); } @@ -254,13 +259,25 @@ class SimBattle{ } void use(SimBattler& user, SimBattler& target, const std::string& move_name, std::optional slot, bool moves_first){ - if (move_name.empty() || user.hp == 0 || target.hp == 0 || !can_move(user, moves_first)){ + if (move_name.empty() || user.hp == 0 || target.hp == 0){ + return; + } + if (!can_move(user, moves_first)){ + user.charging.clear(); return; } if (slot && user.moves[*slot].pp > 0){ user.moves[*slot].pp--; } const MoveData& move = data_of(move_name); + if (two_turn(move) && user.charging.empty()){ + user.charging = move_name; + return; + } + user.charging.clear(); + if (semi_invulnerable(target) && !on_user(move)){ + return; + } if (!hits(user, target, move)){ return; } @@ -271,6 +288,18 @@ class SimBattle{ } } + // A move on its user only: a stat raise, Recover. + static bool on_user(const MoveData& move){ + if (move.power > 0){ + return false; + } + if (move.effect == "restore-hp" || move.effect == "softboiled"){ + return true; + } + const std::optional change = stage_change(move.effect); + return change && change->on_user; + } + uint32_t hit_count(const MoveData& move){ if (move.effect == "double-hit" || move.effect == "twineedle"){ return 2; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h index 17d85d2337..0d77c5e75c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h @@ -17,8 +17,11 @@ * game's counter), poison (1/8 HP a turn), paralysis (1/4 no move), Leech * Seed (1/8 a turn to the other side), flinch, one- and two-stage stat * changes, multi-hit moves (2 to 5 as the game draws them), drain (half) - * and Recover (half) are played; burn, freeze, confusion, recoil, - * recharge, two-turn moves, trapping, abilities and held items are not. + * and Recover (half) are played, and two-turn moves (two_turn()): the + * charge turn, PP paid then and no choice the next, cut short by a turn + * the user can't move; under ground or in the air every move at it but + * its own stat raises and heals misses. Burn, freeze, confusion, recoil, + * recharge, trapping, abilities and held items are not. * The lead is never switched out: a fainted lead is a lost battle. * */ From b48afbc830f4b4c34cf94eb1aba85c2654a69938 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 18:55:29 -0800 Subject: [PATCH 17/21] FRLG 00.25: to the nurse off Oak's aide's row; stop a battle with no overworld for 60 s Lt. Surge's win puts Oak's aide on Vermilion City's (25, 7) until talked to; the story's maps don't have him, and the walk to the Center went west along row 7 into him (console B, 2026-11-03). It now goes along row 10 to the water and up column 14. After losing to Surge the battle loop pressed + and B for 8 minutes with no battle screen and no START menu. BattleEndWatch::overworld_lost() stops it 60 s after the last battle screen once START has looked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonFRLG_AutoStoryBattle.cpp | 6 +-- .../PokemonFRLG_AutoStoryBattleDecisions.cpp | 8 ++++ .../PokemonFRLG_AutoStoryBattleDecisions.h | 4 ++ .../PokemonFRLG_AutoStoryParty_Tests.cpp | 33 +++++++++++++ ...PokemonFRLG_AutoStoryTableVermilionGym.cpp | 11 ++++- ...okemonFRLG_AutoStoryVermilionGym_Tests.cpp | 47 +++++++++++++++++-- 6 files changed, 100 insertions(+), 9 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 31be4d097c..beee8981ed 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -512,9 +512,10 @@ class BattleLoop{ ErrorReportMode::SEND_ERROR_REPORT, "fight_battle(): no battle on screen.", m_console ); } - if (now - m_start > BATTLE_TIMEOUT){ + // Neither the battle nor the overworld for a minute: a stop, not 10 minutes of + and B. + if (now - m_start > BATTLE_TIMEOUT || m_end.overworld_lost(now)){ OperationFailedExceptionWithScreenshot::fire( - ErrorReportMode::SEND_ERROR_REPORT, "fight_battle(): the battle took over 10 minutes.", m_console + ErrorReportMode::SEND_ERROR_REPORT, now - m_start > BATTLE_TIMEOUT ? std::string("fight_battle(): the battle took over 10 minutes.") : std::format("fight_battle(): no battle screen and no START menu for 60 s after the battle (turn {}{}).", m_decisions.turns(), m_decisions.lost() ? ", the lead fainted: the battle is lost" : ""), m_console ); } } @@ -993,7 +994,6 @@ OverworldInterruptSet story_interrupts(Supplies& supplies, bool fight_wild, Stor } - } } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp index f1c69acc79..b82787cadb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp @@ -38,6 +38,10 @@ constexpr Milliseconds BATTLE_GONE = std::chrono::milliseconds(3000); constexpr Milliseconds OVERWORLD_CHECK = std::chrono::milliseconds(3000); // A and B pressed blind for this long with no battle menu: give up. constexpr Milliseconds BLIND_PRESSES = std::chrono::milliseconds(30000); +// No battle on screen for this long, and START has not opened the menu: +// stop. Longer than a gym leader's speech after the battle and a +// whiteout's walk to the nurse, with B pressed through them. +constexpr Milliseconds NO_OVERWORLD = std::chrono::milliseconds(60000); // Turns a move turned down is left alone: the longest Disable (pret // battle_script_commands.c Cmd_disablelastusedattack, 2 to 5 turns). constexpr int DISABLE_TURNS = 5; @@ -565,6 +569,10 @@ bool BattleEndWatch::pressed_blind(WallClock now){ return now - *m_blind_since >= BLIND_PRESSES; } +bool BattleEndWatch::overworld_lost(WallClock now) const{ + return m_last_battle && m_last_check && now - *m_last_battle >= NO_OVERWORLD; +} + } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h index b574e72cfc..2b0ddee29e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h @@ -236,6 +236,10 @@ class BattleEndWatch{ // BLIND_PRESSES with no battle menu: the loop stops, and the walker, // which finds the player again, goes on. bool pressed_blind(WallClock now); + // True once START has looked for the overworld and no battle has shown + // for NO_OVERWORLD: neither is coming back (console B, 2026-11-03, + // after losing to Lt. Surge: 8 minutes of + and B on a grey screen). + bool overworld_lost(WallClock now) const; private: std::optional m_last_battle; 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 86a68373c9..b72f10f539 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include "Common/Cpp/Exceptions.h" @@ -639,6 +640,37 @@ UnitTestResult test_end_watch_timing(Logger&){ return true; } +// Console B, 2026-11-03, after losing to Lt. Surge: no battle screen and +// no START menu, and the loop pressed + and B for 8 minutes. It gives up +// 60 s after the last battle screen, once START has looked; a battle +// screen in between starts that time over. +UnitTestResult test_end_watch_gives_up(Logger&){ + const WallClock start = current_time(); + const auto at = [&](int ms){ return start + std::chrono::milliseconds(ms); }; + BattleEndWatch watch; + if (watch.overworld_lost(at(120000))){ + return "Gave up before any battle screen showed."; + } + watch.seen(BattleSeen::BATTLE, at(0)); + std::optional gave_up; + for (int ms = 300; ms <= 600000 && !gave_up; ms += 300){ + watch.seen(BattleSeen::NONE, at(ms)); + watch.check_overworld(at(ms)); + if (ms == 30000){ + watch.seen(BattleSeen::BATTLE, at(ms)); + } + if (watch.overworld_lost(at(ms))){ + gave_up = ms; + } + } + if (!gave_up || *gave_up < 90000 || *gave_up > 95000){ + return gave_up + ? std::format("Expected to give up 60 s after the battle screen at 30 s, at {} ms.", *gave_up) + : std::string("Never gave up on the overworld in 10 minutes."); + } + return true; +} + } @@ -878,6 +910,7 @@ void add_tests_AutoStoryParty(UnitTestDatabase& database){ database.add("no B during an evolution", test_no_b_during_evolution); database.add("RUN, then a dark cave: no evolution wait, START soon", test_run_then_overworld); database.add("battle end: START after the battle is gone, A and B for 30 s at most", test_end_watch_timing); + database.add("battle end: no overworld 60 s after the battle, a stop", test_end_watch_gives_up); database.add("attacks by matchup: Bite on the rival's Bulbasaur and Misty", test_attack_by_matchup); database.add("attacks against an unknown foe: the plan's order", test_attack_unknown_foe); database.add("Bite learned over Tail Whip or Withdraw", test_bite_learned); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp index 85c2e7677c..7599328d52 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp @@ -132,14 +132,21 @@ StoryCheckpoint to_lt_surge(){ // Voltorb Lv21, Pikachu Lv18 and Raichu Lv24, Shock Wave and Thunder // Wave, a Super Potion and a Full Heal (sParty_LeaderLtSurge); then the // THUNDERBADGE and TM34, read to the overworld with the battle's text. -// Out, the tree, and the nurse. +// Out, the tree, and the nurse. Surge's win also puts Oak's aide on +// Vermilion City's (25, 7) (VermilionCity_Gym_EventScript_ShowOaksAide), +// where he stays until talked to; the story's maps don't have him. The +// way to the nurse along row 7 runs into him (console B, 2026-11-03: the +// leg west stopped beside him and the step into the Center's door went +// into a house), so it goes along row 10 to the water instead, clear of +// him, and up column 14 from there. StoryCheckpoint lt_surge(){ return row("00.25", "Vermilion Gym: Lt. Surge, the THUNDERBADGE, then the Center", at(VERMILION_GYM, 5, 3, NORTH), after_brock(), StartMenu::FULL, { talk("challenge_lt_surge", "\"Hey, kid! What do you think you're doing here?\""), battle("lt_surge", at(VERMILION_GYM, 5, 3, NORTH), false, "Voltorb, Pikachu and Raichu; THUNDERBADGE and TM34"), running(route("below_gym_tree", at(VERMILION, 19, 25, NORTH), "down through the beams, out of the gym, east to below the tree, grown back")), cut_tree("cut_tree_out", at(VERMILION, 19, 24, NORTH), "the tree; a step onto its tile"), - running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "north through the city to the Pokemon Center")), + running(route("below_oaks_aide", at(VERMILION, 14, 10), "north through the city and west along row 10 to the water, (14, 10): off Oak's aide's row 7")), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "up to the Pokemon Center")), heal("heal_vermilion"), }); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp index b32ba7d784..a25527d7bc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -15,11 +15,13 @@ #include #include #include +#include #include #include #include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_AutoStoryDamage.h" @@ -99,10 +101,9 @@ MapData& map_named(std::vector& maps, const std::string& name){ throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No map " + name + "."); } -// The story's maps with the trainers beaten that rows before 00.24 -// fought: Route 6's Jeff (00.21) and Vermilion Gym's three (00.23). -std::vector maps_after_gym(){ - std::vector maps = story_maps(); +// `maps` with the trainers beaten that rows before 00.24 fought: Route +// 6's Jeff (00.21) and Vermilion Gym's three (00.23). +std::vector maps_after_gym(std::vector maps = story_maps()){ for (MapObject& object : map_named(maps, GYM).objects){ object.trainer.reset(); } @@ -114,6 +115,17 @@ std::vector maps_after_gym(){ return maps; } +// maps_after_gym() with Oak's aide on Vermilion City's (25, 7): Lt. +// Surge's win shows him (VermilionCity_Gym_EventScript_ShowOaksAide), +// and he stays until talked to (VermilionCity_EventScript_HideOaksAide). +std::vector maps_after_surge(){ + 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 maps_after_gym(overworld_maps(flags)); +} + // The walk to `can`'s stand on `sim` as the trash can step plans it, on // trash_can_maps(): ends there facing the can, or says why not. std::string walk_to_stand(OverworldSim& sim, StoryPose& at, int can){ @@ -337,6 +349,32 @@ UnitTestResult test_to_surge_and_out(){ return true; } +// Console B, 00.25 (2026-11-03 18:17): the walk to the nurse went up +// column 27 to row 7 and west along it, into Oak's aide on (25, 7), there +// since Surge's win; the leg was taken as walked and the step into the +// Center's door went into a house's wall. 00.25 from Surge to the nurse +// with the aide there, meeting no one. +UnitTestResult test_out_past_oaks_aide(){ + const StoryCheckpoint& row = checkpoint("00.25"); + const std::vector maps = maps_after_surge(); + const MapData& city = *std::find_if(maps.begin(), maps.end(), [](const MapData& map){ return map.name == StoryTable::VERMILION; }); + if (object_at(city, {25, 7}) == nullptr){ + return "Expected Oak's aide on (25, 7) after Lt. Surge."; + } + StoryPose at = *story_pose(*row.start); + OverworldSim sim(maps, MapPosition{at.map, at.tile}, *at.facing); + for (const StoryStep& step : row.steps){ + if (const std::string failure = gym_step_on_sim(sim, at, step, row.id); !failure.empty()){ + return failure; + } + } + const MapPosition nurse{StoryTable::VERMILION_CENTER, {7, 4}}; + if (!sim.state().violations.empty() || !(sim.state().position == nurse)){ + return "Expected the nurse reached past Oak's aide; on " + to_string(sim.state().position) + (sim.state().violations.empty() ? "." : ", " + sim.state().violations.back()); + } + return true; +} + // The story's lead before Surge: Wartortle at `level` with the S.S. // Anne's moves (00.22), stats at OWN_GENES. PartyMember surge_lead(uint8_t level){ @@ -475,6 +513,7 @@ void add_tests_AutoStoryVermilionGym(UnitTestDatabase& database){ database.add("00.23 Vermilion Gym: Dwayne, Baily and Tucker, the cans at once or after a reset", test_gym_row); database.add("00.23 every can's stand from Tucker's tile and from every other, and out", test_cans_from_everywhere); database.add("00.24 and 00.25 through the beams to Lt. Surge and out", test_to_surge_and_out); + database.add("00.25 out to the nurse past Oak's aide on (25, 7)", test_out_past_oaks_aide); database.add("Lt. Surge: Dig over Withdraw, the sim's chance", test_surge_chance); database.add("Lt. Surge: the Raichu's Double Team at full HP", test_surge_raichu_double_team); database.add("Lt. Surge: Dig takes two turns, m73's lead under 79%", test_surge_dig_two_turns); From d034ec75ebed13fe5db0359c72952184feb488f2 Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 6 Nov 2026 20:09:44 -0800 Subject: [PATCH 18/21] Use string views for Vermilion Gym test checkpoint lookups --- .../AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp index 6beca8efc5..b59faf6d75 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include "Common/Cpp/Exceptions.h" @@ -80,11 +81,11 @@ class Test_AutoStoryVermilionGym : public UnitTest{ const char* const GYM = StoryTable::VERMILION_GYM; -const StoryCheckpoint& checkpoint(const std::string& id){ +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, "No checkpoint " + id + "."); + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::format("No checkpoint {}.", id)); } return *row; } From 703f32f370f1b3846e786c6d3a04098fd9149295 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 01:36:00 -0800 Subject: [PATCH 19/21] FRLG AutoStory: GAME steps as rows of the story action table The GAME if-chain in StoryExecutor::game() becomes four rows keyed by variant (close_trainer_card, leave_gift_summary, cut_tree, trash_cans) and the GAME default for the rest (captures, PC, abilities), so the console executor has no per-kind virtuals left. The parity tests check each row against the if-chain it replaces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 209 +++++++++--------- .../AutoStory/PokemonFRLG_AutoStoryEvents.h | 10 - .../PokemonFRLG_AutoStoryStepTable.cpp | 10 + .../PokemonFRLG_AutoStoryStepTable.h | 15 +- .../PokemonFRLG_AutoStoryStepTable_Tests.cpp | 24 +- 5 files changed, 154 insertions(+), 114 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 0405efdf16..2ccb254eb8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -483,85 +483,6 @@ std::optional wall_beside(const MapData& map, const TilePos& tile){ namespace{ -// How the console does an action that needs only the env. -using ConsoleAction = Outcome (*)(StoryEnv& env, RunState& state, const StoryStep& step); - -ConsoleAction console_action_of(StoryActionId id){ - switch (id){ - case StoryActionId::NEW_GAME: - return [](StoryEnv& env, RunState&, const StoryStep&){ - play_new_game_intro(env.console, env.context, env.player_name); - return Pokemon::outcome_ok(); - }; - case StoryActionId::ROUTE: - return [](StoryEnv& env, RunState& state, const StoryStep& step){ return walk_route(env, state, step); }; - case StoryActionId::LEGS: - return walk_legs; - case StoryActionId::STEP: - return take_step; - case StoryActionId::TALK: - return [](StoryEnv& env, RunState&, const StoryStep&){ - pbf_press_button(env.context, BUTTON_A, 160ms, 800ms); - return Pokemon::outcome_ok(); - }; - case StoryActionId::ANSWER: - return [](StoryEnv& env, RunState&, const StoryStep& step){ - answer_yes_no(env.console, env.context, step.yes, BUTTON_B, YES_NO_TIMEOUT); - return Pokemon::outcome_ok(); - }; - case StoryActionId::MASH: - return [](StoryEnv& env, RunState&, const StoryStep&){ - mash_until_overworld(env.console, env.context, SCENE_TIMEOUT); - return Pokemon::outcome_ok(); - }; - case StoryActionId::BATTLE: - return [](StoryEnv& env, RunState&, const StoryStep& step){ return battle(env, step); }; - case StoryActionId::HEAL: - return [](StoryEnv& env, RunState&, const StoryStep&){ - heal_at_center(env.console, env.context); - healed_by_nurse(env.supplies); - return Pokemon::outcome_ok(); - }; - case StoryActionId::SHOP: - return [](StoryEnv& env, RunState&, const StoryStep& step){ return shop(env, step); }; - case StoryActionId::READY: - return get_ready; - case StoryActionId::OPTIONS: - case StoryActionId::GAME: - break; - } - throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::string("No console action for ") + story_action_name(id)); -} - -} // namespace - - -StoryExecutor::StoryExecutor(StoryEnv& env) - : m_env(env) - , m_table(make_story_action_table([this](StoryActionId id){ return console_action(id); })) - , m_executor(m_table, m_guard) -{} - -Outcome StoryExecutor::run(RunState& state, const StoryStep& step){ - return m_executor.run(state, step); -} - -Pokemon::StoryAction StoryExecutor::console_action(StoryActionId id){ - if (id == StoryActionId::OPTIONS){ - return {.act = [this](RunState&, const StoryStep&){ - set_options(m_env.console, m_env.context, m_menu, m_env.language); - return Pokemon::outcome_ok(); - }, .needs_console = true}; - } - if (id == StoryActionId::GAME){ - return {.act = [this](RunState& state, const StoryStep& step){ return game(state, step); }, .needs_console = true}; - } - const ConsoleAction action = console_action_of(id); - return {.act = [this, action](RunState& state, const StoryStep& step){ return action(m_env, state, step); }, .needs_console = true}; -} - -namespace{ - bool walked(StoryEnv& env, RunState& state, const StoryStep& step){ const Outcome outcome = walk_route(env, state, step); if (outcome.succeeded() && outcome.pose){ @@ -717,32 +638,17 @@ Outcome open_gym_door(StoryEnv& env, RunState& state, const StoryStep& step){ return Pokemon::outcome_ok(state.pose); } -} // namespace - - -Outcome StoryExecutor::game(RunState& state, const StoryStep& step){ - if (step.variant == CLOSE_TRAINER_CARD){ - close_trainer_card(m_env.console, m_env.context); - return Pokemon::outcome_ok(); - } - if (step.variant == LEAVE_GIFT_SUMMARY){ - leave_gift_summary(m_env.console, m_env.context); - return Pokemon::outcome_ok(); - } - if (step.variant == CUT_TREE_VARIANT){ - return cut_tree(m_env, state, step); - } - if (step.variant == TRASH_CANS_VARIANT){ - return open_gym_door(m_env, state, step); - } +// The GAME steps without a row of their own: a capture or a PC deposit or +// withdrawal, a MART, TEACH, LEVEL, GRIND or fossil CHOICE. +Outcome other_game_step(StoryEnv& env, RunState& state, const StoryStep& step){ const Pokemon::Pose patch = state.pose; - ConsoleCaptureActions capture(m_env.console, m_env.context, m_env.language, [&]{ - pace_trainer_battle(m_env, state, step, patch); + ConsoleCaptureActions capture(env.console, env.context, env.language, [&]{ + pace_trainer_battle(env, state, step, patch); }); - ConsolePcStorageActions pc(m_env.console, m_env.context); - std::optional done = run_storage_game_step(m_env.console.logger(), &m_env.console, step, capture, pc); + ConsolePcStorageActions pc(env.console, env.context); + std::optional done = run_storage_game_step(env.console.logger(), &env.console, step, capture, pc); if (!done){ - done = run_abilities(m_env, state, step); + done = run_abilities(env, state, step); } if (done && done->succeeded() && !done->pose){ // Pacing leaves the player on either of its tiles. @@ -754,6 +660,105 @@ Outcome StoryExecutor::game(RunState& state, const StoryStep& step){ return Pokemon::unsupported_step(step); } +// How the console does an action that needs only the env. +using ConsoleAction = Outcome (*)(StoryEnv& env, RunState& state, const StoryStep& step); + +// The console's GAME actions; nullptr for another id. +ConsoleAction game_console_action(StoryActionId id){ + switch (id){ + case StoryActionId::CLOSE_TRAINER_CARD: + return [](StoryEnv& env, RunState&, const StoryStep&){ + close_trainer_card(env.console, env.context); + return Pokemon::outcome_ok(); + }; + case StoryActionId::LEAVE_GIFT_SUMMARY: + return [](StoryEnv& env, RunState&, const StoryStep&){ + leave_gift_summary(env.console, env.context); + return Pokemon::outcome_ok(); + }; + case StoryActionId::CUT_TREE: + return cut_tree; + case StoryActionId::TRASH_CANS: + return open_gym_door; + case StoryActionId::GAME: + return other_game_step; + default: + return nullptr; + } +} + +ConsoleAction console_action_of(StoryActionId id){ + if (const ConsoleAction game = game_console_action(id)){ + return game; + } + switch (id){ + case StoryActionId::NEW_GAME: + return [](StoryEnv& env, RunState&, const StoryStep&){ + play_new_game_intro(env.console, env.context, env.player_name); + return Pokemon::outcome_ok(); + }; + case StoryActionId::ROUTE: + return [](StoryEnv& env, RunState& state, const StoryStep& step){ return walk_route(env, state, step); }; + case StoryActionId::LEGS: + return walk_legs; + case StoryActionId::STEP: + return take_step; + case StoryActionId::TALK: + return [](StoryEnv& env, RunState&, const StoryStep&){ + pbf_press_button(env.context, BUTTON_A, 160ms, 800ms); + return Pokemon::outcome_ok(); + }; + case StoryActionId::ANSWER: + return [](StoryEnv& env, RunState&, const StoryStep& step){ + answer_yes_no(env.console, env.context, step.yes, BUTTON_B, YES_NO_TIMEOUT); + return Pokemon::outcome_ok(); + }; + case StoryActionId::MASH: + return [](StoryEnv& env, RunState&, const StoryStep&){ + mash_until_overworld(env.console, env.context, SCENE_TIMEOUT); + return Pokemon::outcome_ok(); + }; + case StoryActionId::BATTLE: + return [](StoryEnv& env, RunState&, const StoryStep& step){ return battle(env, step); }; + case StoryActionId::HEAL: + return [](StoryEnv& env, RunState&, const StoryStep&){ + heal_at_center(env.console, env.context); + healed_by_nurse(env.supplies); + return Pokemon::outcome_ok(); + }; + case StoryActionId::SHOP: + return [](StoryEnv& env, RunState&, const StoryStep& step){ return shop(env, step); }; + case StoryActionId::READY: + return get_ready; + default: + break; + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::string("No console action for ") + story_action_name(id)); +} + +} // namespace + + +StoryExecutor::StoryExecutor(StoryEnv& env) + : m_env(env) + , m_table(make_story_action_table([this](StoryActionId id){ return console_action(id); })) + , m_executor(m_table, m_guard) +{} + +Outcome StoryExecutor::run(RunState& state, const StoryStep& step){ + return m_executor.run(state, step); +} + +Pokemon::StoryAction StoryExecutor::console_action(StoryActionId id){ + if (id == StoryActionId::OPTIONS){ + return {.act = [this](RunState&, const StoryStep&){ + set_options(m_env.console, m_env.context, m_menu, m_env.language); + return Pokemon::outcome_ok(); + }, .needs_console = true}; + } + const ConsoleAction action = console_action_of(id); + return {.act = [this, action](RunState& state, const StoryStep& step){ return action(m_env, state, step); }, .needs_console = true}; +} namespace{ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index 808c9f949e..1c42830763 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -287,16 +287,6 @@ class StoryExecutor : public Pokemon::StoryStepExecutor{ // The step's action from the table. virtual Pokemon::Outcome run(Pokemon::RunState& state, const StoryStep& step) override; -protected: - // CLOSE_TRAINER_CARD: back from SID Helper (close_trainer_card()). - // LEAVE_GIFT_SUMMARY: back from Gift RNG (leave_gift_summary()). - // A capture or a PC deposit or withdrawal (capture_step(), pc_step() - // in PokemonFRLG_AutoStoryCapture.h); a shiny met in a capture stops - // the program without reloading. A MART, TEACH, LEVEL, GRIND or - // fossil CHOICE (PokemonFRLG_AutoStoryAbilities.h); a GRIND heals by - // routing to its target, the nurse, and back. - virtual Pokemon::Outcome game(Pokemon::RunState& state, const StoryStep& step) override; - private: // How the console does `id`. Pokemon::StoryAction console_action(StoryActionId id); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp index 2869e1c299..3781630bd9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp @@ -5,7 +5,9 @@ */ #include "Common/Cpp/Exceptions.h" +#include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryStepTable.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -28,6 +30,10 @@ const char* story_action_name(StoryActionId id){ case StoryActionId::HEAL: return "heal"; case StoryActionId::SHOP: return "shop"; case StoryActionId::READY: return "ready"; + case StoryActionId::CLOSE_TRAINER_CARD: return "close_trainer_card"; + case StoryActionId::LEAVE_GIFT_SUMMARY: return "leave_gift_summary"; + case StoryActionId::CUT_TREE: return "cut_tree"; + case StoryActionId::TRASH_CANS: return "trash_cans"; case StoryActionId::GAME: return "game"; } throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown story action."); @@ -47,6 +53,10 @@ const std::vector& story_action_rows(){ {StoryStepKind::HEAL, "", StoryActionId::HEAL}, {StoryStepKind::SHOP, "", StoryActionId::SHOP}, {StoryStepKind::READY, "", StoryActionId::READY}, + {StoryStepKind::GAME, CLOSE_TRAINER_CARD, StoryActionId::CLOSE_TRAINER_CARD}, + {StoryStepKind::GAME, LEAVE_GIFT_SUMMARY, StoryActionId::LEAVE_GIFT_SUMMARY}, + {StoryStepKind::GAME, CUT_TREE_VARIANT, StoryActionId::CUT_TREE}, + {StoryStepKind::GAME, TRASH_CANS_VARIANT, StoryActionId::TRASH_CANS}, {StoryStepKind::GAME, "", StoryActionId::GAME}, }; return ROWS; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.h index 002e749cb3..515bc5c3ce 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.h @@ -39,7 +39,20 @@ enum class StoryActionId{ HEAL, SHOP, READY, - // A GAME step: one only FRLG has. + // CLOSE_TRAINER_CARD: back from SID Helper (close_trainer_card()). + CLOSE_TRAINER_CARD, + // LEAVE_GIFT_SUMMARY: back from Gift RNG (leave_gift_summary()). + LEAVE_GIFT_SUMMARY, + // CUT_TREE_VARIANT: the tree ahead cut, and a step onto its tile. + CUT_TREE, + // TRASH_CANS_VARIANT: the search for Vermilion Gym's switches. + TRASH_CANS, + // Any other GAME step: a capture or a PC deposit or withdrawal + // (capture_step(), pc_step() in PokemonFRLG_AutoStoryCapture.h); a + // shiny met in a capture stops the program without reloading. A MART, + // TEACH, LEVEL, GRIND or fossil CHOICE + // (PokemonFRLG_AutoStoryAbilities.h); a GRIND heals by routing to its + // target, the nurse, and back. GAME, }; // "route", "game": the action's name in logs and test traces. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp index 1934284d6a..15e52e5ed3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp @@ -17,7 +17,9 @@ #include "Common/Cpp/TestRunners/UnitTest.h" #include "Pokemon/AutoStory/Pokemon_StoryActions.h" #include "PokemonFRLG_AutoStoryStepTable.h" +#include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -35,6 +37,23 @@ const StoryStepKind ALL_KINDS[] = { StoryStepKind::SAVE, StoryStepKind::READY, StoryStepKind::GAME, }; +// The action StoryExecutor::game() ran for a GAME `step` before the table. +StoryActionId old_game_dispatch(const StoryStep& step){ + if (step.variant == CLOSE_TRAINER_CARD){ + return StoryActionId::CLOSE_TRAINER_CARD; + } + if (step.variant == LEAVE_GIFT_SUMMARY){ + return StoryActionId::LEAVE_GIFT_SUMMARY; + } + if (step.variant == CUT_TREE_VARIANT){ + return StoryActionId::CUT_TREE; + } + if (step.variant == TRASH_CANS_VARIANT){ + return StoryActionId::TRASH_CANS; + } + return StoryActionId::GAME; +} + // The action StoryExecutor ran for `step` before the table: the shared // per-kind dispatch (StoryStepExecutor::run()) into the kinds it // overrode. Nothing for the kinds it left to the shared executor. @@ -52,7 +71,7 @@ std::optional old_dispatch(const StoryStep& step){ case StoryStepKind::HEAL: return StoryActionId::HEAL; case StoryStepKind::SHOP: return StoryActionId::SHOP; case StoryStepKind::READY: return StoryActionId::READY; - case StoryStepKind::GAME: return StoryActionId::GAME; + case StoryStepKind::GAME: return old_game_dispatch(step); case StoryStepKind::TRAIN: case StoryStepKind::SAVE: return std::nullopt; @@ -112,6 +131,9 @@ std::string parity_error(FakeGame& game, const StoryStep& step){ } // Every kind, with no variant, an unknown one, and each one a row names. +// The table finds a row by the variant's first word, where game() compared +// the whole variant; the rows' variants are one word, as every authored +// step's is (test_parity_story_steps()). std::vector every_kind_and_variant(){ std::vector variants{"", "no_such_variant", "no_such_variant key=1"}; for (const StoryActionRow& row : story_action_rows()){ From 14d8a5c46ac4882cc6cb7ff034ccb6aedda86dd5 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 03:01:04 -0800 Subject: [PATCH 20/21] FRLG AutoStory sim: run the story through the action table The full-run simulation (the test's FakeGame) and row_step_on_sim() dispatched by kind themselves, beside the table the console and the replay run. StoryMapsSim (the whole story on the story maps) and OverworldStepSim (a row's steps on the OverworldSim) now bind make_story_action_table() and run through TableExecutor, stubbing the actions that need the console; row_step_on_sim() runs its step through OverworldStepSim. SimStubs lists each row's stubbed steps (stub_report()), and a test checks that the whole story stubs exactly the steps whose action needs the console. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 410 +++++++++++++++--- .../AutoStory/PokemonFRLG_AutoStoryRowSim.h | 135 +++++- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 234 ++-------- 3 files changed, 524 insertions(+), 255 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index 9d4fa72246..bb5287cd2f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -4,9 +4,15 @@ * */ +#include +#include +#include #include #include +#include #include +#include "Common/Cpp/Exceptions.h" +#include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryRowSim.h" @@ -14,6 +20,259 @@ namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ +using Pokemon::Outcome; +using Pokemon::RunState; + + +void SimStubs::begin_row(std::string row){ + m_rows.push_back({std::move(row), {}}); +} +void SimStubs::record(const StoryStep& step){ + if (m_rows.empty()){ + m_rows.push_back({"", {}}); + } + m_rows.back().steps.push_back(step.name); +} +Pokemon::StoryAction SimStubs::stub(Pokemon::StoryActionFunction then){ + return { + .act = [this, then = std::move(then)](RunState& state, const StoryStep& step){ + record(step); + return then ? then(state, step) : Pokemon::outcome_ok(); + }, + .needs_console = true, + }; +} + +std::string stub_report(const std::vector& rows){ + std::string out; + for (const RowStubs& row : rows){ + out += std::format("{}: {} stubbed", row.row, row.steps.size()); + for (size_t i = 0; i < row.steps.size(); i++){ + out += (i == 0 ? ": " : ", ") + row.steps[i]; + } + out += "\n"; + } + return out; +} + + +namespace{ + +const MapData* map_named(const std::string& name){ + for (const MapData& map : story_maps()){ + if (map.name == name){ + return ↦ + } + } + return nullptr; +} + +// Whether the player, at the end of a WALK `step`, stops there holding on: +// the map's edge, a wall, or water to Surf onto, but no warp of any kind, +// even one to a map that isn't loaded. +bool stops_past_end(const RouteStep& step){ + const MapData& map = *map_named(step.end.map); + const TilePos here = step.end.point; + const TilePos ahead = here.next(step.direction); + if (!in_bounds(map, ahead)){ + return true; + } + if (warps_on_step(map, ahead) || exit_mat(map, here) == step.direction + || (step.direction == Direction::NORTH && is_door(map, ahead)) + ){ + return false; + } + const StepKind kind = classify_step(map, PlayerState{here, elevation_at(map, here), step.surfing}, step.direction); + return kind == StepKind::BLOCKED || kind == StepKind::START_SURF; +} + +// The first leg of `route` to a wall that holding on would carry past its +// end, or nothing (console B, 2026-11-02: one rode Route 4's Pokemon +// Center escalator). +std::optional overrun_wall_leg(const std::vector& route){ + for (const RouteStep& step : route){ + if (step.to_wall && !stops_past_end(step)){ + return to_string(step); + } + } + return std::nullopt; +} + +// Where the run stands, with a facing: a wall beside it when unknown. +struct Standing{ + MapPosition position; + Direction facing = Direction::NORTH; + std::string error; +}; +Standing standing(const Pokemon::Pose& pose){ + const std::optional at = story_pose(pose); + if (!at){ + return {{}, Direction::NORTH, "no one-tile pose: " + Pokemon::to_string(pose)}; + } + std::optional facing = at->facing; + if (!facing){ + const MapData* map = map_named(at->map); + facing = map ? wall_beside(*map, at->tile) : std::nullopt; + } + if (!facing){ + return {{}, Direction::NORTH, "facing unknown and no wall beside " + to_string(MapPosition{at->map, at->tile})}; + } + return {{at->map, at->tile}, *facing, ""}; +} + +// An action this simulation plays. +Pokemon::StoryAction played(Pokemon::StoryActionFunction act){ + return {.act = std::move(act), .needs_console = false}; +} + +} // namespace + + +StoryMapsSim::StoryMapsSim() + : Pokemon::TableExecutor(m_table, m_guard) + , m_table(make_story_action_table([this](StoryActionId id){ return action(id); })) +{} + +void StoryMapsSim::begin_attempt(const Pokemon::StoryCheckpoint& data){ + m_stubs.begin_row(data.id); +} + +Pokemon::StoryAction StoryMapsSim::action(StoryActionId id){ + switch (id){ + case StoryActionId::ROUTE: + return played([this](RunState& state, const StoryStep& step){ return walk_to(state.pose, step); }); + case StoryActionId::LEGS: + return played([this](RunState& state, const StoryStep& step){ return legs(state.pose, step); }); + case StoryActionId::STEP: + return played([this](RunState& state, const StoryStep& step){ return step_ahead(state.pose, step); }); + // The nurse's way must plan too, but the run goes on from where it stands. + case StoryActionId::READY: + return played([this](RunState& state, const StoryStep& step){ + const Outcome to_nurse = walk_to(state.pose, step); + return to_nurse.succeeded() ? Pokemon::outcome_ok() : to_nurse; + }); + case StoryActionId::SHOP: + return played([this](RunState&, const StoryStep& step){ return shop(step); }); + // Pacing leaves the player on either of its tiles, as on the console. + case StoryActionId::CLOSE_TRAINER_CARD: + case StoryActionId::LEAVE_GIFT_SUMMARY: + case StoryActionId::CUT_TREE: + case StoryActionId::TRASH_CANS: + case StoryActionId::GAME: + return m_stubs.stub([](RunState& state, const StoryStep& step){ + return Pokemon::outcome_ok(paced_pose(state.pose, step)); + }); + case StoryActionId::NEW_GAME: + case StoryActionId::OPTIONS: + case StoryActionId::TALK: + case StoryActionId::ANSWER: + case StoryActionId::MASH: + case StoryActionId::BATTLE: + case StoryActionId::HEAL: + return m_stubs.stub(); + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown story action."); +} + +Outcome StoryMapsSim::legs(const Pokemon::Pose& pose, const StoryStep& step) const{ + const Standing at = standing(pose); + if (!at.error.empty()){ + return Pokemon::outcome_failed("no_pose", at.error); + } + const std::optional> route = fixed_route(story_maps(), at.position, step.legs); + if (!route || route->empty()){ + return Pokemon::outcome_failed("bad_legs", to_string(at.position)); + } + return Pokemon::outcome_ok(to_pose(StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after})); +} + +// A step into a trainer's line goes on until a trainer can see the +// player, as step_until_seen() does on the console. +Outcome StoryMapsSim::step_ahead(const Pokemon::Pose& pose, const StoryStep& step) const{ + const std::optional at = story_pose(pose); + if (!at){ + return Pokemon::outcome_failed("no_pose", Pokemon::to_string(pose)); + } + const MapData* map = map_named(at->map); + if (step.variant != TRAINER_LINE_VARIANT || map == nullptr){ + return Pokemon::outcome_ok(to_pose(StoryPose{at->map, at->tile.next(step.direction), step.direction})); + } + TilePos onto = at->tile; + const std::optional stepped = step_until_seen( + TRAINER_LINE_TILES, + [&]{ onto = onto.next(step.direction); }, + [&]{ + return std::any_of(map->objects.begin(), map->objects.end(), [&](const MapObject& object){ + return object.trainer && trainer_may_see(*map, object, onto); + }); + } + ); + if (!stepped){ + return Pokemon::outcome_failed("no_trainer", to_string(MapPosition{at->map, onto})); + } + return Pokemon::outcome_ok(to_pose(StoryPose{at->map, onto, step.direction})); +} + +// The Viridian Mart's list as the game shows it. After a purchase the +// cursor stays on the row bought (console B, 2026-11-01), so each +// order moves it from there, as buy_items() does since ad5d40885. +Outcome StoryMapsSim::shop(const StoryStep& step){ + static const std::vector LIST{"poke_ball", "potion", "antidote", "parlyz_heal", "cancel"}; + const std::optional> orders = viridian_mart_orders(step.items); + if (!orders){ + return Pokemon::outcome_failed("unknown_item", step.name); + } + int cursor = 0; + for (size_t c = 0; c < orders->size(); c++){ + const MartOrder& order = (*orders)[c]; + // The presses: down or up from where the cursor is. + cursor += order.row - cursor; + if (cursor < 0 || cursor >= (int)LIST.size() || LIST[cursor] == "cancel"){ + return Pokemon::outcome_failed("mart_cancel", std::format("order {} ends on row {}", c + 1, cursor + 1)); + } + if (LIST[cursor] != step.items[c].item){ + return Pokemon::outcome_failed("wrong_item", std::format("{} instead of {}", LIST[cursor], step.items[c].item)); + } + m_bag[LIST[cursor]] += order.quantity; + } + return Pokemon::outcome_ok(); +} + +Outcome StoryMapsSim::walk_to(const Pokemon::Pose& pose, const StoryStep& step) const{ + if (Pokemon::tiles_of(pose).size() > 1){ + // From the tiles a pacing step leaves: the route must plan, and + // test_routes_after_pacing() walks it on the sim from each. + const std::optional target = story_pose(*step.target); + const RouteGoal goal{{target->map, target->tile}, target->facing}; + const std::optional> route = plan_story_route(story_maps(), pose, std::nullopt, goal); + if (!route || route->empty()){ + return Pokemon::outcome_failed("no_route", Pokemon::to_string(pose) + " to " + to_string(goal.position)); + } + return Pokemon::outcome_ok(to_pose(StoryPose{target->map, target->tile, route->back().facing_after})); + } + const Standing at = standing(pose); + if (!at.error.empty()){ + return Pokemon::outcome_failed("no_pose", at.error); + } + const std::optional target = story_pose(*step.target); + const RouteGoal goal{{target->map, target->tile}, target->facing}; + const std::optional> route = plan_overworld_route( + story_maps(), at.position, goal, story_route_costs(), at.facing + ); + if (!route){ + return Pokemon::outcome_failed("no_route", to_string(at.position) + " to " + to_string(goal.position)); + } + const MapPosition end = follow_overworld_route(story_maps(), at.position, *route); + if (!(end == goal.position)){ + return Pokemon::outcome_failed("wrong_end", to_string(end)); + } + if (const std::optional leg = overrun_wall_leg(*route)){ + return Pokemon::outcome_failed("wall_overrun", *leg); + } + const Direction facing = route->empty() ? at.facing : route->back().facing_after; + return Pokemon::outcome_ok(to_pose(StoryPose{target->map, target->tile, facing})); +} + // The route the program walks for `step`, a ROUTE or LEGS, from `at`: // planned, or the legs laid out. @@ -27,75 +286,112 @@ std::optional> walked_route(const StoryPose& at, const St return plan_overworld_route(story_maps(), start, goal, story_route_costs(), at.facing); } -namespace{ + +OverworldStepSim::OverworldStepSim(OverworldSim& sim, std::string row, StoryPose at) + : Pokemon::TableExecutor(m_table, m_guard) + , m_sim(sim) + , m_at(std::move(at)) + , m_table(make_story_action_table([this](StoryActionId id){ return action(id); })) +{ + m_stubs.begin_row(std::move(row)); +} + +Pokemon::StoryAction OverworldStepSim::action(StoryActionId id){ + using Body = std::string (OverworldStepSim::*)(const StoryStep&); + Body body = nullptr; + switch (id){ + case StoryActionId::ROUTE: + case StoryActionId::LEGS: + body = &OverworldStepSim::walk; + break; + case StoryActionId::STEP: + body = &OverworldStepSim::step_ahead; + break; + case StoryActionId::CUT_TREE: + body = &OverworldStepSim::cut; + break; + case StoryActionId::BATTLE: + body = &OverworldStepSim::battle; + break; + default: + return m_stubs.stub(); + } + return played([this, body](RunState&, const StoryStep& step){ + const std::string failure = (this->*body)(step); + return failure.empty() ? Pokemon::outcome_ok() : Pokemon::outcome_failed("sim_failure", failure); + }); +} + +// 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){ + const std::optional> route = walked_route(m_at, step); + if (route && route->empty()){ + // Already there. + 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."; + m_at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : m_at; + return failure; +} + +// A step one tap at a time, or for a step into a trainer's line by +// step_until_seen(), each watched for the trainer. +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); + } + m_sim.tap(step.direction); + }; + const auto seen = [this]{ + return m_sim.watch({SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, TRAINER_LINE_WATCH).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."; +} // A CUT_TREE_VARIANT step: turned to the tree, cut, and a step onto its -// tile. Moves `at` there; returns the failure, or empty. -std::string cut_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step){ - if (sim.state().facing != step.direction){ - sim.tap(step.direction); +// tile. +std::string OverworldStepSim::cut(const StoryStep& step){ + if (m_sim.state().facing != step.direction){ + m_sim.tap(step.direction); } - if (!sim.cut_tree()){ + if (!m_sim.cut_tree()){ return "no tree to cut."; } - sim.tap(step.direction); - at = StoryPose{at.map, at.tile.next(step.direction), step.direction}; - if (!(sim.state().position == MapPosition{at.map, at.tile})){ - return "expected to step onto the tree's tile " + to_string(MapPosition{at.map, at.tile}) + "."; + m_sim.tap(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}) + "."; + } + return ""; +} + +// A battle from the trainer's message, which must be up, ending where the +// table says. +std::string OverworldStepSim::battle(const StoryStep& step){ + if (!m_sim.close_message() || m_sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ + return "no_battle."; + } + 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}) + "."; } return ""; } -} // namespace -// Runs `step` of row `id` on `sim` as the program does: routes and legs -// by the sim's walker from `at`, where the table says the player stands, -// fighting a trainer who sees the player on the way; a step one tap at a -// time, or for a step into a trainer's line by step_until_seen(), each -// watched for the trainer; a battle from the trainer's message, which -// must be up; a cut tree cut and stepped onto. Moves `at` to where the -// table says the step ends. Returns the failure, or empty. std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id){ - const std::string where = id + "/" + step.name + ": "; - const auto on = [&sim]{ return " On " + to_string(sim.state().position) + "."; }; - if (step.kind == StoryStepKind::LEGS || step.kind == StoryStepKind::ROUTE){ - const std::optional> route = walked_route(at, step); - if (route && route->empty()){ - // Already there. - return ""; - } - const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = MapPosition{at.map, at.tile}, .label = step.name}; - const std::string failure = route ? OverworldSimWalker(sim).run(*route, planning) : "no legs."; - at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : at; - return failure.empty() ? "" : where + failure + on(); - } - if (step.kind == StoryStepKind::STEP){ - const auto tap = [&sim, &step]{ - if (sim.state().facing != step.direction){ - sim.tap(step.direction); - } - sim.tap(step.direction); - }; - const auto seen = [&sim]{ - return sim.watch({SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, TRAINER_LINE_WATCH).has_value(); - }; - const int tiles = step.variant == TRAINER_LINE_VARIANT ? TRAINER_LINE_TILES : 1; - return step_until_seen(tiles, tap, seen) ? "" : where + "no trainer saw the player." + on(); - } - if (step.kind == StoryStepKind::GAME && step.variant == CUT_TREE_VARIANT){ - const std::string failure = cut_on_sim(sim, at, step); - return failure.empty() ? "" : where + failure + on(); - } - if (step.kind == StoryStepKind::BATTLE){ - if (!sim.close_message() || sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ - return where + "no_battle." + on(); - } - at = *story_pose(*step.pose_after); - if (!(sim.state().position == MapPosition{at.map, at.tile})){ - return where + "expected " + to_string(MapPosition{at.map, at.tile}) + "." + on(); - } + OverworldStepSim executor(sim, id, at); + RunState state; + const Outcome outcome = executor.run(state, step); + at = executor.at(); + if (outcome.succeeded()){ + return ""; } - return ""; + return id + "/" + step.name + ": " + outcome.detail + " On " + to_string(sim.state().position) + "."; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h index 855c25c783..1e96bd0172 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h @@ -2,38 +2,159 @@ * * From: https://github.com/PokemonAutomation/ * - * A story row's steps on the OverworldSim, for the simulation tests of - * each stage's rows: the walks as the program plans or lays them out, - * the steps into trainers' lines and their battles. + * FRLG's story on the simulation, through the same action table as the + * console (make_story_action_table()): each binding plays the actions it + * can and stubs the rest, which need the console. StoryMapsSim runs the + * whole story on the story maps; OverworldStepSim walks a row's steps on + * the OverworldSim, for the simulation tests of each stage's rows. * */ #ifndef PokemonAutomation_PokemonFRLG_AutoStoryRowSim_H #define PokemonAutomation_PokemonFRLG_AutoStoryRowSim_H +#include #include #include #include +#include "Pokemon/AutoStory/Pokemon_StoryActions.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryStepTable.h" namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ +// The steps of one attempt at a row that a simulation did not play. +struct RowStubs{ + std::string row; + std::vector steps; +}; + +// The steps a simulation stubs, by row: the stand-in it binds to each +// action it cannot play. +class SimStubs{ +public: + // An attempt at `row` begins; its stubs are listed apart from the last. + void begin_row(std::string row); + // Needs the console; adds the step to the row's stubs, then does + // `then` (what the simulation keeps of the step: where pacing leaves + // the player), or passes with the pose as it was. + Pokemon::StoryAction stub(Pokemon::StoryActionFunction then = {}); + const std::vector& rows() const{ + return m_rows; + } + +private: + void record(const StoryStep& step); + + std::vector m_rows; +}; + +// Each attempt's stubs, a line a row: "00.03: 2 stubbed: oak, rival". +std::string stub_report(const std::vector& rows); + +// A failed step fails the run as it threw: a simulation has no save to +// reload. +class SimStepGuard : public Pokemon::StepGuard{ +public: + virtual bool stops_on_failure(const Pokemon::StoryStep&) const override{ + return true; + } +}; + + +// The whole story on the story maps, without a walker: each ROUTE planned +// and followed from where the run stands (turning to a wall when the +// facing is unknown, as the program does), each LEGS laid out, a step +// into a trainer's line taken until a trainer can see the player, a +// READY's way to the nurse planned, the Viridian Mart's orders checked +// against its list, and a GAME leaving the player where pacing would +// (paced_pose()). The rest is stubbed. +class StoryMapsSim : public Pokemon::TableExecutor{ +public: + // The table and guard are members, built after the base: it only keeps + // references to them until a step runs. + StoryMapsSim(); + StoryMapsSim(const StoryMapsSim&) = delete; + void operator=(const StoryMapsSim&) = delete; + + virtual void begin_attempt(const Pokemon::StoryCheckpoint& data) override; + // The story's actions as this simulation binds them. + const Pokemon::StoryActionTable& actions() const{ + return m_table; + } + const SimStubs& stubs() const{ + return m_stubs; + } + // What the Mart sold, by its list's names. + const std::map& bag() const{ + return m_bag; + } + +private: + Pokemon::StoryAction action(StoryActionId id); + Pokemon::Outcome walk_to(const Pokemon::Pose& pose, const StoryStep& step) const; + Pokemon::Outcome legs(const Pokemon::Pose& pose, const StoryStep& step) const; + Pokemon::Outcome step_ahead(const Pokemon::Pose& pose, const StoryStep& step) const; + Pokemon::Outcome shop(const StoryStep& step); + + SimStepGuard m_guard; + SimStubs m_stubs; + std::map m_bag; + Pokemon::StoryActionTable m_table; +}; + + // The route the program walks for `step`, a ROUTE or LEGS, from `at`: // planned, or the legs laid out. std::optional> walked_route(const StoryPose& at, const StoryStep& step); -// Runs `step` of row `id` on `sim` as the program does: routes and legs -// by the sim's walker from `at`, where the table says the player stands, +// A row's steps on the OverworldSim as the program does them: routes and +// legs by the sim's walker from where the table says the player stands, // fighting a trainer who sees the player on the way; a step one tap at a // time, or for a step into a trainer's line by step_until_seen(), each // watched for the trainer; a battle from the trainer's message, which -// must be up; a cut tree cut and stepped onto. Moves `at` to where the -// table says the step ends. Returns the failure, or empty. +// must be up; a cut tree cut and stepped onto. The rest is stubbed. +class OverworldStepSim : public Pokemon::TableExecutor{ +public: + // `sim` must outlive the executor. + OverworldStepSim(OverworldSim& sim, std::string row, StoryPose at); + OverworldStepSim(const OverworldStepSim&) = delete; + void operator=(const OverworldStepSim&) = delete; + + // Where the table says the player stands: moved to where it says each + // step ends. + const StoryPose& at() const{ + return m_at; + } + const Pokemon::StoryActionTable& actions() const{ + return m_table; + } + const SimStubs& stubs() const{ + return m_stubs; + } + +private: + Pokemon::StoryAction action(StoryActionId id); + std::string walk(const StoryStep& step); + std::string step_ahead(const StoryStep& step); + std::string cut(const StoryStep& step); + std::string battle(const StoryStep& step); + + OverworldSim& m_sim; + StoryPose m_at; + SimStepGuard m_guard; + SimStubs m_stubs; + Pokemon::StoryActionTable m_table; +}; + +// Runs `step` of row `id` on `sim` through OverworldStepSim, from `at`, +// and moves `at` to where the table says the step ends. Returns the +// failure, "/: On .", or empty. std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id); 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 e618428540..15e33469d8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -5,11 +5,11 @@ * A run of the whole story table, new game to the Boulder Badge, without * the console: story_checkpoints() built into engine checkpoints by the * shared builder, saving at each end as the program does, run by the - * shared runner with the program's retry policy, against a fake game. - * The fake plans each ROUTE on the story maps from where the run's pose - * says we stand (turning to a wall when the facing is unknown, as the - * program does), walks each LEGS on them, steps onto triggers, and passes - * every other step unless told to fail it. + * shared runner with the program's retry policy, on the simulation of the + * story maps (StoryMapsSim): the story's action table as the console's, + * bound to the simulation, which plans each ROUTE on the story maps from + * where the run's pose says we stand, walks each LEGS on them, steps onto + * triggers and stubs what needs the console, unless told to fail a step. * */ @@ -54,69 +54,9 @@ std::string joined(const std::vector& lines){ return text; } -const MapData* map_named(const std::string& name){ - for (const MapData& map : story_maps()){ - if (map.name == name){ - return ↦ - } - } - return nullptr; -} - -// Whether the player, at the end of a WALK `step`, stops there holding on: -// the map's edge, a wall, or water to Surf onto, but no warp of any kind, -// even one to a map that isn't loaded. -bool stops_past_end(const RouteStep& step){ - const MapData& map = *map_named(step.end.map); - const TilePos here = step.end.point; - const TilePos ahead = here.next(step.direction); - if (!in_bounds(map, ahead)){ - return true; - } - if (warps_on_step(map, ahead) || exit_mat(map, here) == step.direction - || (step.direction == Direction::NORTH && is_door(map, ahead)) - ){ - return false; - } - const StepKind kind = classify_step(map, PlayerState{here, elevation_at(map, here), step.surfing}, step.direction); - return kind == StepKind::BLOCKED || kind == StepKind::START_SURF; -} - -// The first leg of `route` to a wall that holding on would carry past its -// end, or nothing (console B, 2026-11-02: one rode Route 4's Pokemon -// Center escalator). -std::optional overrun_wall_leg(const std::vector& route){ - for (const RouteStep& step : route){ - if (step.to_wall && !stops_past_end(step)){ - return to_string(step); - } - } - return std::nullopt; -} - -// Where the run stands, with a facing: a wall beside it when unknown. -struct Standing{ - MapPosition position; - Direction facing = Direction::NORTH; - std::string error; -}; -Standing standing(const Pokemon::Pose& pose){ - const std::optional at = story_pose(pose); - if (!at){ - return {{}, Direction::NORTH, "no one-tile pose: " + Pokemon::to_string(pose)}; - } - std::optional facing = at->facing; - if (!facing){ - const MapData* map = map_named(at->map); - facing = map ? wall_beside(*map, at->tile) : std::nullopt; - } - if (!facing){ - return {{}, Direction::NORTH, "facing unknown and no wall beside " + to_string(MapPosition{at->map, at->tile})}; - } - return {{at->map, at->tile}, *facing, ""}; -} - -class FakeGame : public Pokemon::StoryStepExecutor{ +// The story on the story maps (StoryMapsSim), with a trace of what ran +// and steps told to fail. +class FakeGame : public StoryMapsSim{ public: struct Failure{ std::string failure; @@ -127,10 +67,9 @@ class FakeGame : public Pokemon::StoryStepExecutor{ std::map failures; // Where the save loaded stands, when not at its checkpoint's start. std::optional save_stands; - // What the Mart sold, by its list's names. - std::map bag; virtual void begin_attempt(const Pokemon::StoryCheckpoint& data) override{ + StoryMapsSim::begin_attempt(data); m_row = data.id; trace.push_back("begin " + data.id); } @@ -142,126 +81,10 @@ class FakeGame : public Pokemon::StoryStepExecutor{ failure->second.times--; return Pokemon::outcome_failed(failure->second.failure, key); } - return Pokemon::StoryStepExecutor::run(state, step); - } -protected: - virtual Outcome route(RunState& state, const StoryStep& step) override{ - return walk_to(state.pose, step); - } - virtual Outcome legs(RunState& state, const StoryStep& step) override{ - const Standing at = standing(state.pose); - if (!at.error.empty()){ - return Pokemon::outcome_failed("no_pose", at.error); - } - const std::optional> route = fixed_route(story_maps(), at.position, step.legs); - if (!route || route->empty()){ - return Pokemon::outcome_failed("bad_legs", to_string(at.position)); - } - return Pokemon::outcome_ok(to_pose(StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after})); - } - // A step into a trainer's line goes on until a trainer can see the - // player, as step_until_seen() does on the console. - virtual Outcome step(RunState& state, const StoryStep& step) override{ - const std::optional at = story_pose(state.pose); - if (!at){ - return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); - } - const MapData* map = map_named(at->map); - if (step.variant != TRAINER_LINE_VARIANT || map == nullptr){ - return Pokemon::outcome_ok(to_pose(StoryPose{at->map, at->tile.next(step.direction), step.direction})); - } - TilePos onto = at->tile; - const std::optional stepped = step_until_seen( - TRAINER_LINE_TILES, - [&]{ onto = onto.next(step.direction); }, - [&]{ - return std::any_of(map->objects.begin(), map->objects.end(), [&](const MapObject& object){ - return object.trainer && trainer_may_see(*map, object, onto); - }); - } - ); - if (!stepped){ - return Pokemon::outcome_failed("no_trainer", to_string(MapPosition{at->map, onto})); - } - return Pokemon::outcome_ok(to_pose(StoryPose{at->map, onto, step.direction})); - } - // The nurse's way must plan too, but the run goes on from where it stands. - virtual Outcome ready(RunState& state, const StoryStep& step) override{ - const Outcome to_nurse = walk_to(state.pose, step); - return to_nurse.succeeded() ? Pokemon::outcome_ok() : to_nurse; - } - virtual Outcome new_game(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome options(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome talk(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome answer(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome mash(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome battle(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome heal(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - // Pacing leaves the player on either of its tiles, as on the console. - virtual Outcome game(RunState& state, const StoryStep& step) override{ - return Pokemon::outcome_ok(paced_pose(state.pose, step)); - } - // The Viridian Mart's list as the game shows it. After a purchase the - // cursor stays on the row bought (console B, 2026-11-01), so each - // order moves it from there, as buy_items() does since ad5d40885. - virtual Outcome shop(RunState&, const StoryStep& step) override{ - static const std::vector LIST{"poke_ball", "potion", "antidote", "parlyz_heal", "cancel"}; - const std::optional> orders = viridian_mart_orders(step.items); - if (!orders){ - return Pokemon::outcome_failed("unknown_item", step.name); - } - int cursor = 0; - for (size_t c = 0; c < orders->size(); c++){ - const MartOrder& order = (*orders)[c]; - // The presses: down or up from where the cursor is. - cursor += order.row - cursor; - if (cursor < 0 || cursor >= (int)LIST.size() || LIST[cursor] == "cancel"){ - return Pokemon::outcome_failed("mart_cancel", std::format("order {} ends on row {}", c + 1, cursor + 1)); - } - if (LIST[cursor] != step.items[c].item){ - return Pokemon::outcome_failed("wrong_item", std::format("{} instead of {}", LIST[cursor], step.items[c].item)); - } - bag[LIST[cursor]] += order.quantity; - } - return Pokemon::outcome_ok(); + return StoryMapsSim::run(state, step); } private: - Outcome walk_to(const Pokemon::Pose& pose, const StoryStep& step){ - if (Pokemon::tiles_of(pose).size() > 1){ - // From the tiles a pacing step leaves: the route must plan, and - // test_routes_after_pacing() walks it on the sim from each. - const std::optional target = story_pose(*step.target); - const RouteGoal goal{{target->map, target->tile}, target->facing}; - const std::optional> route = plan_story_route(story_maps(), pose, std::nullopt, goal); - if (!route || route->empty()){ - return Pokemon::outcome_failed("no_route", Pokemon::to_string(pose) + " to " + to_string(goal.position)); - } - return Pokemon::outcome_ok(to_pose(StoryPose{target->map, target->tile, route->back().facing_after})); - } - const Standing at = standing(pose); - if (!at.error.empty()){ - return Pokemon::outcome_failed("no_pose", at.error); - } - const std::optional target = story_pose(*step.target); - const RouteGoal goal{{target->map, target->tile}, target->facing}; - const std::optional> route = plan_overworld_route( - story_maps(), at.position, goal, story_route_costs(), at.facing - ); - if (!route){ - return Pokemon::outcome_failed("no_route", to_string(at.position) + " to " + to_string(goal.position)); - } - const MapPosition end = follow_overworld_route(story_maps(), at.position, *route); - if (!(end == goal.position)){ - return Pokemon::outcome_failed("wrong_end", to_string(end)); - } - if (const std::optional leg = overrun_wall_leg(*route)){ - return Pokemon::outcome_failed("wall_overrun", *leg); - } - const Direction facing = route->empty() ? at.facing : route->back().facing_after; - return Pokemon::outcome_ok(to_pose(StoryPose{target->map, target->tile, facing})); - } - std::string m_row; }; @@ -332,6 +155,8 @@ struct Simulation{ struct AutoStorySimulationInput{ std::string name; UnitTestResult (*body)(); + // A body that logs, run instead of `body` if given. + UnitTestResult (*logged)(Logger&) = {}; }; struct AutoStorySimulationExpectation{ @@ -349,9 +174,9 @@ class Test_AutoStorySimulation : public UnitTest{ : UnitTest("PokemonFRLG::AutoStory - Simulation: " + scenario.input.name) , m_scenario(std::move(scenario)) {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ try{ - return m_scenario.input.body(); + return m_scenario.input.logged ? m_scenario.input.logged(logger) : m_scenario.input.body(); }catch (Exception& e){ return std::string(e.name()) + ": " + e.message(); } @@ -392,9 +217,9 @@ UnitTestResult test_whole_story(){ } // 00.07's shopping, with the cursor staying on each row bought. const std::map bought{{"potion", 5}, {"antidote", 3}, {"parlyz_heal", 1}}; - if (sim.game.bag != bought){ + if (sim.game.bag() != bought){ std::string text; - for (const auto& item : sim.game.bag){ + for (const auto& item : sim.game.bag()){ text += std::format(" {} {}", item.second, item.first); } return "Expected 5 Potions, 3 Antidotes and 1 Parlyz Heal; bought" + text + "."; @@ -402,6 +227,32 @@ UnitTestResult test_whole_story(){ return true; } +// The whole story through the table as the simulation binds it, each +// row's stubs logged: a step is stubbed exactly when its action needs the +// console, so no step the simulation plays is passed over. +UnitTestResult test_stubbed_steps_need_the_console(Logger& logger){ + Simulation sim; + if (!sim.run({}).completed){ + return "Expected the whole story through:" + joined(sim.lines); + } + logger.log("Steps the simulation does not play, by row:\n" + stub_report(sim.game.stubs().rows())); + for (const RowStubs& stubs : sim.game.stubs().rows()){ + const auto row = std::find_if(sim.rows.begin(), sim.rows.end(), [&](const StoryCheckpoint& data){ return data.id == stubs.row; }); + if (row == sim.rows.end()){ + return "Stubs for no row: " + stubs.row; + } + for (const StoryStep& step : row->steps){ + const Pokemon::StoryAction* action = sim.game.actions().find(step); + const bool stubbed = std::ranges::find(stubs.steps, step.name) != stubs.steps.end(); + if (action == nullptr || stubbed != action->needs_console){ + return std::format("{} {}: {}, but its action {} the console.", row->id, step.name, + stubbed ? "stubbed" : "played", action != nullptr && action->needs_console ? "needs" : "does not need"); + } + } + } + return true; +} + UnitTestResult test_start_and_end(){ Simulation sim; const Pokemon::SegmentReport report = sim.run({.from = "00.05", .to = "00.07"}); @@ -913,6 +764,7 @@ UnitTestResult test_out_of_bill_cottage(){ const AutoStorySimulationTestCase AUTO_STORY_SIMULATION_CASES[] = { {.input = {.name = "new game to Brock, in order, each start where the last save stood", .body = test_whole_story}, .expectation = {.description = "runs full story progression from new game through Brock gym"}}, + {.input = {.name = "the steps it stubs need the console", .body = nullptr, .logged = test_stubbed_steps_need_the_console}, .expectation = {.description = "stubs each needs-console step of the whole story and plays the rest"}}, {.input = {.name = "start and end checkpoints", .body = test_start_and_end}, .expectation = {.description = "executes segment bounded by start and end checkpoints"}}, {.input = {.name = "a failed walk reloads its checkpoint's save", .body = test_retry_from_save}, .expectation = {.description = "reloads checkpoint save state on simulated movement failure"}}, {.input = {.name = "a battle lost every time stops after 3 attempts", .body = test_gives_up_after_attempts}, .expectation = {.description = "aborts after three consecutive battle blackouts"}}, From 1417971495ec4c0e9f9382bc8c2ec3934751dd0c Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 04:42:00 -0800 Subject: [PATCH 21/21] FRLG AutoStory sim: clear dialogs between steps as the console does Binds the FRLG between-step interrupts (step_interrupts()) to the story maps simulation. StorySimKnobs::dialog_before pops a dialog up before a step; until it is pressed through it stops a step that walks or talks, as on the console. With clear_interrupts (the default) the executor's look finds it and closes it; off, it stays up as before the binding. Tests: a dialog before every step (red before the binding: the walk after the first one failed), and dialogs before one step in five over 5 seeds, 0 of 5 runs through before and 5 of 5 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 60 +++++++++++++++++- .../AutoStory/PokemonFRLG_AutoStoryRowSim.h | 40 +++++++++++- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 62 +++++++++++++++++++ 3 files changed, 158 insertions(+), 4 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index bb5287cd2f..c6d23e3371 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -128,13 +128,69 @@ Pokemon::StoryAction played(Pokemon::StoryActionFunction act){ } // namespace -StoryMapsSim::StoryMapsSim() +StoryMapsSim::StoryMapsSim(StorySimKnobs knobs) : Pokemon::TableExecutor(m_table, m_guard) , m_table(make_story_action_table([this](StoryActionId id){ return action(id); })) -{} + , m_knobs(std::move(knobs)) + , m_interrupts(step_interrupts([this](bool){ return look(); }, sim_interrupt_actions())) +{ + if (m_knobs.clear_interrupts){ + set_interrupts(&m_interrupts); + } +} void StoryMapsSim::begin_attempt(const Pokemon::StoryCheckpoint& data){ m_stubs.begin_row(data.id); + m_row = data.id; + // A reload starts the row on the quiet overworld. + m_dialog_up = m_dialog_rose = false; +} + +Outcome StoryMapsSim::run(RunState& state, const StoryStep& step){ + if (m_knobs.dialog_before && m_knobs.dialog_before(m_row, step.name)){ + m_dialog_up = m_dialog_rose = true; + m_dialog_notes.push_back(m_row + "/" + step.name + ": a dialog pops up before it."); + } + return Pokemon::TableExecutor::run(state, step); +} + +Outcome StoryMapsSim::run_action(RunState& state, const StoryStep& step){ + if (m_dialog_up){ + if (!presses_through_text(step.kind)){ + return Pokemon::outcome_failed("dialog", step.name + ": a dialog that popped up before it is still up"); + } + m_dialog_up = false; + } + return Pokemon::TableExecutor::run_action(state, step); +} + +Pokemon::ScreenLook StoryMapsSim::look(){ + const WallClock start = WallClock() + std::chrono::hours(1); + Pokemon::ScreenLook out; + out.log.record({}, start); + if (std::exchange(m_dialog_rose, false)){ + out.log.record({Pokemon::ScreenEvent::DIALOG_BOX}, start + Milliseconds(33)); + } + out.window = {.start = start, .end = start + BETWEEN_STEPS_LOOK}; + return out; +} + +// Every kind closes what is up and notes it: the simulation shows only +// the dialogs it pops up. +StepInterruptActions StoryMapsSim::sim_interrupt_actions(){ + const auto cleared = [this](const char* what){ + return [this, what](RunState&, const Pokemon::InterruptVerdict&){ + m_dialog_notes.push_back(std::string(what) + " cleared before the next step."); + m_dialog_up = false; + return Pokemon::outcome_ok(); + }; + }; + return StepInterruptActions{ + .wild = cleared("a wild battle"), + .trainer = cleared("a trainer's battle"), + .dialog = cleared("a dialog"), + .warp = cleared("a warp"), + }; } Pokemon::StoryAction StoryMapsSim::action(StoryActionId id){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h index 1e96bd0172..b8e5526496 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h @@ -13,6 +13,7 @@ #ifndef PokemonAutomation_PokemonFRLG_AutoStoryRowSim_H #define PokemonAutomation_PokemonFRLG_AutoStoryRowSim_H +#include #include #include #include @@ -21,6 +22,7 @@ #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryStepInterrupts.h" #include "PokemonFRLG_AutoStoryStepTable.h" namespace PokemonAutomation{ @@ -67,22 +69,36 @@ class SimStepGuard : public Pokemon::StepGuard{ }; +// What happens on the story maps besides the story's steps. +struct StorySimKnobs{ + // Whether a dialog pops up right before row `row`'s step `step`: + // someone walks up and talks between two steps. Until it is pressed + // through, it stops a step that walks or talks, as on the console. + std::function dialog_before = nullptr; + // Whether the executor clears what came up between steps + // (step_interrupts()), as the console does; off, as before it did. + bool clear_interrupts = true; +}; + // The whole story on the story maps, without a walker: each ROUTE planned // and followed from where the run stands (turning to a wall when the // facing is unknown, as the program does), each LEGS laid out, a step // into a trainer's line taken until a trainer can see the player, a // READY's way to the nurse planned, the Viridian Mart's orders checked // against its list, and a GAME leaving the player where pacing would -// (paced_pose()). The rest is stubbed. +// (paced_pose()). The rest is stubbed. The dialogs `knobs` pops up are +// the only interrupts between steps it shows. class StoryMapsSim : public Pokemon::TableExecutor{ public: // The table and guard are members, built after the base: it only keeps // references to them until a step runs. - StoryMapsSim(); + explicit StoryMapsSim(StorySimKnobs knobs = {}); StoryMapsSim(const StoryMapsSim&) = delete; void operator=(const StoryMapsSim&) = delete; virtual void begin_attempt(const Pokemon::StoryCheckpoint& data) override; + // The step, after a dialog pops up before it if the knobs say so. + virtual Pokemon::Outcome run(Pokemon::RunState& state, const StoryStep& step) override; // The story's actions as this simulation binds them. const Pokemon::StoryActionTable& actions() const{ return m_table; @@ -94,6 +110,15 @@ class StoryMapsSim : public Pokemon::TableExecutor{ const std::map& bag() const{ return m_bag; } + // "00.03/oak: a dialog pops up before it.", and how it was cleared. + const std::vector& dialog_notes() const{ + return m_dialog_notes; + } + +protected: + // A dialog that popped up and is still up stops a step that walks or + // talks; a step that presses through text closes it. + virtual Pokemon::Outcome run_action(Pokemon::RunState& state, const StoryStep& step) override; private: Pokemon::StoryAction action(StoryActionId id); @@ -101,11 +126,22 @@ class StoryMapsSim : public Pokemon::TableExecutor{ Pokemon::Outcome legs(const Pokemon::Pose& pose, const StoryStep& step) const; Pokemon::Outcome step_ahead(const Pokemon::Pose& pose, const StoryStep& step) const; Pokemon::Outcome shop(const StoryStep& step); + // The screen between steps: the dialog that popped up, if one did. + Pokemon::ScreenLook look(); + StepInterruptActions sim_interrupt_actions(); SimStepGuard m_guard; SimStubs m_stubs; std::map m_bag; Pokemon::StoryActionTable m_table; + StorySimKnobs m_knobs; + Pokemon::StoryInterrupts m_interrupts; + std::string m_row; + // A dialog popped up (StorySimKnobs::dialog_before) and is still up; + // it rose since the last look. + bool m_dialog_up = false; + bool m_dialog_rose = false; + std::vector m_dialog_notes; }; 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 b396a9ef79..faf96e34c5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -17,7 +17,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -58,6 +60,10 @@ std::string joined(const std::vector& lines){ // and steps told to fail. class FakeGame : public StoryMapsSim{ public: + explicit FakeGame(StorySimKnobs knobs = {}) + : StoryMapsSim(std::move(knobs)) + {} + struct Failure{ std::string failure; size_t times = 0; @@ -93,6 +99,10 @@ class FakeGame : public StoryMapsSim{ // run takes with every handoff off, each saving at its end (hooks.save // stands for save_game()). struct Simulation{ + explicit Simulation(StorySimKnobs knobs = {}) + : game(std::move(knobs)) + {} + std::vector rows = plan_story_run(story_checkpoints(), 0, story_checkpoints().size() - 1, StoryHandoffs{}).rows; FakeGame game; std::vector lines; @@ -762,6 +772,56 @@ UnitTestResult test_out_of_bill_cottage(){ } +// Someone talks to the player before every step (StorySimKnobs:: +// dialog_before): cleared between steps, the whole story still passes; +// left up, as before the executor looked, a walk fails on it. +UnitTestResult test_dialog_before_every_step(){ + const auto always = [](const std::string&, const std::string&){ + return true; + }; + Simulation cleared({.dialog_before = always}); + if (!cleared.run({}).completed){ + return "Expected the story through, a dialog before every step cleared:" + joined(cleared.lines); + } + Simulation left({.dialog_before = always, .clear_interrupts = false}); + const bool completed = left.run({}).completed; + const auto stopped = [](const std::string& line){ + return line.find("is still up") != std::string::npos; + }; + if (completed || std::ranges::none_of(left.lines, stopped)){ + return "Expected a dialog left up to stop a walk:" + joined(left.lines); + } + return true; +} + +// Dialogs pop up at random before one step in five: how many whole runs +// pass with them cleared between steps, and with them left up. +UnitTestResult test_dialogs_at_random(Logger& logger){ + constexpr uint32_t SEEDS = 5; + size_t passes[2] = {0, 0}; + for (uint32_t seed = 1; seed <= SEEDS; seed++){ + for (bool clear : {false, true}){ + const auto rng = std::make_shared(seed); + Simulation sim({ + .dialog_before = [rng](const std::string&, const std::string&){ + return std::bernoulli_distribution(0.2)(*rng); + }, + .clear_interrupts = clear, + }); + passes[clear] += sim.run({}).completed ? 1 : 0; + } + } + logger.log(std::format( + "Dialogs before one step in five, {} seeds: {} passed left up, {} cleared between steps.", + SEEDS, passes[0], passes[1] + )); + if (passes[1] != SEEDS){ + return std::format("Expected every run through with the dialogs cleared, {} of {}.", passes[1], SEEDS); + } + return true; +} + + const AutoStorySimulationTestCase AUTO_STORY_SIMULATION_CASES[] = { {.input = {.name = "new game to Brock, in order, each start where the last save stood", .body = test_whole_story}, .expectation = {.description = "runs full story progression from new game through Brock gym"}}, {.input = {.name = "the steps it stubs need the console", .body = nullptr, .logged = test_stubbed_steps_need_the_console}, .expectation = {.description = "stubs each needs-console step of the whole story and plays the rest"}}, @@ -778,6 +838,8 @@ const AutoStorySimulationTestCase AUTO_STORY_SIMULATION_CASES[] = { {.input = {.name = "00.20 onto the grass out of Shane's sight, the leg west a tile short", .body = test_oddish_patch_west_short}, .expectation = {.description = "recovers when approaching grass patch with short western leg"}}, {.input = {.name = "the routes after pacing, from either of its tiles", .body = test_routes_after_pacing}, .expectation = {.description = "plans valid routes regardless of which pacing tile character ends on"}}, {.input = {.name = "a save off the start pose stops before any step", .body = test_save_off_start_pose}, .expectation = {.description = "fails the start check without walking or saving"}}, + {.input = {.name = "a dialog pops up before every step", .body = test_dialog_before_every_step}, .expectation = {.description = "cleared between steps, the story passes; left up, a walk fails on it"}}, + {.input = {.name = "dialogs pop up before one step in five, over 5 seeds", .body = nullptr, .logged = test_dialogs_at_random}, .expectation = {.description = "every run passes with them cleared"}}, }; } // namespace