diff --git a/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.cpp b/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.cpp new file mode 100644 index 0000000000..765baba05f --- /dev/null +++ b/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.cpp @@ -0,0 +1,156 @@ +/* System Keyboard Detector + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" +#include "CommonTools/Images/SolidColorTest.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch_SystemKeyboardDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ + +using namespace std::chrono_literals; + + +// Key grid of the QWERTY layout and where each key sits: rows "1".."z", columns "1".."-". +static const char* const KEY_ROWS[] = {"1234567890-", "qwertyuiop/", "asdfghjkl:'", "zxcvbnm,.?!"}; +static constexpr double KEY_ROW_Y[] = {0.500, 0.593, 0.681, 0.770}; +static constexpr double KEY_COL_X0 = 0.077; +static constexpr double KEY_COL_STEP = 0.075; + + +SystemKeyboardDetector::SystemKeyboardDetector(Color color) + : m_color(color) + , m_key_panel(0.15, 0.555, 0.65, 0.020) + , m_bottom_bar(0.10, 0.935, 0.35, 0.030) + , m_ok_button(0.875, 0.760, 0.045, 0.020) +{} +void SystemKeyboardDetector::make_overlays(VideoOverlaySet& items) const{ + items.add(m_color, m_key_panel); + items.add(m_color, m_bottom_bar); + items.add(m_color, m_ok_button); +} +bool SystemKeyboardDetector::detect(const ImageViewRGB32& screen){ + if (!is_white(extract_box_reference(screen, m_key_panel)) || + !is_white(extract_box_reference(screen, m_bottom_bar)) + ){ + return false; + } + // OK is blue, or flat grey while the field is empty (disabled). + const ImageStats ok = image_stats(extract_box_reference(screen, m_ok_button)); + return is_solid(ok, {0.172, 0.276, 0.552}) || is_grey(ok, 600, 690); +} + + +bool find_system_keyboard_cursor(const ImageViewRGB32& screen, int& row, int& col){ + // The cursor is a cyan border around one key. Neighbouring keys overlap that border, + // so take the centroid of all cyan pixels and map it to the nearest key. + const ImageFloatBox area(0.02, 0.44, 0.84, 0.38); + ImageViewRGB32 keys = extract_box_reference(screen, area); + double count = 0, sum_x = 0, sum_y = 0; + for (size_t y = 0; y < keys.height(); y++){ + for (size_t x = 0; x < keys.width(); x++){ + const uint32_t pixel = keys.pixel(x, y); + const int red = (pixel >> 16) & 0xff; + const int green = (pixel >> 8) & 0xff; + const int blue = pixel & 0xff; + if (red < 150 && green > 200 && blue > 190){ + count++; + sum_x += (double)x; + sum_y += (double)y; + } + } + } + // The border is about 0.15% of the screen; a few stray pixels are not a cursor. + if (count < 0.0005 * screen.width() * screen.height()){ + return false; + } + const double cx = area.x + sum_x / count / keys.width() * area.width; + const double cy = area.y + sum_y / count / keys.height() * area.height; + col = (int)std::lround((cx - KEY_COL_X0) / KEY_COL_STEP); + row = 0; + for (int r = 1; r < 4; r++){ + if (std::abs(KEY_ROW_Y[r] - cy) < std::abs(KEY_ROW_Y[row] - cy)){ + row = r; + } + } + return 0 <= col && col < 11; +} + +bool system_keyboard_field_has_text(const ImageViewRGB32& screen){ + // Left part of the field, after the caret's position on an empty field. + const ImageStats stats = image_stats(extract_box_reference(screen, ImageFloatBox(0.11, 0.235, 0.19, 0.030))); + return stats.stddev.sum() > 30; +} + + +void type_on_system_keyboard(VideoStream& stream, ProControllerContext& context, const std::string& text){ + for (char ch : text){ + const char key = (char)std::tolower((unsigned char)ch); + int target_row = -1; + int target_col = -1; + for (int r = 0; r < 4 && target_row < 0; r++){ + const char* p = std::strchr(KEY_ROWS[r], key); + if (p != nullptr && key != '\0'){ + target_row = r; + target_col = (int)(p - KEY_ROWS[r]); + } + } + if (target_row < 0){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + std::string("System keyboard: no key for '") + ch + "'.", + stream + ); + } + + // A button pressed while the keyboard slides in is ignored, so read the cursor + // again after each move until it is on the target. + bool arrived = false; + for (size_t attempt = 0; attempt < 10 && !arrived; attempt++){ + context.wait_for_all_requests(); + int row = -1; + int col = -1; + VideoSnapshot screen = stream.video().snapshot(); + if (!screen || !find_system_keyboard_cursor(screen, row, col)){ + pbf_wait(context, 300ms); + continue; + } + if (row == target_row && col == target_col){ + arrived = true; + break; + } + for (; row < target_row; row++) pbf_press_dpad(context, DPAD_DOWN, 100ms, 200ms); + for (; row > target_row; row--) pbf_press_dpad(context, DPAD_UP, 100ms, 200ms); + for (; col < target_col; col++) pbf_press_dpad(context, DPAD_RIGHT, 100ms, 200ms); + for (; col > target_col; col--) pbf_press_dpad(context, DPAD_LEFT, 100ms, 200ms); + pbf_wait(context, 300ms); + } + if (!arrived){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + std::string("System keyboard: the cursor did not reach '") + key + "'.", + stream + ); + } + if (std::isupper((unsigned char)ch)){ + pbf_press_button(context, BUTTON_LCLICK, 100ms, 250ms); + } + pbf_press_button(context, BUTTON_A, 80ms, 300ms); + } + context.wait_for_all_requests(); +} + + + +} +} diff --git a/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h b/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h new file mode 100644 index 0000000000..aa23e7ec81 --- /dev/null +++ b/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h @@ -0,0 +1,67 @@ +/* System Keyboard Detector + * + * From: https://github.com/PokemonAutomation/ + * + * The Switch's own software keyboard, which games open for names (e.g. the + * player's name in BDSP): detect it, find the key under its cursor, and type + * a string by moving the cursor and checking where it landed. + * + * Measured on 1920x1080 captures of the QWERTY layout. + * + */ + +#ifndef PokemonAutomation_NintendoSwitch_SystemKeyboardDetector_H +#define PokemonAutomation_NintendoSwitch_SystemKeyboardDetector_H + +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "CommonTools/VisualDetector.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ + + +// White key panel and bottom bar, and the blue OK button. +class SystemKeyboardDetector : public StaticScreenDetector{ +public: + SystemKeyboardDetector(Color color = COLOR_CYAN); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + +private: + Color m_color; + ImageFloatBox m_key_panel; + ImageFloatBox m_bottom_bar; + ImageFloatBox m_ok_button; +}; +class SystemKeyboardWatcher : public DetectorToFinder{ +public: + SystemKeyboardWatcher(Color color = COLOR_CYAN) + : DetectorToFinder("SystemKeyboardWatcher", std::chrono::milliseconds(250), color) + {} +}; + + +// The key under the cursor, as row (0: digits .. 3: "zxcv") and column (0..10). +// The keyboard remembers the cursor between uses, so read it, don't assume it. +// Returns false if no cursor border is visible. +bool find_system_keyboard_cursor(const ImageViewRGB32& screen, int& row, int& col); + +// Whether the text field holds any text. Pressing B on an empty field closes the +// keyboard, so delete only while this is true. +bool system_keyboard_field_has_text(const ImageViewRGB32& screen); + +// Types `text` (letters, digits and the symbols on the QWERTY grid). For each character, +// reads the cursor, moves it with the D-pad and checks it arrived before pressing A; an +// uppercase letter is typed with Shift (left stick click). Throws, with a screenshot, if +// the cursor can't be read or doesn't arrive. +void type_on_system_keyboard(VideoStream& stream, ProControllerContext& context, const std::string& text); + + + +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp index 3df61e3b68..c6f5d497ba 100644 --- a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp @@ -40,8 +40,8 @@ MenuDetector::MenuDetector(Color color, bool skip_new_banner) , m_line4(menu_stripe(4, skip_new_banner)) , m_cross(0.20, 0.15, 0.60, 0.37) , m_panel_top(0.150, 0.100, 0.700, 0.015) - , m_panel_left(0.150, 0.125, 0.020, 0.460) - , m_panel_right(0.835, 0.125, 0.015, 0.460) + , m_panel_left(0.156, 0.125, 0.014, 0.460) + , m_panel_right(0.835, 0.125, 0.009, 0.460) , m_footer(0.050, 0.963, 0.600, 0.025) , m_confirm_button(0.736, 0.960, 0.022, 0.038) , m_confirm_button_with_swap(0.653, 0.960, 0.022, 0.038) @@ -95,7 +95,8 @@ static bool has_menu_button(const ImageViewRGB32& patch){ } bool MenuDetector::detect_menu_chrome(const ImageViewRGB32& screen) const{ - // The surrounding panel remains the same with six or eight unlocked icons. + // The surrounding panel remains the same with two, six or eight unlocked icons. + // Its edges are blue strips, so the side boxes stay inside the white. for (const ImageFloatBox& box : {m_panel_top, m_panel_left, m_panel_right}){ if (!is_white(image_stats(extract_box_reference(screen, box)))){ return false; diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.cpp new file mode 100644 index 0000000000..1ff7438165 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.cpp @@ -0,0 +1,67 @@ +/* Name List Detector + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" +#include "CommonTools/Images/SolidColorTest.h" +#include "PokemonBDSP_SelectionArrow.h" +#include "PokemonBDSP_NameListDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ +// Row centers, as a fraction of the screen height (1080p: 307, 370, 433, ...). +const double FIRST_ROW_Y = 0.284; +const double ROW_STEP_Y = 0.0587; +const int ROWS = 5; +} + + +NameListDetector::NameListDetector(Color color) + : m_color(color) + , m_arrows(0.137, 0.245, 0.030, 0.310) + , m_panel(0.140, 0.245, 0.140, 0.300) +{} +void NameListDetector::make_overlays(VideoOverlaySet& items) const{ + items.add(m_color, m_arrows); + items.add(m_color, m_panel); +} +bool NameListDetector::detect(const ImageViewRGB32& screen){ + return selected_row(screen) >= 0; +} +int NameListDetector::selected_row(const ImageViewRGB32& screen) const{ + // The list is a white panel with a little dark text. A thin margin strip is not + // enough: the border's antialiasing reaches into it on some frames. + const ImageViewRGB32 panel = extract_box_reference(screen, m_panel); + size_t white = 0; + for (size_t y = 0; y < panel.height(); y += 2){ + for (size_t x = 0; x < panel.width(); x += 2){ + const uint32_t p = panel.pixel(x, y); + white += ((p >> 16) & 0xff) > 220 && ((p >> 8) & 0xff) > 220 && (p & 0xff) > 220; + } + } + const size_t samples = ((panel.width() + 1) / 2) * ((panel.height() + 1) / 2); + if (samples == 0 || white < samples * 6 / 10){ + return -1; + } + ImageViewRGB32 area = extract_box_reference(screen, m_arrows); + std::vector arrows = find_selection_arrows(area); + if (arrows.size() != 1){ + return -1; + } + const double center_y = m_arrows.y + + m_arrows.height * (arrows[0].min_y + arrows[0].max_y) / 2.0 / (double)area.height(); + const int row = (int)std::lround((center_y - FIRST_ROW_Y) / ROW_STEP_Y); + return row >= 0 && row < ROWS ? row : -1; +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h new file mode 100644 index 0000000000..59fa1efbeb --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h @@ -0,0 +1,41 @@ +/* Name List Detector + * + * From: https://github.com/PokemonAutomation/ + * + * The list of preset names shown for the rival: "I'll tell you...", then four names. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_NameListDetector_H +#define PokemonAutomation_PokemonBDSP_NameListDetector_H + +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonTools/VisualDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +class NameListDetector : public StaticScreenDetector{ +public: + NameListDetector(Color color = COLOR_MAGENTA); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + + // The row the arrow is on: 0 is "I'll tell you...", 1 the first preset name. + // -1 if the list is not showing. + int selected_row(const ImageViewRGB32& screen) const; + +private: + Color m_color; + ImageFloatBox m_arrows; + ImageFloatBox m_panel; +}; + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h index b8d7347339..4c87fcd2fb 100644 --- a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h @@ -8,6 +8,8 @@ #define PokemonAutomation_PokemonBDSP_SelectionArrow_H #include +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" #include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" #include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" @@ -16,6 +18,9 @@ namespace NintendoSwitch{ namespace PokemonBDSP{ +// The menu selection arrows in `image`, in its pixel coordinates. +std::vector find_selection_arrows(const ImageViewRGB32& image); + class SelectionArrowFinder : public VisualInferenceCallback{ public: SelectionArrowFinder( diff --git a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp index 569d479276..10dd5da86f 100644 --- a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp @@ -18,6 +18,7 @@ #include "Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" #include "PokemonBDSP_Tests.h" #include "Inference/PokemonBDSP_MenuDetector.h" +#include "Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -288,6 +289,7 @@ bool run_bag_screenshot_tests(Logger& logger, const std::string& folder){ void add_tests(UnitTestDatabase& database){ database.add(); add_tests_MenuDetector(database); + add_tests_AutoStory(database); } } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.cpp new file mode 100644 index 0000000000..c27c00b3e2 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.cpp @@ -0,0 +1,112 @@ +/* BDSP AutoStory - Jubilife City tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_JubilifeCity.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace JubilifeCity{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map of Jubilife City (1024 x 772 px): 16 px tiles +// from the top-left corner; the last 4 px rows are left out. Road and paved +// plaza are walkable; buildings, roofs, trees, planters, lamps, signs, fences +// and fountains are blocked. Tiles were classified by colour and only the +// ones on the 00.05 route checked by eye, so some open plaza next to a lamp +// or a pattern line may read as blocked; the planner goes around those, and +// anything the map lacks is found by the route walker. +// The DP layout matches BDSP (console B, 2026-10-28): the Trainers' School +// and the Pokemon Center either side of the main road, the Poke Mart and the +// fountain park north of them, the TV station at the top of the main road +// and the Poketch Company in the north-west. +// NPCs are the 00.05 story people, placed from the console: they stand still. +const TileGrid& grid(){ + // 0 1 2 3 4 5 6 + // 0123456789012345678901234567890123456789012345678901234567890123 + static const TileGrid grid({ + R"(################################################################)", // 0 + R"(################################################################)", // 1 + R"(##########################################......################)", // 2 + R"(##########################################......################)", // 3 + R"(##########################################......################)", // 4 + R"(###########################...##########.......#################)", // 5 + R"(###########################...##########........################)", // 6 + R"(########.##########.#######...##########..#.....################)", // 7 + R"(########.##########.#######...##########.......#################)", // 8 + R"(########.##########.#######....#########..#....#################)", // 9 + R"(########.##########.#######...#####D####..#....#################)", // 10 + R"(########..#########.#######.......N.......#....#################)", // 11 + R"(########............#######...............#....########....#####)", // 12 + R"(########...##N......#######..........##...#....########....#####)", // 13 + R"(######.#####.......#######.....................#######...#######)", // 14 + R"(##........................................#....#...........#####)", // 15 + R"(##...#..................................................#..#####)", // 16 + R"(##...#...###..............###...........................#..#####)", // 17 + R"(##...#..#####.################............#............#########)", // 18 + R"(##############################..###########....#.###...#########)", // 19 + R"(##############################..###########....######..#########)", // 20 + R"(##############################..###########....#######..####.###)", // 21 + R"(##############################..##########.....#################)", // 22 + R"(##############################..###########....#......N#########)", // 23 + R"(########.#####################..#.....##.......#........########)", // 24 + R"(########.#####################.......####............#..####.###)", // 25 + R"(########.#####.#########.######.##########..N.....###..#########)", // 26 + R"(##############..########.######.###########....#######.#########)", // 27 + R"(########.#####.############################....#################)", // 28 + R"(########.#####.############################....#################)", // 29 + R"(#################################.##.##D###....####D############)", // 30 + R"(########.#####..#########.#####...........#....#........########)", // 31 + R"(#############....#######..####.####.....................####.###)", // 32 + R"(#############....##.......########...................#..####.###)", // 33 + R"(##############............###############..........N.N.#########)", // 34 + R"(##############............###############.#............#########)", // 35 + R"(#############.............##############.......#....############)", // 36 + R"(########.#####.###...###.################.#....#...#############)", // 37 + R"(########.#####.###...###.################.#....#...#############)", // 38 + R"(########.#####.###...###.################.#....#...#############)", // 39 + R"(########.#.#.#...........##########.####..#....#...#########.###)", // 40 + R"(#########................##########.....###........#########.###)", // 41 + R"(########.........#.......##########.......#....#...#########.###)", // 42 + R"(########.#.#.#.#..........#########.......#....#...#########.###)", // 43 + R"(#########################################........###############)", // 44 + R"(########################################..........##############)", // 45 + R"(################################################################)", // 46 + R"(################################################################)", // 47 + }); + return grid; +} + +// The top of the stairs from Route 202, where Dawn's scene leaves us: the +// bottom of the main road (columns 43..46), above the steps at row 46. +const std::vector& after_dawn(){ + static const std::vector tiles{{44, 44}, {45, 44}, {46, 44}, {44, 45}, {45, 45}, {46, 45}}; + return tiles; +} + +const TilePos TRAINERS_SCHOOL_DOOR{39, 30}; +const TilePos POKEMON_CENTER_DOOR{51, 30}; +const TilePos TV_STATION_DOOR{35, 10}; + +// On the main road just north of the school and the Center. Talked to from +// the east, facing west (also works from the south). +const TilePos POKETCH_PRESIDENT{44, 26}; +// Below the Poke Mart's planters, where the road meets the plaza. +const TilePos CLOWN_MART{54, 23}; +// West of the TV station door. +const TilePos CLOWN_TV_STATION{34, 11}; +// East of the Poketch Company's sign. Talked to from the south on the +// console; from the east a walk west along row 13 stops right beside him. +const TilePos CLOWN_POKETCH_COMPANY{13, 13}; + + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.h new file mode 100644 index 0000000000..e858f08a8c --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.h @@ -0,0 +1,43 @@ +/* BDSP AutoStory - Jubilife City tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Jubilife City as a tile grid, read from Bulbapedia's DP map, with the + * places and people of checkpoint 00.05. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_JubilifeCity_H +#define PokemonAutomation_PokemonBDSP_AutoStory_JubilifeCity_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace JubilifeCity{ + + +const Pokemon::TileGrid& grid(); + +// Where we may stand after Dawn's scene at the top of the stairs. +const std::vector& after_dawn(); + +// Doors: walk north from the tile below to enter. +extern const Pokemon::TilePos TRAINERS_SCHOOL_DOOR; +extern const Pokemon::TilePos POKEMON_CENTER_DOOR; +extern const Pokemon::TilePos TV_STATION_DOOR; + +// The Poketch Company president and the three coupon clowns. +extern const Pokemon::TilePos POKETCH_PRESIDENT; +extern const Pokemon::TilePos CLOWN_MART; +extern const Pokemon::TilePos CLOWN_TV_STATION; +extern const Pokemon::TilePos CLOWN_POKETCH_COMPANY; + + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.cpp new file mode 100644 index 0000000000..116ee89633 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.cpp @@ -0,0 +1,77 @@ +/* BDSP AutoStory - Oreburgh Mine tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_OreburghMine.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace OreburghMine{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map Oreburgh_Mine_B2F_DP.png with +// devtools/maps/levels.py (--profile cave --height 512 --offset 0,4 +// --objects on --edits oreburgh_mine_b2f_edits.txt). The archive image is +// squashed to 512x395; stretched back to 512x512 its rocks sit on 16 px +// tiles both ways. Rocks, boulders, crates, the coal block, the workers and +// their Machop are blocked. Checked on the console (2026-10-28): the +// corridor's small rocks zigzag a row at a time; the coal block's top edge +// is drawn over the room's second row, which is walkable and is how to pass +// the worker on the first row; Roark stands a row above the bottom row. +const TileGrid& grid(){ + static const TileGrid grid({ + // 0 1 2 3 + R"(################################)", // 0 + R"(################################)", // 1 + R"(################################)", // 2 + R"(###############.################)", // 3 + R"(#############.....##############)", // 4 + R"(#############.....##############)", // 5 + R"(#############.....##############)", // 6 + R"(#############.....##############)", // 7 + R"(#############...################)", // 8 + R"(#############...################)", // 9 + R"(#############.....##############)", // 10 + R"(#############..#..##############)", // 11 + R"(###############...##############)", // 12 + R"(#############...################)", // 13 + R"(#############.....##############)", // 14 + R"(###############...##############)", // 15 + R"(########...####.....##.....#.###)", // 16 + R"(##............................##)", // 17 + R"(##.#..###################.....##)", // 18 + R"(##....###################.....##)", // 19 + R"(##...######################...##)", // 20 + R"(#####.#####################...##)", // 21 + R"(##.########################...##)", // 22 + R"(##..########################..##)", // 23 + R"(##..########################..##)", // 24 + R"(##....#####################...##)", // 25 + R"(#####.#####################...##)", // 26 + R"(####..####################....##)", // 27 + R"(####..####################..####)", // 28 + R"(####....#..#.....#N#........####)", // 29 + R"(######......................####)", // 30 + R"(################################)", // 31 + }); + return grid; +} + +const std::vector& corridor_top(){ + static const std::vector tiles{{14, 4}, {15, 4}, {16, 4}}; + return tiles; +} + +const TilePos ROARK{18, 29}; +const TilePos CORRIDOR_EXIT_START{14, 11}; + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.h new file mode 100644 index 0000000000..48764f46ba --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.h @@ -0,0 +1,39 @@ +/* BDSP AutoStory - Oreburgh Mine tiles + * + * From: https://github.com/PokemonAutomation/ + * + * The Oreburgh Mine's coal room (Bulbapedia's "B2F") as a tile grid, with + * Roark, for checkpoint 01.03. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_OreburghMine_H +#define PokemonAutomation_PokemonBDSP_AutoStory_OreburghMine_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace OreburghMine{ + + +const Pokemon::TileGrid& grid(); + +// Where the walk in from the entrance hall leaves us: the top of the rocky +// corridor, below its timber frame. +const std::vector& corridor_top(); + +// Roark, between two rocks a row above the room's bottom row. Talked to from +// the tile below him, facing north. +extern const Pokemon::TilePos ROARK; +// The corridor's corner a wall-to-wall route ends on, below a straight +// walk north out into the entrance hall. +extern const Pokemon::TilePos CORRIDOR_EXIT_START; + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.cpp new file mode 100644 index 0000000000..1da12ad29c --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.cpp @@ -0,0 +1,111 @@ +/* BDSP AutoStory - Ravaged Path tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_RavagedPath.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace RavagedPath{ + +using namespace Pokemon; + + +// Traced by hand from Bulbapedia's Ravaged_Path_DP.png. The archive image +// is squashed to 528x672; stretched back to 528x864 its tiles sit on 16 px +// with an 8 px offset both ways (rocks and the two door lights centre on +// that grid). Only the lower corridor and the room below its stairs are +// traced; '?' marks where the untraced west chambers continue (they end in +// water). Rocks are '#'; see rocks(). Checked against the hand run of +// 2026-10-29: in from the south door and up the stairs to the corridor, two +// rocks across it east of the stairs, and the way out a tile west of the +// corridor's east end, walking south. +// +// Not yet checked on the console tile by tile: the rows are measured from +// the stretched image, so a row may be off by one. +const TileGrid& grid(){ + static const TileGrid grid({ + // 0 1 2 3 + R"(#################################)", // 0 + R"(#################################)", // 1 + R"(#################################)", // 2 + R"(#################################)", // 3 + R"(#################################)", // 4 + R"(#################################)", // 5 + R"(#################################)", // 6 + R"(#################################)", // 7 + R"(#################################)", // 8 + R"(#################################)", // 9 + R"(#################################)", // 10 + R"(#################################)", // 11 + R"(#################################)", // 12 + R"(#################################)", // 13 + R"(#################################)", // 14 + R"(#################################)", // 15 + R"(#################################)", // 16 + R"(#################################)", // 17 + R"(#################################)", // 18 + R"(#################################)", // 19 + R"(#################################)", // 20 + R"(#################################)", // 21 + R"(#################################)", // 22 + R"(#################################)", // 23 + R"(#################################)", // 24 + R"(#################################)", // 25 + R"(#################################)", // 26 + R"(#################################)", // 27 + R"(#################################)", // 28 + R"(#################################)", // 29 + R"(#################################)", // 30 + R"(#################################)", // 31 + R"(#################################)", // 32 + R"(#################################)", // 33 + R"(#################################)", // 34 + R"(#################################)", // 35 + R"(############??###################)", // 36 + R"(############??###################)", // 37 + R"(###########?.####################)", // 38 + R"(###########?..###################)", // 39 + R"(###########?.....#...############)", // 40 + R"(###########?#....#...############)", // 41 + R"(##################....#.......###)", // 42 + R"(##################....#.......###)", // 43 + R"(##################.....#......###)", // 44 + R"(##################...#######D####)", // 45 + R"(##################...############)", // 46 + R"(##################...############)", // 47 + R"(##################...############)", // 48 + R"(##################...############)", // 49 + R"(##################...############)", // 50 + R"(##################...############)", // 51 + R"(###################D#############)", // 52 + R"(#################################)", // 53 + }); + return grid; +} + +const std::vector& rocks(){ + static const std::vector tiles{ + {13, 38}, {17, 40}, {12, 41}, {22, 42}, {22, 43}, {23, 44}, + }; + return tiles; +} + +const TilePos ENTRANCE{19, 51}; + +const std::vector& before_rocks(){ + static const std::vector tiles{{21, 42}, {21, 43}}; + return tiles; +} + +const TilePos EAST_END{29, 44}; +const TilePos ABOVE_EXIT{28, 44}; + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.h new file mode 100644 index 0000000000..4f03dacf39 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.h @@ -0,0 +1,47 @@ +/* BDSP AutoStory - Ravaged Path tiles + * + * From: https://github.com/PokemonAutomation/ + * + * The Ravaged Path (the cave on Route 204) as a tile grid, for checkpoint + * 01.05: from its south door, where Route 204's south half enters, east + * along its lower corridor to the door out onto Route 204's north half. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_RavagedPath_H +#define PokemonAutomation_PokemonBDSP_AutoStory_RavagedPath_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace RavagedPath{ + + +// With every Rock Smash rock in place (blocked). +const Pokemon::TileGrid& grid(); + +// The Rock Smash rocks of the traced part, blocked in grid(). Each breaks +// for good once smashed, so a plan after a smash opens that tile. +const std::vector& rocks(); + +// Where walking in from Route 204's south half leaves us: the tile above the +// south door, at the foot of the room below the stairs. +extern const Pokemon::TilePos ENTRANCE; +// The tiles west of the two rocks across the lower corridor: from either, +// facing east, A offers Rock Smash, and the corridor is open to its east end. +const std::vector& before_rocks(); +// The corridor's east end on its bottom row, where walls stop a walk east +// and south: a route can end here, and nowhere else near the way out. +extern const Pokemon::TilePos EAST_END; +// The tile above the door out onto Route 204's north half, a tile west of +// EAST_END: walk south. +extern const Pokemon::TilePos ABOVE_EXIT; + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp new file mode 100644 index 0000000000..ec3468b4c6 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp @@ -0,0 +1,67 @@ +/* BDSP AutoStory - Route 201 tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_Route201.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route201{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map of Route 201 (1040 x 320 px). As on the +// Route 202 map, the tile grid starts 8 px in from the left edge: tile +// (x, y) covers pixels [8 + 16x, 24 + 16x) x [16y, 16y + 16). The trees sit +// about 3 px lower on their tiles than on the Route 202 map; tall grass and +// the NPC at the Potion girl fit a grid with no vertical offset. +// +// Trees follow the Route 202 rules: tiles under trunks or mostly under a +// canopy are blocked. Ledges: row 10 west of the tall grass, and (53..56,7) +// in the north-east. On the console the north-east ledge is one row higher +// than it looks on the DP map, as on Route 202; the west one is unchecked. +// West edge, rows 9..12: towards Lake Verity. South gap (14..17, 19): +// Twinleaf Town. East edge, rows 3..6: Sandgem Town. +// Route 201 has no trainers. Its NPCs wander, so they are left off: a +// planned wall must stay put. The Poke Mart lady, who gives 10 Potions, is +// one of them: she roams the north-east tall grass (42..52, 3) and the open +// ground under the trees above it, and eight runs (r113a..r120a) never got +// her talk, so 00.04 buys its Potions in Sandgem instead. +const TileGrid& grid(){ + // 0 1 2 3 4 5 6 + // 0123456789012345678901234567890123456789012345678901234567890123 + static const TileGrid grid({ + R"(################################################################)", // 0 + R"(################################################################)", // 1 + R"(################################################################)", // 2 + R"(##########################################"""""""""""...........)", // 3 + R"(##########################################"""""""""""..S........)", // 4 + R"(##########################################"""""""""##...........)", // 5 + R"(##..####..####....######..##....##..####.."""""""""##...........)", // 6 + R"(##..####..####....######..##....##..####.."""""""""##vvvv.######)", // 7 + R"(##........####............................"""""""""##.....######)", // 8 + R"(....####.................................."""""""""##.....######)", // 9 + R"(vvvv####vvvvvvvvvvvvvvvvvvvvvvvv........S."""""""""##.....######)", // 10 + R"(....####............""""""""""............................######)", // 11 + R"(....................""""""""""............................######)", // 12 + R"(########.....S......##########............................######)", // 13 + R"(##############....############################............######)", // 14 + R"(##############....############################""""""""""""######)", // 15 + R"(##############....##############################################)", // 16 + R"(##############....##############################################)", // 17 + R"(##############....##############################################)", // 18 + R"(##############....##############################################)", // 19 + }); + return grid; +} + + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h new file mode 100644 index 0000000000..607defdb1b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h @@ -0,0 +1,28 @@ +/* BDSP AutoStory - Route 201 tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Route 201 (Twinleaf Town to Sandgem Town) as a tile grid, read from + * Bulbapedia's DP map; BDSP keeps DP's tile layout. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Route201_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Route201_H + +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route201{ + + +const Pokemon::TileGrid& grid(); + + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp new file mode 100644 index 0000000000..b404a62c4b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp @@ -0,0 +1,109 @@ +/* BDSP AutoStory - Route 202 tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_Route202.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route202{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map of Route 202 (592 x 416 px). Its tile grid +// starts 8 px in from the left edge, so tile (x, y) covers pixels +// [8 + 16x, 24 + 16x) x [16y, 16y + 16). The half tiles at both side edges +// are trees and are left out. +// +// Trees: each tile under a trunk or a canopy that hides most of the ground +// is blocked. The thin top of a canopy that only overhangs the row above a +// tree is not. +// Ledges: rows 9 and 18. On the DP map the south ledge looks like row 19, +// but on the console the player stands at (22,19) right under it, beside +// the trees at (21,19), and the sign at (24,20) is one row below. +// The Poke Ball item at (3,6) is blocked. +// The east tree wall comes in to column 31 at rows 15 to 17: walking east +// along row 16 stops at column 30 on the console (r75), and column 30 is +// open north to row 4. +// NPCs stand on the tile under their feet; the sprites rise into the row +// above. The girl at (22,15), pixel (365,235), is a trainer. +const TileGrid& grid(){ + // 0 1 2 3 + // 012345678901234567890123456789012345 + static const TileGrid grid({ + R"(###########........#################)", // 0 + R"(###########........#################)", // 1 + R"(###########........#################)", // 2 + R"(###########........#################)", // 3 + R"(###""""""........S..""""".N....#####)", // 4 + R"(###""""""..........."""""......#####)", // 5 + R"(####"""""..........."""""......#####)", // 6 + R"(###"""""".N...########"""......#####)", // 7 + R"(#########.....########.........#####)", // 8 + R"(#########vvvvv########vvvvvv...""###)", // 9 + R"(#######.......########"""""""""""###)", // 10 + R"(#######N..........####"""""""""""###)", // 11 + R"(###...............####"""""""""""###)", // 12 + R"(###............S..####"""""""""""###)", // 13 + R"(###...............####"""""""""""###)", // 14 + R"(###....##.........####N."""""""#####)", // 15 + R"(###""""##..............."""""""#####)", // 16 + R"(###""""##....................""#####)", // 17 + R"(###""""##vvvvv########vvvvvvvvv#####)", // 18 + R"(###""""##.....########.........#####)", // 19 + R"(###""""""......""""""...S......#####)", // 20 + R"(###""""""......""""""..........#####)", // 21 + R"(###""""""......""""""..........#####)", // 22 + R"(#####################..........#####)", // 23 + R"(#####################..........#####)", // 24 + R"(#########################......#####)", // 25 + }); + return grid; +} + + +// Where 00.03's save after the catch stands: the bottom row of the tall-grass +// patch at (15..20, 20..22), nearest the Sandgem Town entrance at the bottom +// right. On the console the sign at (24,20) is 5 columns right and 2 rows up, +// and walking east stops at the trees after 11 tiles. +const TilePos CATCH_SPOT{19, 22}; +// Where 00.04 saves once it has Potions, before Route 202's trainers (the +// console's saves from r113a..r117a stand here too): under the south ledge, +// against the east trees. +const TilePos SAVE_SPOT{30, 19}; + +// Blue-cap trainer, feet at about (128,186), facing south. +const TilePos TRAINER_A{7, 11}; +// Blue-cap trainer, feet at about (175,125), facing east. +const TilePos TRAINER_B{10, 7}; +// Blue-cap trainer, feet at about (430,77), facing south. +const TilePos TRAINER_C{26, 4}; +// The girl at pixel (365,235), facing south. On the console she challenged +// us at (22,17), walking east along row 17. +const TilePos TRAINER_D{22, 15}; + +// Facing is read from the sprites. The range is a guess; DP trainers on +// early routes see a few tiles. +const std::vector& trainer_sights(){ + static const std::vector sights{ + {"trainer_a", TRAINER_A, Direction::SOUTH, 4}, + {"trainer_b", TRAINER_B, Direction::EAST, 4}, + {"trainer_c", TRAINER_C, Direction::SOUTH, 4}, + {"trainer_d", TRAINER_D, Direction::SOUTH, 4}, + }; + return sights; +} + +// Pixel (256,8): the path in the middle of the 8-tile gap at (11..18, 0). +const TilePos EXIT_JUBILIFE{15, 0}; + + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h new file mode 100644 index 0000000000..f4fd37fac3 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h @@ -0,0 +1,49 @@ +/* BDSP AutoStory - Route 202 tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Route 202 (Sandgem Town to Jubilife City) as a tile grid. BDSP keeps the + * tile layout of DP, so the grid is read from Bulbapedia's DP map. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Route202_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Route202_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route202{ + + +const Pokemon::TileGrid& grid(); + +// Where the player stands in the tall grass after the catching tutorial. +extern const Pokemon::TilePos CATCH_SPOT; +// Under the south ledge, against the east trees: where the console's 00.04 +// save stands. Two walls pin it. +extern const Pokemon::TilePos SAVE_SPOT; + +// The three trainers, south to north. Which one is Tristan, Natalie or +// Logan is not known from the map. +extern const Pokemon::TilePos TRAINER_A; +extern const Pokemon::TilePos TRAINER_B; +extern const Pokemon::TilePos TRAINER_C; +extern const Pokemon::TilePos TRAINER_D; + +// Their sight lines, in this map's tiles. The story wants these battles. +const std::vector& trainer_sights(); + +// A tile in the gap of the north edge; walking north from it leaves for +// Jubilife City. +extern const Pokemon::TilePos EXIT_JUBILIFE; + + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.cpp new file mode 100644 index 0000000000..0cfa65ba3b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.cpp @@ -0,0 +1,81 @@ +/* BDSP AutoStory - Route 204 tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_Route204.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route204{ + +using namespace Pokemon; + + +// Traced by hand from Bulbapedia's Sinnoh_Route_204_DP.png (512x848, 16 px +// tiles, not squashed), rows 0 to 27 of 53: the north half, above the +// cliff the Ravaged Path runs under. Checked against the hand run of +// 2026-10-29: out of the cave onto the sand below the cliff, west to the +// stairs, up them into a pocket closed at the top by a ledge (row 12, 'v', +// only crossed southward), back down to the sand, east and north through +// the tall grass to the flower beds, the twins below the right-hand bed, +// and north between the beds' fences into Floaroma. +// +// Not yet checked tile by tile on the console: the fence and tree edges +// fall on tile boundaries in the image, so a column may be off by one. +// '?' is the way down the cliff to the south half (not traced). +const TileGrid& north_grid(){ + static const TileGrid grid({ + // 0 1 2 3 + R"(#########....###################)", // 0 + R"(#########....###################)", // 1 + R"(#########....#######.###########)", // 2 + R"(#########....#######.###########)", // 3 + R"(#########....#######.###########)", // 4 + R"(#########....#######.###########)", // 5 + R"(#########.....NN.....###########)", // 6 + R"(#...............S....###########)", // 7 + R"(#...#####............###########)", // 8 + R"(##########...........###########)", // 9 + R"(##########..#####...N..#########)", // 10 + R"(##########..######.....#########)", // 11 + R"(##########vv#######""""#########)", // 12 + R"(##########..#######""""#########)", // 13 + R"(##########..#######""""#########)", // 14 + R"(##########..#######""""#########)", // 15 + R"(##########.......N.""""#########)", // 16 + R"(##########...........###########)", // 17 + R"(##########..###......###########)", // 18 + R"(##########..###....#.###########)", // 19 + R"(##########..####################)", // 20 + R"(##########..####################)", // 21 + R"(##########..########D###########)", // 22 + R"(##########....N......###########)", // 23 + R"(####..................##########)", // 24 + R"(####..................##########)", // 25 + R"(##########???###################)", // 26 + R"(################################)", // 27 + }); + return grid; +} + +const TilePos CAVE_EXIT{20, 23}; + +bool in_floaroma_path(const TilePos& pos){ + return pos.x >= 9 && pos.x <= 12 && pos.y >= 6 && pos.y <= 8; +} + +std::vector north_trainer_sights(){ + return { + {"Twins (left)", {14, 6}, Direction::SOUTH, 3}, + {"Twins (right)", {15, 6}, Direction::SOUTH, 3}, + }; +} + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.h new file mode 100644 index 0000000000..5b5af38615 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.h @@ -0,0 +1,39 @@ +/* BDSP AutoStory - Route 204 tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Route 204's north half as a tile grid, for checkpoint 01.05: from the + * door out of the Ravaged Path up to the path into Floaroma Town. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Route204_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Route204_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route204{ + + +const Pokemon::TileGrid& north_grid(); + +// Where leaving the Ravaged Path puts us: the tile below its door. +extern const Pokemon::TilePos CAVE_EXIT; + +// Whether a tile is in the path north into Floaroma, between the flower +// beds' fences: walking north from here leaves the route. +bool in_floaroma_path(const Pokemon::TilePos& pos); + +// The twins below the right-hand flower bed: a double battle when they see +// us walk past below them. +std::vector north_trainer_sights(); + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp new file mode 100644 index 0000000000..6c5a135a49 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp @@ -0,0 +1,99 @@ +/* BDSP AutoStory - Sandgem Town tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_SandgemTown.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace SandgemTown{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map of Sandgem Town (Sandgem_Town_DP.png, +// 560 x 368 px). The tile grid starts 8 px in from the left edge, as on the +// route maps: tile (x, y) covers pixels [8 + 16x, 24 + 16x) x [16y, 16y + 16). +// +// Buildings are blocked; each door is the tile the player walks into, on +// the building's front row. Top left: Professor Rowan's lab, with a fence +// running west along row 6 to the map edge. Top middle: Pokemon Center. +// Top right: Poke Mart. Exits: west, rows 7..11 (Route 201); north, +// (20..29, 0) (Route 202); south, (20..25, 27) (the beach). +// +// Rows 7 and on are the game's own collision, not the map image: Platinum's +// land data for Sandgem (pret/pokeplatinum res/field/maps/data/ +// map_data_007.bin, its permissions' collision bit; grid row = the map's +// row - 4, column = the map's column), with the signs and doors from +// res/field/events/events_sandgem_town.json. The image squeezed rows 7..18 +// into 7..15: it put both houses' doors on row 15, where the game has them +// on row 18, and the east house's wall at rows 10..14 where the game has +// open ground. r123a planned a west leg to that wall on row ~11, ran on +// past it and entered the west house's door going north. The townspeople +// south of the Mart (a youngster and two breeders) wander: not marked. +// +// Checked on the console: walking south from Route 202 (30,22) stops at +// (29,2) above the Poke Mart, and walking west from there stops at (20,2). +// The lab's sign stands on row 7, in front of the fence: walking west along +// row 7 stops at it. +const TileGrid& grid(){ + // 0 1 2 3 + // 0123456789012345678901234567890123 + static const TileGrid grid({ + R"(####################..........####)", // 0 + R"(####################..........####)", // 1 + R"(####################..........####)", // 2 + R"(####################......########)", // 3 + R"(####################......########)", // 4 + R"(####################......########)", // 5 + R"(########D###..###D##S....S#D######)", // 6 + R"(.....S........................####)", // 7 + R"(..............................####)", // 8 + R"(..............................####)", // 9 + R"(..............................####)", // 10 + R"(..............................####)", // 11 + R"(######........................####)", // 12 + R"(######........................####)", // 13 + R"(######.........#####..........####)", // 14 + R"(##########.....#####..........####)", // 15 + R"(##########.....#####..........####)", // 16 + R"(##########.....#####..........####)", // 17 + R"(#######D##....S#D###..S.......####)", // 18 + R"(######........................####)", // 19 + R"(######........................####)", // 20 + R"(######........................####)", // 21 + R"(######....................########)", // 22 + R"(####################......########)", // 23 + R"(####################......########)", // 24 + R"(####################......########)", // 25 + R"(####################......########)", // 26 + R"(####################......########)", // 27 + }); + return grid; +} + + +// Pixel (144,110): the lab's door, between its two front windows. +const TilePos LAB_DOOR{8, 6}; +// Pixel (288,104): the Pokemon Center's glass door. +const TilePos POKEMON_CENTER_DOOR{17, 6}; +// On the console, walking south from (20,2) stops at (20,5), above it. +const TilePos POKEMON_CENTER_SIGN{20, 6}; +// Pixel (448,104): the Poke Mart's door. +const TilePos POKE_MART_DOOR{27, 6}; +// Walking east along the doors' front row stops here, against the trees. +const TilePos FRONT_ROW_EAST_END{29, 7}; + +std::vector below_sign_gap(){ + return {{21, 7}, {22, 7}, {23, 7}, {24, 7}}; +} + + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h new file mode 100644 index 0000000000..7d34f0b783 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h @@ -0,0 +1,46 @@ +/* BDSP AutoStory - Sandgem Town tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Sandgem Town as a tile grid, read from Bulbapedia's DP map; BDSP keeps + * DP's tile layout. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_SandgemTown_H +#define PokemonAutomation_PokemonBDSP_AutoStory_SandgemTown_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace SandgemTown{ + + +const Pokemon::TileGrid& grid(); + +extern const Pokemon::TilePos LAB_DOOR; +extern const Pokemon::TilePos POKEMON_CENTER_DOOR; +// The sign east of the Pokemon Center's door. Walking north from row 10 in +// its column stops on the tile below it. +extern const Pokemon::TilePos POKEMON_CENTER_SIGN; +extern const Pokemon::TilePos POKE_MART_DOOR; +// The east end of the row below the Center's and the Mart's doors, at the +// trees: one known tile to plan the way north to Route 202 from. From the +// Mart's door, a tile either side of its column, no plan north is robust. +extern const Pokemon::TilePos FRONT_ROW_EAST_END; +// The doors' front row below the gap between the Center's sign and the +// Mart's, columns 21..24: the only way north from that row that passes no +// door. Walking north from any of them leaves town for Route 202. Walking +// north from anywhere else on the row between columns 18 and 26 stops at a +// sign or a wall; the Center's door is at 17, the Mart's at 27. +std::vector below_sign_gap(); + + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp new file mode 100644 index 0000000000..f259226ea9 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp @@ -0,0 +1,138 @@ +/* BDSP AutoStory - Story maps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_JubilifeCity.h" +#include "PokemonBDSP_AutoStory_OreburghMine.h" +#include "PokemonBDSP_AutoStory_RavagedPath.h" +#include "PokemonBDSP_AutoStory_Route204.h" +#include "PokemonBDSP_AutoStory_Route201.h" +#include "PokemonBDSP_AutoStory_Route202.h" +#include "PokemonBDSP_AutoStory_SandgemTown.h" +#include "PokemonBDSP_AutoStory_StoryMaps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace Pokemon; + + +const char* const MAP_ROUTE_201 = "Route 201"; +const char* const MAP_SANDGEM_TOWN = "Sandgem Town"; +const char* const MAP_ROUTE_202 = "Route 202"; +const char* const MAP_JUBILIFE_CITY = "Jubilife City"; +const char* const MAP_OREBURGH_MINE = "Oreburgh Mine"; +const char* const MAP_RAVAGED_PATH = "Ravaged Path"; +const char* const MAP_ROUTE_204 = "Route 204"; + + +// Offsets come from how the Bulbapedia maps join: +// - Route 201 to Sandgem Town: the 16 px strip at the right of the Route +// 201 image matches the left of the Sandgem image, which puts Sandgem's +// tile column 0 on Route 201's column 64. Rows are 3 px apart, so the +// row comes from the edges: Sandgem's lab fence (row 6) lines up with +// the fence post at the right of Route 201's row 3, and Route 201's +// open rows 4..6 meet Sandgem's open rows 7..9. +// - Sandgem Town to Route 202: the 8 px strip at the bottom of the Route +// 202 image matches the top of the Sandgem image 16 px to the right, so +// Route 202's column x + 1 sits above Sandgem's column x. Route 202's +// open bottom row (25..30, 25) then meets Sandgem's (24..29, 0), with +// trees beside both. Sandgem's row 0 is taken as the row below Route +// 202's row 25; the 8 px overlap leaves a half-row doubt. +const MapSet& story_maps(){ + static const MapSet maps = []{ + MapSet maps; + maps.add(MAP_ROUTE_201, Route201::grid(), {0, 0}); + maps.add(MAP_SANDGEM_TOWN, SandgemTown::grid(), {64, -3}); + maps.add(MAP_ROUTE_202, Route202::grid(), {63, -29}); + return maps; + }(); + return maps; +} + +const MapSet& jubilife_maps(){ + static const MapSet maps = []{ + MapSet maps; + maps.add(MAP_JUBILIFE_CITY, JubilifeCity::grid(), {0, 0}); + return maps; + }(); + return maps; +} + +const MapSet& oreburgh_mine_maps(){ + static const MapSet maps = []{ + MapSet maps; + maps.add(MAP_OREBURGH_MINE, OreburghMine::grid(), {0, 0}); + return maps; + }(); + return maps; +} + +MapSet ravaged_path_maps(const std::vector& smashed){ + TileGrid grid = RavagedPath::grid(); + for (const TilePos& rock : smashed){ + grid.set(rock, TileType::WALKABLE); + } + MapSet maps; + maps.add(MAP_RAVAGED_PATH, grid, {0, 0}); + return maps; +} + +const MapSet& route204_north_maps(){ + static const MapSet maps = []{ + MapSet maps; + maps.add(MAP_ROUTE_204, Route204::north_grid(), {0, 0}); + return maps; + }(); + return maps; +} + +bool story_map_plannable(const std::string& map){ + return map == MAP_ROUTE_201 || map == MAP_ROUTE_202; +} + +bool story_at_jubilife_exit(const TilePos& world){ + const std::optional tile = story_maps().to_local(world); + return tile && tile->map == MAP_ROUTE_202 && tile->pos.y <= 3; +} + +std::vector story_save_spots(){ + const MapSet& maps = story_maps(); + return { + maps.to_world(MAP_ROUTE_202, Route202::CATCH_SPOT), + maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT), + }; +} + +std::vector story_door_front(const char* map, const TilePos& door){ + const TilePos below = story_maps().to_world(map, door.next(Direction::SOUTH)); + return {below.next(Direction::WEST), below, below.next(Direction::EAST)}; +} + +std::vector story_after_training_area(){ + const TilePos catch_spot = story_maps().to_world(MAP_ROUTE_202, Route202::CATCH_SPOT); + std::vector area; + for (int y = catch_spot.y - 2; y <= catch_spot.y; y++){ + for (int x = catch_spot.x - 1; x <= catch_spot.x + 1; x++){ + area.push_back({x, y}); + } + } + return area; +} + +std::vector story_trainer_sights(){ + std::vector sights = Route202::trainer_sights(); + for (TrainerSight& sight : sights){ + sight.pos = story_maps().to_world(MAP_ROUTE_202, sight.pos); + } + return sights; +} + + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h new file mode 100644 index 0000000000..71779441f1 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h @@ -0,0 +1,75 @@ +/* BDSP AutoStory - Story maps + * + * From: https://github.com/PokemonAutomation/ + * + * The maps the story walks through, joined on one grid. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_StoryMaps_H +#define PokemonAutomation_PokemonBDSP_AutoStory_StoryMaps_H + +#include "Pokemon/Navigation/Pokemon_MapSet.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +extern const char* const MAP_ROUTE_201; +extern const char* const MAP_SANDGEM_TOWN; +extern const char* const MAP_ROUTE_202; +extern const char* const MAP_JUBILIFE_CITY; +extern const char* const MAP_OREBURGH_MINE; +extern const char* const MAP_RAVAGED_PATH; +extern const char* const MAP_ROUTE_204; + +// Route 201, Sandgem Town and Route 202. +const Pokemon::MapSet& story_maps(); + +// Jubilife City on its own: 00.05 starts there after Dawn's scene, so it is +// not joined to Route 202. +const Pokemon::MapSet& jubilife_maps(); +// The Oreburgh Mine's coal room on its own (01.03). +const Pokemon::MapSet& oreburgh_mine_maps(); + +// The Ravaged Path on its own (01.05), with the rocks in `smashed` open. +Pokemon::MapSet ravaged_path_maps(const std::vector& smashed = {}); + +// Route 204's north half on its own (01.05). +const Pokemon::MapSet& route204_north_maps(); + +// Whether a plan may cross this map. Routes are traced from the DP maps and +// their tree walls carried over to BDSP; towns changed more in the remake, so +// a town is crossed by screen-driven legs until the console has confirmed its +// grid. +bool story_map_plannable(const std::string& map); + +// The open rows at the top of Route 202, in world tiles: walking north from +// any of them enters Jubilife City. +bool story_at_jubilife_exit(const Pokemon::TilePos& world); + +// Where the post-catch half of 00.04 may start, in world tiles: the catch +// spot (00.03 saves there after the catch) and the save spot under the +// ledge (00.04 saves there once it has Potions, before Route 202's trainers). +std::vector story_save_spots(); + +// Where a walk out of the door `door` of `map` may leave us, in world tiles: +// the tile below it and its neighbours either side, since the door opening +// is not lined up with one tile (Jubilife's Center, r85). +std::vector story_door_front(const char* map, const Pokemon::TilePos& door); + +// Where we may stand after training in the catch grass, in world tiles: the +// training walks up and down the catch spot's column, between the tree walls +// north and south of the grass, so a battle may stop us on any of its three +// rows. A column either side allows for where the catch left us. +std::vector story_after_training_area(); + +// Route 202's trainers, in world tiles of story_maps(). +std::vector story_trainer_sights(); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp index f4b9f1676b..1e8d650440 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp @@ -1,33 +1,25 @@ - - #include #include +#include "PokemonBDSP_AutoStory.h" +#include "Utils/PokemonBDSP_AutoStory_NewGame.h" + #include "CommonFramework/Globals.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" #include "CommonFramework/Notifications/ProgramNotifications.h" - #include "CommonTools/Async/InferenceRoutines.h" -#include "CommonTools/Options/StringSelectOption.h" #include "CommonTools/VisualDetectors/ImageMatchDetector.h" - #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Programs/NintendoSwitch_GameEntry.h" #include "StorySegments/PokemonBDSP_AutoStory_Segment_00.h" #include "StorySegments/PokemonBDSP_AutoStory_Segment_01.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_02.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_03.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_04.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_05.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_06.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_07.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_08.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_09.h" -#include "PokemonBDSP_AutoStory.h" +#include "CommonTools/Options/StringSelectOption.h" -namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ using namespace std::chrono_literals; @@ -40,14 +32,6 @@ static std::vector> make_segment_list(){ std::vector> segment_list; segment_list.emplace_back(std::make_unique()); segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); return segment_list; } @@ -67,180 +51,11 @@ static std::vector> make_checkpoint_list() checkpoint_list.emplace_back(std::make_unique()); //Segment 01 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - //Segment 02 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - //Segment 03 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 04 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 05 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 06 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 07 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 08 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 09 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - - //checkpoint_list.emplace_back(std::make_unique()); - /*checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique());*/ + checkpoint_list.emplace_back(std::make_unique()); + checkpoint_list.emplace_back(std::make_unique()); + checkpoint_list.emplace_back(std::make_unique()); + checkpoint_list.emplace_back(std::make_unique()); + checkpoint_list.emplace_back(std::make_unique()); return checkpoint_list; } @@ -254,38 +69,20 @@ static const std::vector>& ALL_CHECKPOINTS // Segment database // --------------------------------------------------------------------------- -// Section databases retain global index slugs. Never execute across the -// tutorial/main-story boundary: the starter must be returned from HOME first. -static constexpr size_t TUTORIAL_CHECKPOINT_COUNT = 6; -static const StringSelectDatabase& section_database(bool checkpoints, bool tutorial){ - static const StringSelectDatabase tutorial_segments = [] { +static const StringSelectDatabase& section_database(bool checkpoints){ + static const StringSelectDatabase segments = []{ StringSelectDatabase db; - db.add_entry(StringSelectEntry("0", ALL_SEGMENTS()[0]->name())); - return db; - }(); - static const StringSelectDatabase main_segments = [] { - StringSelectDatabase db; - for (size_t i = 1; i < ALL_SEGMENTS().size(); i++){ + for (size_t i = 0; i < ALL_SEGMENTS().size(); i++) db.add_entry(StringSelectEntry(std::to_string(i), ALL_SEGMENTS()[i]->name())); - } return db; }(); - static const StringSelectDatabase tutorial_checkpoints = [] { + static const StringSelectDatabase checkpoint_db = []{ StringSelectDatabase db; - for (size_t i = 0; i < TUTORIAL_CHECKPOINT_COUNT; i++){ + for (size_t i = 0; i < ALL_CHECKPOINTS().size(); i++) db.add_entry(StringSelectEntry(std::to_string(i), ALL_CHECKPOINTS()[i]->name())); - } return db; }(); - static const StringSelectDatabase main_checkpoints = [] { - StringSelectDatabase db; - for (size_t i = TUTORIAL_CHECKPOINT_COUNT; i < ALL_CHECKPOINTS().size(); i++){ - db.add_entry(StringSelectEntry(std::to_string(i), ALL_CHECKPOINTS()[i]->name())); - } - return db; - }(); - return checkpoints ? (tutorial ? tutorial_checkpoints : main_checkpoints) - : (tutorial ? tutorial_segments : main_segments); + return checkpoints ? checkpoint_db : segments; } // --------------------------------------------------------------------------- @@ -293,20 +90,24 @@ static const StringSelectDatabase& section_database(bool checkpoints, bool tutor // --------------------------------------------------------------------------- BDSPAutoStory_Descriptor::BDSPAutoStory_Descriptor() - : SingleSwitchProgramDescriptor("PokemonBDSP:AutoStory", - "Pokemon BDSP", - "Auto Story", - "Programs/PokemonBDSP/AutoStory.html", - "Automatically completes BDSP story segments.", - PanelQualityStanding::THIRD_PARTY_UNVETTED, - ProgramControllerClass::StandardController_NoRestrictions, - FeedbackType::REQUIRED, - AllowCommandsWhenRunning::DISABLE_COMMANDS, - {"Tesseract"}){ -} - - -std::unique_ptr BDSPAutoStory_Descriptor::make_stats() const { + : SingleSwitchProgramDescriptor( + "PokemonBDSP:AutoStory", + "Pokemon BDSP", + "Auto Story", + "Programs/PokemonBDSP/AutoStory.html", + "Automatically completes BDSP story segments.", + PanelQualityStanding::OFFICIAL_BETA, + ProgramControllerClass::StandardController_NoRestrictions, + FeedbackType::REQUIRED, + AllowCommandsWhenRunning::DISABLE_COMMANDS, + {"Tesseract"} + ) +{} + + + +std::unique_ptr +BDSPAutoStory_Descriptor::make_stats() const{ return std::make_unique(); } @@ -318,16 +119,13 @@ std::unique_ptr BDSPAutoStory_Descriptor::make_stats() const { BDSPAutoStory::~BDSPAutoStory(){ OUTSIDE_HELP_POKEMON.remove_listener(*this); HELP_MODE.remove_listener(*this); - STORY_SECTION.remove_listener(*this); - START_SEGMENT_MAINSTORY.remove_listener(*this); - END_SEGMENT_MAINSTORY.remove_listener(*this); - START_CHECKPOINT_MAINSTORY.remove_listener(*this); - END_CHECKPOINT_MAINSTORY.remove_listener(*this); START_CHECKPOINT_TUTORIAL.remove_listener(*this); END_CHECKPOINT_TUTORIAL.remove_listener(*this); START_SEGMENT.remove_listener(*this); END_SEGMENT.remove_listener(*this); ENABLE_ADVANCED_MODE.remove_listener(*this); + + } @@ -339,40 +137,39 @@ BDSPAutoStory::BDSPAutoStory() : SingleSwitchProgramInstance() // DIAGNOSTIC ONLY: Remove before pushing to production. , DIAGNOSTIC_POST_CATCH_ONLY( - "Diagnostic only: Run 00.04 after catch through 00.05
" - "Start at the saved position immediately after catching the second Pokemon. " - "Applies only in advanced Tutorial mode when Start Checkpoint is 00.04. Runs its post-catch part, then 00.05. Other starting checkpoints run normally. " - "Remove before pushing to production.", - LockMode::LOCK_WHILE_RUNNING, - false) + "Diagnostic only: Run 00.04 after catch through 00.05
" + "Start at the saved position immediately after catching the second Pokemon. " + "Applies only in advanced Tutorial mode when Start Checkpoint is 00.04. Runs its post-catch part, then 00.05. Other starting checkpoints run normally. " + "Remove before pushing to production.", + LockMode::LOCK_WHILE_RUNNING, + false + ) // DIAGNOSTIC ONLY: Remove before pushing to production. , DIAGNOSTIC_FAKE_CHECKPOINT_SAVE( - "Diagnostic only: Fake checkpoint saves
" - "Use the fake save helper instead of normal checkpoint saving. " - "Use an existing in-game save at the selected starting checkpoint or segment. " - "Any failure reloads that save and reruns the entire selected range from its start. " - "Checkpoint saves are not verified in this mode. Remove before pushing to production.", - LockMode::LOCK_WHILE_RUNNING, - false) - , STORY_SECTION("Story Section:", - {{StorySection::TUTORIAL, "tutorial", "Tutorial"}, {StorySection::MAIN_STORY, "main-story", "Main Story"}}, - LockMode::LOCK_WHILE_RUNNING, - StorySection::TUTORIAL) - , MAINSTORY_NOTE("", - "Return your maxed starter from Pokemon HOME before starting Main Story. " - "Use the required moveset and the starting position shown below.") - , ENABLE_ADVANCED_MODE("Advanced mode:
" - "Use checkpoints instead of segments.", - LockMode::UNLOCK_WHILE_RUNNING, - false) - , START_CHECKPOINT_TUTORIAL("Start Checkpoint:", - section_database(true, true), - LockMode::LOCK_WHILE_RUNNING, - "0") - , END_CHECKPOINT_TUTORIAL("End Checkpoint:", - section_database(true, true), - LockMode::UNLOCK_WHILE_RUNNING, - "5") + "Diagnostic only: Fake checkpoint saves
" + "Use the fake save helper instead of normal checkpoint saving. " + "Use an existing in-game save at the selected starting checkpoint or segment. " + "Any failure reloads that save and reruns the entire selected range from its start. " + "Checkpoint saves are not verified in this mode. Remove before pushing to production.", + LockMode::LOCK_WHILE_RUNNING, false) + , ENABLE_ADVANCED_MODE( + "Advanced mode:
" + "Use checkpoints instead of segments.", + LockMode::UNLOCK_WHILE_RUNNING, + false + ) + , START_CHECKPOINT_TUTORIAL( + "Start Checkpoint:", + section_database(true), + LockMode::LOCK_WHILE_RUNNING, + "0" + ) + , END_CHECKPOINT_TUTORIAL( + "End Checkpoint:", + section_database(true), + LockMode::UNLOCK_WHILE_RUNNING, + "5" + ) , START_DESCRIPTION_SEGMENT("Start:
", "") , END_DESCRIPTION_SEGMENT("End:
", "") @@ -380,135 +177,155 @@ BDSPAutoStory::BDSPAutoStory() , START_DESCRIPTION_CHECKPOINT("Start:
", "") , END_DESCRIPTION_CHECKPOINT("End:
", "") - , START_CHECKPOINT_MAINSTORY("Start Checkpoint (Main):", - section_database(true, false), + , START_SEGMENT( + "Start Segment:", + section_database(false), LockMode::LOCK_WHILE_RUNNING, - "6") + "0" + ) - , END_CHECKPOINT_MAINSTORY("End Checkpoint (Main):", - section_database(true, false), + , END_SEGMENT( + "End Segment:", + section_database(false), LockMode::UNLOCK_WHILE_RUNNING, - std::to_string(ALL_CHECKPOINTS().size() - 1)) - - , START_SEGMENT("Start Segment:", section_database(false, true), LockMode::LOCK_WHILE_RUNNING, "0") - - , END_SEGMENT("End Segment:", section_database(false, true), LockMode::UNLOCK_WHILE_RUNNING, "0") - - , START_SEGMENT_MAINSTORY("Start Segment:", - section_database(false, false), - LockMode::LOCK_WHILE_RUNNING, - "1") - , END_SEGMENT_MAINSTORY("End Segment:", - section_database(false, false), - LockMode::LOCK_WHILE_RUNNING, - std::to_string(ALL_SEGMENTS().size() - 1)) - , STARTERCHOICE("Starter Pokemon:", - { - {StarterChoice::TURTWIG, "turtwig", "Turtwig"}, - {StarterChoice::CHIMCHAR, "chimchar", "Chimchar"}, - {StarterChoice::PIPLUP, "piplup", "Piplup"}, - }, - LockMode::LOCK_WHILE_RUNNING, - StarterChoice::CHIMCHAR) - - , STARTER_SHINY("Shiny Starter:", LockMode::LOCK_WHILE_RUNNING, false) - - , CATCH_LEGENDARY("Catch Box Legendary:", LockMode::LOCK_WHILE_RUNNING, false) - - , HELP_MODE("Help Mode:", - { - {HelpMode::NO_OUTSIDE_HELP, "no_outside_help", "No Outside Help"}, - {HelpMode::OUTSIDE_HELP, "outside_help", "Outside Help"}, - }, - LockMode::LOCK_WHILE_RUNNING, - HelpMode::NO_OUTSIDE_HELP) - - , OUTSIDE_HELP_POKEMON("Pokemon:", - { - {OutsideHelpPokemon::TORTERRA, "torterra", "Torterra"}, - }, - LockMode::LOCK_WHILE_RUNNING, - OutsideHelpPokemon::TORTERRA) - - , OUTSIDE_HELP_ATTACK_1("Attack 1:", - { - {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, - {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, - {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, - {TorterraMove::CRUNCH, "crunch", "Crunch"}, - {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, - {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, - {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, - {TorterraMove::FACADE, "facade", "Facade"}, - {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, - {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, - {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, - {TorterraMove::STRENGTH, "strength", "Strength"}, - {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, - {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, - }, - LockMode::LOCK_WHILE_RUNNING, - TorterraMove::EARTHQUAKE) - - , OUTSIDE_HELP_ATTACK_2("Attack 2:", - { - {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, - {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, - {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, - {TorterraMove::CRUNCH, "crunch", "Crunch"}, - {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, - {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, - {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, - {TorterraMove::FACADE, "facade", "Facade"}, - {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, - {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, - {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, - {TorterraMove::STRENGTH, "strength", "Strength"}, - {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, - {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, - }, - LockMode::LOCK_WHILE_RUNNING, - TorterraMove::ROCK_SLIDE) - - , OUTSIDE_HELP_ATTACK_3("Attack 3:", - { - {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, - {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, - {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, - {TorterraMove::CRUNCH, "crunch", "Crunch"}, - {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, - {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, - {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, - {TorterraMove::FACADE, "facade", "Facade"}, - {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, - {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, - {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, - {TorterraMove::STRENGTH, "strength", "Strength"}, - {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, - {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, - }, - LockMode::LOCK_WHILE_RUNNING, - TorterraMove::WOOD_HAMMER) - - , OUTSIDE_HELP_ATTACK_4("Attack 4:", - { - {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, - {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, - {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, - {TorterraMove::CRUNCH, "crunch", "Crunch"}, - {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, - {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, - {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, - {TorterraMove::FACADE, "facade", "Facade"}, - {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, - {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, - {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, - {TorterraMove::STRENGTH, "strength", "Strength"}, - {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, - {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, - }, - LockMode::LOCK_WHILE_RUNNING, - TorterraMove::CRUNCH) + "0" + ) + + , NEW_GAME_INTRO( + "Start a new game first:
" + "For a game with no save: restart it and play the intro (language, appearance, names) " + "until the player is in control in the bedroom. Runs before the selected range.", + LockMode::LOCK_WHILE_RUNNING, false) + , PLAYER_NAME(false, "Player name:
For a new game. Letters and digits.", LockMode::LOCK_WHILE_RUNNING, "Kosmos", "Kosmos") + , STARTERCHOICE( + "Starter Pokemon:", + { + {StarterChoice::TURTWIG, "turtwig", "Turtwig"}, + {StarterChoice::CHIMCHAR, "chimchar", "Chimchar"}, + {StarterChoice::PIPLUP, "piplup", "Piplup"}, + }, + LockMode::LOCK_WHILE_RUNNING, + StarterChoice::CHIMCHAR + ) + + , STARTER_SHINY( + "Shiny Starter:", + LockMode::LOCK_WHILE_RUNNING, + false + ) + + , CATCH_LEGENDARY( + "Catch Box Legendary:", + LockMode::LOCK_WHILE_RUNNING, + false + ) + + , HELP_MODE( + "Help Mode:", + { + {HelpMode::NO_OUTSIDE_HELP, "no_outside_help", "No Outside Help"}, + {HelpMode::OUTSIDE_HELP, "outside_help", "Outside Help"}, + }, + LockMode::LOCK_WHILE_RUNNING, + HelpMode::NO_OUTSIDE_HELP + ) + + , OUTSIDE_HELP_POKEMON( + "Pokemon:", + { + {OutsideHelpPokemon::TORTERRA, "torterra", "Torterra"}, + }, + LockMode::LOCK_WHILE_RUNNING, + OutsideHelpPokemon::TORTERRA + ) + + , OUTSIDE_HELP_ATTACK_1( + "Attack 1:", + { + {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, + {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, + {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, + {TorterraMove::CRUNCH, "crunch", "Crunch"}, + {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, + {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, + {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, + {TorterraMove::FACADE, "facade", "Facade"}, + {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, + {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, + {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, + {TorterraMove::STRENGTH, "strength", "Strength"}, + {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, + {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, + }, + LockMode::LOCK_WHILE_RUNNING, + TorterraMove::EARTHQUAKE + ) + + , OUTSIDE_HELP_ATTACK_2( + "Attack 2:", + { + {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, + {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, + {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, + {TorterraMove::CRUNCH, "crunch", "Crunch"}, + {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, + {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, + {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, + {TorterraMove::FACADE, "facade", "Facade"}, + {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, + {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, + {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, + {TorterraMove::STRENGTH, "strength", "Strength"}, + {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, + {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, + }, + LockMode::LOCK_WHILE_RUNNING, + TorterraMove::ROCK_SLIDE + ) + + , OUTSIDE_HELP_ATTACK_3( + "Attack 3:", + { + {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, + {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, + {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, + {TorterraMove::CRUNCH, "crunch", "Crunch"}, + {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, + {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, + {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, + {TorterraMove::FACADE, "facade", "Facade"}, + {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, + {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, + {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, + {TorterraMove::STRENGTH, "strength", "Strength"}, + {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, + {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, + }, + LockMode::LOCK_WHILE_RUNNING, + TorterraMove::WOOD_HAMMER + ) + + , OUTSIDE_HELP_ATTACK_4( + "Attack 4:", + { + {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, + {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, + {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, + {TorterraMove::CRUNCH, "crunch", "Crunch"}, + {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, + {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, + {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, + {TorterraMove::FACADE, "facade", "Facade"}, + {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, + {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, + {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, + {TorterraMove::STRENGTH, "strength", "Strength"}, + {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, + {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, + }, + LockMode::LOCK_WHILE_RUNNING, + TorterraMove::CRUNCH + ) , NOTIFICATION_STATUS_UPDATE("Status Update", true, false, std::chrono::seconds(30)) , NOTIFICATIONS({ @@ -525,29 +342,25 @@ BDSPAutoStory::BDSPAutoStory() PA_ADD_OPTION(DIAGNOSTIC_POST_CATCH_ONLY); PA_ADD_OPTION(DIAGNOSTIC_FAKE_CHECKPOINT_SAVE); - PA_ADD_OPTION(STORY_SECTION); - PA_ADD_OPTION(MAINSTORY_NOTE); PA_ADD_OPTION(ENABLE_ADVANCED_MODE); // Segment UI PA_ADD_OPTION(START_SEGMENT); - PA_ADD_OPTION(START_SEGMENT_MAINSTORY); PA_ADD_OPTION(START_DESCRIPTION_SEGMENT); PA_ADD_OPTION(END_SEGMENT); - PA_ADD_OPTION(END_SEGMENT_MAINSTORY); PA_ADD_OPTION(END_DESCRIPTION_SEGMENT); // Checkpoint UI PA_ADD_OPTION(START_CHECKPOINT_TUTORIAL); - PA_ADD_OPTION(START_CHECKPOINT_MAINSTORY); PA_ADD_OPTION(START_DESCRIPTION_CHECKPOINT); PA_ADD_OPTION(END_CHECKPOINT_TUTORIAL); - PA_ADD_OPTION(END_CHECKPOINT_MAINSTORY); PA_ADD_OPTION(END_DESCRIPTION_CHECKPOINT); + PA_ADD_OPTION(NEW_GAME_INTRO); + PA_ADD_OPTION(PLAYER_NAME); PA_ADD_OPTION(STARTERCHOICE); PA_ADD_OPTION(STARTER_SHINY); PA_ADD_OPTION(CATCH_LEGENDARY); @@ -569,36 +382,27 @@ BDSPAutoStory::BDSPAutoStory() ENABLE_ADVANCED_MODE.add_listener(*this); START_SEGMENT.add_listener(*this); END_SEGMENT.add_listener(*this); - STORY_SECTION.add_listener(*this); - START_SEGMENT_MAINSTORY.add_listener(*this); - END_SEGMENT_MAINSTORY.add_listener(*this); - START_CHECKPOINT_MAINSTORY.add_listener(*this); - END_CHECKPOINT_MAINSTORY.add_listener(*this); on_config_value_changed(nullptr); } void BDSPAutoStory::update_ui_state(){ const bool advanced = ENABLE_ADVANCED_MODE; - const bool tutorial = STORY_SECTION == StorySection::TUTORIAL; auto visible = [](ConfigOption& option, bool show){ option.set_visibility(show ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); }; - visible(START_SEGMENT, !advanced && tutorial); - visible(END_SEGMENT, !advanced && tutorial); - visible(START_SEGMENT_MAINSTORY, !advanced && !tutorial); - visible(END_SEGMENT_MAINSTORY, !advanced && !tutorial); - visible(START_CHECKPOINT_TUTORIAL, advanced && tutorial); - visible(END_CHECKPOINT_TUTORIAL, advanced && tutorial); - visible(START_CHECKPOINT_MAINSTORY, advanced && !tutorial); - visible(END_CHECKPOINT_MAINSTORY, advanced && !tutorial); + visible(START_SEGMENT, !advanced); + visible(END_SEGMENT, !advanced); + visible(START_CHECKPOINT_TUTORIAL, advanced); + visible(END_CHECKPOINT_TUTORIAL, advanced); visible(START_DESCRIPTION_SEGMENT, !advanced); visible(END_DESCRIPTION_SEGMENT, !advanced); visible(START_DESCRIPTION_CHECKPOINT, advanced); visible(END_DESCRIPTION_CHECKPOINT, advanced); - visible(MAINSTORY_NOTE, !tutorial); bool show_help = ((HelpMode)HELP_MODE == HelpMode::OUTSIDE_HELP); - OUTSIDE_HELP_POKEMON.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); + OUTSIDE_HELP_POKEMON.set_visibility( + show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN + ); OUTSIDE_HELP_ATTACK_1.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); OUTSIDE_HELP_ATTACK_2.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); @@ -606,31 +410,38 @@ void BDSPAutoStory::update_ui_state(){ OUTSIDE_HELP_ATTACK_4.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); } void BDSPAutoStory::on_config_value_changed(void*){ - const bool tutorial = STORY_SECTION == StorySection::TUTORIAL; if (ENABLE_ADVANCED_MODE){ const auto& checkpoints = ALL_CHECKPOINTS(); - size_t start = std::stoul((tutorial ? START_CHECKPOINT_TUTORIAL : START_CHECKPOINT_MAINSTORY).slug()); - size_t end = std::stoul((tutorial ? END_CHECKPOINT_TUTORIAL : END_CHECKPOINT_MAINSTORY).slug()); + size_t start = std::stoul(START_CHECKPOINT_TUTORIAL.slug()); + size_t end = std::stoul(END_CHECKPOINT_TUTORIAL.slug()); if (start < checkpoints.size()){ - START_DESCRIPTION_CHECKPOINT.set_text("Start: " + checkpoints[start]->start_text()); + START_DESCRIPTION_CHECKPOINT.set_text( + "Start: " + checkpoints[start]->start_text() + ); } if (end < checkpoints.size()){ - END_DESCRIPTION_CHECKPOINT.set_text("End: " + checkpoints[end]->end_text()); + END_DESCRIPTION_CHECKPOINT.set_text( + "End: " + checkpoints[end]->end_text() + ); } }else{ const auto& segments = ALL_SEGMENTS(); - size_t start = std::stoul((tutorial ? START_SEGMENT : START_SEGMENT_MAINSTORY).slug()); - size_t end = std::stoul((tutorial ? END_SEGMENT : END_SEGMENT_MAINSTORY).slug()); + size_t start = std::stoul(START_SEGMENT.slug()); + size_t end = std::stoul(END_SEGMENT.slug()); if (start < segments.size()){ - START_DESCRIPTION_SEGMENT.set_text("Start: " + segments[start]->start_text()); + START_DESCRIPTION_SEGMENT.set_text( + "Start: " + segments[start]->start_text() + ); } if (end < segments.size()){ - END_DESCRIPTION_SEGMENT.set_text("End: " + segments[end]->end_text()); + END_DESCRIPTION_SEGMENT.set_text( + "End: " + segments[end]->end_text() + ); } } @@ -640,59 +451,86 @@ void BDSPAutoStory::on_config_value_changed(void*){ // Program logic // --------------------------------------------------------------------------- -void BDSPAutoStory::program(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ +void BDSPAutoStory::program( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context +){ AutoStoryStats& stats = env.current_stats(); - AutoStoryOptions options{Language::English, + AutoStoryOptions options{ + Language::English, STARTERCHOICE, NOTIFICATION_STATUS_UPDATE, (HelpMode)HELP_MODE, - OutsideHelpConfig{(OutsideHelpPokemon)OUTSIDE_HELP_POKEMON, + OutsideHelpConfig{ + (OutsideHelpPokemon)OUTSIDE_HELP_POKEMON, { (TorterraMove)OUTSIDE_HELP_ATTACK_1, (TorterraMove)OUTSIDE_HELP_ATTACK_2, (TorterraMove)OUTSIDE_HELP_ATTACK_3, (TorterraMove)OUTSIDE_HELP_ATTACK_4, - }}, - DIAGNOSTIC_FAKE_CHECKPOINT_SAVE ? CheckpointSaveMode::FAKE : CheckpointSaveMode::REAL}; + } + }, + DIAGNOSTIC_FAKE_CHECKPOINT_SAVE ? CheckpointSaveMode::FAKE : CheckpointSaveMode::REAL + }; const bool fake_saves = options.checkpoint_save_mode == CheckpointSaveMode::FAKE; - if (fake_saves){ - options.checkpoint_retry_scope = CheckpointRetryScope::DEFER_TO_SELECTED_RANGE; - } + if (fake_saves) options.checkpoint_retry_scope = CheckpointRetryScope::DEFER_TO_SELECTED_RANGE; + // Logs what a checkpoint is for before it runs, and that it finished after. + auto run_checkpoint_logged = [&](size_t index, const AutoStory_Checkpoint& checkpoint){ + stats.m_current = index; + env.update_stats(); + env.log("[AutoStory] Objective: " + checkpoint.name() + ". Ends: " + checkpoint.end_text(), COLOR_BLUE); + add_wrapped_log(env.console.overlay(), "Now: " + checkpoint.name(), COLOR_WHITE); + add_wrapped_log(env.console.overlay(), "Goal: " + checkpoint.end_text(), COLOR_GRAY); + checkpoint.run_checkpoint(env, context, options, stats); + stats.m_completed++; + stats.m_last_completed = index; + env.update_stats(); + env.log("[AutoStory] Completed: " + checkpoint.name() + ".", COLOR_DARKGREEN); + add_wrapped_log(env.console.overlay(), "Done: " + checkpoint.name(), COLOR_GREEN); + }; auto run_selected_range = [&](size_t attempt){ - if (attempt > 0){ - env.log("[Fake saves] Reloaded initial save; restarting the entire selected range from its start."); - } + if (attempt > 0) env.log("[Fake saves] Reloaded initial save; restarting the entire selected range from its start."); // DIAGNOSTIC ONLY: Remove this branch before pushing to production. - if (DIAGNOSTIC_POST_CATCH_ONLY && ENABLE_ADVANCED_MODE && STORY_SECTION == StorySection::TUTORIAL && + if (DIAGNOSTIC_POST_CATCH_ONLY && ENABLE_ADVANCED_MODE && START_CHECKPOINT_TUTORIAL.slug() == "4"){ env.log("Diagnostic only: Running checkpoint 00.04 after catch."); + stats.m_current = 4; + env.update_stats(); + env.log("[AutoStory] Objective: " + AutoStory_Checkpoint_0004().name() + " (after the catch). Ends: " + + AutoStory_Checkpoint_0004().end_text(), COLOR_BLUE); + add_wrapped_log(env.console.overlay(), "Now: " + AutoStory_Checkpoint_0004().name() + " (after the catch)", COLOR_WHITE); + add_wrapped_log(env.console.overlay(), "Goal: " + AutoStory_Checkpoint_0004().end_text(), COLOR_GRAY); diagnostic_checkpoint_0004_after_catch(env, context, options, stats); + stats.m_completed++; + stats.m_last_completed = 4; + env.update_stats(); + env.log("[AutoStory] Completed: " + AutoStory_Checkpoint_0004().name() + ".", COLOR_DARKGREEN); env.log("Diagnostic continuation: Running checkpoint 00.05."); - AutoStory_Checkpoint_0005().run_checkpoint(env, context, options, stats); + run_checkpoint_logged(5, AutoStory_Checkpoint_0005()); return; } - const bool tutorial = STORY_SECTION == StorySection::TUTORIAL; const auto& segments = ALL_SEGMENTS(); if (ENABLE_ADVANCED_MODE){ - size_t start = std::stoul((tutorial ? START_CHECKPOINT_TUTORIAL : START_CHECKPOINT_MAINSTORY).slug()); - size_t end = std::stoul((tutorial ? END_CHECKPOINT_TUTORIAL : END_CHECKPOINT_MAINSTORY).slug()); + size_t start = std::stoul(START_CHECKPOINT_TUTORIAL.slug()); + size_t end = std::stoul(END_CHECKPOINT_TUTORIAL.slug()); - env.log("Starting AutoStory from checkpoint " + std::to_string(start) + " to " + std::to_string(end)); + env.log("Starting AutoStory from checkpoint " + std::to_string(start) + + " to " + std::to_string(end)); const auto& checkpoints = ALL_CHECKPOINTS(); for (size_t i = start; i <= end && i < checkpoints.size(); i++){ env.log("Running Checkpoint: " + std::to_string(i)); - checkpoints[i]->run_checkpoint(env, context, options, stats); + run_checkpoint_logged(i, *checkpoints[i]); } }else{ - size_t start = std::stoul((tutorial ? START_SEGMENT : START_SEGMENT_MAINSTORY).slug()); - size_t end = std::stoul((tutorial ? END_SEGMENT : END_SEGMENT_MAINSTORY).slug()); + size_t start = std::stoul(START_SEGMENT.slug()); + size_t end = std::stoul(END_SEGMENT.slug()); for (size_t i = start; i <= end && i < segments.size(); i++){ env.log("Running Segment: " + std::to_string(i)); @@ -700,23 +538,35 @@ void BDSPAutoStory::program(SingleSwitchProgramEnvironment& env, ProControllerCo } } }; - if (fake_saves){ - checkpoint_reattempt_loop(env, - context, - options.notif_status_update, - stats, - std::move(run_selected_range), - true, - CheckpointSaveMode::FAKE, - CheckpointRetryScope::SELECTED_RANGE); - }else{ - run_selected_range(0); + try{ + if (NEW_GAME_INTRO){ + play_new_game_intro(env, context, PLAYER_NAME); + } + if (fake_saves){ + checkpoint_reattempt_loop(env, context, options.notif_status_update, stats, + std::move(run_selected_range), true, CheckpointSaveMode::FAKE, + CheckpointRetryScope::SELECTED_RANGE); + }else{ + run_selected_range(0); + } + }catch (OperationFailedException&){ + // Leave the Switch in a known state: on HOME with the game closed, so the next + // run (or the user) starts from there. The error already holds its screenshot. + env.log("AutoStory failed: going to HOME and closing the game.", COLOR_RED); + try{ + go_home(env.console, context); + close_game_from_home(env.console, context); + }catch (OperationFailedException& e){ + env.log("Could not close the game: " + e.message(), COLOR_RED); + } + throw; } send_program_finished_notification(env, NOTIFICATION_PROGRAM_FINISH); } + } } } \ No newline at end of file diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h index 541918b1c0..2ef3dc412d 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h @@ -2,29 +2,20 @@ #include #include - #include "Common/Cpp/Options/BooleanCheckBoxOption.h" #include "Common/Cpp/Options/EnumDropdownOption.h" #include "Common/Cpp/Options/StaticTextOption.h" - +#include "Common/Cpp/Options/StringOption.h" #include "CommonFramework/Notifications/EventNotificationsTable.h" - #include "CommonTools/Options/StringSelectOption.h" - #include "NintendoSwitch/NintendoSwitch_SingleSwitchProgram.h" - #include "PokemonBDSP_AutoStoryTools.h" -namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - -enum class StorySection { - TUTORIAL, - MAIN_STORY -}; +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ -class BDSPAutoStory_Descriptor : public SingleSwitchProgramDescriptor { +class BDSPAutoStory_Descriptor : public SingleSwitchProgramDescriptor{ public: BDSPAutoStory_Descriptor(); @@ -32,26 +23,26 @@ class BDSPAutoStory_Descriptor : public SingleSwitchProgramDescriptor { }; -class BDSPAutoStory : public SingleSwitchProgramInstance, public ConfigOption::Listener { +class BDSPAutoStory : public SingleSwitchProgramInstance, public ConfigOption::Listener{ public: using Descriptor = BDSPAutoStory_Descriptor; ~BDSPAutoStory(); BDSPAutoStory(); - virtual void program(SingleSwitchProgramEnvironment& env, ProControllerContext& context) override; + virtual void program( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context + ) override; private: virtual void on_config_value_changed(void* object) override; void update_ui_state(); - private: // DIAGNOSTIC ONLY: Remove before pushing to production. BooleanCheckBoxOption DIAGNOSTIC_POST_CATCH_ONLY; // DIAGNOSTIC ONLY: Remove before pushing to production. BooleanCheckBoxOption DIAGNOSTIC_FAKE_CHECKPOINT_SAVE; - EnumDropdownOption STORY_SECTION; - StaticTextOption MAINSTORY_NOTE; BooleanCheckBoxOption ENABLE_ADVANCED_MODE; StringSelectOption START_CHECKPOINT_TUTORIAL; @@ -62,22 +53,20 @@ class BDSPAutoStory : public SingleSwitchProgramInstance, public ConfigOption::L StaticTextOption START_DESCRIPTION_CHECKPOINT; StaticTextOption END_DESCRIPTION_CHECKPOINT; - StringSelectOption START_CHECKPOINT_MAINSTORY; - StringSelectOption END_CHECKPOINT_MAINSTORY; StringSelectOption START_SEGMENT; StringSelectOption END_SEGMENT; - StringSelectOption START_SEGMENT_MAINSTORY; - StringSelectOption END_SEGMENT_MAINSTORY; - EnumDropdownOption STARTERCHOICE; - BooleanCheckBoxOption STARTER_SHINY; - BooleanCheckBoxOption CATCH_LEGENDARY; - EnumDropdownOption HELP_MODE; + BooleanCheckBoxOption NEW_GAME_INTRO; + StringOption PLAYER_NAME; + EnumDropdownOption STARTERCHOICE; + BooleanCheckBoxOption STARTER_SHINY; + BooleanCheckBoxOption CATCH_LEGENDARY; + EnumDropdownOption HELP_MODE; EnumDropdownOption OUTSIDE_HELP_POKEMON; - EnumDropdownOption OUTSIDE_HELP_ATTACK_1; - EnumDropdownOption OUTSIDE_HELP_ATTACK_2; - EnumDropdownOption OUTSIDE_HELP_ATTACK_3; - EnumDropdownOption OUTSIDE_HELP_ATTACK_4; + EnumDropdownOption OUTSIDE_HELP_ATTACK_1; + EnumDropdownOption OUTSIDE_HELP_ATTACK_2; + EnumDropdownOption OUTSIDE_HELP_ATTACK_3; + EnumDropdownOption OUTSIDE_HELP_ATTACK_4; EventNotificationOption NOTIFICATION_STATUS_UPDATE; EventNotificationsOption NOTIFICATIONS; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp index 62de9bebf9..29f1a82c89 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp @@ -1,6 +1,7 @@ - - - +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/PrettyPrint.h" /* BDSP AutoStory Tools * * From: https://github.com/PokemonAutomation/ @@ -45,6 +46,7 @@ #include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" #include "Detect/PokemonBDSP_AutoStory_OverworldDetector.h" +#include "Utils/PokemonBDSP_AutoStory_Location.h" #include "PokemonBDSP_AutoStoryTools.h" @@ -107,10 +109,77 @@ bool fake_save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& c return true; } -bool save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ +void add_wrapped_log(VideoOverlay& overlay, const std::string& text, Color color){ + // The log box is 35% of the video wide at a font 4% of its height: about + // 34 characters. + constexpr size_t WIDTH = 34; + std::string line; + size_t start = 0; + while (start < text.size()){ + size_t end = text.find(' ', start); + if (end == std::string::npos){ + end = text.size(); + } + const std::string word = text.substr(start, end - start); + if (!line.empty() && line.size() + 1 + word.size() > WIDTH){ + overlay.add_log(line, color); + line = " "; + } + line += (line.empty() || line == " " ? "" : " ") + word; + start = end + 1; + } + if (!line.empty()){ + overlay.add_log(line, color); + } +} + +// Opens the save prompt from the overworld and keeps a screenshot of it: it +// shows the play time and badges, a record of where each checkpoint save +// stands. Returns the map its card names. +static std::string open_save_prompt(VideoStream& stream, ProControllerContext& context){ + overworld_to_menu(stream, context); + pbf_press_button(context, BUTTON_R, 80ms, 2000ms); + context.wait_for_all_requests(); + VideoSnapshot screen = stream.video().snapshot(); + if (!screen){ + return ""; + } + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/saves/"; + Filesystem::create_directories(Filesystem::Path(folder)); + const std::string path = folder + now_to_filestring() + ".png"; + screen.frame->save(path); + stream.log("Save prompt screenshot: " + path); + const std::string location = read_save_screen_location(*screen.frame); + stream.log("Save screen location: \"" + location + "\"."); + return location; +} + +// Backs out of the save prompt and its menu to the overworld. +static void leave_save_prompt(VideoStream& stream, ProControllerContext& context){ + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + OverworldWatcher overworld; + if (wait_until(stream, context, std::chrono::seconds(15), {overworld}) < 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Failed to detect the overworld after leaving the save prompt.", stream); + } +} + +std::string read_location_from_save_screen(VideoStream& stream, ProControllerContext& context){ + const std::string location = open_save_prompt(stream, context); + leave_save_prompt(stream, context); + return location; +} + +bool save_game( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const std::string& expected_location +) { static constexpr size_t MAX_RETRIES = 3; - for (size_t attempt = 0; attempt < MAX_RETRIES; attempt++){ + std::string wrong_place; + for (size_t attempt = 0; attempt < MAX_RETRIES && wrong_place.empty(); attempt++){ try { env.console.log("Checkpoint save attempt: " + std::to_string(attempt + 1)); @@ -120,9 +189,16 @@ bool save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& contex pbf_mash_button(context, BUTTON_B, 1000ms); } - // Reuse existing feedback-based save: opens menu with MenuWatcher - // + respects GameSettings::OVERWORLD_TO_MENU_DELAY0 internally. - save_game(env.console, context); + // The feedback-based save of save_game(VideoStream&, ...), split at the + // save prompt to check where it stands. + const std::string location = open_save_prompt(env.console, context); + if (!expected_location.empty() && !location_banner_matches(location, expected_location)){ + wrong_place = "About to save in \"" + location + "\", expected " + expected_location + "; not saving."; + env.console.log(wrong_place, COLOR_RED); + leave_save_prompt(env.console, context); + break; + } + pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); // Return to overworld cleanly. pbf_mash_button(context, BUTTON_B, 1000ms); @@ -148,6 +224,10 @@ bool save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& contex context.wait_for(1000ms); } } + // No blind fallback for the wrong place: it would save there. + if (!wrong_place.empty()){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, wrong_place, env.console); + } // Blind fallback: reuse the non-feedback save_game(context) from GameNavigation // rather than duplicating the button sequence manually. @@ -213,12 +293,17 @@ void checkpoint_reattempt_loop(SingleSwitchProgramEnvironment& env, action(i); return; // success - } catch (OperationFailedException& e){ - if (retry_scope == CheckpointRetryScope::DEFER_TO_SELECTED_RANGE){ - throw; + }catch (OperationFailedException& e){ + if (retry_scope == CheckpointRetryScope::DEFER_TO_SELECTED_RANGE) throw; + env.console.log("checkpoint_reattempt_loop: attempt " + std::to_string(i) + " failed: " + e.message(), COLOR_RED); + // Keep what the screen showed at the failure; the retry below resets the game. + if (VideoSnapshot screen = env.console.video().snapshot()){ + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/failures/"; + Filesystem::create_directories(Filesystem::Path(folder)); + const std::string path = folder + now_to_filestring() + "-attempt" + std::to_string(i) + ".png"; + screen.frame->save(path); + env.console.log("Failure screenshot: " + path); } - env.console.log("checkpoint_reattempt_loop: attempt " + std::to_string(i) + " failed: " + e.message(), - COLOR_RED); stats.m_reset++; env.update_stats(); @@ -936,27 +1021,43 @@ static double confirmed_potion_hp(VideoStream& stream, ProControllerContext& con stream); } -void use_potion_first_pokemon(VideoStream& stream, ProControllerContext& context){ +bool bag_has_item(VideoStream& stream, ProControllerContext& context, const std::string& name, int target_pocket) { + const bool found = find_bag_item_by_name(stream, context, name, target_pocket, 5); + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + OverworldWatcher overworld; + if (wait_until(stream, context, std::chrono::seconds(10), {{overworld}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Bag: Did not return to the overworld after looking for " + name + ".", stream); + } + return found; +} + +void use_potion_first_pokemon(VideoStream& stream, ProControllerContext& context, double heal_below) { OverlayBoxScope hp_overlay(stream.overlay(), COLOR_GREEN, {0.137, 0.160, 0.202, 0.016}, ""); - BagItemTemplate potion; - potion.reference_path = "PokemonBDSP/Autostory/Bag_Potion.png"; - potion.label = "Potion"; - potion.reference_row = 1; bool healed = false; for (size_t dose = 0; dose < 3; dose++){ stream.log("[Potion] Finding Bag/Medicine/Potion; dose " + std::to_string(dose + 1) + "/3."); - if (!find_bag_item(stream, context, potion, 1, 5, 64, std::chrono::seconds(45))){ + if (!find_bag_item_by_name(stream, context, "potion", 1, 5)) { + if (dose == 0 && heal_below < 1.0) { + // Only a check: without Potions there is nothing to do here. + stream.log("[Potion] No Potion in the Bag; skipping the HP check.", COLOR_ORANGE); + healed = true; + break; + } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, "Potion: Could not verify Potion in the Medicine pocket.", stream); } const double before = confirmed_potion_hp(stream, context); stream.log("[Potion] Slot 1 HP bar before use=" + std::to_string(before * 100) + "%."); - if (before >= 0.98){ + if (before >= 0.98) { healed = true; break; } + if (dose == 0 && before >= heal_below) { + stream.log("[Potion] Slot 1 HP is at least " + std::to_string(heal_below * 100) + "%; no Potion needed."); healed = true; break; } - if (before <= 0.005){ + if (before <= 0.005) { OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, "Potion: Slot 1 appears fainted; a Potion cannot revive it.", stream); @@ -1801,10 +1902,89 @@ bool find_bag_item(VideoStream& stream, return false; } -bool use_escape_rope(VideoStream& stream, ProControllerContext& context, int num_icons){ - const BagItemTemplate rope{// "PokemonBDSP/AutoStory/Bag_EscapeRope.png", "escape-rope" - "PokemonBDSP/Autostory/Bag_EscapeRope.png", - "escape-rope"}; +bool find_bag_item_by_name( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket, + int num_icons +){ + open_menu(stream, context, MenuCursorPosition::BAG, num_icons); + pbf_wait(context, 1000ms); + return find_bag_item_in_open_bag(stream, context, name, target_pocket); +} + +bool find_bag_item_in_open_bag( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket +){ + context.wait_for_all_requests(); + if (!bag_navigate_to_pocket(stream, context, target_pocket)) return false; + + // Walk down the list reading the cursor row. The list does not wrap, so + // turn around once the cursor stops moving. + DpadPosition direction = DPAD_DOWN; + std::string previous; + size_t same = 0; + size_t confirmed = 0; + size_t unknown_cursor = 0; + for (size_t scan = 0; scan < 128; scan++){ + context.wait_for_all_requests(); + const VideoSnapshot frame = stream.video().snapshot(); + if (!frame || bag_list_is_empty(*frame.frame)){ + stream.log("[Bag search] " + name + ": pocket is empty.", COLOR_ORANGE); + return false; + } + const int cursor = bag_cursor_position(*frame.frame); + if (cursor == 0){ + if (++unknown_cursor >= 3){ + stream.log("[Bag search] " + name + ": cursor not found.", COLOR_ORANGE); + return false; + } + pbf_wait(context, 200ms); + continue; + } + unknown_cursor = 0; + const std::string text = OCR::tesseract_ocr_read(Language::English, + extract_box_reference(*frame.frame, bag_list_name_box(cursor))); + std::string normalized; + for (unsigned char ch : text){ + if (std::isalpha(ch)) normalized.push_back((char)std::tolower(ch)); + } + stream.log("[Bag search] Row " + std::to_string(cursor) + " reads \"" + normalized + "\"."); + if (normalized == name){ + if (++confirmed >= 2){ + stream.log("[Bag search] " + name + " confirmed in row " + std::to_string(cursor) + ".", COLOR_GREEN); + return true; + } + pbf_wait(context, 150ms); + continue; + } + confirmed = 0; + same = normalized == previous ? same + 1 : 0; + previous = normalized; + if (same >= 2){ + if (direction == DPAD_UP) break; + direction = DPAD_UP; + same = 0; + } + pbf_press_dpad(context, direction, 80ms, 300ms); + } + stream.log("[Bag search] " + name + " not found.", COLOR_ORANGE); + return false; +} + +bool use_escape_rope( + VideoStream& stream, + ProControllerContext& context, + int num_icons +){ + const BagItemTemplate rope{ + // "PokemonBDSP/AutoStory/Bag_EscapeRope.png", "escape-rope" + "PokemonBDSP/Autostory/Bag_EscapeRope.png", "escape-rope" + }; if (!find_bag_item(stream, context, rope, 5, num_icons)){ return false; } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h index 7d511bea85..7a5e6e1c20 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h @@ -18,6 +18,7 @@ #include "CommonFramework/GlobalAutoPaths.h" #include "CommonFramework/ImageTools/ImageBoxes.h" #include "CommonFramework/Language.h" +#include "CommonFramework/VideoPipeline/VideoOverlay.h" #include "CommonFramework/Notifications/EventNotificationsTable.h" #include "CommonFramework/ProgramStats/StatsTracking.h" #include "CommonFramework/Tools/VideoStream.h" @@ -44,14 +45,24 @@ struct AutoStoryStats : public StatsTracker { AutoStoryStats() : m_checkpoint(m_stats["Checkpoint"]) , m_segment(m_stats["Segment"]) - , m_reset(m_stats["Reset"]){ + , m_reset(m_stats["Reset"]) + , m_current(m_stats["Current Checkpoint (00.0X = X)"]) + , m_completed(m_stats["Checkpoints Completed"]) + , m_last_completed(m_stats["Last Completed (00.0X = X)"]) + { m_display_order.emplace_back("Checkpoint"); m_display_order.emplace_back("Segment"); m_display_order.emplace_back("Reset"); + m_display_order.emplace_back("Current Checkpoint (00.0X = X)"); + m_display_order.emplace_back("Checkpoints Completed"); + m_display_order.emplace_back("Last Completed (00.0X = X)"); } std::atomic& m_checkpoint; std::atomic& m_segment; std::atomic& m_reset; + std::atomic& m_current; + std::atomic& m_completed; + std::atomic& m_last_completed; bool m_first_run = true; }; @@ -166,7 +177,24 @@ class AutoStory_Checkpoint { // Save the game from the overworld with retries. Returns true if the save was // verified via overworld detection, false if the blind fallback was used. -bool save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& context); +// Adds `text` to the video overlay's log, split at spaces into lines short +// enough for the log box, which draws each line on one row and cuts it off. +void add_wrapped_log(VideoOverlay& overlay, const std::string& text, Color color); + +// With `expected_location`, the map named on the save screen's card must be +// it (compared as a location banner is), or the save is backed out of and +// the function fails with a screenshot: r124a saved in Sandgem Town after a +// walk that was sure it stood on Route 202. +bool save_game( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const std::string& expected_location = "" +); + +// Opens the save screen from the overworld, reads where the game says we +// are (read_save_screen_location() in the Location module), and backs out +// without saving. +std::string read_location_from_save_screen(VideoStream& stream, ProControllerContext& context); // Fake save game to be replaced with save_game when program runs smoothly void checkpoint_save(SingleSwitchProgramEnvironment& env, ProControllerContext& context); @@ -354,6 +382,28 @@ bool find_bag_item(VideoStream& stream, size_t max_scrolls_per_pocket = 512, std::chrono::milliseconds search_timeout = std::chrono::minutes(10)); +// Starts in the overworld, opens the Bag on `target_pocket`, and moves the cursor +// onto the item whose name reads `name` (letters only, lower case, e.g. "potion"). +// Reads the cursor row's name with OCR, so it does not depend on the capture +// card's colors or sharpness the way a template match does. +// Returns false if the pocket is empty or the name is not found. +bool find_bag_item_by_name( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket, + int num_icons = 8 +); + +// The same search in a Bag that is already open, such as the one a battle's +// Bag command opens. +bool find_bag_item_in_open_bag( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket +); + // Find Escape Rope, choose Use, and verify a transition back to the overworld. bool use_escape_rope(VideoStream& stream, ProControllerContext& context, int num_icons = 8); @@ -406,7 +456,24 @@ bool fly_to(VideoStream& stream, ProControllerContext& context, FlyPoint place); // num_icons: 5 (early-game layout) or 8 (late-game layout). void open_menu(VideoStream& stream, ProControllerContext& context, MenuCursorPosition target, int num_icons); -void use_potion_first_pokemon(VideoStream& stream, ProControllerContext& context); +// Open the Bag from the overworld, look for `name` in the pocket, and close +// it again. True if the item is there. +bool bag_has_item( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket +); + +// Read the lead's HP bar in the Bag and, if it is below `heal_below` (a +// fraction of full), use Potions until it is full. heal_below = 1 always +// heals; 0.5 only checks and heals when the lead is under half, and is +// skipped when the Bag has no Potion. +void use_potion_first_pokemon( + VideoStream& stream, + ProControllerContext& context, + double heal_below = 1.0 +); enum class GearLevel { Fast, diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp new file mode 100644 index 0000000000..55cb21e74b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -0,0 +1,870 @@ +/* BDSP AutoStory Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "Pokemon/Navigation/Pokemon_TileGrid.h" +#include "Data/PokemonBDSP_AutoStory_JubilifeCity.h" +#include "Data/PokemonBDSP_AutoStory_OreburghMine.h" +#include "Data/PokemonBDSP_AutoStory_RavagedPath.h" +#include "Data/PokemonBDSP_AutoStory_Route204.h" +#include "Data/PokemonBDSP_AutoStory_Route201.h" +#include "Data/PokemonBDSP_AutoStory_Route202.h" +#include "Data/PokemonBDSP_AutoStory_SandgemTown.h" +#include "Data/PokemonBDSP_AutoStory_StoryMaps.h" +#include "Utils/PokemonBDSP_AutoStory_Location.h" +#include "Utils/PokemonBDSP_AutoStory_NewGame.h" +#include "Utils/PokemonBDSP_AutoStory_PokemonCenter.h" +#include "Utils/PokemonBDSP_AutoStory_RouteWalk.h" +#include "PokemonBDSP_AutoStory_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ + +using Pokemon::Direction; +using Pokemon::TileGrid; +using Pokemon::TileLeg; +using Pokemon::TilePos; +using Pokemon::TileType; + +// A small map with every tile type. +// Row 2 is a ledge with a gap at x = 4. The bottom row has a sign at (1,4), +// an NPC at (3,4) and a door at (5,4); (3,0) is off the map. +const TileGrid& small_grid(){ + static const TileGrid grid({ + R"(#..?..#)", + R"(#.."".#)", + R"(#vvv..#)", + R"(#.....#)", + R"(#S.N.D#)", + }); + return grid; +} + +class Test_TileGridParse : public UnitTest{ +public: + Test_TileGridParse() + : UnitTest("PokemonBDSP::AutoStory - tile grid parse") + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const TileGrid& grid = small_grid(); + if (grid.width() != 7 || grid.height() != 5){ + return "Small grid is " + std::to_string(grid.width()) + "x" + std::to_string(grid.height()) + "."; + } + struct Case{ + TilePos pos; + TileType type; + }; + const Case cases[] = { + {{0, 0}, TileType::BLOCKED}, + {{1, 0}, TileType::WALKABLE}, + {{3, 1}, TileType::TALL_GRASS}, + {{2, 2}, TileType::LEDGE}, + {{3, 4}, TileType::NPC}, + {{1, 4}, TileType::SIGN}, + {{5, 4}, TileType::DOOR}, + {{3, 0}, TileType::UNKNOWN}, + {{-1, 0}, TileType::UNKNOWN}, + {{7, 4}, TileType::UNKNOWN}, + }; + for (const Case& c : cases){ + if (grid.at(c.pos) != c.type){ + return "Tile " + Pokemon::to_string(c.pos) + " parsed as " + std::to_string((int)grid.at(c.pos)) + "."; + } + } + for (const std::vector& bad : { + std::vector{"...", ".."}, + std::vector{"..x"}, + }){ + try{ + TileGrid parsed(bad); + return "A malformed grid was accepted."; + }catch (InternalProgramError&){} + } + + if (grid.rows()[4] != "#S.N.D#"){ + return "Small grid row 4 prints as " + grid.rows()[4] + "."; + } + + struct Map{ + const char* name; + const TileGrid& grid; + int width; + int height; + }; + const Map maps[] = { + {MAP_ROUTE_201, Route201::grid(), 64, 20}, + {MAP_SANDGEM_TOWN, SandgemTown::grid(), 34, 28}, + {MAP_ROUTE_202, Route202::grid(), 36, 26}, + }; + for (const Map& map : maps){ + if (map.grid.width() != map.width || map.grid.height() != map.height){ + return std::string(map.name) + " is " + std::to_string(map.grid.width()) + "x" + std::to_string(map.grid.height()) + "."; + } + } + + struct Named{ + const char* map; + TilePos pos; + TileType type; + }; + const Named named[] = { + {MAP_SANDGEM_TOWN, SandgemTown::LAB_DOOR, TileType::DOOR}, + {MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR, TileType::DOOR}, + {MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN, TileType::SIGN}, + {MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR, TileType::DOOR}, + {MAP_SANDGEM_TOWN, SandgemTown::FRONT_ROW_EAST_END, TileType::WALKABLE}, + {MAP_ROUTE_202, Route202::CATCH_SPOT, TileType::TALL_GRASS}, + {MAP_ROUTE_202, Route202::SAVE_SPOT, TileType::WALKABLE}, + {MAP_ROUTE_202, Route202::TRAINER_A, TileType::NPC}, + {MAP_ROUTE_202, Route202::TRAINER_B, TileType::NPC}, + {MAP_ROUTE_202, Route202::TRAINER_C, TileType::NPC}, + {MAP_ROUTE_202, Route202::TRAINER_D, TileType::NPC}, + {MAP_ROUTE_202, Route202::EXIT_JUBILIFE, TileType::WALKABLE}, + }; + const Pokemon::MapSet& world = story_maps(); + for (const Named& c : named){ + const TilePos pos = world.to_world(c.map, c.pos); + if (world.world().at(pos) != c.type){ + return std::string(c.map) + " tile " + Pokemon::to_string(c.pos) + " is type " + + std::to_string((int)world.world().at(pos)) + " in the joined maps."; + } + const std::optional local = world.to_local(pos); + if (!local || local->map != c.map || local->pos != c.pos){ + return std::string(c.map) + " tile " + Pokemon::to_string(c.pos) + " does not map back."; + } + } + // Sandgem's Poke Mart is next door east of its Center, on the same + // front row: 00.04 walks from the one door to the other. + { + const TilePos center = world.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR); + const TilePos mart = world.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); + if (center.y != mart.y || mart.x <= center.x){ + return "The Poke Mart's door is not east of the Pokemon Center's on the same row."; + } + for (int x = center.x; x <= mart.x; x++){ + if (!world.world().standable({x, mart.y + 1})){ + return "The row below the Center's and the Mart's doors is not open at " + world.describe({x, mart.y + 1}) + "."; + } + } + } + + // The maps join where their exits meet. + struct Join{ + const char* from_map; + TilePos from; + Direction direction; + const char* to_map; + TilePos to; + }; + const Join joins[] = { + {MAP_ROUTE_201, {63, 4}, Direction::EAST, MAP_SANDGEM_TOWN, {0, 7}}, + {MAP_ROUTE_201, {63, 6}, Direction::EAST, MAP_SANDGEM_TOWN, {0, 9}}, + {MAP_SANDGEM_TOWN, {24, 0}, Direction::NORTH, MAP_ROUTE_202, {25, 25}}, + {MAP_SANDGEM_TOWN, {29, 0}, Direction::NORTH, MAP_ROUTE_202, {30, 25}}, + }; + for (const Join& join : joins){ + const TilePos from = world.to_world(join.from_map, join.from); + const std::optional next = world.world().step(from, join.direction); + if (!next || *next != world.to_world(join.to_map, join.to)){ + return "No step from " + world.describe(from) + " to " + join.to_map + " " + Pokemon::to_string(join.to) + "."; + } + } + Pokemon::MapSet overlapping; + overlapping.add("a", small_grid(), {0, 0}); + overlapping.add("b", small_grid(), {6, 4}); // shares one blocked tile + try{ + overlapping.add("c", small_grid(), {5, 4}); + return "Maps that overlap on a door were accepted."; + }catch (InternalProgramError&){} + return true; + } +}; + +class Test_TileGridWallHit : public UnitTest{ +public: + Test_TileGridWallHit() + : UnitTest("PokemonBDSP::AutoStory - tile grid wall hit") + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + struct Case{ + const TileGrid& grid; + TilePos from; + Direction direction; + TilePos stop; + size_t tiles; + bool anchored; + }; + const TileGrid& small = small_grid(); + const TileGrid& route = Route202::grid(); + const Case cases[] = { + {small, {1, 1}, Direction::EAST, {5, 1}, 4, true}, // through grass to the east wall + {small, {1, 0}, Direction::SOUTH, {1, 3}, 3, true}, // hops the ledge, stops at the sign + {small, {2, 3}, Direction::NORTH, {2, 3}, 0, true}, // a ledge stops a north walk + {small, {3, 3}, Direction::SOUTH, {3, 3}, 0, true}, // the NPC is a wall + {small, {4, 4}, Direction::NORTH, {4, 0}, 4, false}, // through the gap, off the top edge + {small, {4, 3}, Direction::EAST, {5, 3}, 1, true}, + {small, {4, 4}, Direction::EAST, {4, 4}, 0, false}, // into the door + {small, {2, 0}, Direction::EAST, {2, 0}, 0, false}, // off the map + {route, Route202::CATCH_SPOT, Direction::WEST, {3, 22}, 16, true}, + {route, Route202::CATCH_SPOT, Direction::NORTH, {19, 20}, 2, true}, + {route, Route202::CATCH_SPOT, Direction::EAST, {30, 22}, 11, true}, + // Measured on the console from the post-catch save. + {route, {30, 22}, Direction::NORTH, {30, 19}, 3, true}, + {route, {30, 19}, Direction::WEST, {22, 19}, 8, true}, + {route, {22, 19}, Direction::SOUTH, {22, 24}, 5, true}, + {route, {7, 12}, Direction::NORTH, {7, 12}, 0, true}, // trainer_a holds the player + // After trainer_d, as measured in r75: the east wall is at column 31 + // on row 16, and column 30 is open up to the trees above row 4. + {route, {22, 16}, Direction::EAST, {30, 16}, 8, true}, + {route, {30, 16}, Direction::NORTH, {30, 4}, 12, true}, + // Through the gap east of the north ledge, over the south ledge + // and off the bottom edge to Sandgem Town. + {route, {29, 4}, Direction::SOUTH, {29, 25}, 21, false}, + {route, {12, 8}, Direction::SOUTH, {12, 22}, 14, true}, // hops both ledges + {route, Route202::EXIT_JUBILIFE, Direction::NORTH, Route202::EXIT_JUBILIFE, 0, false}, + }; + auto check = [](const Case& c) -> std::string{ + const Pokemon::WallHit hit = Pokemon::walk_to_wall(c.grid, c.from, c.direction); + if (hit.stop != c.stop || hit.tiles != c.tiles || hit.anchored != c.anchored){ + return "Walking " + std::string(Pokemon::direction_name(c.direction)) + " from " + + Pokemon::to_string(c.from) + " stops at " + Pokemon::to_string(hit.stop) + " after " + + std::to_string(hit.tiles) + " tiles" + (hit.anchored ? "" : " (not anchored)") + + ", expected " + Pokemon::to_string(c.stop) + " after " + std::to_string(c.tiles) + "."; + } + return ""; + }; + for (const Case& c : cases){ + const std::string error = check(c); + if (!error.empty()){ + return error; + } + } + // Sandgem Town on the joined maps (00.04): the front row of the + // Center and the Mart runs east to the trees past the Mart, and the + // Mart's door is a warp, not a wall. + const Pokemon::MapSet& maps = story_maps(); + const TileGrid& world = maps.world(); + auto town = [&](const TilePos& pos){ return maps.to_world(MAP_SANDGEM_TOWN, pos); }; + const TilePos below_mart = town(SandgemTown::POKE_MART_DOOR.next(Direction::SOUTH)); + const TilePos row_end = town(SandgemTown::FRONT_ROW_EAST_END); + const Case sandgem[] = { + {world, town(SandgemTown::POKEMON_CENTER_DOOR.next(Direction::SOUTH)), Direction::EAST, row_end, 12, true}, + {world, below_mart, Direction::EAST, row_end, 2, true}, + {world, below_mart, Direction::NORTH, below_mart, 0, false}, + {world, row_end, Direction::NORTH, row_end, 0, true}, + }; + for (const Case& c : sandgem){ + const std::string error = check(c); + if (!error.empty()){ + return error; + } + } + return true; + } +}; + +// Plans from `from` to `goal` allowing open legs a tile short or long, and +// logs the legs with every map tile each may end on. +std::string check_robust_plan( + Logger& logger, const std::string& name, const Pokemon::MapSet& maps, + const std::vector& from, const std::function& goal +){ + const auto legs = Pokemon::plan_robust_route(maps.world(), from, goal); + if (!legs){ + return name + ": no robust route found."; + } + std::string text = name + " (robust): from"; + for (const TilePos& pos : from){ + text += " " + maps.describe(pos); + } + for (const Pokemon::PlannedLeg& leg : *legs){ + text += "\n " + Pokemon::to_string(leg.leg) + " to"; + for (const TilePos& pos : leg.after){ + text += " " + maps.describe(pos); + } + } + logger.log(text); + const std::vector& end = legs->empty() ? from : legs->back().after; + for (const TilePos& pos : end){ + if (!goal(pos)){ + return name + ": the robust plan may end on " + maps.describe(pos) + "."; + } + } + return ""; +} + +// Checks that no leg of a robust plan from `from` to `goal` can stop in tall +// grass, where a walk cannot read that it has stopped (the sway keeps the +// screen changing): a leg that only runs out of time there would be taken +// as an anchor. +std::string check_plan_stops_readable( + const std::string& name, const Pokemon::MapSet& maps, + const std::vector& from, const std::function& goal +){ + const auto legs = Pokemon::plan_robust_route(maps.world(), from, goal); + if (!legs){ + return name + ": no robust route found."; + } + for (const Pokemon::PlannedLeg& leg : *legs){ + if (leg.leg.ends_at_wall && !route_wall_readable(maps.world(), leg.after)){ + return name + ": " + Pokemon::to_string(leg.leg) + " ends on a wall in tall grass, where a stop cannot be read."; + } + } + return ""; +} + +// Checks that no leg of a robust plan from `from` to `goal` can walk into a +// door, from any tile the leg before may have left us on, even if an open +// leg runs on to twice its length plus two: r123a's legs in Sandgem ran +// past where the grid said and the last one entered a house. +std::string check_plan_avoids_doors( + const std::string& name, const Pokemon::MapSet& maps, + const std::vector& from, const std::function& goal +){ + const TileGrid& grid = maps.world(); + const auto legs = Pokemon::plan_robust_route(grid, from, goal); + if (!legs){ + return name + ": no robust route found."; + } + std::vector starts = from; + for (const Pokemon::PlannedLeg& planned : *legs){ + const TileLeg& leg = planned.leg; + const size_t reach = leg.ends_at_wall ? (size_t)grid.width() + grid.height() : leg.tiles * 2 + 2; + for (TilePos pos : starts){ + for (size_t i = 0; i < reach; i++){ + const TilePos next = pos.next(leg.direction); + if (grid.at(next) == TileType::DOOR){ + return name + ": " + Pokemon::to_string(leg) + " may walk into the door at " + maps.describe(next) + "."; + } + const std::optional step = grid.step(pos, leg.direction); + if (!step){ + break; + } + pos = *step; + } + } + starts = planned.after; + } + return ""; +} + +// Checks a plan from `from` to `to`: every leg can be walked, ledges are +// crossed only southward, and each leg's wall flag matches the grid. +// With `maps`, legs are printed with the map tile they end on, and with +// the trainers whose sight they cross. +std::string check_plan( + Logger& logger, const std::string& name, + const TileGrid& grid, const TilePos& from, const TilePos& to, + const Pokemon::MapSet* maps = nullptr, + const std::vector& trainers = {} +){ + const std::optional> legs = Pokemon::plan_tile_route(grid, from, to); + if (!legs){ + return name + ": no route found."; + } + auto describe = [&](const TilePos& pos){ + return maps ? maps->describe(pos) : Pokemon::to_string(pos); + }; + const std::vector> seen = Pokemon::legs_crossing_sight(grid, from, *legs, trainers); + std::string text = name + ": from " + describe(from); + TilePos end = from; + for (size_t i = 0; i < legs->size(); i++){ + const TileLeg& leg = (*legs)[i]; + end = Pokemon::trace_legs(grid, end, {leg}).back(); + text += "\n " + Pokemon::to_string(leg) + " to " + describe(end); + for (const std::string& trainer : seen[i]){ + text += ", crosses " + trainer + "'s sight"; + } + } + logger.log(text); + + std::vector tiles; + try{ + tiles = Pokemon::trace_legs(grid, from, *legs); + }catch (InternalProgramError& e){ + return name + ": " + e.message(); + } + if (tiles.back() != to){ + return name + ": the legs end at " + Pokemon::to_string(tiles.back()) + "."; + } + for (size_t i = 1; i < tiles.size(); i++){ + const TilePos& a = tiles[i - 1]; + const TilePos& b = tiles[i]; + if (!grid.standable(b)){ + return name + ": the route stands on " + Pokemon::to_string(b) + "."; + } + const int dx = b.x - a.x; + const int dy = b.y - a.y; + if (std::abs(dx) + std::abs(dy) == 1){ + continue; + } + if (dx != 0 || dy != 2 || grid.at(a.next(Direction::SOUTH)) != TileType::LEDGE){ + return name + ": jump from " + Pokemon::to_string(a) + " to " + Pokemon::to_string(b) + " is not a south ledge hop."; + } + } + TilePos pos = from; + for (const TileLeg& leg : *legs){ + if (leg.tiles == 0){ + return name + ": empty leg."; + } + pos = Pokemon::trace_legs(grid, pos, {leg}).back(); + if (leg.ends_at_wall != grid.at_wall(pos, leg.direction)){ + return name + ": leg " + Pokemon::to_string(leg) + " has the wrong wall flag."; + } + } + for (size_t i = 1; i < legs->size(); i++){ + if ((*legs)[i].direction == (*legs)[i - 1].direction){ + return name + ": two legs in a row go " + Pokemon::direction_name((*legs)[i].direction) + "."; + } + } + return ""; +} + +class Test_TileGridPlans : public UnitTest{ +public: + Test_TileGridPlans() + : UnitTest("PokemonBDSP::AutoStory - tile grid plans") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + const TileGrid& small = small_grid(); + + // South over the ledge is a straight hop; back north must use the gap. + std::string error = check_plan(logger, "Small south", small, {2, 0}, {2, 4}); + if (!error.empty()){ + return error; + } + error = check_plan(logger, "Small north", small, {2, 4}, {2, 0}); + if (!error.empty()){ + return error; + } + const auto south = Pokemon::plan_tile_route(small, {2, 0}, {2, 4}); + if (south->size() != 1 || south->front().direction != Direction::SOUTH){ + return "Small south should be one leg over the ledge."; + } + + // Walled in by ledges from below. + const TileGrid pit({ + R"(#.#)", + R"(#v#)", + R"(#.#)", + }); + if (Pokemon::plan_tile_route(pit, {1, 2}, {1, 0})){ + return "A route north over a ledge was found."; + } + + // 00.04 after the catch: every leg it walks, in order. + const Pokemon::MapSet& maps = story_maps(); + const std::vector trainers = story_trainer_sights(); + const TilePos catch_spot = maps.to_world(MAP_ROUTE_202, Route202::CATCH_SPOT); + const TilePos jubilife = maps.to_world(MAP_ROUTE_202, Route202::EXIT_JUBILIFE); + error = check_plan(logger, "Catch spot to Jubilife", maps.world(), catch_spot, jubilife, &maps, trainers); + if (!error.empty()){ + return error; + } + // 00.04's walks in order, from where the step before may leave us. + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); + const std::vector grass = story_after_training_area(); + const TilePos below_sign = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN.next(Direction::SOUTH)); + const TilePos below_mart = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR.next(Direction::SOUTH)); + const TilePos row_end = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::FRONT_ROW_EAST_END); + // From the trees east of the Mart, 00.04 walks six tiles west, three + // off at most, and north (north_through_sign_gap()): from every + // column it may reach, north passes no door, and only the gap + // between the signs leads out of town. + std::vector above_sign_gap; + for (int x = row_end.x - 11; x <= row_end.x - 3; x++){ + const TilePos pos{x, row_end.y}; + const Pokemon::WallHit hit = Pokemon::walk_to_wall(maps.world(), pos, Direction::NORTH); + if (maps.world().at(hit.wall) == TileType::DOOR){ + return "North from " + maps.describe(pos) + " walks into a door."; + } + const std::vector gap = SandgemTown::below_sign_gap(); + const bool in_gap = std::find(gap.begin(), gap.end(), maps.to_local(pos)->pos) != gap.end(); + if (in_gap != (hit.tiles > 2)){ + return "North from " + maps.describe(pos) + " goes " + std::to_string(hit.tiles) + " tiles."; + } + if (in_gap){ + above_sign_gap.push_back(hit.stop); + } + } + // The walks that start or end in the catch grass must stop in it; + // the Sandgem walks (`readable`) never need to. + const struct{ const char* name; std::vector from; std::function goal; bool readable; } walks[] = { + {"Save spots to the catch grass", story_save_spots(), + [&](const TilePos& pos){ return std::find(grass.begin(), grass.end(), pos) != grass.end(); }, false}, + {"After training to the Pokemon Center", story_after_training_area(), + [&](const TilePos& pos){ return pos == below_sign; }, false}, + // The Mart's door takes a tile either side: enter_lit_door() lines up by sight. + {"Pokemon Center to the Poke Mart", story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR), + [&](const TilePos& pos){ return pos.y == below_mart.y && std::abs(pos.x - below_mart.x) <= 1; }, true}, + {"Poke Mart to the trees east of it", story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR), + [&](const TilePos& pos){ return pos == row_end; }, true}, + {"North of the gap between the signs to the save spot", above_sign_gap, + [&](const TilePos& pos){ return pos == save_spot; }, true}, + {"Save spots to Jubilife", story_save_spots(), story_at_jubilife_exit, false}, + }; + for (const auto& walk : walks){ + error = check_robust_plan(logger, walk.name, maps, walk.from, walk.goal); + if (!error.empty()){ + return error; + } + error = check_plan_avoids_doors(walk.name, maps, walk.from, walk.goal); + if (!error.empty()){ + return error; + } + if (walk.readable){ + error = check_plan_stops_readable(walk.name, maps, walk.from, walk.goal); + if (!error.empty()){ + return error; + } + } + } + + // 00.05 in Jubilife: every leg the segment walks, from where the one + // before it may have left us. + { + using namespace JubilifeCity; + const Pokemon::MapSet& town = jubilife_maps(); + auto at = [&](const TilePos& pos){ return town.to_world(MAP_JUBILIFE_CITY, pos); }; + std::vector dawn; + for (const TilePos& pos : after_dawn()){ + dawn.push_back(at(pos)); + } + const TilePos school = at(TRAINERS_SCHOOL_DOOR.next(Direction::SOUTH)); + const TilePos center = at(POKEMON_CENTER_DOOR.next(Direction::SOUTH)); + const std::vector out_of_school{school.next(Direction::WEST), school, school.next(Direction::EAST)}; + const TilePos president = at(POKETCH_PRESIDENT).next(Direction::EAST); + const TilePos clown_mart = at(CLOWN_MART).next(Direction::SOUTH); + const TilePos clown_tv = at(CLOWN_TV_STATION).next(Direction::EAST); + const TilePos clown_company = at(CLOWN_POKETCH_COMPANY).next(Direction::EAST); + // A door's goal is its front row within a tile of its column; an + // NPC's is the one tile beside them. + const struct{ const char* name; std::vector from; TilePos to; bool door; } legs[] = { + {"Jubilife: after Dawn to the Trainers' School", dawn, school, true}, + {"Jubilife: school to the president", out_of_school, president, false}, + {"Jubilife: president to the clown by the Mart", {president}, clown_mart, false}, + {"Jubilife: Mart clown to the TV station clown", {clown_mart}, clown_tv, false}, + {"Jubilife: TV station clown to the Poketch Company clown", {clown_tv}, clown_company, false}, + {"Jubilife: Poketch Company clown to the president", {clown_company}, president, false}, + {"Jubilife: president to the Pokemon Center", {president}, center, true}, + }; + std::string failed; + // 01.03 in the Oreburgh Mine: the corridor's top to below Roark, and back. + { + const Pokemon::MapSet& mine = oreburgh_mine_maps(); + auto m = [&](const TilePos& pos){ return mine.to_world(MAP_OREBURGH_MINE, pos); }; + std::vector top; + for (const TilePos& pos : OreburghMine::corridor_top()){ + top.push_back(m(pos)); + } + const TilePos below_roark = m(OreburghMine::ROARK).next(Direction::SOUTH); + const TilePos roark = m(OreburghMine::ROARK); + std::string leg_error = check_robust_plan(logger, "Mine: corridor top to the stretch east of Roark", mine, top, + [&](const TilePos& pos){ return pos.y >= roark.y && pos.y <= roark.y + 1 && pos.x >= roark.x + 2 && pos.x <= roark.x + 7; }); + failed += leg_error.empty() ? "" : leg_error + " "; + const TilePos exit = m(OreburghMine::CORRIDOR_EXIT_START); + leg_error = check_robust_plan(logger, "Mine: below Roark to the corridor's corner", mine, {below_roark}, + [&](const TilePos& pos){ return pos == exit; }); + failed += leg_error.empty() ? "" : leg_error + " "; + } + // 01.05 in the Ravaged Path: from the south door to a rock across the + // corridor, and once it is smashed, to the tile above the way out. + { + const Pokemon::MapSet cave = ravaged_path_maps(); + for (const TilePos& rock : RavagedPath::rocks()){ + if (cave.world().at(rock) != Pokemon::TileType::BLOCKED){ + failed += "Ravaged Path: rock " + cave.describe(rock) + " is not blocked. "; + } + } + const std::vector& before = RavagedPath::before_rocks(); + std::string leg_error = check_robust_plan(logger, "Ravaged Path: south door to a rock", cave, {RavagedPath::ENTRANCE}, + [&](const TilePos& pos){ return std::find(before.begin(), before.end(), pos) != before.end(); }); + failed += leg_error.empty() ? "" : leg_error + " "; + for (const TilePos& from : before){ + const TilePos rock = from.next(Direction::EAST); + const Pokemon::MapSet open = ravaged_path_maps({rock}); + leg_error = check_robust_plan(logger, "Ravaged Path: after smashing " + open.describe(rock) + ", to the corridor's east end", open, {from}, + [&](const TilePos& pos){ return pos == RavagedPath::EAST_END; }); + failed += leg_error.empty() ? "" : leg_error + " "; + } + // What 01.05 plans: from either tile, with both rocks open. + std::vector both; + for (const TilePos& from : before){ + both.push_back(from.next(Direction::EAST)); + } + leg_error = check_robust_plan(logger, "Ravaged Path: from either rock's tile to the corridor's east end", ravaged_path_maps(both), before, + [&](const TilePos& pos){ return pos == RavagedPath::EAST_END; }); + failed += leg_error.empty() ? "" : leg_error + " "; + if (!(RavagedPath::EAST_END.next(Direction::WEST) == RavagedPath::ABOVE_EXIT) || + cave.world().at(RavagedPath::ABOVE_EXIT.next(Direction::SOUTH)) != Pokemon::TileType::DOOR + ){ + failed += "Ravaged Path: the way out is not a door a tile west of the corridor's east end. "; + } + } + // 01.05 on Route 204's north half: from the Ravaged Path's door to + // the path between the flower beds into Floaroma. + { + const Pokemon::MapSet& route = route204_north_maps(); + const std::string leg_error = check_robust_plan(logger, "Route 204: Ravaged Path to the Floaroma path", route, {Route204::CAVE_EXIT}, + Route204::in_floaroma_path); + failed += leg_error.empty() ? "" : leg_error + " "; + for (const Pokemon::TrainerSight& twin : Route204::north_trainer_sights()){ + if (route.world().at(twin.pos) != Pokemon::TileType::NPC){ + failed += "Route 204: " + twin.name + " is not on an NPC tile. "; + } + } + } + for (const auto& leg : legs){ + const TilePos to = leg.to; + const bool door = leg.door; + const std::string leg_error = check_robust_plan(logger, leg.name, town, leg.from, + [&](const TilePos& pos){ + return door ? pos.y == to.y && pos.x >= to.x - 1 && pos.x <= to.x + 1 : pos == to; + }); + failed += leg_error.empty() ? "" : leg_error + " "; + } + if (!failed.empty()){ + return failed; + } + } + return true; + } +}; + +class Test_IntroDecisions : public UnitTest{ +public: + Test_IntroDecisions() + : UnitTest("PokemonBDSP::AutoStory - new-game intro decisions") + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + struct Case{ + const char* screen; + IntroProgress progress; + IntroStep expected; + }; + const IntroProgress start{false, false}; + const IntroProgress named{true, false}; + const IntroProgress done{true, true}; + const Case cases[] = { + {"user-select", start, IntroStep::SELECT_USER}, + {"home", done, IntroStep::FAIL_NOT_IN_GAME}, + {"keyboard", start, IntroStep::ENTER_PLAYER_NAME}, + {"keyboard", named, IntroStep::CANCEL_RIVAL_KEYBOARD}, + {"rival-list-tell", named, IntroStep::MOVE_TO_PRESET}, + {"rival-list-preset", named, IntroStep::PICK_PRESET}, + {"menu", done, IntroStep::CLOSE_MENU_AND_FINISH}, + // The appearance grid has its prompt in a dialogue box. + {"dialog", start, IntroStep::ADVANCE}, + {"dialog", done, IntroStep::ADVANCE}, + // Language and the appearance preview: no X before the bedroom is possible. + {"other", start, IntroStep::ADVANCE}, + {"other", named, IntroStep::ADVANCE}, + {"other", done, IntroStep::PROBE_FOR_MENU}, + }; + for (const Case& c : cases){ + const IntroStep actual = decide_intro_step(c.screen, c.progress); + if (actual != c.expected){ + return std::string("Screen \"") + c.screen + "\" (player named " + + (c.progress.player_named ? "yes" : "no") + ", rival named " + + (c.progress.rival_named ? "yes" : "no") + "): step " + + std::to_string((int)actual) + ", expected " + std::to_string((int)c.expected) + "."; + } + } + if (advance_after_probe(true)){ + return "A after X changed the screen would pick from whatever X opened."; + } + if (!advance_after_probe(false)){ + return "A must advance a screen X left unchanged."; + } + return true; + } +}; + +} + + +class Test_LocationBanner : public UnitTest{ +public: + Test_LocationBanner() + : UnitTest("PokemonBDSP::AutoStory - location banner match") + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + struct Case{ + const char* read; + const char* expected; + bool match; + }; + const Case cases[] = { + {"sandgemtown", "Sandgem Town", true}, + {"andgemltown", "Sandgem Town", true}, // r109's OCR + {"Route 202", "Route 202", true}, + {"route2o2", "Route 202", false}, // a digit read as a letter is not trusted + {"route201", "Route 202", false}, + {"jubilifecity", "Sandgem Town", false}, + {"twinleaftown", "Sandgem Town", false}, + {"", "Sandgem Town", false}, + }; + for (const Case& c : cases){ + if (location_banner_matches(c.read, c.expected) != c.match){ + return std::string("Banner \"") + c.read + "\" for " + c.expected + + (c.match ? " should match." : " should not match."); + } + } + return true; + } +}; + + +// The walk to the Poke Mart may be stopped short by someone in the way, and +// is then made again from the tiles the doorway on screen puts us on, give +// or take one each way: from every such block of tiles south of the Mart, +// a robust plan to the row below its door must exist. +class Test_PokeMartShortWalks : public UnitTest{ +public: + Test_PokeMartShortWalks() + : UnitTest("PokemonBDSP::AutoStory - walks short of the Poke Mart") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + const Pokemon::MapSet& maps = story_maps(); + const TileGrid& grid = maps.world(); + const TilePos front = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR.next(Direction::SOUTH)); + const auto at_front = [&](const TilePos& pos){ return pos.y == front.y && std::abs(pos.x - front.x) <= 1; }; + // The open ground south of the Mart, to the trees east of town and + // the fence rows below. + for (int y = front.y + 1; y <= front.y + 7; y++){ + for (int x = front.x - 6; x <= front.x + 2; x++){ + std::vector block; + for (int dy = -1; dy <= 1; dy++){ + for (int dx = -1; dx <= 1; dx++){ + if (grid.standable({x + dx, y + dy})){ + block.push_back({x + dx, y + dy}); + } + } + } + if (block.empty()){ + continue; + } + const std::string error = check_robust_plan(logger, "Short of the Poke Mart about " + maps.describe({x, y}), + maps, block, at_front); + if (!error.empty()){ + return error; + } + } + } + return true; + } +}; + + +// The Mart's doorway on the failure screenshots of r121a and r122a, kept in +// the unit test resources (the CommandLineTests checkout): where it is +// relative to the player, who is in the middle of the screen. +class Test_LitDoorwayScreenshots : public UnitTest{ +public: + Test_LitDoorwayScreenshots() + : UnitTest("PokemonBDSP::AutoStory - lit doorway screenshots") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + struct Case{ + const char* file; + double tiles_across_min; + double tiles_across_max; + double rows_up_min; + double rows_up_max; + }; + const Case cases[] = { + // r121a: below-left of the door, which is part of a tile east, straight ahead. + {"PokemonBDSP/AutoStory/SandgemMartDoorUpRight-20261030-023443.png", 0.2, 0.7, 0.5, 1.5}, + // r122a: a tile west of the door and three or four rows south of its front. + {"PokemonBDSP/AutoStory/SandgemMartDoorFarLeft-20261030-024648.png", -1.3, -0.5, 2.5, 4.5}, + }; + for (const Case& c : cases){ + const std::string path = UNIT_TEST_RESOURCE_PATH() + c.file; + if (!Filesystem::is_regular_file(path)){ + return UnitTestResult(UnitTestResult::SKIPPED, "Screenshot not found: " + path); + } + const ImageRGB32 screen(path); + const std::optional doorway = find_lit_doorway(screen); + if (!doorway){ + return std::string(c.file) + ": no lit doorway found."; + } + logger.log(std::string(c.file) + ": doorway at " + std::to_string(doorway->x) + ", " + + std::to_string(doorway->y) + ": " + std::to_string(doorway->tiles_across) + " tiles across, " + + std::to_string(doorway->rows_up) + " rows up."); + if (doorway->tiles_across < c.tiles_across_min || doorway->tiles_across > c.tiles_across_max){ + return std::string(c.file) + ": doorway " + std::to_string(doorway->tiles_across) + + " tiles across, expected " + std::to_string(c.tiles_across_min) + " to " + + std::to_string(c.tiles_across_max) + "."; + } + if (doorway->rows_up < c.rows_up_min || doorway->rows_up > c.rows_up_max){ + return std::string(c.file) + ": doorway " + std::to_string(doorway->rows_up) + + " rows up, expected " + std::to_string(c.rows_up_min) + " to " + + std::to_string(c.rows_up_max) + "."; + } + } + return true; + } +}; + + +// The save prompt r124a took beside Sandgem's Poke Mart, sure it stood on +// Route 202 (in CommandLineTests under PokemonBDSP/AutoStory): its card +// names Sandgem Town, which the save must read before confirming. +class Test_SaveScreenLocation : public UnitTest{ +public: + Test_SaveScreenLocation() + : UnitTest("PokemonBDSP::AutoStory - save screen location") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + const std::string path = UNIT_TEST_RESOURCE_PATH() + "PokemonBDSP/AutoStory/SaveScreenSandgemTown-20261030-034444.png"; + if (!Filesystem::is_regular_file(path)){ + return UnitTestResult(UnitTestResult::SKIPPED, "Screenshot not found: " + path); + } + const std::string read = read_save_screen_location(ImageRGB32(path)); + logger.log("Save screen location read: \"" + read + "\"."); + if (!location_banner_matches(read, MAP_SANDGEM_TOWN)){ + return "Save screen location \"" + read + "\" should match Sandgem Town."; + } + if (location_banner_matches(read, MAP_ROUTE_202)){ + return "Save screen location \"" + read + "\" should not match Route 202."; + } + return true; + } +}; + + +void add_tests_AutoStory(UnitTestDatabase& database){ + database.add(); + database.add(); + database.add(); + database.add(); + database.add(); + database.add(); + database.add(); + database.add(); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h new file mode 100644 index 0000000000..18b8c8a666 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h @@ -0,0 +1,26 @@ +/* BDSP AutoStory Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Tests_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Tests_H + +#include "Common/Cpp/TestRunners/UnitTest.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + + +// AutoStory tests that need no console: the new-game intro's decisions. +void add_tests_AutoStory(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 6b68b60593..5f379a14a9 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -5,1389 +5,1562 @@ * */ +#include "CommonFramework/GlobalAutoPaths.h" +#include "PokemonBDSP_AutoStory_Segment_00.h" +#include "../Data/PokemonBDSP_AutoStory_JubilifeCity.h" +#include "../Data/PokemonBDSP_AutoStory_Route202.h" +#include "../Data/PokemonBDSP_AutoStory_SandgemTown.h" +#include "../Data/PokemonBDSP_AutoStory_StoryMaps.h" +#include "../Utils/PokemonBDSP_AutoStory_Location.h" +#include "../Utils/PokemonBDSP_AutoStory_PokemonCenter.h" +#include "../Utils/PokemonBDSP_AutoStory_RouteWalk.h" +#include "../Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" +#include "../Utils/PokemonBDSP_AutoStory_Walk.h" +#include "../Utils/PokemonBDSP_OverworldMatchers.h" + #include #include - #include - -#include "CommonFramework/Exceptions/OperationFailedException.h" -#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" -#include "CommonFramework/GlobalAutoPaths.h" #include "CommonFramework/ImageTypes/ImageRGB32.h" #include "CommonFramework/VideoPipeline/VideoFeed.h" #include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" - -#include "CommonTools/Async/InferenceRoutines.h" -#include "CommonTools/VisualDetectors/BlackScreenDetector.h" -#include "CommonTools/VisualDetectors/ImageMatchDetector.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" -#include "NintendoSwitch/Programs/DateManip/NintendoSwitch_DateManip.h" -#include "Pokemon/Pokemon_Notification.h" #include "Pokemon/Pokemon_Strings.h" +#include "Pokemon/Pokemon_Notification.h" #include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" -#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" -#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h" -#include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" #include "PokemonBDSP/Programs/ShinyHunting/PokemonBDSP_StarterReset.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "CommonTools/VisualDetectors/ImageMatchDetector.h" -#include "PokemonBDSP_AutoStory_Segment_00.h" +#include "NintendoSwitch/Programs/DateManip/NintendoSwitch_DateManip.h" + +#include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" using namespace std::chrono_literals; +using PokemonAutomation::Pokemon::MapSet; +using PokemonAutomation::Pokemon::TilePos; +using PokemonAutomation::Pokemon::TrainerSight; namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { -// --------------------------------------------------------------------------- -// Starter selection -// --------------------------------------------------------------------------- - -// Watch before movement starts, release on the fade, and hold the -// following recorded wait until the destination is visible again. -static void recorded_move_until_transition(VideoStream& stream, - ProControllerContext& context, - double x, - double y, - Milliseconds duration, - const std::string& label){ - context.wait_for_all_requests(); - stream.log("[Tutorial transition] " + label + ": approaching fade."); - BlackScreenWatcher black; - int ret = run_until(stream, - context, - [x, y, duration](ProControllerContext& ctx){ - pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); - // Keep observing a delayed fade after the recorded hold. - pbf_wait(ctx, 5000ms); - }, - {{black}}); - if (ret != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Black transition not detected.", - stream); - } - BlackScreenWatcher visible(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 100, 10, BlackScreenWatcher::FinderType::GONE, 500ms); - if (wait_until(stream, context, 15s, {{visible}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Transition did not finish.", - stream); - } - stream.log("[Tutorial transition] " + label + ": destination visible."); -} + namespace NintendoSwitch { + namespace PokemonBDSP { + // --------------------------------------------------------------------------- + // Starter selection + // --------------------------------------------------------------------------- + + // Watch before movement starts, release on the fade, and hold the + // following recorded wait until the destination is visible again. + static void recorded_move_until_transition( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds duration, const std::string& label + ) { + context.wait_for_all_requests(); + stream.log("[Tutorial transition] " + label + ": approaching fade."); + BlackScreenWatcher black; + int ret = run_until(stream, context, + [x, y, duration](ProControllerContext& ctx) { + pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); + // Keep observing a delayed fade after the recorded hold. + pbf_wait(ctx, 5000ms); + }, {{black}}); + if (ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Black transition not detected.", stream); + } + BlackScreenWatcher visible(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, + 100, 10, BlackScreenWatcher::FinderType::GONE, 500ms); + if (wait_until(stream, context, 15s, {{visible}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Transition did not finish.", stream); + } + stream.log("[Tutorial transition] " + label + ": destination visible."); + } -static void select_turtwig(VideoStream& stream, ProControllerContext& context){ - stream.log("[DEBUG] Turtwig Selection: Starting starter selection"); - - pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); - context.wait_for_all_requests(); - - ShortDialogWatcher watcher(COLOR_CYAN); - int ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher}); - if (ret != 0){ - ShortDialogWatcher watcher_red(COLOR_RED); - ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher_red}); - } - stream.log(ret == 0 ? "[DEBUG] Turtwig Selection: Confirmation dialogue detected" - : "[WARNING] Turtwig Selection: Dialogue detection failed, proceeding anyway"); - - pbf_wait(context, 400ms); - pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); - pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); - context.wait_for_all_requests(); - stream.log("[DEBUG] Turtwig Selection: Selection complete"); -} + static void select_turtwig(VideoStream& stream, ProControllerContext& context) { + stream.log("[DEBUG] Turtwig Selection: Starting starter selection"); -static void select_chimchar(VideoStream& stream, ProControllerContext& context){ - stream.log("[DEBUG] Chimchar Selection: Starting starter selection"); - - pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); - pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); - context.wait_for_all_requests(); - - ShortDialogWatcher watcher(COLOR_CYAN); - int ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher}); - if (ret != 0){ - ShortDialogWatcher watcher_red(COLOR_RED); - ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher_red}); - } - stream.log(ret == 0 ? "[DEBUG] Chimchar Selection: Confirmation dialogue detected" - : "[WARNING] Chimchar Selection: Dialogue detection failed, proceeding anyway"); - - pbf_wait(context, 400ms); - pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); - pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); - context.wait_for_all_requests(); - stream.log("[DEBUG] Chimchar Selection: Selection complete"); -} + pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); + context.wait_for_all_requests(); -static void select_piplup(VideoStream& stream, ProControllerContext& context){ - stream.log("[DEBUG] Piplup Selection: Starting starter selection"); - - pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); - pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); - pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); - context.wait_for_all_requests(); - - ShortDialogWatcher watcher(COLOR_CYAN); - int ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher}); - if (ret != 0){ - ShortDialogWatcher watcher_red(COLOR_RED); - ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher_red}); - } - stream.log(ret == 0 ? "[DEBUG] Piplup Selection: Confirmation dialogue detected" - : "[WARNING] Piplup Selection: Dialogue detection failed, proceeding anyway"); - - pbf_wait(context, 400ms); - pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); - pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); - context.wait_for_all_requests(); - stream.log("[DEBUG] Piplup Selection: Selection complete"); -} + ShortDialogWatcher watcher(COLOR_CYAN); + int ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher } + ); + if (ret != 0) { + ShortDialogWatcher watcher_red(COLOR_RED); + ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher_red } + ); + } + stream.log(ret == 0 ? "[DEBUG] Turtwig Selection: Confirmation dialogue detected" + : "[WARNING] Turtwig Selection: Dialogue detection failed, proceeding anyway"); + + pbf_wait(context, 400ms); + pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); + pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); + context.wait_for_all_requests(); + stream.log("[DEBUG] Turtwig Selection: Selection complete"); + } -// Navigation functions for each part of the tutorial. These functions are meant to be used -// in order and rely on the state of the game being what it is at the end of the previous function. -// They may also be modified or removed without warning. - -// --------------------------------------------------------------------------- -// Segment 00-01 begins -// Navigates from player's room to Route 201 through Mom/Barry dialogues. -// --------------------------------------------------------------------------- - -static void -tutorial_part_1_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - DateTime t{2026, 1, 1, 0, 0, 0}; - set_time_fast(env, context, t); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 527ms, 0ms); - pbf_move_left_joystick(context, {1.000000, 1.000000}, 456ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 915ms, 0ms); - pbf_move_left_joystick(context, {1.000000, 1.000000}, 642ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 557ms, 0ms); - pbf_wait(context, 978ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1112ms, 0ms); - pbf_wait(context, 4967ms); - - wait_for_dialogue(stream, context, "Phase 1 (Mom)"); - - pbf_mash_button(context, BUTTON_A, 5000ms); - pbf_wait(context, 300ms); - - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1662ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -1.000000}, 49ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1215ms, 0ms); - pbf_wait(context, 1419ms); - - wait_for_dialogue(stream, context, "Phase 2 (Mom Warning)"); - - pbf_mash_button(context, BUTTON_A, 2500ms); - pbf_wait(context, 300ms); - context.wait_for_all_requests(); - - pbf_move_left_joystick(context, {0.000000, -1.000000}, 680ms, 0ms); - - pbf_wait(context, 4000ms); - - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1748ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 18ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2763ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 58ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1364ms, 0ms); - pbf_wait(context, 4000ms); - context.wait_for_all_requests(); - - wait_for_dialogue(stream, context, "Phase 3 (Barry 1st)"); - pbf_mash_button(context, BUTTON_A, 6000ms); - pbf_wait(context, 800ms); - wait_for_dialogue(stream, context, "Phase 3 (Barry 1st)"); - pbf_mash_button(context, BUTTON_A, 6000ms); - pbf_wait(context, 800ms); - - pbf_wait(context, 1306ms); - context.wait_for_all_requests(); - stream.log("[Barry house] Entering; waiting for entry fade."); - BlackScreenWatcher entry_black; - int entry_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_move_left_joystick(ctx, {0, 1}, 5000ms, 0ms); - }, - {{entry_black}}); - if (entry_ret != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Barry house: Entry fade not detected.", - stream); - } - // Release the joystick during loading; resume indoor movement only - // after the screen has stayed non-black for half a second. - BlackScreenWatcher entry_visible(COLOR_RED, - {0.1, 0.1, 0.8, 0.8}, - 100, - 10, - BlackScreenWatcher::FinderType::GONE, - 500ms); - if (wait_until(stream, context, 15s, {{entry_visible}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Barry house: Entry fade did not finish.", - stream); - } - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - stream.log("[Barry house] Entry settled; starting stair approach."); - // Align left after entry before the next upward stair approach. - pbf_move_left_joystick(context, {0.000000, 1.000000}, 400ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 0.000000}, 1600ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2722ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1600ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 501ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 652ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1817ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 975ms, 0ms); - pbf_wait(context, 1422ms); - - wait_for_dialogue(stream, context, "Phase 8 (Barry 2nd dialog)"); - pbf_mash_button(context, BUTTON_A, 10000ms); - - pbf_wait(context, 856ms); - - - // Recorded route from the top of Barry's stairs. Moves rounded to - // 100ms, waits to 50ms; transition waits gate both floor changes. - recorded_move_until_transition(stream, context, -1.0, 0.0, 900ms, "Barry house downstairs"); - pbf_wait(context, 750ms); - pbf_move_left_joystick(context, {-1.0, 0.0}, 2000ms, 0ms); - pbf_wait(context, 250ms); - pbf_move_left_joystick(context, {0.0, -1.0}, 2000ms, 0ms); - pbf_wait(context, 150ms); - pbf_move_left_joystick(context, {1.0, 0.0}, 1400ms, 0ms); - pbf_wait(context, 500ms); - recorded_move_until_transition(stream, context, 0.0, -1.0, 1200ms, "Barry house exit"); - // First-run video placed the player east of the northbound path. - // Shorten the rightward approach; tune further from the next recording. - stream.log("[Route 201] Aligning east for 2300ms before northbound approach."); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 2300ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 5861ms, 0ms); - pbf_wait(context, 134ms); - - wait_for_dialogue(stream, context, "Phase 9 (Barry Route 201)"); - - pbf_mash_button(context, BUTTON_B, 6000ms); - pbf_wait(context, 800ms); - - pbf_move_left_joystick(context, {0.000000, 1.000000}, 700ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 279ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 0.000000}, 10ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 145ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 0.000000}, 7703ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 140ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1700ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 39ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 0.000000}, 544ms, 1000ms); - - pbf_wait(context, 1000ms); - - // save_game(stream, context); // Disabled: checkpoint save mode controls saving. - context.wait_for_all_requests(); -} -// --------------------------------------------------------------------------- -// End of Segment 00 - 01 -// Standing in front lake varity ready to enter lake Verity for starter selection. -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Segment 00-02 begins -// Navigates into lake Verity to recieve starter -// --------------------------------------------------------------------------- -static void starter_nav(VideoStream& stream, ProControllerContext& context){ - // re-sync control - pbf_press_button(context, BUTTON_B, 20ms, 200ms); - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); - - // continue movement - pbf_move_left_joystick(context, {0.000000, 1.000000}, 300ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 300ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 10 (Barry before the starter selection)"); - - pbf_mash_button(context, BUTTON_A, 1000ms); - - pbf_wait(context, 500ms); - std::shared_ptr briefcase = - std::make_shared(RESOURCE_PATH() + "PokemonBDSP/StarterBriefcase.png"); - - // Mash B until we see the briefcase. - ImageMatchWatcher detector(briefcase, {0.5, 0.1, 0.5, 0.7}, 100, true); - - int ret = run_until(stream, - context, - [](ProControllerContext& context){ - pbf_mash_button(context, BUTTON_B, 120000ms); - }, - {{detector}}); - - if (ret == 0){ - stream.log("Detected briefcase!"); - }else{ - stream.log("Timed out waiting for briefcase."); - throw OperationFailedExceptionWithScreenshot(ErrorReportMode::SEND_ERROR_REPORT, - "Failed to detect briefcase in Segment 00.", - stream); - } + static void select_chimchar(VideoStream& stream, ProControllerContext& context) { + stream.log("[DEBUG] Chimchar Selection: Starting starter selection"); - // Wait for briefcase to fully open. - stream.log("Mashing B for briefcase to fully open."); - pbf_mash_button(context, BUTTON_B, 2000ms); -} -static void post_starly_clenaup(VideoStream& stream, ProControllerContext& context){ - pbf_mash_button(context, BUTTON_B, 60000ms); -} -// --------------------------------------------------------------------------- -// End of Segment 00 - 02 -// Standing in front lake varity after starter selection. -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Segment 00-03 begins -// Navigates from Exit of lake Verity to receipt of the Pokedex -// --------------------------------------------------------------------------- - -static void -tutorial_part_2_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); - - pbf_wait(context, 563ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1700ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -1.000000}, 510ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 50ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -1.000000}, 310ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 5500ms, 0ms); - pbf_wait(context, 3963ms); - context.wait_for_all_requests(); - - wait_for_dialogue(stream, context, "Phase 10 (Professor dialog to mom)"); - pbf_mash_button(context, BUTTON_B, 40000ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 820ms, 0ms); - pbf_wait(context, 4000ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 800ms, 0ms); - pbf_wait(context, 555ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 50ms, 0ms); - pbf_wait(context, 23ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 5020ms, 0ms); - pbf_wait(context, 169ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 745ms, 0ms); - DateTime t{2026, 1, 1, 0, 0, 50}; - set_time_fast(env, context, t); - walk_through_grass(stream, context, 1, 0, 20, "right through grass to path", BattleType::STARTER); - walk_through_grass(stream, context, 0, 1, 18, "up through grass 2", BattleType::STARTER); - walk_through_grass(stream, context, 1, 0, 10, "right through grass 2", BattleType::STARTER); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 730ms, 0ms); - pbf_wait(context, 941ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1950ms, 0ms); - wait_for_dialogue(stream, context, "Phase 11 (dawn conversation)"); - pbf_mash_button(context, BUTTON_B, 50000ms); - wait_for_dialogue(stream, context, "Phase 12 (Nickname reject)"); - pbf_mash_button(context, BUTTON_B, 40000ms); - wait_for_dialogue(stream, context, "Phase 13 (Pokedex accept)"); - pbf_mash_button(context, BUTTON_A, 40000ms); - - - // pokedex received -} + pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); + pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); + context.wait_for_all_requests(); + ShortDialogWatcher watcher(COLOR_CYAN); + int ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher } + ); + if (ret != 0) { + ShortDialogWatcher watcher_red(COLOR_RED); + ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher_red } + ); + } + stream.log(ret == 0 ? "[DEBUG] Chimchar Selection: Confirmation dialogue detected" + : "[WARNING] Chimchar Selection: Dialogue detection failed, proceeding anyway"); + + pbf_wait(context, 400ms); + pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); + pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); + context.wait_for_all_requests(); + stream.log("[DEBUG] Chimchar Selection: Selection complete"); + } -// --------------------------------------------------------------------------- -// End of Segment 00 - 03 -// Standing in front of the professor after receiving the pokedex. -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Begin Segment 00 - 04 -// Will heal at Sandgem pokeCenter to reliably catch a second pokemon for Home -// transfer. -// Collects poketech and stands in front of the PC to end the tutorial section -// and send the starter to pokemon HOME. -// --------------------------------------------------------------------------- - -static void -tutorial_part_3_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - // Arm exit detection before movement and finish the fade before - // starting the dialogue watcher, which cancels queued commands. - recorded_move_until_transition(stream, context, 0.0, -1.0, 2010ms, "Rowan lab exit"); - context.wait_for_all_requests(); - - wait_for_dialogue(stream, context, "Phase 14 (dawn tour)"); - - pbf_mash_button(context, BUTTON_B, 25000ms); - - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 5125ms, 0ms); - pbf_wait(context, 336ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1700ms, 0ms); - pbf_wait(context, 213ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2325ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 950ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2360ms, 0ms); - pbf_wait(context, 253ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 4610ms, 0ms); - pbf_wait(context, 954ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 220ms, 0ms); - pbf_wait(context, 587ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1375ms, 0ms); - pbf_wait(context, 464ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 775ms, 0ms); - pbf_wait(context, 1655ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 640ms, 0ms); - pbf_wait(context, 3128ms); - pbf_press_dpad(context, DPAD_UP, 180ms, 0ms); - pbf_wait(context, 472ms); - pbf_press_dpad(context, DPAD_UP, 130ms, 0ms); - pbf_wait(context, 1621ms); - pbf_press_dpad(context, DPAD_RIGHT, 130ms, 0ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 15 (Mom conversation to accept parcel)"); - - pbf_mash_button(context, BUTTON_B, 40000ms); - - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - pbf_press_dpad(context, DPAD_LEFT, 150ms, 0ms); - pbf_wait(context, 1143ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 875ms, 0ms); - pbf_wait(context, 1169ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - pbf_wait(context, 946ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - pbf_wait(context, 9092ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - pbf_wait(context, 740ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - pbf_wait(context, 2998ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 240ms, 0ms); - pbf_wait(context, 893ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 5300ms, 0ms); - pbf_wait(context, 282ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 260ms, 0ms); - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - - DateTime t{2026, 1, 1, 0, 0, 50}; - set_time_fast(env, context, t); - walk_through_grass(stream, context, 1, 0, 20, "right through grass to path", BattleType::STARTER); - walk_through_grass(stream, context, 0, 1, 18, "up through grass 2", BattleType::STARTER); - walk_through_grass(stream, context, 1, 0, 10, "right through grass 2", BattleType::STARTER); - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - - pbf_move_left_joystick(context, {0.000000, -1.000000}, 560ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 5000ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1000ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1855ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 430ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 850ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1120ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 16 (catch tutorial)"); - - pbf_mash_button(context, BUTTON_B, 70000ms); - context.wait_for_all_requests(); - pbf_wait(context, 2000ms); - catch_1_pokemon(stream, context); - pbf_wait(context, 1500ms); - context.wait_for_all_requests(); -} + static void select_piplup(VideoStream& stream, ProControllerContext& context) { + stream.log("[DEBUG] Piplup Selection: Starting starter selection"); -// Align to a route reference before continuing recorded navigation. -// Matcher offsets and thresholds are fractions of screen width/height. -static void align_to_tutorial_landmarks(VideoStream& stream, - ProControllerContext& context, - OverworldMatchResult (*matcher)(const ImageRGB32&), - const std::string& label){ - context.wait_for_all_requests(); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - size_t confirmations = 0, coarse_confirmations = 0; - double previous_dx = 0, previous_dy = 0; - for (size_t attempt = 0; attempt < 60; attempt++){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": No video frame.", - stream); - } - const auto result = matcher(*frame.frame); - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); + pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); + pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); + pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); + context.wait_for_all_requests(); + + ShortDialogWatcher watcher(COLOR_CYAN); + int ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher } + ); + if (ret != 0) { + ShortDialogWatcher watcher_red(COLOR_RED); + ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher_red } + ); + } + stream.log(ret == 0 ? "[DEBUG] Piplup Selection: Confirmation dialogue detected" + : "[WARNING] Piplup Selection: Dialogue detection failed, proceeding anyway"); + + pbf_wait(context, 400ms); + pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); + pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); + context.wait_for_all_requests(); + stream.log("[DEBUG] Piplup Selection: Selection complete"); + } + + // Navigation functions for each part of the tutorial. These functions are meant to be used + // in order and rely on the state of the game being what it is at the end of the previous function. + // They may also be modified or removed without warning. + + // --------------------------------------------------------------------------- + // Segment 00-01 begins + // Navigates from player's room to Route 201 through Mom/Barry dialogues. + // --------------------------------------------------------------------------- + + static void tutorial_part_1_navigation(SingleSwitchProgramEnvironment& env, + VideoStream& stream, ProControllerContext& context) { + DateTime t{ 2026, 1, 1, 0, 0, 0 }; + set_time_fast(env, context, t); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 527ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, 1.000000 }, 456ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 915ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, 1.000000 }, 642ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 557ms, 0ms); + pbf_wait(context, 978ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1112ms, 0ms); + pbf_wait(context, 4967ms); + + wait_for_dialogue(stream, context, "Phase 1 (Mom)"); + + pbf_mash_button(context, BUTTON_A, 5000ms); + pbf_wait(context, 300ms); + + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1662ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -1.000000 }, 49ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1215ms, 0ms); + pbf_wait(context, 1419ms); + + wait_for_dialogue(stream, context, "Phase 2 (Mom Warning)"); + + pbf_mash_button(context, BUTTON_A, 2500ms); + pbf_wait(context, 300ms); + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 680ms, 0ms); + + pbf_wait(context, 4000ms); + + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1748ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 18ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 2763ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 58ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1364ms, 0ms); + pbf_wait(context, 4000ms); + context.wait_for_all_requests(); + + wait_for_dialogue(stream, context, "Phase 3 (Barry 1st)"); + pbf_mash_button(context, BUTTON_A, 6000ms); + pbf_wait(context, 800ms); + wait_for_dialogue(stream, context, "Phase 3 (Barry 1st)"); + pbf_mash_button(context, BUTTON_A, 6000ms); + pbf_wait(context, 800ms); + + pbf_wait(context, 1306ms); + context.wait_for_all_requests(); + stream.log("[Barry house] Entering; waiting for entry fade."); + BlackScreenWatcher entry_black; + int entry_ret = run_until(stream, context, + [](ProControllerContext& ctx) { + pbf_move_left_joystick(ctx, {0, 1}, 5000ms, 0ms); + }, {{entry_black}}); + if (entry_ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Barry house: Entry fade not detected.", stream); + } + // Release the joystick during loading; resume indoor movement only + // after the screen has stayed non-black for half a second. + BlackScreenWatcher entry_visible(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, + 100, 10, BlackScreenWatcher::FinderType::GONE, 500ms); + if (wait_until(stream, context, 15s, {{entry_visible}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Barry house: Entry fade did not finish.", stream); } - stream.log("[" + label + "] " + landmark.name + " score=" + std::to_string(landmark.score) + - " margin=" + std::to_string(landmark.margin) + " accepted=" + std::to_string(landmark.accepted) + - " screen-relative offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + stream.log("[Barry house] Entry settled; starting stair approach."); + // Align left after entry before the next upward stair approach. + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 400ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 0.000000 }, 1600ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 2722ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1600ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 501ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 652ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 1817ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 975ms, 0ms); + pbf_wait(context, 1422ms); + + wait_for_dialogue(stream, context, "Phase 8 (Barry 2nd dialog)"); + pbf_mash_button(context, BUTTON_A, 10000ms); + + pbf_wait(context, 856ms); + + + // Recorded route from the top of Barry's stairs. Moves rounded to + // 100ms, waits to 50ms; transition waits gate both floor changes. + recorded_move_until_transition(stream, context, -1.0, 0.0, 900ms, + "Barry house downstairs"); + pbf_wait(context, 750ms); + pbf_move_left_joystick(context, {-1.0, 0.0}, 2000ms, 0ms); + pbf_wait(context, 250ms); + pbf_move_left_joystick(context, {0.0, -1.0}, 2000ms, 0ms); + pbf_wait(context, 150ms); + pbf_move_left_joystick(context, {1.0, 0.0}, 1400ms, 0ms); + pbf_wait(context, 500ms); + recorded_move_until_transition(stream, context, 0.0, -1.0, 1200ms, + "Barry house exit"); + // First-run video placed the player east of the northbound path. + // Shorten the rightward approach; tune further from the next recording. + stream.log("[Route 201] Aligning east for 2300ms before northbound approach."); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 2300ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 5861ms, 0ms); + pbf_wait(context, 134ms); + + wait_for_dialogue(stream, context, "Phase 9 (Barry Route 201)"); + + pbf_mash_button(context, BUTTON_B, 6000ms); + pbf_wait(context, 800ms); + + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 700ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 279ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 0.000000 }, 10ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 145ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 0.000000 }, 7703ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 140ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 1700ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 39ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 0.000000 }, 544ms, 1000ms); + + pbf_wait(context, 1000ms); + + // save_game(stream, context); // Disabled: checkpoint save mode controls saving. + context.wait_for_all_requests(); } - stream.log("[" + label + "] located=" + std::to_string(result.located) + - " mode=" + (result.coarse ? "vertical acquisition" : "fine alignment") + - " reference screen-relative offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")"); - if (result.coarse && result.located){ - if (coarse_confirmations && std::abs(result.dx - previous_dx) <= 0.00625 && - std::abs(result.dy - previous_dy) <= 1.0 / 90){ - coarse_confirmations++; - }else{ - coarse_confirmations = 1; + // --------------------------------------------------------------------------- + // End of Segment 00 - 01 + // Standing in front lake varity ready to enter lake Verity for starter selection. + // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // Segment 00-02 begins + // Navigates into lake Verity to recieve starter + // --------------------------------------------------------------------------- + static void starter_nav(VideoStream& stream, ProControllerContext& context) { + // re-sync control + pbf_press_button(context, BUTTON_B, 20ms, 200ms); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + + // continue movement + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 300ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 300ms, 0ms); + + wait_for_dialogue(stream, context, "Phase 10 (Barry before the starter selection)"); + + pbf_mash_button(context, BUTTON_A, 1000ms); + + pbf_wait(context, 500ms); + std::shared_ptr briefcase = + std::make_shared( + RESOURCE_PATH() + "PokemonBDSP/StarterBriefcase.png" + ); + + // Mash B until we see the briefcase. + ImageMatchWatcher detector(briefcase, { 0.5, 0.1, 0.5, 0.7 }, 100, true); + + int ret = run_until( + stream, context, + [](ProControllerContext& context) { + pbf_mash_button(context, BUTTON_B, 120000ms); + }, + { {detector} } + ); + + if (ret == 0) { + stream.log("Detected briefcase!"); + } + else { + stream.log("Timed out waiting for briefcase."); + throw OperationFailedExceptionWithScreenshot( + ErrorReportMode::SEND_ERROR_REPORT, + "Failed to detect briefcase in Segment 00.", + stream + ); } - previous_dx = result.dx; - previous_dy = result.dy; - }else{ - coarse_confirmations = 0; + + // Wait for briefcase to fully open. + stream.log("Mashing B for briefcase to fully open."); + pbf_mash_button(context, BUTTON_B, 2000ms); } - if (!result.located){ - confirmations = 0; - pbf_wait(context, 250ms); - }else if (result.coarse && coarse_confirmations < 3){ - confirmations = 0; - stream.log("[" + label + "] Waiting for consistent vertical acquisition."); - pbf_wait(context, 250ms); - }else if (result.at_target){ - if (++confirmations >= 3){ - stream.log("[" + label + "] Reference position confirmed."); - return; + static void post_starly_clenaup(VideoStream& stream, ProControllerContext& context) { + pbf_mash_button(context, BUTTON_B, 60000ms); + } + // --------------------------------------------------------------------------- + // End of Segment 00 - 02 + // Standing in front lake varity after starter selection. + // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // Segment 00-03 begins + // Navigates from Exit of lake Verity to receipt of the Pokedex + // --------------------------------------------------------------------------- + + static void tutorial_part_2_navigation(SingleSwitchProgramEnvironment& env, + VideoStream& stream, ProControllerContext& context) { + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + + pbf_wait(context, 563ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1700ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -1.000000 }, 510ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 50ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -1.000000 }, 310ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 5500ms, 0ms); + pbf_wait(context, 3963ms); + context.wait_for_all_requests(); + + wait_for_dialogue(stream, context, "Phase 10 (Professor dialog to mom)"); + pbf_mash_button(context, BUTTON_B, 40000ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 820ms, 0ms); + pbf_wait(context, 4000ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 800ms, 0ms); + pbf_wait(context, 555ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 50ms, 0ms); + pbf_wait(context, 23ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 5020ms, 0ms); + pbf_wait(context, 169ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 745ms, 0ms); + DateTime t{ 2026, 1, 1, 0, 0, 50 }; + set_time_fast(env, context, t); + walk_through_grass(stream, context, 1, 0, 20, "right through grass to path", BattleType::STARTER); + walk_through_grass(stream, context, 0, 1, 18, "up through grass 2", BattleType::STARTER); + walk_through_grass(stream, context, 1, 0, 10, "right through grass 2", BattleType::STARTER); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 730ms, 0ms); + pbf_wait(context, 941ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 1950ms, 0ms); + wait_for_dialogue(stream, context, "Phase 11 (dawn conversation)"); + pbf_mash_button(context, BUTTON_B, 50000ms); + wait_for_dialogue(stream, context, "Phase 12 (Nickname reject)"); + pbf_mash_button(context, BUTTON_B, 40000ms); + wait_for_dialogue(stream, context, "Phase 13 (Pokedex accept)"); + pbf_mash_button(context, BUTTON_A, 40000ms); + + + // pokedex received + } + + + // --------------------------------------------------------------------------- + // End of Segment 00 - 03 + // Standing in front of the professor after receiving the pokedex. + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // Begin Segment 00 - 04 + // Will heal at Sandgem pokeCenter to reliably catch a second pokemon for Home + // transfer. + // Collects poketech and stands in front of the PC to end the tutorial section + // and send the starter to pokemon HOME. + // --------------------------------------------------------------------------- + + // Inside the player's house, just through the door: Mom watches TV a + // column east of the door and two rows north of the mat. The door fixes + // our column, but counted taps north lost a row (r108b stood a row below + // her). So find her row: face east and press A, and step one tile north + // at a time until she answers. A press from a row off talks to nobody, + // and the TV stand stops us a row above her. + static void talk_to_mom_at_home(VideoStream& stream, ProControllerContext& context) { + for (size_t row = 0; row < 4; row++) { + if (talk_to_adjacent_npc(stream, context, {Pokemon::Direction::EAST}, "Home: Mom")) { + stream.log("[00.04] Mom answered after " + std::to_string(row) + " step(s) north."); + return; + } + // Facing east, a short tap north only turns; facing north, it is one tile. + route_turn(stream, context, Pokemon::Direction::NORTH); + pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); + context.wait_for_all_requests(); } - pbf_wait(context, 300ms); - }else{ - confirmations = 0; - const bool horizontal = !result.coarse && std::abs(result.dx) >= std::abs(result.dy) * 9.0 / 16; - const double error = horizontal ? result.dx : result.dy * 9.0 / 16; - const auto duration = std::abs(error) > 1.0 / 24 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; - stream.log("[" + label + "] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + " axis " + - (horizontal ? (result.dx > 0 ? "right" : "left") : (result.dy < 0 ? "up" : "down")) + " for " + - std::to_string(duration.count()) + "ms."); - // Reuse confirmed-Run encounter handling if correction enters grass. - walk_through_grass(stream, - context, - horizontal ? (result.dx > 0 ? 1 : -1) : 0, - horizontal ? 0 : (result.dy < 0 ? 1 : -1), - 1, - label, - BattleType::STANDARD, - duration); + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Home: Mom did not answer from any of four rows north of the door.", stream); } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Reference position not confirmed; route movement stopped.", - stream); -} -static void align_to_sandgem_junction(VideoStream& stream, ProControllerContext& context){ - align_to_tutorial_landmarks(stream, context, match_sandgem_junction, "Sandgem junction"); -} + static void tutorial_part_3_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { + // Arm exit detection before movement and finish the fade before + // starting the dialogue watcher, which cancels queued commands. + recorded_move_until_transition(stream, context, 0.0, -1.0, 2010ms, + "Rowan lab exit"); + context.wait_for_all_requests(); + + wait_for_dialogue(stream, context, "Phase 14 (dawn tour)"); + + pbf_mash_button(context, BUTTON_B, 25000ms); + + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 5125ms, 0ms); + pbf_wait(context, 336ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1700ms, 0ms); + pbf_wait(context, 213ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 2325ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 950ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 2360ms, 0ms); + pbf_wait(context, 253ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 4610ms, 0ms); + pbf_wait(context, 954ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 220ms, 0ms); + pbf_wait(context, 587ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1375ms, 0ms); + pbf_wait(context, 464ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 775ms, 0ms); + pbf_wait(context, 1655ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 640ms, 0ms); + pbf_wait(context, 3128ms); + talk_to_mom_at_home(stream, context); + + wait_for_dialogue(stream, context, "Phase 15 (Mom conversation to accept parcel)"); + + pbf_mash_button(context, BUTTON_B, 40000ms); + + context.wait_for_all_requests(); + pbf_wait(context, 1500ms); + pbf_press_dpad(context, DPAD_LEFT, 150ms, 0ms); + pbf_wait(context, 1143ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 875ms, 0ms); + pbf_wait(context, 1169ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + pbf_wait(context, 946ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + pbf_wait(context, 9092ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + pbf_wait(context, 740ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + pbf_wait(context, 2998ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 240ms, 0ms); + pbf_wait(context, 893ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 5300ms, 0ms); + pbf_wait(context, 282ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 260ms, 0ms); + context.wait_for_all_requests(); + pbf_wait(context, 1500ms); + + DateTime t{ 2026, 1, 1, 0, 0, 50 }; + set_time_fast(env, context, t); + walk_through_grass(stream, context, 1, 0, 20, "right through grass to path", BattleType::STARTER); + walk_through_grass(stream, context, 0, 1, 18, "up through grass 2", BattleType::STARTER); + walk_through_grass(stream, context, 1, 0, 10, "right through grass 2", BattleType::STARTER); + context.wait_for_all_requests(); + pbf_wait(context, 1500ms); + + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 560ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 5000ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1000ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 1855ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 430ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 850ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1120ms, 0ms); + + wait_for_dialogue(stream, context, "Phase 16 (catch tutorial)"); + + pbf_mash_button(context, BUTTON_B, 70000ms); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + catch_1_pokemon(stream, context); + pbf_wait(context, 1500ms); + context.wait_for_all_requests(); + } -// Resumes from the save made after catching the second Pokemon. -static void tutorial_part_3_after_catch_navigation(SingleSwitchProgramEnvironment& env, - VideoStream& stream, - ProControllerContext& context){ - pbf_wait(context, 1500ms); - //-------------------------------------------------------- - // To get potions - // ------------------------------------------------------- - walk_through_grass(stream, - context, - 1, - 0, - 10, - "Route 202 grass exit", - BattleType::STANDARD, - 200ms, - match_route202_grass_exit); - // Recording: the exit reference was reached after two 200ms steps. - // Eight further steps (1600ms) reached the eastern tree boundary. - // Continue outside grass to preserve the next down movement's start. - stream.log("[Tutorial] Route 202 grass exit confirmed; continuing right to the tree boundary."); - pbf_move_left_joystick(context, {1.0, 0.0}, 1600ms, 0ms); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1421ms, 0ms); - pbf_wait(context, 52ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1013ms, 0ms); - pbf_wait(context, 142ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1112ms, 0ms); - pbf_wait(context, 45ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 3206ms, 0ms); - pbf_wait(context, 912ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 856ms, 0ms); - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - walk_through_grass(stream, context, -1, 0, 18, "left through grass 3"); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 17 (accept potions)"); - - pbf_mash_button(context, BUTTON_B, 10000ms); - context.wait_for_all_requests(); - // Catching the second Pokemon can damage the starter. Verify healing - // once Potions are available, before continuing toward Tristan. - stream.log("[Tutorial] Checking starter HP after the first Potion pickup."); - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - // Complete one training encounter before Tristan. The helper waits - // through XP/automatic move learning and heals after the fight. - battle_1_pokemon(stream, context); - - // Stop on the verified Route 201 reference instead of overshooting - // with ten fixed steps. The limit also allows small final corrections. - walk_through_grass(stream, - context, - 1, - 0, - 20, - "Route 201 Potion exit", - BattleType::STANDARD, - 200ms, - match_route201_potion_exit); - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - - // --------------------------------------------------------- - // Navigate back to catch tutorial grass - // --------------------------------------------------------- - pbf_move_left_joystick(context, {0.000000, -1.000000}, 560ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 5000ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1000ms, 0ms); - pbf_wait(context, 1500ms); - align_to_sandgem_junction(stream, context); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1855ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 430ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 850ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1120ms, 0ms); - - // Continue on to jubilife - walk_through_grass(stream, context, -1, 0, 18, "left through grass 3"); - walk_through_grass(stream, context, 0, 1, 18, "up through grass 3"); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1000ms, 0ms); - wait_for_dialogue(stream, context, "Phase 18 (tristan encounter dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); - fight_tristan(stream, context); - - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1366ms, 0ms); - pbf_wait(context, 467ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1018ms, 0ms); - pbf_wait(context, 311ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 353ms, 0ms); - pbf_wait(context, 660ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 580ms, 0ms); - pbf_wait(context, 1672ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1344ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 19 (natalie encounter dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); - - fight_Natalie(stream, context); - context.wait_for_all_requests(); - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1301ms, 0ms); - walk_through_grass(stream, context, 0, 1, 15, "up through grass"); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 392ms, 0ms); - pbf_wait(context, 1261ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 815ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 20 (logan encounter dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); - fight_Natalie(stream, context); - context.wait_for_all_requests(); - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - walk_through_grass(stream, context, -1, 0, 8, "left through grass"); - - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2469ms, 0ms); - wait_for_dialogue(stream, context, "Phase 21 (dawn Jubilife dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); -} -// --------------------------------------------------------------------------- -// End of Segment 00 - 04 -// Standing in Jubilife after speaking to dawn. -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Begin Segment 00 - 05 -// Standing at the entrance to jubilife after speaking to dawn, will navigate to the poketech challenge. -// Collects poketech and stands in front of the PC to end the tutorial section -// and send the starter to pokemon HOME. -// --------------------------------------------------------------------------- - -// Stop the approach as soon as buildings can guide it, instead of -// overshooting south and then blindly walking back north. -static void approach_poketch_landmarks(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - Milliseconds walked = 0ms; - while (walked < 1900ms){ - auto frame = stream.video().snapshot(); - if (!frame){ + // Align to a route reference before continuing recorded navigation. + // Matcher offsets and thresholds are fractions of screen width/height. + static void align_to_tutorial_landmarks( + VideoStream& stream, ProControllerContext& context, + OverworldMatchResult (*matcher)(const ImageRGB32&), const std::string& label + ) { + context.wait_for_all_requests(); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + size_t confirmations = 0, coarse_confirmations = 0; + double previous_dx = 0, previous_dy = 0; + for (size_t attempt = 0; attempt < 60; attempt++) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No video frame.", stream); + const auto result = matcher(*frame.frame); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[" + label + "] " + landmark.name + + " score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin) + + " accepted=" + std::to_string(landmark.accepted) + + " screen-relative offset=(" + std::to_string(landmark.dx) + + "," + std::to_string(landmark.dy) + ")"); + } + stream.log("[" + label + "] located=" + std::to_string(result.located) + + " mode=" + (result.coarse ? "vertical acquisition" : "fine alignment") + + " reference screen-relative offset=(" + std::to_string(result.dx) + + "," + std::to_string(result.dy) + ")"); + if (result.coarse && result.located) { + if (coarse_confirmations && std::abs(result.dx-previous_dx)<=0.00625 && + std::abs(result.dy-previous_dy)<=1.0/90) coarse_confirmations++; + else coarse_confirmations=1; + previous_dx=result.dx; + previous_dy=result.dy; + } else coarse_confirmations=0; + if (!result.located) { + confirmations = 0; + pbf_wait(context, 250ms); + } else if (result.coarse && coarse_confirmations<3) { + confirmations = 0; + stream.log("[" + label + "] Waiting for consistent vertical acquisition."); + pbf_wait(context, 250ms); + } else if (result.at_target) { + if (++confirmations >= 3) { + stream.log("[" + label + "] Reference position confirmed."); + return; + } + pbf_wait(context, 300ms); + } else { + confirmations = 0; + const bool horizontal = !result.coarse && + std::abs(result.dx) >= std::abs(result.dy)*9.0/16; + const double error = horizontal ? result.dx : result.dy*9.0/16; + const auto duration = std::abs(error) > 1.0/24 ? 80ms : + std::abs(error) > 0.015625 ? 40ms : 16ms; + stream.log("[" + label + "] Correcting " + + std::string(horizontal ? "horizontal" : "vertical") + " axis " + + (horizontal ? (result.dx>0 ? "right" : "left") : (result.dy<0 ? "up" : "down")) + + " for " + std::to_string(duration.count()) + "ms."); + // Reuse confirmed-Run encounter handling if correction enters grass. + walk_through_grass(stream, context, + horizontal ? (result.dx > 0 ? 1 : -1) : 0, + horizontal ? 0 : (result.dy < 0 ? 1 : -1), + 1, label, BattleType::STANDARD, duration); + } + context.wait_for_all_requests(); + } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poketch approach: No video frame.", - stream); + label + ": Reference position not confirmed; route movement stopped.", stream); } - const auto result = match_poketch_landmarks(*frame.frame); - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); + + static void align_to_sandgem_junction(VideoStream& stream, ProControllerContext& context) { + align_to_tutorial_landmarks(stream, context, match_sandgem_junction, "Sandgem junction"); + } + + // What the post-catch half of 00.04 does, logged up front and as each + // step starts. A Lv5 Piplup fainted to Route 202's trainers (r72), so: + // training, a heal at the Center, and Potions (they also saved the + // Barry fight in r85) before Route 202's trainers. The Poke Mart lady + // on Route 201 gives 10 for free, but she wanders between her grass + // row and the open ground above it, and eight runs (r113a..r120a) + // never got her talk; the Mart's clerk stays put. + static const char* const AFTER_CATCH_STEPS[] = { + "Train: fight 3 wild Pokemon in the catch grass, with the best move", + "Walk south into Sandgem Town and heal at its Pokemon Center", + "Buy 5 Potions at the Poke Mart next door, and check the Bag has them", + "Walk back to Route 202's save spot and save", + "Walk north up Route 202 to the Jubilife exit, fighting trainers who see us; after each, use a Potion if HP is under half", + "Enter Jubilife and get through Dawn's scene", + }; + constexpr size_t AFTER_CATCH_STEP_COUNT = sizeof(AFTER_CATCH_STEPS) / sizeof(AFTER_CATCH_STEPS[0]); + + static void log_after_catch_steps(VideoStream& stream) { + std::string text = "[00.04] After the catch, the steps are:"; + for (size_t i = 0; i < AFTER_CATCH_STEP_COUNT; i++) { + text += "\n " + std::to_string(i + 1) + ". " + AFTER_CATCH_STEPS[i]; } - stream.log("[Poketch approach] " + landmark.name + " score=" + std::to_string(landmark.score) + - " margin=" + std::to_string(landmark.margin)); + stream.log(text, COLOR_BLUE); } - if (result.located){ - stream.log("[Poketch approach] Landmarks acquired after " + std::to_string(walked.count()) + - "ms; ending downward approach."); - return; + + static void after_catch_step(VideoStream& stream, size_t number) { + const std::string count = std::to_string(number) + "/" + std::to_string(AFTER_CATCH_STEP_COUNT); + std::string text = "[00.04 step " + count + "] " + AFTER_CATCH_STEPS[number - 1]; + text += number < AFTER_CATCH_STEP_COUNT ? std::string(" -- next: ") + AFTER_CATCH_STEPS[number] : std::string(" -- last step"); + stream.log(text, COLOR_BLUE); + add_wrapped_log(stream.overlay(), "00.04 step " + count + ": " + AFTER_CATCH_STEPS[number - 1], COLOR_WHITE); + if (number < AFTER_CATCH_STEP_COUNT) { + add_wrapped_log(stream.overlay(), "Next: " + std::string(AFTER_CATCH_STEPS[number]), COLOR_GRAY); + } } - const Milliseconds step = std::min(200ms, 1900ms - walked); - pbf_move_left_joystick(context, {0, -1}, step, 0ms); - walked += step; - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - } - stream.log("[Poketch approach] Reached 1900ms downward limit; handing off to locator."); -} -static void align_and_talk_to_poketch_man(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - size_t confirmed = 0, coarse_confirmed = 0; - double previous_dx = 0, previous_dy = 0; - size_t acquisition_misses = 0, vertical_probe = 0; - // Two small excursions around the approach position. This is - // visibility recovery, not permission to interact without a match. - const int vertical_probes[] = {-80, 160, -240, 320, -160, -120, 240, -360, 480, -240}; - for (size_t attempt = 0; attempt < 80; attempt++){ - auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poketch interaction: No video frame.", - stream); + // Fights a trainer whose dialogue has opened, with whatever starter we + // have. Someone who only talks leaves no battle to fight. + static void fight_route_trainer(VideoStream& stream, ProControllerContext& context, const std::string& name) { + pbf_mash_button(context, BUTTON_B, 5000ms); + context.wait_for_all_requests(); + BattleMenuWatcher battle_menu(BattleType::TRAINER, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + if (wait_until(stream, context, 8s, {{battle_menu}, {battle_dialog}}) < 0) { + stream.log("[00.04] " + name + ": no battle followed the talk."); + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + return; + } + fight_trainer(stream, context, name); + context.wait_for_all_requests(); + // The next trainer may be a few tiles on: check HP now. With no + // Potion in the Bag this only looks. + use_potion_first_pokemon(stream, context, 0.5); } - const OverworldMatchResult result = match_poketch_landmarks(*frame.frame); - stream.log(result.coarse ? "[Poketch landmarks] Coarse acquisition." : "[Poketch landmarks] Fine alignment."); - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); + + // The starter starts around level 5 with only a Normal move; the Route + // 202 trainers need a level or two more. Starts from either save: the + // catch spot (00.03's save, in the grass) or the save spot under the + // ledge. + static void train_in_catch_grass( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + const std::vector grass = story_after_training_area(); + walk_route(stream, context, maps, story_save_spots(), + [&](const TilePos& pos) { return std::find(grass.begin(), grass.end(), pos) != grass.end(); }, + trainers, fight, "To the catch grass"); + for (size_t battle = 0; battle < 3; battle++) { + route_status(stream, "Training battle " + std::to_string(battle + 1) + "/3"); + battle_1_pokemon(stream, context); } - stream.log("[Poketch landmarks] " + landmark.name + " score=" + std::to_string(landmark.score) + - " margin=" + std::to_string(landmark.margin) + " accepted=" + std::to_string(landmark.accepted) + - " screen-relative offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); } - if (result.located){ - acquisition_misses = 0; + + // South into Sandgem Town, to the tile below the Pokemon Center's sign, + // which the house west of its column and the sign pin (r75 walked this + // and healed). The Center's door is three tiles west; the heal lines up + // with it by sight. Returns where we stand after: below the door. + static std::vector heal_at_sandgem_center( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + const TilePos below_sign = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN.next(Pokemon::Direction::SOUTH)); + walk_route(stream, context, maps, story_after_training_area(), + [&](const TilePos& pos) { return pos == below_sign; }, trainers, fight, "To the Pokemon Center"); + heal_at_pokemon_center(stream, context, "Sandgem Town Pokemon Center"); + return story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR); } - if (result.coarse && result.located){ - if (coarse_confirmed && std::abs(result.dx - previous_dx) <= 0.00625 && - std::abs(result.dy - previous_dy) <= 1.0 / 90){ - coarse_confirmed++; - }else{ - coarse_confirmed = 1; + + // How many Potions to buy. A Potion is 300 and a new game starts with + // 3000; the money on screen cannot be read yet. Five heal the starter + // through Route 202's four trainers and leave half the money. + constexpr int POTIONS_TO_BUY = 5; + + // From below the Center's door to below the Poke Mart's, ten tiles + // east along the same row, and in. The last leg is open and movement + // is not locked to tiles, so we may stand part of a tile off the door: + // whole-tile steps either side missed it in r121a. The Mart's doorway + // is lit like the Center's, so it is lined up with by sight the same + // way. Two townspeople wander the dirt south of the Mart and stop a + // leg short without the walk knowing (r122a counted to the door's + // row from three rows south of it): where the doorway shows on screen + // says so, and the walk is made once more from there. + static void enter_sandgem_poke_mart( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + const TilePos door = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); + const TilePos front = door.next(Pokemon::Direction::SOUTH); + const auto at_front = [&](const TilePos& pos) { return pos.y == front.y && std::abs(pos.x - front.x) <= 1; }; + walk_route(stream, context, maps, story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR), + at_front, trainers, fight, "To the Poke Mart"); + const std::vector short_of_door = tiles_short_of_door(stream, context, maps.world(), door, "To the Poke Mart"); + if (!short_of_door.empty()) { + route_status(stream, "To the Poke Mart: stopped short of the door; walking again."); + walk_route(stream, context, maps, short_of_door, at_front, trainers, fight, "To the Poke Mart again"); } - previous_dx = result.dx; - previous_dy = result.dy; - }else{ - coarse_confirmed = 0; + enter_lit_door(stream, context, "Sandgem Poke Mart: through the door"); } - if (!result.located){ - confirmed = 0; - if (++acquisition_misses >= 4 && vertical_probe < sizeof(vertical_probes) / sizeof(vertical_probes[0])){ - int probe = vertical_probes[vertical_probe++]; - acquisition_misses = 0; - stream.log("[Poketch landmarks] Bounded vertical visibility probe " + std::to_string(vertical_probe) + - "/10: " + (probe < 0 ? "down " : "up ") + std::to_string(std::abs(probe)) + "ms."); - pbf_move_left_joystick(context, {0, probe < 0 ? -1.0 : 1.0}, Milliseconds(std::abs(probe)), 0ms); - pbf_wait(context, 700ms); - }else{ - stream.log("[Poketch landmarks] No trustworthy acquisition; waiting for another frame."); - pbf_wait(context, 250ms); + + // Inside the Mart, the clerk's counter is straight ahead of the door + // (every Mart is laid out so; Oreburgh's and Eterna's are walked the + // same way). Walk up to it, buy the Potions, and walk back out. + // buy_items() opens the talk and finds the Potion row by OCR of its + // name and price; the quantity is counted out blind. + static void buy_potions_at_the_counter(VideoStream& stream, ProControllerContext& context) { + route_status(stream, "Poke Mart: to the clerk."); + const WalkResult walk = walk_until(stream, context, 0, 1, 6000ms, "Poke Mart: to the clerk"); + if (walk.stop != WalkStop::BLOCKED && walk.stop != WalkStop::DIALOG) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart: the counter was not reached (" + std::string(walk_stop_name(walk.stop)) + ").", stream); } - }else if (result.coarse && coarse_confirmed < 3){ - confirmed = 0; - stream.log("[Poketch landmarks] Waiting for consistent coarse offset before movement."); - pbf_wait(context, 250ms); - }else if (result.at_target){ - if (++confirmed >= 3){ - stream.log("[Poketch landmarks] Above-NPC reference confirmed; turning down."); - pbf_press_dpad(context, DPAD_DOWN, 40ms, 400ms); - context.wait_for_all_requests(); - pbf_press_button(context, BUTTON_A, 80ms, 500ms); - context.wait_for_all_requests(); - wait_for_dialogue(stream, context, "Phase 27 (poketech challenge completed dialog)", 10s); - return; + route_status(stream, "Poke Mart: buying " + std::to_string(POTIONS_TO_BUY) + " Potions."); + if (!buy_items(stream, context, {{ShopItem::Potion, POTIONS_TO_BUY}})) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart: could not buy " + std::to_string(POTIONS_TO_BUY) + " Potions.", stream); } - pbf_wait(context, 300ms); - }else{ - confirmed = 0; - const double dx = result.dx, dy = result.dy; - stream.log("[Poketch landmarks] Reference screen-relative offset=(" + std::to_string(result.dx) + "," + - std::to_string(result.dy) + ")."); - const bool horizontal = - std::abs(dx) > 7.0 / 960 && (std::abs(dy) <= 7.0 / 540 || std::abs(dx) >= std::abs(dy) * 9.0 / 16); - double error = horizontal ? dx : dy * 9.0 / 16; - auto duration = std::abs(error) > 0.03125 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; - if (std::abs(dx) <= 7.0 / 960 && std::abs(dy) <= 7.0 / 540){ - stream.log("[Poketch landmarks] Coarse position near target; waiting for fine confirmation."); - pbf_wait(context, 300ms); - context.wait_for_all_requests(); - continue; + leave_door_south(stream, context, "Poke Mart: out the door"); + } + + // Potions let us heal where we stand. Sandgem's Poke Mart is next + // door east of its Center. The purchase is checked in the Bag after, + // since buy_items() counts the quantity out without reading it. + // Returns where we stand after: below the Mart's door. + static std::vector buy_potions_in_sandgem( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + enter_sandgem_poke_mart(stream, context, maps, trainers, fight); + buy_potions_at_the_counter(stream, context); + if (!bag_has_item(stream, context, "potion", 1)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart: the purchase went through, but no Potion is in the Bag.", stream); } - stream.log("[Poketch landmarks] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + - " axis for " + std::to_string(duration.count()) + "ms."); - pbf_move_left_joystick(context, - {horizontal ? (dx > 0 ? 1.0 : -1.0) : 0.0, horizontal ? 0.0 : (dy < 0 ? 1.0 : -1.0)}, - duration, - 0ms); - pbf_wait(context, 700ms); + return story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poketch interaction: Building landmarks did not confirm the reference position; A not pressed.", - stream); -} -void align_and_enter_jubilife_center(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - size_t confirmed = 0; - for (size_t attempt = 0; attempt < 60; attempt++){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Jubilife Center alignment: No video frame.", - stream); + // A walk_until() callback that keeps every location banner it sees. + static std::function banner_keeper(std::vector& banners) { + return [&banners](const ImageViewRGB32& screen) { + const std::string read = read_location_banner(screen); + if (!read.empty() && (banners.empty() || banners.back() != read)) { + banners.push_back(read); + } + return false; + }; + } + + static bool any_banner_names(const std::vector& banners, const char* map) { + return std::any_of(banners.begin(), banners.end(), + [&](const std::string& read) { return location_banner_matches(read, map); }); } - const auto result = match_jubilife_center_entrance(*frame.frame); - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); + + // Whether all of `tiles` are on `map`. + static bool all_on_map(const MapSet& maps, const std::vector& tiles, const char* map) { + return !tiles.empty() && std::all_of(tiles.begin(), tiles.end(), [&](const TilePos& pos) { + const std::optional tile = maps.to_local(pos); + return tile && tile->map == map; + }); + } + + // North from below the gap between the signs until a wall stops us, + // nudging a tile west, twice at most, when blocked at once (below a + // sign or the Mart). Returns whether Route 202's banner showed: only + // one of the gap's columns leads out of town before a wall. + static bool walk_north_through_gap(VideoStream& stream, ProControllerContext& context) { + using Pokemon::Direction; + std::vector banners; + for (size_t nudges = 0;; nudges++){ + const WalkResult north = walk_until(stream, context, 0, 1, 8000ms, "North through the gap", banner_keeper(banners)); + if (north.stop == WalkStop::TRANSITION || north.stop == WalkStop::DIALOG || north.wild_battles > 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "North through the gap: " + std::string(walk_stop_name(north.stop)) + " instead of a wall.", stream); + } + // Blocked at once: the wall reads after about 800ms still. + if (north.walked > ROUTE_START_DELAY + 800ms + 2 * ROUTE_MS_PER_TILE_UP_DOWN){ + break; + } + if (nudges == 2){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "North through the gap: still blocked after two tiles west.", stream); + } + route_status(stream, "North through the gap: blocked at once, below a sign or the Mart; a tile west."); + route_turn(stream, context, Direction::WEST); + walk_for(stream, context, -1, 0, route_open_hold(Direction::WEST, 1), "A tile west"); + route_turn(stream, context, Direction::NORTH); + } + context.wait_for_all_requests(); + if (VideoSnapshot screen = stream.video().snapshot()){ + banner_keeper(banners)(*screen.frame); } - stream.log("[Jubilife Center alignment] score=" + std::to_string(landmark.score) + - " margin=" + std::to_string(landmark.margin) + " screen-relative offset=(" + - std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); + return any_banner_names(banners, MAP_ROUTE_202); } - if (!result.located){ - confirmed = 0; - pbf_wait(context, 250ms); - }else if (result.at_target){ - if (++confirmed >= 3){ - stream.log("[Jubilife Center alignment] Door position confirmed; entering."); - recorded_move_until_transition(stream, context, 0, 1, 2000ms, "Jubilife Pokemon Center entry"); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - return; + + // From the trees at the east end of the doors' row, west into the gap + // between the Center's sign and the Mart's (below_sign_gap()) and + // north through it until a wall stops us. Six tiles west from column + // 29 ends on 23, and on 20 to 26 if two tiles off: north from all of + // those passes no door, and only the gap lets us through. A walk + // north that goes nowhere stood below a sign or the Mart: a tile + // west, twice at most, never reaches the Center's door at 17. The + // planner would rather go round the south of town on walls, where + // townspeople wander. Returns where the walk north may have ended: + // the gap's columns end on Sandgem's top row, bar one that runs on to + // Route 202, and its banner tells the two apart. + static std::vector north_through_sign_gap(VideoStream& stream, ProControllerContext& context, const MapSet& maps){ + using Pokemon::Direction; + route_status(stream, "To the gap between the Center's and the Mart's signs: west 6."); + route_turn(stream, context, Direction::WEST); + walk_for(stream, context, -1, 0, route_open_hold(Direction::WEST, 6), "To the gap between the signs"); + const bool on_route = walk_north_through_gap(stream, context); + std::vector ends; + for (const TilePos& pos : SandgemTown::below_sign_gap()){ + const TilePos end = Pokemon::walk_to_wall(maps.world(), maps.to_world(MAP_SANDGEM_TOWN, pos), Direction::NORTH).stop; + if (all_on_map(maps, {end}, MAP_ROUTE_202) == on_route){ + ends.push_back(end); + } } - pbf_wait(context, 300ms); - }else{ - confirmed = 0; - bool horizontal = std::abs(result.dx) >= std::abs(result.dy) * 9.0 / 16; - double error = horizontal ? result.dx : result.dy * 9.0 / 16; - const auto duration = std::abs(error) > 1.0 / 24 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; - pbf_move_left_joystick(context, - {horizontal ? (result.dx > 0 ? 1.0 : -1.0) : 0.0, horizontal ? 0.0 : (result.dy < 0 ? 1.0 : -1.0)}, - duration, - 0ms); - pbf_wait(context, 700ms); + route_status(stream, std::string("North through the gap: Route 202's banner ") + + (on_route ? "showed; on Route 202." : "did not show; on Sandgem Town's top row.")); + return ends; } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Jubilife Center alignment: Door reference not confirmed; entry not attempted.", - stream); -} -static void -tutorial_part_4_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2580ms, 0ms); - pbf_wait(context, 504ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 830ms, 0ms); - pbf_wait(context, 617ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 800ms, 0ms); - pbf_wait(context, 509ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2000ms, 0ms); - pbf_wait(context, 598ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 200ms, 0ms); - pbf_wait(context, 581ms); - pbf_press_button(context, BUTTON_A, 150ms, 0ms); - pbf_wait(context, 795ms); - - wait_for_dialogue(stream, context, "Phase 22 (barry town map dialog)"); - - pbf_mash_button(context, BUTTON_B, 15000ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1521ms, 0ms); - pbf_wait(context, 602ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1100ms, 0ms); - pbf_wait(context, 864ms); - pbf_press_dpad(context, DPAD_RIGHT, 138ms, 0ms); - pbf_wait(context, 2398ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1018ms, 0ms); - pbf_wait(context, 3811ms); - - wait_for_dialogue(stream, context, "Phase 23 (poketech challenge dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); - context.wait_for_all_requests(); - - pbf_move_left_joystick(context, {1.000000, -0.000000}, 192ms, 0ms); - pbf_wait(context, 820ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 980ms, 0ms); - pbf_wait(context, 543ms); - pbf_press_dpad(context, DPAD_UP, 203ms, 0ms); - pbf_wait(context, 593ms); - pbf_press_dpad(context, DPAD_UP, 115ms, 0ms); - pbf_wait(context, 566ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1567ms, 0ms); - pbf_wait(context, 538ms); - pbf_press_dpad(context, DPAD_LEFT, 157ms, 0ms); - pbf_wait(context, 735ms); - pbf_press_dpad(context, DPAD_LEFT, 164ms, 0ms); - pbf_wait(context, 564ms); - pbf_press_dpad(context, DPAD_LEFT, 250ms, 0ms); - pbf_wait(context, 513ms); - pbf_press_dpad(context, DPAD_LEFT, 250ms, 0ms); - pbf_wait(context, 513ms); - pbf_press_dpad(context, DPAD_DOWN, 163ms, 0ms); - pbf_wait(context, 609ms); - pbf_press_dpad(context, DPAD_DOWN, 193ms, 0ms); - pbf_wait(context, 584ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 151ms, 0ms); - context.wait_for_all_requests(); - - //phase 24 Clown 1 dialog - clown_dialog_helper(stream, context, DPAD_RIGHT); - context.wait_for_all_requests(); - - pbf_press_dpad(context, DPAD_UP, 187ms, 0ms); - pbf_wait(context, 106ms); - pbf_press_dpad(context, DPAD_UP, 32ms, 0ms); - pbf_wait(context, 671ms); - pbf_press_dpad(context, DPAD_UP, 262ms, 0ms); - pbf_wait(context, 684ms); - pbf_press_dpad(context, DPAD_UP, 372ms, 0ms); - pbf_wait(context, 392ms); - pbf_press_dpad(context, DPAD_UP, 419ms, 0ms); - pbf_wait(context, 658ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 2013ms, 0ms); - pbf_wait(context, 369ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1448ms, 0ms); - pbf_wait(context, 2435ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1077ms, 0ms); - pbf_wait(context, 1084ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1280ms, 0ms); - pbf_wait(context, 1895ms); - pbf_press_dpad(context, DPAD_LEFT, 88ms, 0ms); - - context.wait_for_all_requests(); - - //phase 25 Clown 2 dialog - - clown_dialog_helper(stream, context, DPAD_UP); - - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 511ms, 0ms); - pbf_wait(context, 1318ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 140ms, 0ms); - pbf_wait(context, 1502ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2093ms, 0ms); - pbf_wait(context, 2327ms); - pbf_press_dpad(context, DPAD_LEFT, 151ms, 0ms); - pbf_wait(context, 549ms); - pbf_press_dpad(context, DPAD_LEFT, 118ms, 0ms); - pbf_wait(context, 999ms); - pbf_press_dpad(context, DPAD_UP, 206ms, 0ms); - pbf_wait(context, 474ms); - pbf_press_dpad(context, DPAD_UP, 249ms, 0ms); - pbf_wait(context, 333ms); - pbf_press_dpad(context, DPAD_UP, 212ms, 0ms); - pbf_wait(context, 427ms); - pbf_press_dpad(context, DPAD_UP, 210ms, 0ms); - context.wait_for_all_requests(); - //phase 26 Clown 3 dialog - - clown_dialog_helper(stream, context, DPAD_LEFT); - - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1098ms, 0ms); - pbf_wait(context, 2779ms); - pbf_press_dpad(context, DPAD_LEFT, 177ms, 0ms); - pbf_wait(context, 488ms); - pbf_press_dpad(context, DPAD_LEFT, 200ms, 0ms); - pbf_wait(context, 649ms); - pbf_press_dpad(context, DPAD_LEFT, 160ms, 0ms); - pbf_wait(context, 4285ms); - pbf_press_dpad(context, DPAD_DOWN, 307ms, 0ms); - pbf_wait(context, 639ms); - pbf_press_dpad(context, DPAD_DOWN, 463ms, 0ms); - pbf_wait(context, 2521ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 3600ms, 0ms); - pbf_wait(context, 610ms); - approach_poketch_landmarks(stream, context); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 192ms, 0ms); - pbf_wait(context, 3238ms); - align_and_talk_to_poketch_man(stream, context); - - pbf_mash_button(context, BUTTON_B, 15000ms); - context.wait_for_all_requests(); - - // poketech acquired - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1194ms, 0ms); - pbf_wait(context, 1984ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 832ms, 0ms); - pbf_wait(context, 1522ms); - pbf_press_dpad(context, DPAD_RIGHT, 144ms, 0ms); - pbf_wait(context, 1767ms); - align_and_enter_jubilife_center(stream, context); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1099ms, 0ms); - pbf_wait(context, 1222ms); - pbf_press_dpad(context, DPAD_RIGHT, 228ms, 0ms); - pbf_wait(context, 625ms); - pbf_press_dpad(context, DPAD_RIGHT, 238ms, 0ms); - pbf_wait(context, 537ms); - pbf_press_dpad(context, DPAD_RIGHT, 210ms, 0ms); - pbf_wait(context, 566ms); - pbf_press_dpad(context, DPAD_RIGHT, 179ms, 0ms); - pbf_wait(context, 692ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 642ms, 0ms); -} -// --------------------------------------------------------------------------- -// Segment 00-05 ends -// Standing in front of the Jubilife City Pokemon Center PC. -// Player will need to deposit pokemon in the pc and transfer to Pokemon Home -// and level to 100 before starting the next segment. -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Segment / Checkpoint classes -// --------------------------------------------------------------------------- - -std::string AutoStory_Segment_00::name() const { - return "00: Intro to Poketech"; -} -std::string AutoStory_Segment_00::start_text() const { - return "Start: Game launched, Standing in the player's room after tv stops playing"; -} -std::string AutoStory_Segment_00::end_text() const { - return "End: Starter Pokemon chosen, received, and Pokedex acquired, standing in front of the PC."; -} + // Back up to Route 202's save spot under the ledge: it is where a + // retry may start (story_save_spots()), its walls pin it, and the + // trainers are north of it. First east to the trees that end the + // doors' row, so the way north starts from one known tile, then north + // through the gap between the signs: r123a, planned round the south + // of town on a map that had it wrong, walked into a house. + static void walk_to_route_202_save_spot( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector at, std::vector& trainers, const RouteTrainerFight& fight + ) { + const TilePos row_end = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::FRONT_ROW_EAST_END); + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); + if (!all_on_map(maps, at, MAP_ROUTE_202)) { + at = walk_route(stream, context, maps, std::move(at), + [&](const TilePos& pos) { return pos == row_end; }, trainers, fight, "To the trees east of the Poke Mart"); + at = north_through_sign_gap(stream, context, maps); + } + walk_route(stream, context, maps, std::move(at), + [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); + } -void AutoStory_Segment_00::run_segment(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - stats.m_segment++; - env.update_stats(); - context.wait_for_all_requests(); - env.console.log("Start Segment " + name(), COLOR_ORANGE); - - AutoStory_Checkpoint_0000().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0001().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0002().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0003().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0004().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0005().run_checkpoint(env, context, options, stats); - - context.wait_for_all_requests(); - env.console.log("End Segment " + name(), COLOR_GREEN); -} + // Step 4: the walk above, then the save, which the save screen's own + // location must confirm (r124a saved beside the Mart, sure it stood + // on Route 202). A save in Sandgem would need the training walk to + // plan from town as well. + static void save_at_route_202_save_spot( + SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector at, std::vector& trainers, const RouteTrainerFight& fight + ) { + walk_to_route_202_save_spot(stream, context, maps, std::move(at), trainers, fight); + stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); + save_game(env, context, MAP_ROUTE_202); + } -std::string AutoStory_Checkpoint_0000::name() const { - return "00.00 - Verify Settings"; -} -std::string AutoStory_Checkpoint_0000::start_text() const { - return "Standing in the initial bedroom before beginning the tutorial."; -} -std::string AutoStory_Checkpoint_0000::end_text() const { - return "Required game settings verified and saved; ready for 00.01."; -} -void AutoStory_Checkpoint_0000::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - // Before any route movement or checkpoint save: fail visibly if - // options cannot be read, rather than navigate with unknown settings. - verify_tutorial_settings(env.console, context); - context.wait_for_all_requests(); -} + // From anywhere on Sandgem Town's open ground, to tiles the map can + // confirm: east until the trees or a wall stop us, then north until a + // wall. The columns under the north exit run on to Route 202, whose + // banner then says so; the others end on the Mart's row, where its + // lit doorway says which tile (r124a's save stands beside its door). + static std::vector anchor_in_sandgem(VideoStream& stream, ProControllerContext& context, const MapSet& maps) { + using Pokemon::Direction; + route_status(stream, "Sandgem Town: east to the trees, then north to the Mart's row."); + std::vector banners; + const WalkResult east = walk_until(stream, context, 1, 0, 6000ms, "Sandgem: east to the trees"); + const WalkResult north = walk_until(stream, context, 0, 1, 9000ms, "Sandgem: north to the Mart's row", banner_keeper(banners)); + for (const WalkResult& walk : {east, north}) { + if (walk.stop == WalkStop::TRANSITION || walk.stop == WalkStop::DIALOG) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Sandgem Town: " + std::string(walk_stop_name(walk.stop)) + " instead of a wall.", stream); + } + } + context.wait_for_all_requests(); + VideoSnapshot screen = stream.video().snapshot(); + if (!screen) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, "Sandgem Town: no video frame.", stream); + } + banner_keeper(banners)(*screen.frame); + if (any_banner_names(banners, MAP_ROUTE_202)) { + std::vector ends; + for (int x : {25, 29}) { + ends.push_back(Pokemon::walk_to_wall(maps.world(), maps.to_world(MAP_SANDGEM_TOWN, {x, 0}), Direction::NORTH).stop); + } + route_status(stream, "Sandgem Town: Route 202's banner showed on the way north; on Route 202."); + return ends; + } + const TilePos door = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); + const std::optional doorway = find_lit_doorway(*screen.frame); + const std::vector at = doorway ? tiles_by_lit_doorway(*doorway, maps.world(), door) : std::vector{}; + if (at.empty()) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Sandgem Town: no Route 202 banner and no lit doorway in sight; cannot place the player.", stream); + } + route_status(stream, "Sandgem Town: the Mart's doorway is " + std::to_string(doorway->tiles_across) + + " tiles across and " + std::to_string(doorway->rows_up) + " rows up."); + return at; + } -static void checkpoint_0001(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-00: Intro and starter selection - tutorial_part_1_navigation(env, env.console, context); - }, - true); -} -static void checkpoint_0002(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-01: Starter acquisition - starter_nav(env.console, context); - // Segment 00-00 starter selection - switch (options.starter_choice){ - case StarterChoice::TURTWIG: - select_turtwig(env.console, context); - break; - case StarterChoice::CHIMCHAR: - select_chimchar(env.console, context); - break; - case StarterChoice::PIPLUP: - select_piplup(env.console, context); - break; + // With Potions in the Bag, steps 1 to 4 are done, but where the save + // stands is the save screen's to say: step 4's at Route 202's save + // spot, or r124a's in Sandgem Town. From town, walk back to the save + // spot first, without saving again. + static void resume_with_potions( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + const std::string where = read_location_from_save_screen(stream, context); + if (location_banner_matches(where, MAP_ROUTE_202)) { + stream.log("[00.04] The Bag has Potions and the save is on Route 202: step 4's save; on to Route 202's trainers.", COLOR_BLUE); + return; } - fight_starly(env.console, context); - post_starly_clenaup(env.console, context); - }, - true); -} -static void checkpoint_0003(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-002: Pokedex acquisition - tutorial_part_2_navigation(env, env.console, context); - }, - true); -} -static void checkpoint_0004(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-003: Poketech acquisition - tutorial_part_3_navigation(env, env.console, context); - }, - true); - - // Save and advance progress at the catch boundary. Retries of this - // section must start from the caught-Pokemon save, not the old route. - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - tutorial_part_3_after_catch_navigation(env, env.console, context); - }, - true); -} -// DIAGNOSTIC ONLY: Remove before pushing to production. -void diagnostic_checkpoint_0004_after_catch(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - tutorial_part_3_after_catch_navigation(env, env.console, context); - }, - true); -} + if (!location_banner_matches(where, MAP_SANDGEM_TOWN)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "The Bag has Potions, but the save screen reads \"" + where + "\": neither Route 202 nor Sandgem Town.", stream); + } + stream.log("[00.04] The Bag has Potions, but the save is in Sandgem Town: back to Route 202's save spot first, without saving.", COLOR_BLUE); + walk_to_route_202_save_spot(stream, context, maps, anchor_in_sandgem(stream, context, maps), trainers, fight); + } -static void checkpoint_0005(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-004: Poketech acquisition - tutorial_part_4_navigation(env, env.console, context); - }, - true); -} + static void meet_dawn_in_jubilife(VideoStream& stream, ProControllerContext& context) { + // Jubilife's south edge is a wall with stairs in it, west of where the + // route may leave us (r78 stood at its east end). Entering starts a + // scene: Dawn walks over while we stand still. So walk north; if the + // wall stops us and no dialogue follows, the stairs are further west. + bool met_dawn = false; + for (size_t attempt = 0; attempt < 6 && !met_dawn; attempt++) { + const WalkResult walk = walk_until(stream, context, 0, 1, 4000ms, "to Dawn in Jubilife"); + if (walk.stop == WalkStop::TRANSITION) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Jubilife: left the area instead of meeting Dawn.", stream); + } + ShortDialogWatcher dialog(COLOR_CYAN); + if (walk.stop == WalkStop::DIALOG || wait_until(stream, context, 6s, {{dialog}}) == 0) { + met_dawn = true; + break; + } + stream.log("[00.04] Jubilife: the wall stopped us; looking for the stairs one tile west."); + route_turn(stream, context, Pokemon::Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, route_open_hold(Pokemon::Direction::WEST, 1), 200ms); + context.wait_for_all_requests(); + } + if (!met_dawn) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Jubilife: no stairs found and no Dawn after six tiles west.", stream); + } + wait_for_dialogue(stream, context, "Phase 21 (dawn Jubilife dialog)"); -// ======================== -// Checkpoint 0001 Wrapper -// ======================== -std::string AutoStory_Checkpoint_0001::name() const { - return "00.01 - After TV"; -} -std::string AutoStory_Checkpoint_0001::start_text() const { - return "Game launched, Standing in the player's room after tv stops playing"; -} -std::string AutoStory_Checkpoint_0001::end_text() const { - return "Standing outside Lake Verity before picking starter."; -} + pbf_mash_button(context, BUTTON_B, 5000ms); + } -void AutoStory_Checkpoint_0001::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0001(env, context, options, stats); -} + // Resumes from the save made after catching the second Pokemon, or + // from step 4's save. The catch save has no Potions; step 4's has + // them, with the starter trained and healed, so Potions in the Bag + // mean steps 1 to 4 are done; where that save stands is read off the + // save screen (resume_with_potions()). + static void tutorial_part_3_after_catch_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { + // A retry reloads the save at whatever time the clock has reached. + // Pin it again so the landmark references see the same lighting. + set_time_fast(env, context, DateTime{ 2026, 1, 1, 0, 0, 50 }); + log_after_catch_steps(stream); + + const MapSet& maps = story_maps(); + std::vector trainers = story_trainer_sights(); + const RouteTrainerFight fight = [&](const std::string& name) { fight_route_trainer(stream, context, name); }; + pbf_wait(context, 1500ms); + + if (bag_has_item(stream, context, "potion", 1)) { + resume_with_potions(stream, context, maps, trainers, fight); + } else { + after_catch_step(stream, 1); + train_in_catch_grass(stream, context, maps, trainers, fight); + + after_catch_step(stream, 2); + heal_at_sandgem_center(stream, context, maps, trainers, fight); + + after_catch_step(stream, 3); + const std::vector at = buy_potions_in_sandgem(stream, context, maps, trainers, fight); + + after_catch_step(stream, 4); + save_at_route_202_save_spot(env, stream, context, maps, at, trainers, fight); + } -// ======================== -// Checkpoint 0002 Wrapper -// ======================== + after_catch_step(stream, 5); + walk_route(stream, context, maps, story_save_spots(), story_at_jubilife_exit, trainers, fight, "To Jubilife"); -std::string AutoStory_Checkpoint_0002::name() const { - return "00.02 - Outside Verity before starter"; -} -std::string AutoStory_Checkpoint_0002::start_text() const { - return AutoStory_Checkpoint_0001().end_text(); -} -std::string AutoStory_Checkpoint_0002::end_text() const { - return "Standing outside Lake Verity after picking starter."; -} + after_catch_step(stream, 6); + meet_dawn_in_jubilife(stream, context); + } + // --------------------------------------------------------------------------- + // End of Segment 00 - 04 + // Standing in Jubilife after speaking to dawn. + // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // Begin Segment 00 - 05 + // Standing at the entrance to jubilife after speaking to dawn, will navigate to the poketech challenge. + // Collects poketech and stands in front of the PC to end the tutorial section + // and send the starter to pokemon HOME. + // --------------------------------------------------------------------------- + + // Stop the approach as soon as buildings can guide it, instead of + // overshooting south and then blindly walking back north. + static void approach_poketch_landmarks(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); + Milliseconds walked = 0ms; + while (walked < 1900ms) { + auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poketch approach: No video frame.", stream); + const auto result = match_poketch_landmarks(*frame.frame); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[Poketch approach] " + landmark.name + " score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin)); + } + if (result.located) { + stream.log("[Poketch approach] Landmarks acquired after " + std::to_string(walked.count()) + "ms; ending downward approach."); + return; + } + const Milliseconds step = std::min(200ms, 1900ms - walked); + pbf_move_left_joystick(context, {0, -1}, step, 0ms); + walked += step; + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + } + stream.log("[Poketch approach] Reached 1900ms downward limit; handing off to locator."); + } -void AutoStory_Checkpoint_0002::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0002(env, context, options, stats); -} + static void align_and_talk_to_poketch_man(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + size_t confirmed = 0, coarse_confirmed = 0; + double previous_dx = 0, previous_dy = 0; + size_t acquisition_misses = 0, vertical_probe = 0; + // Two small excursions around the approach position. This is + // visibility recovery, not permission to interact without a match. + const int vertical_probes[] = {-80, 160, -240, 320, -160, -120, 240, -360, 480, -240}; + for (size_t attempt = 0; attempt < 80; attempt++) { + auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poketch interaction: No video frame.", stream); + const OverworldMatchResult result = match_poketch_landmarks(*frame.frame); + stream.log(result.coarse ? "[Poketch landmarks] Coarse acquisition." : "[Poketch landmarks] Fine alignment."); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[Poketch landmarks] " + landmark.name + " score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin) + " accepted=" + std::to_string(landmark.accepted) + + " screen-relative offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); + } + if (result.located) acquisition_misses = 0; + if (result.coarse && result.located) { + if (coarse_confirmed && std::abs(result.dx - previous_dx) <= 0.00625 && std::abs(result.dy - previous_dy) <= 1.0/90) + coarse_confirmed++; + else coarse_confirmed = 1; + previous_dx = result.dx; previous_dy = result.dy; + } else coarse_confirmed = 0; + if (!result.located) { + confirmed = 0; + if (++acquisition_misses >= 4 && vertical_probe < sizeof(vertical_probes) / sizeof(vertical_probes[0])) { + int probe = vertical_probes[vertical_probe++]; + acquisition_misses = 0; + stream.log("[Poketch landmarks] Bounded vertical visibility probe " + std::to_string(vertical_probe) + + "/10: " + (probe < 0 ? "down " : "up ") + std::to_string(std::abs(probe)) + "ms."); + pbf_move_left_joystick(context, {0, probe < 0 ? -1.0 : 1.0}, Milliseconds(std::abs(probe)), 0ms); + pbf_wait(context, 700ms); + } else { + stream.log("[Poketch landmarks] No trustworthy acquisition; waiting for another frame."); + pbf_wait(context, 250ms); + } + } else if (result.coarse && coarse_confirmed < 3) { + confirmed = 0; + stream.log("[Poketch landmarks] Waiting for consistent coarse offset before movement."); + pbf_wait(context, 250ms); + } else if (result.at_target) { + if (++confirmed >= 3) { + stream.log("[Poketch landmarks] Above-NPC reference confirmed; turning down."); + pbf_press_dpad(context, DPAD_DOWN, 40ms, 400ms); + context.wait_for_all_requests(); + pbf_press_button(context, BUTTON_A, 80ms, 500ms); + context.wait_for_all_requests(); + wait_for_dialogue(stream, context, "Phase 27 (poketech challenge completed dialog)", 10s); + return; + } + pbf_wait(context, 300ms); + } else { + confirmed = 0; + const double dx = result.dx, dy = result.dy; + stream.log("[Poketch landmarks] Reference screen-relative offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")."); + const bool horizontal = std::abs(dx) > 7.0/960 && + (std::abs(dy) <= 7.0/540 || std::abs(dx) >= std::abs(dy)*9.0/16); + double error = horizontal ? dx : dy*9.0/16; + auto duration = std::abs(error) > 0.03125 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; + if (std::abs(dx) <= 7.0/960 && std::abs(dy) <= 7.0/540) { + stream.log("[Poketch landmarks] Coarse position near target; waiting for fine confirmation."); + pbf_wait(context, 300ms); + context.wait_for_all_requests(); + continue; + } + stream.log("[Poketch landmarks] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + + " axis for " + std::to_string(duration.count()) + "ms."); + pbf_move_left_joystick(context, + {horizontal ? (dx > 0 ? 1.0 : -1.0) : 0.0, + horizontal ? 0.0 : (dy < 0 ? 1.0 : -1.0)}, duration, 0ms); + pbf_wait(context, 700ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poketch interaction: Building landmarks did not confirm the reference position; A not pressed.", stream); + } + void align_and_enter_jubilife_center(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + size_t confirmed = 0; + for (size_t attempt = 0; attempt < 60; attempt++) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Jubilife Center alignment: No video frame.", stream); + const auto result = match_jubilife_center_entrance(*frame.frame); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[Jubilife Center alignment] score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin) + " screen-relative offset=(" + + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); + } + if (!result.located) { + confirmed = 0; + pbf_wait(context, 250ms); + } else if (result.at_target) { + if (++confirmed >= 3) { + stream.log("[Jubilife Center alignment] Door position confirmed; entering."); + recorded_move_until_transition(stream, context, 0, 1, 2000ms, "Jubilife Pokemon Center entry"); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + return; + } + pbf_wait(context, 300ms); + } else { + confirmed = 0; + bool horizontal = std::abs(result.dx) >= std::abs(result.dy)*9.0/16; + double error = horizontal ? result.dx : result.dy*9.0/16; + const auto duration = std::abs(error) > 1.0/24 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; + pbf_move_left_joystick(context, + {horizontal ? (result.dx > 0 ? 1.0 : -1.0) : 0.0, + horizontal ? 0.0 : (result.dy < 0 ? 1.0 : -1.0)}, duration, 0ms); + pbf_wait(context, 700ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Jubilife Center alignment: Door reference not confirmed; entry not attempted.", stream); + } -// ======================== -// Checkpoint 0003 Wrapper -// ======================== + static void tutorial_part_4_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { + // What 00.05 does, stepped through on the console before writing it. + // Every walk is planned on Jubilife's tile grid; every talk stands + // beside the person, faces them and presses A once. + static const char* const STEPS[] = { + "Trainers' School: talk to Barry, who gives the Town Map", + "Talk to the Poketch Company president on the main road", + "Get the three coupons from the clowns by the Poke Mart, the TV station and the Poketch Company", + "Show the coupons to the president for the Poketch", + "Walk into the Pokemon Center and stand at the PC", + }; + constexpr size_t STEP_COUNT = sizeof(STEPS) / sizeof(STEPS[0]); + { + std::string text = "[00.05] In Jubilife, the steps are:"; + for (size_t i = 0; i < STEP_COUNT; i++) { + text += "\n " + std::to_string(i + 1) + ". " + STEPS[i]; + } + stream.log(text, COLOR_BLUE); + } + auto step = [&](size_t number) { + std::string text = "[00.05 step " + std::to_string(number) + "/" + std::to_string(STEP_COUNT) + "] " + STEPS[number - 1]; + text += number < STEP_COUNT ? std::string(" -- next: ") + STEPS[number] : std::string(" -- last step"); + stream.log(text, COLOR_BLUE); + add_wrapped_log(stream.overlay(), "00.05 step " + std::to_string(number) + "/" + std::to_string(STEP_COUNT) + ": " + STEPS[number - 1], COLOR_WHITE); + if (number < STEP_COUNT) { + add_wrapped_log(stream.overlay(), "Next: " + std::string(STEPS[number]), COLOR_GRAY); + } + }; + + const MapSet& maps = jubilife_maps(); + auto world = [&](const TilePos& pos) { return maps.to_world(MAP_JUBILIFE_CITY, pos); }; + // Jubilife has no trainers; someone in the way only talks. + std::vector trainers; + auto in_the_way = [&](const std::string&) { finish_dialogue(stream, context, BUTTON_B, "Someone in the way"); }; + std::vector at; + for (const TilePos& pos : JubilifeCity::after_dawn()) { + at.push_back(world(pos)); + } -std::string AutoStory_Checkpoint_0003::name() const { - return "00.03 - Outside Verity after starter"; -} -std::string AutoStory_Checkpoint_0003::start_text() const { - return AutoStory_Checkpoint_0002().end_text(); -} -std::string AutoStory_Checkpoint_0003::end_text() const { - return "Standing in front of The professor after recieving the Pokedex"; -} + step(1); + const TilePos school = world(JubilifeCity::TRAINERS_SCHOOL_DOOR.next(Pokemon::Direction::SOUTH)); + // Doors sit in open plaza, which no wall pins down: end anywhere + // within a tile of the door's column, and let the door search + // step sideways. + auto by_door = [](const TilePos& front) { + return [front](const TilePos& pos) { return pos.y == front.y && pos.x >= front.x - 1 && pos.x <= front.x + 1; }; + }; + at = walk_route(stream, context, maps, at, by_door(school), + trainers, in_the_way, "To the Trainers' School"); + enter_door_north(stream, context, "Trainers' School door"); + // Barry stands at the blackboard, a tile west of the door's column: + // step west, walk north until he stops us, talk. + route_turn(stream, context, Pokemon::Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, route_open_hold(Pokemon::Direction::WEST, 1), 200ms); + walk_until(stream, context, 0, 1, 3000ms, "to Barry"); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + context.wait_for_all_requests(); + { + ShortDialogWatcher dialog(COLOR_CYAN); + if (wait_until(stream, context, 2000ms, {{dialog}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Trainers' School: Barry did not answer.", stream); + } + } + // His scene ends with "You obtained a Town Map!", which A closes. + finish_dialogue(stream, context, BUTTON_A, "Barry"); + // Out: south to the front wall, a tile east (the door's column), out. + walk_until(stream, context, 0, -1, 4000ms, "to the school's front wall"); + route_turn(stream, context, Pokemon::Direction::EAST); + pbf_move_left_joystick(context, {1, 0}, route_open_hold(Pokemon::Direction::EAST, 1), 200ms); + if (walk_until(stream, context, 0, -1, 2500ms, "out of the Trainers' School").stop != WalkStop::TRANSITION) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Trainers' School: did not leave by the door.", stream); + } + context.wait_for(1500ms); + at = {school.next(Pokemon::Direction::WEST), school, school.next(Pokemon::Direction::EAST)}; + + step(2); + // The president talks on with A after each last line; B moves his + // lines on and never starts a new talk. + const TilePos president = world(JubilifeCity::POKETCH_PRESIDENT); + talk_to_npc(stream, context, maps, at, president, + {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH}, trainers, in_the_way, "Poketch president"); + finish_dialogue(stream, context, BUTTON_B, "Poketch president"); + + step(3); + // Each clown asks a question whose default answer, Yes, is right, + // and hands over a coupon with an "obtained" message A closes. + talk_to_npc(stream, context, maps, at, world(JubilifeCity::CLOWN_MART), + {Pokemon::Direction::SOUTH, Pokemon::Direction::WEST, Pokemon::Direction::EAST}, + trainers, in_the_way, "Clown by the Poke Mart"); + finish_dialogue(stream, context, BUTTON_A, "Clown by the Poke Mart"); + talk_to_npc(stream, context, maps, at, world(JubilifeCity::CLOWN_TV_STATION), + {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH, Pokemon::Direction::WEST}, + trainers, in_the_way, "Clown by the TV station"); + finish_dialogue(stream, context, BUTTON_A, "Clown by the TV station"); + talk_to_npc(stream, context, maps, at, world(JubilifeCity::CLOWN_POKETCH_COMPANY), + {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH, Pokemon::Direction::WEST}, + trainers, in_the_way, "Clown by the Poketch Company"); + finish_dialogue(stream, context, BUTTON_A, "Clown by the Poketch Company"); + + step(4); + talk_to_npc(stream, context, maps, at, president, + {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH}, trainers, in_the_way, "Poketch president"); + finish_dialogue(stream, context, BUTTON_B, "Poketch president"); + + step(5); + const TilePos center = world(JubilifeCity::POKEMON_CENTER_DOOR.next(Pokemon::Direction::SOUTH)); + at = walk_route(stream, context, maps, at, by_door(center), + trainers, in_the_way, "To the Pokemon Center"); + enter_door_north(stream, context, "Pokemon Center door"); + // Inside: north to the counter, three tiles east, north to the PC + // at the back wall right of the counter. + walk_until(stream, context, 0, 1, 2500ms, "to the counter"); + route_turn(stream, context, Pokemon::Direction::EAST); + pbf_move_left_joystick(context, {1, 0}, route_open_hold(Pokemon::Direction::EAST, 3), 200ms); + walk_until(stream, context, 0, 1, 1500ms, "to the PC"); + context.wait_for_all_requests(); + } + // --------------------------------------------------------------------------- + // Segment 00-05 ends + // Standing in front of the Jubilife City Pokemon Center PC. + // Player will need to deposit pokemon in the pc and transfer to Pokemon Home + // and level to 100 before starting the next segment. + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // Segment / Checkpoint classes + // --------------------------------------------------------------------------- + + std::string AutoStory_Segment_00::name() const { return "00: Intro to Poketech"; } + std::string AutoStory_Segment_00::start_text() const { return "Start: Game launched, Standing in the player's room after tv stops playing"; } + std::string AutoStory_Segment_00::end_text() const { return "End: Starter Pokemon chosen, received, and Pokedex acquired, standing in front of the PC."; } + + void AutoStory_Segment_00::run_segment( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + stats.m_segment++; + env.update_stats(); + context.wait_for_all_requests(); + env.console.log("Start Segment " + name(), COLOR_ORANGE); + + AutoStory_Checkpoint_0000().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0001().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0002().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0003().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0004().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0005().run_checkpoint(env, context, options, stats); + + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); + } -void AutoStory_Checkpoint_0003::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0003(env, context, options, stats); -} -// ======================== -// Checkpoint 0004 Wrapper -// ======================== + std::string AutoStory_Checkpoint_0000::name() const { return "00.00 - Verify Settings"; } + std::string AutoStory_Checkpoint_0000::start_text() const { return "Standing in the initial bedroom before beginning the tutorial."; } + std::string AutoStory_Checkpoint_0000::end_text() const { return "Required game settings verified and saved; ready for 00.01."; } + void AutoStory_Checkpoint_0000::run_checkpoint( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + AutoStoryOptions options, AutoStoryStats& stats + ) const { + // Before any route movement or checkpoint save: fail visibly if + // options cannot be read, rather than navigate with unknown settings. + verify_tutorial_settings(env.console, context); + context.wait_for_all_requests(); + } -std::string AutoStory_Checkpoint_0004::name() const { - return "00.04 - After Pokedex"; -} -std::string AutoStory_Checkpoint_0004::start_text() const { - return AutoStory_Checkpoint_0003().end_text(); -} -std::string AutoStory_Checkpoint_0004::end_text() const { - return "Standing in in jubilife after speaking to dawn. Next: give town map to barry"; -} + static void checkpoint_0001( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-00: Intro and starter selection + tutorial_part_1_navigation(env, env.console, context); + }, + true + ); + } + static void checkpoint_0002( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-01: Starter acquisition + starter_nav(env.console, context); + // Segment 00-00 starter selection + switch (options.starter_choice) { + case StarterChoice::TURTWIG: select_turtwig(env.console, context); break; + case StarterChoice::CHIMCHAR: select_chimchar(env.console, context); break; + case StarterChoice::PIPLUP: select_piplup(env.console, context); break; + } + fight_starly(env.console, context); + post_starly_clenaup(env.console, context); + }, + true + ); + } + static void checkpoint_0003( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-002: Pokedex acquisition + tutorial_part_2_navigation(env, env.console, context); + }, + true + ); + } + static void checkpoint_0004( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-003: Poketech acquisition + tutorial_part_3_navigation(env, env.console, context); + }, + true + ); + + // Save and advance progress at the catch boundary. Retries of this + // section must start from the caught-Pokemon save, not the old route. + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + tutorial_part_3_after_catch_navigation(env, env.console, context); + }, + true + ); + } + // DIAGNOSTIC ONLY: Remove before pushing to production. + void diagnostic_checkpoint_0004_after_catch( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + AutoStoryOptions options, AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + tutorial_part_3_after_catch_navigation(env, env.console, context); + }, + true + ); + } -void AutoStory_Checkpoint_0004::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0004(env, context, options, stats); -} -// ======================== -// Checkpoint 0005 Wrapper -// ======================== + static void checkpoint_0005( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-004: Poketech acquisition + tutorial_part_4_navigation(env, env.console, context); + }, + true + ); + } -std::string AutoStory_Checkpoint_0005::name() const { - return "00.05 - Jubilife After Dawn"; -} -std::string AutoStory_Checkpoint_0005::start_text() const { - return AutoStory_Checkpoint_0004().end_text(); -} -std::string AutoStory_Checkpoint_0005::end_text() const { - return "Standing in front of PC. Recieved the Poketech. Ready to deposit starter"; -} + // ======================== + // Checkpoint 0001 Wrapper + // ======================== + std::string AutoStory_Checkpoint_0001::name() const { return "00.01 - After TV"; } + std::string AutoStory_Checkpoint_0001::start_text() const { return "Game launched, Standing in the player's room after tv stops playing"; } + std::string AutoStory_Checkpoint_0001::end_text() const { return "Standing outside Lake Verity before picking starter."; } + + void AutoStory_Checkpoint_0001::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0001(env, context, options, stats); + } -void AutoStory_Checkpoint_0005::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0005(env, context, options, stats); -} + // ======================== + // Checkpoint 0002 Wrapper + // ======================== + + std::string AutoStory_Checkpoint_0002::name() const { return "00.02 - Outside Verity before starter"; } + std::string AutoStory_Checkpoint_0002::start_text() const { return AutoStory_Checkpoint_0001().end_text(); } + std::string AutoStory_Checkpoint_0002::end_text() const { return "Standing outside Lake Verity after picking starter."; } + + void AutoStory_Checkpoint_0002::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0002(env, context, options, stats); + } -} -} + + // ======================== + // Checkpoint 0003 Wrapper + // ======================== + + std::string AutoStory_Checkpoint_0003::name() const { return "00.03 - Outside Verity after starter"; } + std::string AutoStory_Checkpoint_0003::start_text() const { return AutoStory_Checkpoint_0002().end_text(); } + std::string AutoStory_Checkpoint_0003::end_text() const { return "Standing in front of The professor after recieving the Pokedex"; } + + void AutoStory_Checkpoint_0003::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0003(env, context, options, stats); + } + // ======================== + // Checkpoint 0004 Wrapper + // ======================== + + std::string AutoStory_Checkpoint_0004::name() const { return "00.04 - After Pokedex"; } + std::string AutoStory_Checkpoint_0004::start_text() const { return AutoStory_Checkpoint_0003().end_text(); } + std::string AutoStory_Checkpoint_0004::end_text() const { return "Standing in in jubilife after speaking to dawn. Next: give town map to barry"; } + + void AutoStory_Checkpoint_0004::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0004(env, context, options, stats); + } + // ======================== + // Checkpoint 0005 Wrapper + // ======================== + + std::string AutoStory_Checkpoint_0005::name() const { return "00.05 - Jubilife After Dawn"; } + std::string AutoStory_Checkpoint_0005::start_text() const { return AutoStory_Checkpoint_0004().end_text(); } + std::string AutoStory_Checkpoint_0005::end_text() const { return "Standing in front of PC. Recieved the Poketech. Ready to deposit starter"; } + + void AutoStory_Checkpoint_0005::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0005(env, context, options, stats); + } + + } + } } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp index 03d1aa16e2..4c6bcc37de 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp @@ -1,1829 +1,1099 @@ -/* BDSP AutoStory - Segment 01 - * From after choosing the starter Pokemon until after acquiring the first badge. - * From: https://github.com/PokemonAutomation/ - */ - -#include -#include -#include - -#include "CommonFramework/Exceptions/OperationFailedException.h" -#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" -#include "CommonFramework/GlobalAutoPaths.h" -#include "CommonFramework/ImageTools/ImageDiff.h" -#include "CommonFramework/VideoPipeline/VideoFeed.h" - -#include "CommonTools/Async/InferenceRoutines.h" -#include "CommonTools/VisualDetectors/BlackScreenDetector.h" -#include "CommonTools/VisualDetectors/ImageMatchDetector.h" - -#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" - -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_EndBattleDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" -#include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" -#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" -#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" -#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.h" - -#include "PokemonBDSP_AutoStory_Segment_01.h" - -using namespace std::chrono_literals; - -namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - -static const std::string& mine_navigation_resource_path(){ - static const std::string path = RESOURCE_PATH() + "PokemonBDSP/Autostory/Oreburg_mine_nav/"; - return path; -} - -enum class MoveDirection { - UP, - DOWN, - LEFT, - RIGHT -}; - -// Left joystick vector for a direction (matches the game's joystick convention used in this file). -static std::pair joystick_for(MoveDirection direction){ - switch (direction){ - case MoveDirection::UP: - return {0.0, 1.0}; - case MoveDirection::DOWN: - return {0.0, -1.0}; - case MoveDirection::LEFT: - return {-1.0, 0.0}; - case MoveDirection::RIGHT: - return {1.0, 0.0}; - } - return {0.0, 0.0}; -} - -// Runs from a wild battle that is already in progress (menu visible or about to be). -// Throws OperationFailedException if it cannot escape within `timeout`. - -// The main game menu is independent evidence that battle has ended. - - -// Watches for a short dialogue box over several attempts. Returns true as soon as one is seen. -// Returns false (without throwing) only if every attempt times out, so callers can skip -// an optional interaction. Multiple attempts guard against a single missed detection. -static bool try_detect_dialogue(VideoStream& stream, - ProControllerContext& context, - const std::string& label, - size_t attempts = 3, - std::chrono::milliseconds window = 5000ms){ - for (size_t attempt = 0; attempt < attempts; attempt++){ - ShortDialogWatcher watcher_cyan(COLOR_CYAN); - ShortDialogWatcher watcher_red(COLOR_RED); - if (wait_until(stream, context, window, {{watcher_cyan}, {watcher_red}}) >= 0){ - stream.log(label + " dialogue detected."); - return true; - } - stream.log(label + " dialogue not detected (attempt " + std::to_string(attempt + 1) + "/" + - std::to_string(attempts) + ").", - COLOR_ORANGE); - } - return false; -} - -struct DoorwayWalkOptions { - BattleType battle_type = BattleType::STANDARD; - // Active movement per step. Time spent in battles or waiting is never counted. - std::chrono::milliseconds step = 200ms; - // Extra walking allowed beyond `travel_time` before giving up. - std::chrono::milliseconds grace = 1600ms; - // How long to watch for a black screen after each step. - std::chrono::milliseconds black_screen_window = 1500ms; - // After a black screen, how long to wait for a battle menu before deciding it was a doorway. - std::chrono::milliseconds battle_menu_window = 8000ms; - size_t max_battles = 10; - // Also treat a white screen as the doorway transition (e.g. leaving the Oreburgh Gate). - bool detect_white_screen = false; -}; - -// Reusable: walk in `direction` for a cumulative `travel_time` of active movement until -// a black screen NOT followed by a battle menu (a doorway/area transition) is seen. -// Wild battles (black screen followed by the battle menu, e.g. in caves or grass) are -// escaped and do not consume the time budget. -// Throws OperationFailedException if the doorway is not found within -// travel_time + options.grace, if more than options.max_battles occur, or if a battle -// cannot be escaped. -// Returns the total active walking time used. -static std::chrono::milliseconds walk_until_doorway(VideoStream& stream, - ProControllerContext& context, - MoveDirection direction, - std::chrono::milliseconds travel_time, - const DoorwayWalkOptions& options = DoorwayWalkOptions()){ - enum class NavState { - WALKING, - BLACK_SCREEN, - IN_BATTLE, - DONE - }; - - const auto [x, y] = joystick_for(direction); - const std::chrono::milliseconds max_travel = travel_time + options.grace; - - std::chrono::milliseconds walked = 0ms; - size_t battles = 0; - NavState state = NavState::WALKING; - - while (state != NavState::DONE){ - switch (state){ - case NavState::WALKING: { - if (walked >= max_travel){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "walk_until_doorway: Doorway not found within time budget.", - stream); - } - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {x, y}, options.step, 0ms); - context.wait_for_all_requests(); - walked += options.step; - - BlackScreenWatcher black_screen; - WhiteScreenWatcher white_screen; - if (options.detect_white_screen){ - int ret = wait_until(stream, context, options.black_screen_window, {{black_screen}, {white_screen}}); - if (ret == 0){ - state = NavState::BLACK_SCREEN; - }else if (ret == 1){ - stream.log("[AutoStory] White screen detected.", COLOR_ORANGE); - state = NavState::DONE; - } - }else if (wait_until(stream, context, options.black_screen_window, {{black_screen}}) == 0){ - state = NavState::BLACK_SCREEN; - } - break; - } - case NavState::BLACK_SCREEN: { - stream.log("[AutoStory] Black screen detected. Checking for battle menu.", COLOR_ORANGE); - BattleMenuWatcher battle_menu(options.battle_type, COLOR_YELLOW); - if (wait_until(stream, context, options.battle_menu_window, {{battle_menu}}) == 0){ - state = NavState::IN_BATTLE; - }else{ - state = NavState::DONE; - } - break; - } - case NavState::IN_BATTLE: { - if (++battles > options.max_battles){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "walk_until_doorway: Too many battles while walking.", - stream); - } - stream.log("[AutoStory] Wild battle. Escaping. Walked " + std::to_string(walked.count()) + "ms of " + - std::to_string(travel_time.count()) + "ms.", - COLOR_ORANGE); - escape_wild_battle(stream, context, options.battle_type); - state = NavState::WALKING; - break; - } - case NavState::DONE: - break; - } - } - stream.log("[AutoStory] Doorway transition detected after " + std::to_string(walked.count()) + "ms of walking.", - COLOR_GREEN); - return walked; -} - - -// ---------------------------------------------------------------- -// Segment 1 Part 1: Jubilife City to Oreburgh Gate -// Navigates the player from Jubilife City to the Oreburgh Gate, including the -// first Barry fight. -// --------------------------------------------------------------------------- -void jubilife_to_oreburgh_gate(SingleSwitchProgramEnvironment& env, - VideoStream& stream, - ProControllerContext& context){ - // Wait for game to fully accept input after save/overworld return - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); - - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1465ms, 0ms); - pbf_wait(context, 127ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 644ms, 0ms); - pbf_wait(context, 698ms); - leave_pokemon_center_before_barry_1(stream, context); - pbf_wait(context, 2340ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 742ms, 0ms); - pbf_wait(context, 348ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2694ms, 0ms); - pbf_wait(context, 482ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 3810ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 1 (Barry 1 fight dialog)"); - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); - pbf_mash_button(context, BUTTON_B, 5000ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 2761ms, 0ms); - pbf_wait(context, 1647ms); - walk_through_grass(stream, context, 1, 0, 5, "right through grass"); - wait_for_dialogue(stream, context, "Dallas fight dialog)"); - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); //fighting Dallas - context.wait_for_all_requests(); - walk_through_grass(stream, context, 1, 0, 5, "right through grass"); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 600ms, 0ms); - walk_through_grass(stream, context, 1, 0, 10, "right through grass"); - //there is a small chance that the player dodges sSebastian, so this - //function addresses that edge case - if (try_detect_dialogue(stream, context, "Sebastian fight")){ - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); //fighting Sebastian - context.wait_for_all_requests(); - }else{ - stream.log("Sebastian dialogue never appeared. Skipping fight.", COLOR_ORANGE); - } - pbf_move_left_joystick(context, {1.000000, -0.000000}, 2000ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 8000ms, 0ms); - pbf_wait(context, 200ms); - context.wait_for_all_requests(); - enter_cave_with_down_right_search(stream, context, "jubilife_to_oreburgh_gate", 10000ms); -} -void oreburgh_gate_nav(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - pbf_wait(context, 2000ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 879ms, 0ms); - wait_for_dialogue(stream, context, "HM Rock Smash Dialog"); - pbf_mash_button(context, BUTTON_B, 15000ms); - walk_through_grass(stream, context, 1, 0, 10, "right through gate"); - //Similar to sebastian, an edge case exists where we run into too many - //wild battles and don't reach curtis or diana. This detect dialog function solves that. - while (!try_detect_dialogue(stream, context, "Curtis fight", 1, 3000ms)){ - walk_through_grass(stream, context, 1, 0, 1, "right through gate"); - } - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); //fighting Curtis - walk_through_grass(stream, context, 1, 0, 10, "right through gate"); - while (!try_detect_dialogue(stream, context, "Diana fight", 1, 3000ms)){ - walk_through_grass(stream, context, 1, 0, 1, "right through gate"); - } - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); //fighting Diana - // Walk right until the gate exit transition (white screen, or black screen without a battle menu). - // Wild battles are escaped without using up the budget. Throws on failure so the - // checkpoint is retried. - DoorwayWalkOptions gate_exit_options; - gate_exit_options.detect_white_screen = true; - walk_until_doorway(stream, context, MoveDirection::RIGHT, 4000ms, gate_exit_options); - context.wait_for_all_requests(); -} -// --------------------------------------------------------------------------- -// Segment 1 Part 2: Oreburgh Gate to Oreburgh Mine -// Assumes that the player is standing outside the Oreburgh Gate on the Jubilife City side. -// Navigates to the mine. -// ---------------------------------------------------------------------------- -void oreburgh_gate_to_mine(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1056ms, 0ms); - while (!try_detect_dialogue(stream, context, "Phase 2 (oreburgh tour)", 1, 3000ms)){ - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1056ms, 0ms); - } - pbf_mash_button(context, BUTTON_B, 12000ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 319ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -1.000000}, 332ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 3124ms, 0ms); - pbf_wait(context, 23ms); - bool black_screen_found = false; - for (size_t attempt = 0; attempt < 3 && !black_screen_found; attempt++){ - BlackScreenWatcher black_screen; - int ret = run_until(stream, - context, - [](ProControllerContext& context){ - pbf_move_left_joystick(context, {0.000000, -1.000000}, 4740ms, 0ms); - }, - {black_screen}); - if (ret == 0){ - stream.log("Black screen detected."); - black_screen_found = true; - } - } - context.wait_for_all_requests(); - - if (!black_screen_found){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "oreburgh_gate_to_mine: No black screen after 3 attempts.", - stream); - } - - // Let the mine entrance transition fully finish so the next step's black screen - // watcher can't pick up the tail of this one. - BlackScreenOverWatcher black_over; - wait_until(stream, context, 6000ms, {{black_over}}); - context.wait_for_all_requests(); - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); -} - -// Diagnostic only: identifies the entrance start without moving the player. - - -struct MineCenterOffset { - bool valid; - double offset; - double score; -}; - -// Finds the sideways shift of stationary scenery relative to the center picture. -// Positive offset: scenery shifted right, so the player is left of center. -static MineCenterOffset measure_mine_center_offset(const ImageViewRGB32& reference, const ImageViewRGB32& live){ - if (!reference || !live){ - return {false, 0.0, 1000.0}; - } - - // Use a fixed working size so the search works at different capture resolutions. - const ImageRGB32 reference_scaled = reference.scale_to(960, 540); - const ImageRGB32 live_scaled = live.scale_to(960, 540); - - // Entrance beam and both posts. Avoid the player and shadow. - const std::array boxes = {ImageFloatBox(0.35, 0.285, 0.30, 0.07), - ImageFloatBox(0.355, 0.37, 0.035, 0.23), - ImageFloatBox(0.615, 0.37, 0.035, 0.23)}; - - const int search_radius = (int)std::lround(0.10 * live_scaled.width()); - std::vector scores(2 * search_radius + 1); - std::vector worst_regions(2 * search_radius + 1); - - int best_shift = 0; - double best_score = 1000.0; - - // Search up to 10% of the screen width in either direction. - for (int shift = -search_radius; shift <= search_radius; shift++){ - double total = 0.0; - double worst = 0.0; - - for (const ImageFloatBox& box : boxes){ - const ImageViewRGB32 reference_crop = extract_box_reference(reference_scaled, box); - - // Translate the same crop horizontally in the live image. - const ImageViewRGB32 live_crop = extract_box_reference(live_scaled, box, shift, 0); - - const double score = ImageMatch::pixel_RMSD(reference_crop, live_crop); - - total += score; - if (score > worst){ - worst = score; - } - } - - const double average = total / boxes.size(); - const size_t index = static_cast(shift + search_radius); - - scores[index] = average; - worst_regions[index] = worst; - - if (average < best_score){ - best_score = average; - best_shift = shift; - } - } - - // Reject another similarly good match well away from the chosen position. - double alternative_score = 1000.0; - - for (int shift = -search_radius; shift <= search_radius; shift++){ - if (std::abs(shift - best_shift) <= std::lround(0.0125 * live_scaled.width())){ - continue; - } - - const double score = scores[static_cast(shift + search_radius)]; - if (score < alternative_score){ - alternative_score = score; - } - } - - const size_t best_index = static_cast(best_shift + search_radius); - - const bool valid = best_score <= 20.0 && worst_regions[best_index] <= 30.0 && - alternative_score - best_score >= 3.0 && std::abs(best_shift) < search_radius; - - return {valid, static_cast(best_shift) / live_scaled.width(), best_score}; -} -// Test only: align with the entrance center, then stop. -static bool oreburgh_mine_alignment_test(VideoStream& stream, ProControllerContext& context){ - const ImageRGB32 reference(mine_navigation_resource_path() + "mine_start_center.png"); - - constexpr size_t MAX_MOVEMENTS = 12; - constexpr double CENTER_TOLERANCE = 0.008; - - size_t movements = 0; - size_t center_streak = 0; - - context.wait_for_all_requests(); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - - while (true){ - const VideoSnapshot frame = stream.video().snapshot(); - - const MineCenterOffset measurement = measure_mine_center_offset(reference, frame); - - stream.log("[Mine align] Offset=" + std::to_string(measurement.offset) + - ", score=" + std::to_string(measurement.score) + (measurement.valid ? ", trusted" : ", unsure")); - - if (!measurement.valid){ - stream.log("[Mine align] Entrance position unsure. Stopping.", COLOR_ORANGE); - return false; - } - - if (std::abs(measurement.offset) <= CENTER_TOLERANCE){ - center_streak++; - - if (center_streak >= 3){ - stream.log("[Mine align] Center reached. Test complete.", COLOR_GREEN); - return true; - } - - pbf_wait(context, 250ms); - context.wait_for_all_requests(); - continue; - } - - center_streak = 0; - - // Check the latest position before enforcing the movement limit. - if (movements >= MAX_MOVEMENTS){ - stream.log("[Mine align] Movement limit reached. Stopping.", COLOR_ORANGE); - return false; - } - - const double x = measurement.offset > 0.0 ? 1.0 : -1.0; - - // Smaller movements when close to the center. - const auto duration = std::abs(measurement.offset) < 0.025 ? 40ms : 80ms; - - stream.log(x > 0.0 ? "[Mine align] Moving a little right." : "[Mine align] Moving a little left."); - - BlackScreenWatcher black; - BattleMenuWatcher menu(BattleType::STANDARD, COLOR_YELLOW); - BattleDialogWatcher dialog(COLOR_YELLOW); - - const int result = run_until(stream, - context, - [x, duration](ProControllerContext& ctx){ - pbf_move_left_joystick(ctx, {x, 0.0}, duration, 0ms); - pbf_wait(ctx, 500ms); - ctx.wait_for_all_requests(); - }, - {black, menu, dialog}); - - if (result >= 0){ - stream.log("[Mine align] Battle or transition detected. " - "Stopping the alignment test.", - COLOR_ORANGE); - return false; - } - - movements++; - } -} -// Confirm a battle before using the existing escape helper. -static bool mine_escape_and_settle(VideoStream& stream, ProControllerContext& context){ - BattleMenuWatcher menu(BattleType::STANDARD, COLOR_YELLOW); - - if (wait_until(stream, context, 12s, {{menu}}) != 0){ - stream.log("[Mine encounter] Battle menu not confirmed. Stopping.", COLOR_ORANGE); - return false; - } - - stream.log("[Mine encounter] Battle confirmed. Escaping."); - escape_wild_battle(stream, context); - context.wait_for_all_requests(); - - // Check the return waits instead of ignoring their results. - BlackScreenWatcher visible(COLOR_YELLOW, - ImageFloatBox(0.1, 0.1, 0.8, 0.8), - 100, - 10, - BlackScreenWatcher::FinderType::GONE, - 500ms); - - if (wait_until(stream, context, 8s, {{visible}}) != 0){ - stream.log("[Mine encounter] Screen did not return. Stopping.", COLOR_ORANGE); - return false; - } - - - pbf_wait(context, 800ms); - context.wait_for_all_requests(); - - stream.log("[Mine encounter] Escaped. Returning to the rock-walking loop.", COLOR_GREEN); - return true; -} - -// Temporary test: wiggle along the corridor until a battle is detected. -static bool mine_force_encounter_test(VideoStream& stream, ProControllerContext& context){ - stream.log("[Mine encounter] Starting forced encounter wiggle."); - - const auto deadline = std::chrono::steady_clock::now() + 60s; - - size_t pulses = 0; - - while (std::chrono::steady_clock::now() < deadline){ - // Alternate down and up. Do not move sideways. - const double y = pulses % 2 == 0 ? -1.0 : 1.0; - - BlackScreenWatcher black; - BattleMenuWatcher menu(BattleType::STANDARD, COLOR_YELLOW); - BattleDialogWatcher dialog(COLOR_YELLOW); - - const int result = run_until(stream, - context, - [y](ProControllerContext& ctx){ - pbf_move_left_joystick(ctx, {0.0, y}, 120ms, 0ms); - pbf_wait(ctx, 400ms); - ctx.wait_for_all_requests(); - }, - {black, menu, dialog}); - - if (result >= 0){ - stream.log("[Mine encounter] Wiggle interrupted. Checking for battle."); - return mine_escape_and_settle(stream, context); - } - - pulses++; - } - - stream.log("[Mine encounter] No encounter within 60 seconds. " - "Test incomplete; stopping.", - COLOR_ORANGE); - return false; -} - -// Watch throughout an action, including its movement and picture checks. -// After an encounter, restart the action using fresh watcher state. -template -static bool mine_run_with_encounters(VideoStream& stream, ProControllerContext& context, Action action){ - for (size_t encounters = 0;;){ - BlackScreenWatcher black; - BattleMenuWatcher menu(BattleType::STANDARD, COLOR_YELLOW); - BattleDialogWatcher dialog(COLOR_YELLOW); - bool completed = false; - - const int result = run_until(stream, - context, - [&](ProControllerContext& ctx){ - completed = action(ctx); - }, - {black, menu, dialog}); - - if (result < 0){ - return completed; - } - - if (++encounters > 10){ - stream.log("[Mine encounter] Encounter limit reached.", COLOR_ORANGE); - return false; - } - - if (!mine_escape_and_settle(stream, context)){ - return false; - } - - stream.log("[Mine encounter] Escaped; resuming interrupted action."); - } -} - -static bool mine_timed_walk(VideoStream& stream, - ProControllerContext& context, - double x, - double y, - std::chrono::milliseconds duration){ - auto remaining = duration; - - return mine_run_with_encounters(stream, context, [&](ProControllerContext& ctx){ - while (remaining > 0ms){ - const auto pulse = remaining < 100ms ? remaining : 100ms; - pbf_move_left_joystick(ctx, {x, y}, pulse, 0ms); - pbf_wait(ctx, 700ms); - ctx.wait_for_all_requests(); - - // An interrupted pulse is retried after escaping. - // This is a timing budget, not exact distance tracking. - remaining -= pulse; - } - return true; - }); -} - - -struct MinePictureScore { - double average = 1000.0; - double worst = 1000.0; - double horizontal = 0; - double vertical = 0; -}; - -// Supports four, five, or six regions. Existing four-region callers keep -// their original search ranges through the default arguments. -template -static MinePictureScore mine_score_picture(const ImageRGB32& reference, - const ImageRGB32& live, - const std::array& boxes, - double horizontal_range = 1.0 / 60, - double vertical_range = 2.0 / 135){ - MinePictureScore best; - const int radius_x = (int)std::lround(horizontal_range * live.width()); - const int radius_y = (int)std::lround(vertical_range * live.height()); - for (int y = -radius_y; y <= radius_y; y++){ - for (int x = -radius_x; x <= radius_x; x++){ - double total = 0.0; - double worst = 0.0; - for (const ImageFloatBox& box : boxes){ - const ImageViewRGB32 ref = extract_box_reference(reference, box); - const ImageViewRGB32 patch = extract_box_reference(live, box, x, y); - const double score = ImageMatch::pixel_RMSD(ref, patch); - total += score; - if (score > worst){ - worst = score; - } - } - const double average = total / boxes.size(); - if (average < best.average){ - best.average = average; - best.worst = worst; - best.horizontal = (double)x / live.width(); - best.vertical = (double)y / live.height(); - } - } - } - return best; -} - - -static bool mine_check_picture(VideoStream& stream, - ProControllerContext& context, - const char* filename, - const std::array& boxes, - const std::string& label, - size_t max_attempts = 8, - double max_average = 20.0, - double max_patch = 30.0){ - const ImageRGB32 reference(mine_navigation_resource_path() + filename); - const ImageRGB32 reference_scaled = reference.scale_to(960, 540); - - const bool confirmed = mine_run_with_encounters(stream, context, [&](ProControllerContext& ctx){ - // The watcher stays active throughout settling and sampling. - // There is no separate 500ms watcher wait before each sample. - ctx.wait_for_all_requests(); - pbf_wait(ctx, 700ms); - ctx.wait_for_all_requests(); - - // Restart confirmation after a battle; do not reuse old matches. - size_t matching_frames = 0; - - for (size_t attempt = 0; attempt < max_attempts; attempt++){ - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - stream.log(label + ": no video frame.", COLOR_ORANGE); - return false; - } - - const ImageRGB32 live = frame->scale_to(960, 540); - const MinePictureScore score = mine_score_picture(reference_scaled, live, boxes); - - stream.log( - label + ": average=" + std::to_string(score.average) + ", worst patch=" + std::to_string(score.worst)); - - if (score.average <= max_average && score.worst <= max_patch){ - if (++matching_frames >= 3){ - return true; - } - }else{ - matching_frames = 0; - } - - pbf_wait(ctx, 250ms); - ctx.wait_for_all_requests(); - } - - return false; - }); - - stream.log(label + (confirmed ? ": confirmed." : ": not confirmed."), confirmed ? COLOR_GREEN : COLOR_ORANGE); - return confirmed; -} - -struct MineWalkOptions { - double x = 0.0; - double y = 0.0; - std::chrono::milliseconds pulse = 40ms; - std::chrono::milliseconds settle = 500ms; - std::chrono::milliseconds initial_settle = 0ms; - size_t max_movements = 30; - double horizontal_range = 0.0125; - double vertical_range = 1.0 / 90; - double max_average = 20.0; - double max_patch = 30.0; -}; - -template -static bool mine_walk_to_endpoints(VideoStream& stream, - ProControllerContext& context, - const std::array& files, - const std::array, Endpoints>& boxes, - const MineWalkOptions& options, - const std::string& label, - bool force_encounter = false){ - std::array, Endpoints> references; - for (size_t index = 0; index < Endpoints; index++){ - const ImageRGB32 image(mine_navigation_resource_path() + files[index]); - references[index] = std::make_unique(image.scale_to(960, 540)); - } - - size_t movements = 0; - bool forced_completed = false; - - while (true){ - bool request_forced_test = false; - const bool reached = mine_run_with_encounters(stream, context, [&](ProControllerContext& ctx){ - if (options.initial_settle > 0ms){ - pbf_wait(ctx, options.initial_settle); - ctx.wait_for_all_requests(); - } - // Reset the match history whenever an encounter interrupts us. - size_t matching_frames = 0; - int previous_endpoint = -1; - - while (true){ - ctx.wait_for_all_requests(); - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - stream.log(label + ": no video frame.", COLOR_ORANGE); - return false; - } - - const ImageRGB32 live = frame->scale_to(960, 540); - int accepted = -1; - double accepted_average = 1000.0; - - for (size_t endpoint = 0; endpoint < Endpoints; endpoint++){ - const MinePictureScore score = mine_score_picture(*references[endpoint], - live, - boxes[endpoint], - options.horizontal_range, - options.vertical_range); - stream.log(label + " Endpoint " + std::to_string(endpoint + 1) + - ": average=" + std::to_string(score.average) + ", worst patch=" + std::to_string(score.worst) + - ", screen-relative offset=(" + std::to_string(score.horizontal) + "," + - std::to_string(score.vertical) + ")"); - - if (score.average <= options.max_average && score.worst <= options.max_patch && - score.average < accepted_average){ - accepted = static_cast(endpoint); - accepted_average = score.average; - } - } - - if (accepted >= 0){ - matching_frames = accepted == previous_endpoint ? matching_frames + 1 : 1; - previous_endpoint = accepted; - if (matching_frames >= 3){ - stream.log(label + " Endpoint " + std::to_string(accepted + 1) + " confirmed.", COLOR_GREEN); - return true; - } - pbf_wait(ctx, 250ms); - ctx.wait_for_all_requests(); - continue; - } - - matching_frames = 0; - previous_endpoint = -1; - - // Exit the guard before invoking the diagnostic wiggle: - // it has its own encounter watcher and must not be nested. - if (force_encounter && !forced_completed && movements >= 4){ - request_forced_test = true; - return false; - } - - if (movements >= options.max_movements){ - stream.log(label + ": movement limit reached.", COLOR_ORANGE); - return false; - } - - // Count issued commands, including interrupted commands. - movements++; - pbf_move_left_joystick(ctx, {options.x, options.y}, options.pulse, 0ms); - pbf_wait(ctx, options.settle); - ctx.wait_for_all_requests(); - } - }); - - if (reached){ - return true; - } - if (!request_forced_test){ - return false; - } - if (!mine_force_encounter_test(stream, context)){ - return false; - } - forced_completed = true; - stream.log(label + ": forced encounter completed; resuming."); - } -} - -static bool -oreburgh_mine_walk_to_first_rock(VideoStream& stream, ProControllerContext& context, bool force_encounter = false){ - const std::array files = {"mine_path_01.png"}; - const std::array, 1> boxes = {{{{ImageFloatBox(0.482, 0.585, 0.055, 0.055), - ImageFloatBox(0.335, 0.670, 0.050, 0.055), - ImageFloatBox(0.412, 0.670, 0.050, 0.055), - ImageFloatBox(0.570, 0.752, 0.045, 0.055), - ImageFloatBox(0.645, 0.752, 0.045, 0.055), - ImageFloatBox(0.570, 0.290, 0.080, 0.120)}}}}; - - MineWalkOptions options; - options.x = 0.0; - options.y = -1.0; - options.pulse = 80ms; - options.max_movements = 40; - options.horizontal_range = 0.025; - return mine_walk_to_endpoints(stream, context, files, boxes, options, "[Mine path 01]", force_encounter); -} - -static bool oreburgh_mine_move_right(VideoStream& stream, ProControllerContext& context){ - const std::array files = {"mine_path_01_1.png", "mine_path_01_2.png"}; - const std::array, 2> boxes = {{{{ImageFloatBox(0.400, 0.585, 0.055, 0.055), - ImageFloatBox(0.245, 0.670, 0.050, 0.055), - ImageFloatBox(0.322, 0.670, 0.050, 0.055), - ImageFloatBox(0.480, 0.752, 0.045, 0.055), - ImageFloatBox(0.555, 0.752, 0.045, 0.055), - ImageFloatBox(0.485, 0.290, 0.080, 0.090)}}, - {{ImageFloatBox(0.325, 0.585, 0.055, 0.055), - ImageFloatBox(0.170, 0.670, 0.050, 0.055), - ImageFloatBox(0.244, 0.670, 0.050, 0.055), - ImageFloatBox(0.395, 0.752, 0.045, 0.055), - ImageFloatBox(0.470, 0.752, 0.045, 0.055), - ImageFloatBox(0.415, 0.290, 0.080, 0.090)}}}}; - - MineWalkOptions options; - options.x = 1.0; - options.y = 0.0; - options.pulse = 40ms; - options.max_movements = 20; - options.horizontal_range = 0.0125; - return mine_walk_to_endpoints(stream, context, files, boxes, options, "[Mine right]"); -} - -static bool oreburgh_mine_down_past_central_rock(VideoStream& stream, ProControllerContext& context){ - const std::array files = {"mine_path_02_1.png", "mine_path_02_2.png"}; - const std::array, 2> boxes = {{{{ImageFloatBox(0.345, 0.420, 0.040, 0.045), - ImageFloatBox(0.195, 0.500, 0.040, 0.045), - ImageFloatBox(0.270, 0.500, 0.040, 0.045), - ImageFloatBox(0.410, 0.590, 0.040, 0.045), - ImageFloatBox(0.480, 0.590, 0.040, 0.045), - ImageFloatBox(0.445, 0.180, 0.060, 0.090)}}, - {{ImageFloatBox(0.420, 0.420, 0.040, 0.045), - ImageFloatBox(0.270, 0.500, 0.040, 0.045), - ImageFloatBox(0.345, 0.500, 0.040, 0.045), - ImageFloatBox(0.485, 0.590, 0.040, 0.045), - ImageFloatBox(0.555, 0.590, 0.040, 0.045), - ImageFloatBox(0.520, 0.180, 0.060, 0.090)}}}}; - - MineWalkOptions options; - options.x = 0.0; - options.y = -1.0; - options.pulse = 40ms; - options.max_movements = 30; - // options.horizontal_range = 0.0125; - // Path 02 can be shifted horizontally after the first right endpoint. - // The screenshot regression matches at about -2.08% of screen width. - options.horizontal_range = 0.025; - return mine_walk_to_endpoints(stream, context, files, boxes, options, "[Mine down 02]"); -} - -static bool oreburgh_mine_move_left_to_gap(VideoStream& stream, ProControllerContext& context){ - const std::array files = {"mine_path_03.png"}; - const std::array, 1> boxes = {{{{ImageFloatBox(0.340, 0.495, 0.040, 0.045), - ImageFloatBox(0.410, 0.495, 0.040, 0.045), - ImageFloatBox(0.555, 0.580, 0.040, 0.045), - ImageFloatBox(0.625, 0.580, 0.040, 0.045), - ImageFloatBox(0.580, 0.180, 0.060, 0.090)}}}}; - - MineWalkOptions options; - options.x = -1.0; - options.y = 0.0; - options.pulse = 40ms; - options.max_movements = 30; - options.horizontal_range = 0.0125; - return mine_walk_to_endpoints(stream, context, files, boxes, options, "[Mine left 03]"); -} - - -// Confirm we are far enough down before beginning the leftward section. - - -// Each picture stage owns its movement and matching settings. -struct MinePictureStep { - const char* file; - const char* label; - std::array boxes; - double x; - double y; - std::chrono::milliseconds pulse = 80ms; - size_t max_movements = 30; - double max_average = 20.0; - double max_patch = 30.0; -}; - -static bool mine_run_picture_step(VideoStream& stream, ProControllerContext& context, const MinePictureStep& step){ - MineWalkOptions options; - options.x = step.x; - options.y = step.y; - options.pulse = step.pulse; - options.max_movements = step.max_movements; - options.max_average = step.max_average; - options.max_patch = step.max_patch; - options.horizontal_range = 1.0 / 60; - options.vertical_range = 2.0 / 135; - options.initial_settle = 700ms; - options.settle = 700ms; - const std::array files = {step.file}; - const std::array, 1> boxes = {{step.boxes}}; - return mine_walk_to_endpoints(stream, context, files, boxes, options, step.label); -} - -static bool oreburgh_mine_confirm_05(VideoStream& stream, ProControllerContext& context){ - const MinePictureStep step = {"mine_path_05.png", - "[Mine endpoint 05]", - {ImageFloatBox(0.240, 0.240, 0.055, 0.080), - ImageFloatBox(0.390, 0.220, 0.050, 0.090), - ImageFloatBox(0.360, 0.090, 0.030, 0.030), - ImageFloatBox(0.550, 0.140, 0.040, 0.040)}, - 0.0, - -1.0, - 60ms, - 10, - 20.0, - 30.0}; - return mine_run_picture_step(stream, context, step); -} - -// Timed movement through paths 6 and 7, with picture checks. -static bool oreburgh_mine_timed_to_07(VideoStream& stream, ProControllerContext& context){ - stream.log("[Mine 04-07] Walking down for 1500ms."); - - if (!mine_timed_walk(stream, context, 0.0, -1.0, 1500ms)){ - return false; - } - - if (!oreburgh_mine_confirm_05(stream, context)){ - return false; - } - - stream.log("[Mine 04-07] Walking left for 3000ms."); - - if (!mine_timed_walk(stream, context, -1.0, 0.0, 3000ms)){ - return false; - } - - // Path 6: crate, nearby small rock, large rock, upper-right small rock. - const std::array path6_boxes = {ImageFloatBox(0.350, 0.250, 0.085, 0.075), - ImageFloatBox(0.405, 0.510, 0.035, 0.040), - ImageFloatBox(0.520, 0.300, 0.050, 0.080), - ImageFloatBox(0.620, 0.350, 0.030, 0.040)}; - - if (!mine_check_picture(stream, context, "mine_path_06.png", path6_boxes, "[Mine midpoint 06]")){ - return false; - } - - stream.log("[Mine 04-07] Path 6 confirmed; walking down for 600ms."); - - if (!mine_timed_walk(stream, context, 0.0, -1.0, 600ms)){ - return false; - } - - // Path 7: crate and three visible rock surfaces. - const std::array path7_boxes = {ImageFloatBox(0.365, 0.025, 0.070, 0.055), - ImageFloatBox(0.420, 0.270, 0.035, 0.040), - ImageFloatBox(0.560, 0.415, 0.025, 0.040), - ImageFloatBox(0.530, 0.650, 0.060, 0.090)}; - - return mine_check_picture(stream, context, "mine_path_07.png", path7_boxes, "[Mine endpoint 07]"); -} - -// Move left from path 7 to the position shown in path 8. -static bool oreburgh_mine_left_to_08(VideoStream& stream, ProControllerContext& context){ - const MinePictureStep step = {"mine_path_08.png", - "[Mine endpoint 08]", - {ImageFloatBox(0.495, 0.035, 0.070, 0.055), - ImageFloatBox(0.550, 0.270, 0.035, 0.040), - ImageFloatBox(0.695, 0.420, 0.030, 0.040), - ImageFloatBox(0.555, 0.585, 0.035, 0.040)}, - -1.0, - 0.0, - 40ms, - 20, - 20.0, - 30.0}; - return mine_run_picture_step(stream, context, step); -} - -// Walk down along the left wall to path 9. - -static bool oreburgh_mine_down_to_09(VideoStream& stream, ProControllerContext& context){ - const MinePictureStep step = {"mine_path_09.png", - "[Mine endpoint 09]", - {ImageFloatBox(0.550, 0.115, 0.030, 0.040), - ImageFloatBox(0.550, 0.390, 0.035, 0.040), - ImageFloatBox(0.620, 0.390, 0.025, 0.035), - ImageFloatBox(0.500, 0.720, 0.085, 0.080)}, - 0.0, - -1.0, - 40ms, - 25, - 25.0, - 35.0}; - return mine_run_picture_step(stream, context, step); -} - - -// Require dialogue to appear first. -// Then finish only after it has been absent for one continuous second. -static bool mine_finish_item_dialogue(VideoStream& stream, ProControllerContext& context){ - ShortDialogDetector dialogue(COLOR_YELLOW); - - bool saw_dialogue = false; - auto absent_since = std::chrono::steady_clock::now(); - - const auto start = std::chrono::steady_clock::now(); - const auto deadline = start + 30s; - - stream.log("[Mine item] Starting interaction."); - - while (std::chrono::steady_clock::now() < deadline){ - context.wait_for_all_requests(); - const VideoSnapshot frame = stream.video().snapshot(); - - if (!frame){ - stream.log("[Mine item] No video frame. Stopping.", COLOR_ORANGE); - return false; - } - - const auto now = std::chrono::steady_clock::now(); - const bool visible = dialogue.detect(frame); - - if (visible){ - if (!saw_dialogue){ - stream.log("[Mine item] Dialogue detected."); - } - - saw_dialogue = true; - absent_since = now; - - // Short mash bursts allow frequent checks. - pbf_mash_button(context, BUTTON_A, 100ms); - }else if (!saw_dialogue){ - if (now - start >= 10s){ - stream.log("[Mine item] No dialogue appeared. Stopping.", COLOR_ORANGE); - return false; - } - - // Try to start the interaction. - pbf_press_button(context, BUTTON_A, 60ms, 100ms); - }else if (now - absent_since >= 1s){ - stream.log("[Mine item] Dialogue absent for one second. Finished.", COLOR_GREEN); - return true; - } - - pbf_wait(context, 100ms); - context.wait_for_all_requests(); - } - - stream.log("[Mine item] Dialogue timeout. Stopping.", COLOR_ORANGE); - return false; -} - - -static bool oreburgh_mine_09_to_14(VideoStream& stream, ProControllerContext& context){ - const std::array path10_boxes = { - ImageFloatBox(0.405, 0.720, 0.085, 0.080), // Crate - ImageFloatBox(0.480, 0.120, 0.030, 0.040), // Upper rock - ImageFloatBox(0.575, 0.420, 0.045, 0.080), // Large right rock - ImageFloatBox(0.550, 0.720, 0.030, 0.040) // Lower small rock - }; - - const MinePictureStep path10_boxes_step = - {"mine_path_10.png", "[Mine endpoint 10]", path10_boxes, 1.0, 0.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path10_boxes_step)){ - return false; - } - - const std::array path11_boxes = {ImageFloatBox(0.405, 0.560, 0.070, 0.080), - ImageFloatBox(0.475, 0.270, 0.030, 0.040), - ImageFloatBox(0.575, 0.290, 0.045, 0.080), - ImageFloatBox(0.550, 0.575, 0.030, 0.040)}; - - const MinePictureStep path11_boxes_step = - {"mine_path_11.png", "[Mine endpoint 11]", path11_boxes, 0.0, -1.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path11_boxes_step)){ - return false; - } - - const std::array path12_boxes = {ImageFloatBox(0.260, 0.560, 0.080, 0.080), - ImageFloatBox(0.355, 0.270, 0.025, 0.035), - ImageFloatBox(0.435, 0.290, 0.040, 0.070), - ImageFloatBox(0.415, 0.585, 0.025, 0.035)}; - - const MinePictureStep path12_boxes_step = - {"mine_path_12.png", "[Mine endpoint 12]", path12_boxes, 1.0, 0.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path12_boxes_step)){ - return false; - } - - const std::array path13_boxes = { - ImageFloatBox(0.290, 0.230, 0.070, 0.060), // Upper crate - ImageFloatBox(0.285, 0.390, 0.070, 0.060), // Lower crate - ImageFloatBox(0.425, 0.265, 0.025, 0.035), // Upper small rock - ImageFloatBox(0.405, 0.580, 0.030, 0.040) // Bottom-left small rock - }; - - const MinePictureStep path13_boxes_step = - {"mine_path_13.png", "[Mine endpoint 13]", path13_boxes, 0.0, -1.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path13_boxes_step)){ - return false; - } - - // Turn left, then verify position before starting the interaction. - pbf_move_left_joystick(context, {-1.0, 0.0}, 40ms, 0ms); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - - if (!mine_check_picture(stream, context, "mine_path_14.png", path13_boxes, "[Mine position 14]", 3)){ - return false; - } - stream.log("[Mine item] Position confirmed. Pressing A."); - - pbf_press_button(context, BUTTON_A, 80ms, 200ms); - context.wait_for_all_requests(); - - return mine_finish_item_dialogue(stream, context); -} - -// Compare a reference patch with a shifted live patch using the same -// brightness adjustment as the native ImageMatchDetector. -static double mine_offset_patch_score(const ImageRGB32& reference, - const ImageRGB32& live, - const ImageFloatBox& box, - double horizontal, - double vertical){ - const ImageViewRGB32 reference_crop = extract_box_reference(reference, box); - ImageFloatBox shifted = box; - shifted.x += horizontal; - shifted.y += vertical; - const ImageViewRGB32 live_crop = extract_box_reference(live, shifted); - ImageRGB32 adjusted = live_crop.scale_to(reference_crop.width(), reference_crop.height()); - - const auto reference_average = ImageMatch::pixel_average(reference_crop, reference_crop); - const auto live_average = ImageMatch::pixel_average(adjusted, reference_crop); - auto scale = reference_average / live_average; - if (std::isnan(scale.r)){ - scale.r = 1.0; - } - if (std::isnan(scale.g)){ - scale.g = 1.0; - } - if (std::isnan(scale.b)){ - scale.b = 1.0; - } - scale.bound(0.8, 1.2); - ImageMatch::scale_brightness(adjusted, scale); - - return ImageMatch::pixel_RMSD(reference_crop, adjusted); -} - -struct MineRoarkOffsetScore { - double left = 1000.0; - double right = 1000.0; - double helmet = 1000.0; - double horizontal = 0; - double vertical = 0; - double helmet_horizontal = 0; - double helmet_vertical = 0; -}; - -static MineRoarkOffsetScore mine_score_roark_offsets(const ImageRGB32& reference, const ImageRGB32& live){ - const ImageFloatBox left_box(0.415, 0.400, 0.050, 0.095); - const ImageFloatBox right_box(0.555, 0.400, 0.055, 0.095); - const ImageFloatBox helmet_box(0.485, 0.335, 0.035, 0.045); - - MineRoarkOffsetScore best; - double best_worst_rock = 1000.0; - double best_total = 2000.0; - - // Both rocks must use the SAME offset. They cannot match independently. - // Search ranges are screen fractions; enumerate pixels only for scoring. - const int radius_x = (int)std::lround(live.width() / 60.0); - const int radius_y = (int)std::lround(live.height() * 2.0 / 135); - for (int y = -radius_y; y <= radius_y; y++){ - for (int x = -radius_x; x <= radius_x; x++){ - const double dx = (double)x / live.width(), dy = (double)y / live.height(); - const double left = mine_offset_patch_score(reference, live, left_box, dx, dy); - const double right = mine_offset_patch_score(reference, live, right_box, dx, dy); - const double worst = left > right ? left : right; - const double total = left + right; - - if (worst < best_worst_rock || (worst == best_worst_rock && total < best_total)){ - best_worst_rock = worst; - best_total = total; - best.left = left; - best.right = right; - best.horizontal = (double)x / live.width(); - best.vertical = (double)y / live.height(); - } - } - } - - // Roark's helmet may move slightly relative to the stationary rocks. - // Keep this extra search small so another red object cannot match far away. - const int helmet_radius_x = (int)std::lround(live.width() * 0.00625); - const int helmet_radius_y = (int)std::lround(live.height() / 90.0); - for (int extra_y = -helmet_radius_y; extra_y <= helmet_radius_y; extra_y++){ - for (int extra_x = -helmet_radius_x; extra_x <= helmet_radius_x; extra_x++){ - const double score = mine_offset_patch_score(reference, - live, - helmet_box, - best.horizontal + (double)extra_x / live.width(), - best.vertical + (double)extra_y / live.height()); - if (score < best.helmet){ - best.helmet = score; - best.helmet_horizontal = (double)extra_x / live.width(); - best.helmet_vertical = (double)extra_y / live.height(); - } - } - } - - return best; -} - -static bool oreburgh_mine_align_to_18(VideoStream& stream, ProControllerContext& context){ - const ImageRGB32 reference(mine_navigation_resource_path() + "Mine_path_18.png"); - const ImageRGB32 reference_scaled = reference.scale_to(960, 540); - const double max_rmsd = 65.0; - size_t movements = 0; - - return mine_run_with_encounters(stream, context, [&](ProControllerContext& ctx){ - // Fresh confirmation after any encounter. - size_t matching_frames = 0; - pbf_wait(ctx, 500ms); - ctx.wait_for_all_requests(); - - while (true){ - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - stream.log("[Mine endpoint 18] No video frame.", COLOR_ORANGE); - return false; - } - - const ImageRGB32 live = frame->scale_to(960, 540); - const MineRoarkOffsetScore score = mine_score_roark_offsets(reference_scaled, live); - - stream.log("[Mine endpoint 18] Left rock=" + std::to_string(score.left) + - ", Right rock=" + std::to_string(score.right) + ", Helmet=" + std::to_string(score.helmet) + - ", shared screen-relative offset=(" + std::to_string(score.horizontal) + "," + - std::to_string(score.vertical) + "), helmet screen-relative extra=(" + - std::to_string(score.helmet_horizontal) + "," + std::to_string(score.helmet_vertical) + ")"); - - if (score.left <= max_rmsd && score.right <= max_rmsd && score.helmet <= max_rmsd){ - if (++matching_frames >= 3){ - stream.log("[Mine endpoint 18] Rocks and helmet confirmed.", COLOR_GREEN); - return true; - } - pbf_wait(ctx, 250ms); - ctx.wait_for_all_requests(); - continue; - } - - matching_frames = 0; - if (movements >= 80){ - stream.log("[Mine endpoint 18] Movement limit reached.", COLOR_ORANGE); - return false; - } - - const bool approaching = score.left <= 120.0 && score.right <= 120.0 && score.helmet <= 150.0; - const bool near = score.left <= 100.0 && score.right <= 100.0 && score.helmet <= 100.0; - const auto pulse = near ? 20ms : approaching ? 40ms : 60ms; - - // Use the outer encounter watcher; do not nest mine_timed_walk. - // Count issued pulses, including any interrupted by a battle. - movements++; - pbf_move_left_joystick(ctx, {1.0, 0.0}, pulse, 0ms); - pbf_wait(ctx, 700ms); - ctx.wait_for_all_requests(); - } - }); -} - -static bool oreburgh_mine_14_to_19(VideoStream& stream, ProControllerContext& context){ - const std::array path15_boxes = { - ImageFloatBox(0.140, 0.220, 0.080, 0.070), // Upper crate - ImageFloatBox(0.125, 0.370, 0.080, 0.070), // Lower crate - ImageFloatBox(0.285, 0.250, 0.035, 0.045), // Small upper rock - ImageFloatBox(0.325, 0.560, 0.035, 0.045) // Lower rock - }; - - const MinePictureStep path15_boxes_step = - {"mine_path_15_actual.png", "[Mine endpoint 15]", path15_boxes, 1.0, 0.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path15_boxes_step)){ - return false; - } - - // Move below the worker before continuing right. - const std::array path16_boxes = { - ImageFloatBox(0.145, 0.160, 0.080, 0.070), // Upper crate - ImageFloatBox(0.135, 0.305, 0.080, 0.070), // Lower crate - ImageFloatBox(0.290, 0.195, 0.035, 0.045), // Small upper rock - ImageFloatBox(0.330, 0.495, 0.035, 0.045) // Lower rock - }; - - const MinePictureStep path16_boxes_step = - {"mine_path_16.png", "[Mine endpoint 16]", path16_boxes, 0.0, -1.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path16_boxes_step)){ - return false; - } - - // Skip 17 and find the actual stopping point at 18. - if (!oreburgh_mine_align_to_18(stream, context)){ - return false; - } - - // Position 19 is the upward turn toward Roark. - pbf_move_left_joystick(context, {0.0, 1.0}, 40ms, 0ms); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - - stream.log("[Mine endpoint 19] Position 18 confirmed; north turn sent.", COLOR_GREEN); - return true; -} - -bool oreburgh_mine_route(VideoStream& stream, ProControllerContext& context, const MineRouteTestOptions& options){ - stream.log("[Mine resources] " + mine_navigation_resource_path()); - struct Step { - const char* name; - bool (*run)(VideoStream&, ProControllerContext&); - }; - - const std::array steps = {{{"Align entrance", oreburgh_mine_alignment_test}, - {"Walk to first rock", - [](VideoStream& video, ProControllerContext& controller){ - return oreburgh_mine_walk_to_first_rock(video, controller, false); - }}, - {"Move right of first rock", oreburgh_mine_move_right}, - {"Move below first rock", oreburgh_mine_down_past_central_rock}, - {"Move left into center gap", oreburgh_mine_move_left_to_gap}, - {"Reach position 07", oreburgh_mine_timed_to_07}, - {"Reach position 08", oreburgh_mine_left_to_08}, - {"Reach position 09", oreburgh_mine_down_to_09}, - {"Reach 14 and collect item", oreburgh_mine_09_to_14}, - {"Reach 18 and turn north for 19", oreburgh_mine_14_to_19}}}; - - const size_t first = static_cast(options.first); - const size_t last = static_cast(options.last); - - if (first > last || last >= steps.size()){ - stream.log("[Mine route] Invalid stage range.", COLOR_ORANGE); - return false; - } - - for (size_t index = first; index <= last; index++){ - const Step& step = steps[index]; - - stream.log(std::string("[Mine route] Starting: ") + step.name); - - bool succeeded = false; - - try { - succeeded = step.run(stream, context); - } catch (OperationFailedException& error){ - stream.log(std::string("[Mine route] Failed during ") + step.name + ": " + error.message(), COLOR_ORANGE); - - if (options.reset_on_failure){ - throw; - } - - stream.log("[Mine route] Test stopped without requesting a reset.", COLOR_ORANGE); - return false; - } - - if (!succeeded){ - stream.log(std::string("[Mine route] Stage failed: ") + step.name, COLOR_ORANGE); - - if (options.reset_on_failure){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - std::string("Mine route failed during: ") + step.name, - stream); - } - - return false; - } - - stream.log(std::string("[Mine route] Completed: ") + step.name, COLOR_GREEN); - } - - stream.log("[Mine route] Selected stages completed.", COLOR_GREEN); - return true; -} - -// Start Roark's conversation once, then advance it with B. -// Require dialogue to be seen before accepting its disappearance. - -static bool mine_talk_to_roark(VideoStream& stream, ProControllerContext& context){ - stream.log("[Mine Roark] Pressing A to start conversation."); - pbf_press_button(context, BUTTON_A, 80ms, 100ms); - context.wait_for_all_requests(); - - // The opening cutscene precedes the first dialogue box. - // Do not mash B or measure dialogue absence during that cutscene. - wait_for_dialogue(stream, context, "[Mine Roark] First box", 30s); - - ShortDialogDetector dialogue(COLOR_YELLOW); - const auto deadline = std::chrono::steady_clock::now() + 60s; - bool timing_absence = false; - auto absent_since = std::chrono::steady_clock::now(); - while (std::chrono::steady_clock::now() < deadline){ - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - stream.log("[Mine Roark] No video frame.", COLOR_ORANGE); - return false; - } - const auto now = std::chrono::steady_clock::now(); - if (dialogue.detect(frame)){ - timing_absence = false; - }else{ - if (!timing_absence){ - timing_absence = true; - absent_since = now; - } - if (now - absent_since >= 4s){ - stream.log("[Mine Roark] Dialogue absent for 4 seconds. Finished.", COLOR_GREEN); - return true; - } - } - pbf_mash_button(context, BUTTON_B, 100ms); - context.wait_for_all_requests(); - } - stream.log("[Mine Roark] Conversation timed out.", COLOR_ORANGE); - return false; -} - -// Retain the developer test entry point; both callers share the same route. -bool oreburgh_mine_first_leg_test(VideoStream& stream, - ProControllerContext& context, - const MineRouteTestOptions& options){ - return oreburgh_mine_route(stream, context, options); -} - -// Shared final step for the live route and the entrance-start test. -static bool mine_talk_and_escape(VideoStream& stream, ProControllerContext& context){ - if (!mine_talk_to_roark(stream, context)){ - return false; - } - stream.log("[Mine exit] Finding Escape Rope in the Bag."); - if (!use_escape_rope(stream, context, 8)){ - return false; - } - stream.log("[Mine exit] Roark conversation and Escape Rope exit succeeded.", COLOR_GREEN); - return true; -} - -bool oreburgh_mine_route_and_escape_test(VideoStream& stream, ProControllerContext& context){ - MineRouteTestOptions route; - route.reset_on_failure = false; - if (!oreburgh_mine_first_leg_test(stream, context, route)){ - return false; - } - try { - if (!mine_talk_and_escape(stream, context)){ - stream.log("[Mine exit test] Failed. Stopping without resetting.", COLOR_ORANGE); - return false; - } - } catch (OperationFailedException& error){ - stream.log("[Mine exit test] " + error.message(), COLOR_ORANGE); - stream.log("[Mine exit test] Stopped without requesting a reset.", COLOR_ORANGE); - return false; - } - return true; -} - -void oreburgh_mine_nav(VideoStream& stream, ProControllerContext& context){ - walk_through_grass(stream, context, 0, -1, 1, "down into mine"); - context.wait_for_all_requests(); - walk_until_doorway(stream, context, MoveDirection::DOWN, 4350ms); - context.wait_for_all_requests(); - pbf_wait(context, 1824ms); - - MineRouteTestOptions route; - route.reset_on_failure = true; - - if (!oreburgh_mine_route(stream, context, route)){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Mine route did not complete.", - stream); - } - - - if (!mine_talk_and_escape(stream, context)){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Mine route: Roark conversation or Escape Rope exit failed.", - stream); - } -} -// --------------------------------------------------------------------------- -// Segment 1 Part 3: Oreburgh Mine to Gym -// Assumes that escape rope was picked up and used to exit the mine. -// Follows the validated blind route into the gym. -// ---------------------------------------------------------------------------- -void oreburgh_mine_to_gym(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - - stream.log("[Mine to gym] Following recorded route from the mine exit."); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 3995ms, 0ms); - pbf_wait(context, 672ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 681ms, 0ms); - pbf_wait(context, 468ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1081ms, 0ms); - pbf_wait(context, 420ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2445ms, 0ms); - pbf_wait(context, 1056ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 95ms, 0ms); - pbf_wait(context, 4787ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1309ms, 0ms); - - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 4450ms, 0ms); - //pbf_wait(context, 3635ms); - //pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 640ms, 0ms); - //pbf_wait(context, 3795ms); - context.wait_for_all_requests(); - - //stream.log("[Mine to gym] Staying west of the hiker; moving north along the left fence."); - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 2000ms, 0ms); - //pbf_wait(context, 602ms); - //pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 2364ms, 0ms); - //pbf_wait(context, 5069ms); - //pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1079ms, 0ms); - //pbf_wait(context, 605ms); - //pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 728ms, 0ms); - //pbf_wait(context, 1081ms); - //pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 155ms, 0ms); - //pbf_wait(context, 2642ms); - //pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 64ms, 0ms); - //pbf_wait(context, 5340ms); - //context.wait_for_all_requests(); - - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 541ms, 0ms); - //pbf_wait(context, 140ms); - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 47ms, 0ms); - //context.wait_for_all_requests(); - //stream.log("[Mine to gym] Recorded movement finished; waiting for gym entry to settle."); - // walk_until_doorway(stream, context, MoveDirection::UP, 5s); - // Finish entering the doorway with a bounded blind input. - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 1000ms, 0ms); - //context.wait_for_all_requests(); - //pbf_wait(context, 1500ms); - //context.wait_for_all_requests(); - stream.log("[Mine to gym] Blind mine-to-gym sequence completed.", COLOR_GREEN); -} - -// --------------------------------------------------------------------------- -// Segment 1 Part 4: Gym navigation -// Navigates the player through the first Gym in Oreburgh City and completes the Gym battle. -// --------------------------------------------------------------------------- - -void gym_Puzzle_01(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - - //Upon entering the Gym. - // The entry camera sequence lasts about 7.5 seconds in the reference - // video. Allow another 500ms before sending puzzle movement inputs. - context.wait_for_all_requests(); - env.log("[Gym puzzle] Waiting for the entry cutscene to finish."); - pbf_wait(context, 8000ms); - context.wait_for_all_requests(); - - struct Move { - DpadPosition dir; - int count; - }; - const Move sequence[] = { - {DPAD_UP, 3}, - {DPAD_LEFT, 4}, - {DPAD_UP, 5}, - {DPAD_RIGHT, 8}, - {DPAD_UP, 11}, - {DPAD_LEFT, 5}, - {DPAD_UP, 4}, - }; - - for (const auto& move : sequence){ - for (int i = 0; i < move.count; i++){ - pbf_press_dpad(context, move.dir, 80ms, 300ms); - } - } - - //Button presses to initiate the Gym battle. - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - wait_for_dialogue(stream, context, "Phase 3 (Roark fight dialog)"); - pbf_mash_button(context, BUTTON_B, 5000ms); - //Barry 1 battle logic can be used here since the moves don't matter this early. - Barry_1_fight(env, stream, context); - - context.wait_for_all_requests(); - wait_for_dialogue(stream, context, "Phase 4 (Roark post battle dialog)"); - ShortDialogDetector post_battle_dialogue(COLOR_YELLOW); - const auto dialogue_deadline = std::chrono::steady_clock::now() + 180s; - auto dialogue_absent_since = std::chrono::steady_clock::now(); - bool dialogue_finished = false; - stream.log("[Gym post battle] Clearing dialogue until absent for 4 seconds."); - while (std::chrono::steady_clock::now() < dialogue_deadline){ - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Gym post battle: No video frame while clearing dialogue.", - stream); - } - const auto now = std::chrono::steady_clock::now(); - if (post_battle_dialogue.detect(frame)){ - dialogue_absent_since = now; - }else if (now - dialogue_absent_since >= 4s){ - dialogue_finished = true; - break; - } - pbf_mash_button(context, BUTTON_B, 100ms); - context.wait_for_all_requests(); - } - if (!dialogue_finished){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Gym post battle: Dialogue did not finish within 180 seconds.", - stream); - } - stream.log("[Gym post battle] Dialogue absent for 4 seconds. Walking down."); - - for (int i = 0; i < 21; i++){ - pbf_press_dpad(context, DPAD_DOWN, 80ms, 300ms); - } - - // Exit the building. - pbf_wait(context, 1000ms); -} - - -std::string AutoStory_Segment_01::name() const { - return "01: Jubilife City to First Badge"; -} -std::string AutoStory_Segment_01::start_text() const { - return "Start: Received Starter back from Pokemon Home. Max leveled pokemon should have the moveset in the wiki."; -} -std::string AutoStory_Segment_01::end_text() const { - return "End: First Gym Badge acquired (Oreburgh City)."; -} - -void AutoStory_Segment_01::run_segment(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - stats.m_segment++; - env.update_stats(); - context.wait_for_all_requests(); - env.console.log("Start Segment " + name(), COLOR_ORANGE); - - AutoStory_checkpoint_0101().run_checkpoint(env, context, options, stats); - AutoStory_checkpoint_0102().run_checkpoint(env, context, options, stats); - AutoStory_checkpoint_0103().run_checkpoint(env, context, options, stats); - context.wait_for_all_requests(); - env.console.log("End Segment " + name(), COLOR_GREEN); -} - - -std::string AutoStory_checkpoint_0101::name() const { - return "01.01 - Maxed Starter from Home"; -} -std::string AutoStory_checkpoint_0101::start_text() const { - return "Received Starter back from Pokemon Home. Max leveled pokemon should have the moveset in the wiki."; -} -std::string AutoStory_checkpoint_0101::end_text() const { - return "Standing outside the Oreburgh Gate on the Jubilife City side."; -} -// ======================== -// Checkpoint 0101 Wrapper -// ======================== -void AutoStory_checkpoint_0101::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0101(env, context, options, stats); -} - - -void checkpoint_0101(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - jubilife_to_oreburgh_gate(env, env.console, context); - oreburgh_gate_nav(env, env.console, context); - }, - true); -} - -// ======================== -// Checkpoint 0102 Wrapper -// ======================== - -std::string AutoStory_checkpoint_0102::name() const { - return "01.02 - Oreburgh Mine"; -} -std::string AutoStory_checkpoint_0102::start_text() const { - return AutoStory_checkpoint_0101().end_text(); -} -std::string AutoStory_checkpoint_0102::end_text() const { - return "Inside Oreburgh Gym, ready to navigate to Roark."; -} - -void AutoStory_checkpoint_0102::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0102(env, context, options, stats); -} - - -void checkpoint_0102(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - oreburgh_gate_to_mine(env, env.console, context); - oreburgh_mine_nav(env.console, context); - // Standalone test entry points are not part of the checkpoint route. - // oreburgh_mine_first_leg_test(env.console, context); - // oreburgh_mine_route_and_escape_test(env.console, context); - oreburgh_mine_to_gym(env, env.console, context); - }, - - true); -} - -// ======================== -// Checkpoint 0103 Wrapper -// ======================== - -std::string AutoStory_checkpoint_0103::name() const { - return "01.03 - Oreburgh Gym"; -} -std::string AutoStory_checkpoint_0103::start_text() const { - return AutoStory_checkpoint_0102().end_text(); -} -std::string AutoStory_checkpoint_0103::end_text() const { - return "Standing at Roark after defeating him and acquiring the first badge."; -} - -void AutoStory_checkpoint_0103::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0103(env, context, options, stats); -} - - -void checkpoint_0103(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - //#. Oreburgh Gym navigation and battle - gym_Puzzle_01(env, env.console, context); - }, - - true); -} - - -} -} -} +/* BDSP AutoStory - Segment 01 + * From the Jubilife Pokemon Center to Oreburgh City. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_Segment_01.h" +#include "../Data/PokemonBDSP_AutoStory_OreburghMine.h" +#include "../Data/PokemonBDSP_AutoStory_RavagedPath.h" +#include "../Data/PokemonBDSP_AutoStory_Route204.h" +#include "../Data/PokemonBDSP_AutoStory_StoryMaps.h" +#include "../Detect/PokemonBDSP_AutoStory_OverworldDetector.h" +#include "../Utils/PokemonBDSP_AutoStory_PokemonCenter.h" +#include "../Utils/PokemonBDSP_AutoStory_RouteWalk.h" +#include "../Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" +#include "../Utils/PokemonBDSP_AutoStory_Walk.h" +#include "Common/Cpp/PrettyPrint.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "CommonFramework/Globals.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" + +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" + +#include +#include + +using namespace std::chrono_literals; + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using Pokemon::Direction; + + +namespace{ + +[[noreturn]] void fail(VideoStream& stream, const std::string& message){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, message, stream); +} + +// One tile south, for a player facing another way. +void step_south(VideoStream& stream, ProControllerContext& context){ + route_turn(stream, context, Direction::SOUTH); + pbf_move_left_joystick(context, {0, -1}, route_open_hold(Direction::SOUTH, 1), 200ms); + context.wait_for_all_requests(); +} + +// True if a dialogue opens within `timeout`: someone who stopped us as the +// walk ended reads as BLOCKED, and their box comes up a moment later. +bool dialogue_opens(VideoStream& stream, ProControllerContext& context, Milliseconds timeout){ + context.wait_for_all_requests(); + ShortDialogWatcher dialog(COLOR_CYAN); + return wait_until(stream, context, timeout, {{dialog}}) == 0; +} + +// For a dialogue that has opened: B through it. A trainer's ends in a +// battle, which is fought with the best move (Potions when HP runs low), +// followed by a Potion if HP is under half. Anyone else's ends back in the +// overworld. Returns whether there was a battle. Between a trainer's last +// line and the battle the overworld shows with no dialogue for a moment, +// so "only talked" needs 4 s of it, and never comes when `battle_expected`. +// `doubles` for tag and twin battles. A lost battle ends in the last +// Pokemon Center; see in_pokemon_center(). +bool in_pokemon_center(VideoStream& stream); +bool talk_or_fight(VideoStream& stream, ProControllerContext& context, const std::string& label, bool battle_expected = false, bool doubles = false){ + ShortDialogDetector dialog; + OverworldDetector overworld; + BattleMenuDetector battle_menu(BattleType::TRAINER); + BlackScreenDetector black; + auto quiet_since = std::chrono::steady_clock::now(); + for (size_t check = 0; check < 120; check++){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (!frame){ + fail(stream, label + ": No video frame."); + } + if (battle_menu.detect(*frame.frame) || black.detect(*frame.frame)){ + route_status(stream, "Fighting " + label + "."); + fight_story_battle(stream, context, label, doubles); + if (!in_pokemon_center(stream)){ + use_potion_first_pokemon(stream, context, 0.5); + } + return true; + } + const bool talking = dialog.detect(*frame.frame); + if (!talking && overworld.detect(*frame.frame)){ + if (!battle_expected && std::chrono::steady_clock::now() - quiet_since >= 4s){ + route_status(stream, label + " only talked."); + return false; + } + pbf_wait(context, 300ms); + continue; + } + quiet_since = std::chrono::steady_clock::now(); + if (talking){ + pbf_press_button(context, BUTTON_B, 80ms, 1100ms); + }else{ + pbf_wait(context, 300ms); + } + } + fail(stream, label + ": neither a battle nor the overworld after the dialogue."); +} + +// Logs a checkpoint's steps up front, then each step as it starts, with the +// one after it, in the log and the overlay. +class StepLog{ +public: + StepLog(VideoStream& stream, std::string checkpoint, std::string intro, std::vector steps) + : m_stream(stream), m_checkpoint(std::move(checkpoint)), m_steps(std::move(steps)) + { + std::string text = "[" + m_checkpoint + "] " + intro + ", the steps are:"; + for (size_t i = 0; i < m_steps.size(); i++){ + text += "\n " + std::to_string(i + 1) + ". " + m_steps[i]; + } + m_stream.log(text, COLOR_BLUE); + } + void operator()(size_t number){ + const std::string count = std::to_string(number) + "/" + std::to_string(m_steps.size()); + const bool last = number >= m_steps.size(); + m_stream.log("[" + m_checkpoint + " step " + count + "] " + m_steps[number - 1] + + (last ? std::string(" -- last step") : " -- next: " + m_steps[number]), COLOR_BLUE); + add_wrapped_log(m_stream.overlay(), m_checkpoint + " step " + count + ": " + m_steps[number - 1], COLOR_WHITE); + if (!last){ + add_wrapped_log(m_stream.overlay(), "Next: " + m_steps[number], COLOR_GRAY); + } + } + +private: + VideoStream& m_stream; + std::string m_checkpoint; + std::vector m_steps; +}; + +void jubilife_to_barry(VideoStream& stream, ProControllerContext& context){ + // What 01.01 does, stepped through on the console before writing it. + StepLog step(stream, "01.01", "From the Jubilife Pokemon Center to Barry", { + "Heal at the nurse, then walk out of the Pokemon Center", + "Walk to Jubilife's east exit and battle Barry at the Route 203 entrance", + }); + + step(1); + // The save stands us at the PC, right of the counter, facing it; west + // is the counter's end. Two tiles south clears it, then three west is + // the floor emblem's column, with the nurse north and the door south. + route_turn(stream, context, Direction::SOUTH); + pbf_move_left_joystick(context, {0, -1}, route_open_hold(Direction::SOUTH, 2), 200ms); + route_turn(stream, context, Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, route_open_hold(Direction::WEST, 3), 200ms); + if (walk_to_talk(stream, context, 0, 1, 3000ms, "to the nurse").stop != WalkStop::DIALOG){ + fail(stream, "Jubilife Pokemon Center: the nurse did not answer."); + } + // A throughout: Yes to "rest your Pokemon?" (B would answer No). + finish_dialogue(stream, context, BUTTON_A, "Nurse"); + // Healing shows no text box for a few seconds, which reads as the end; + // her "Thank you for waiting" lines follow it. + if (dialogue_opens(stream, context, 6000ms)){ + finish_dialogue(stream, context, BUTTON_B, "Nurse, after healing"); + } + // South through the door until a girl standing below it stops us. The + // door's fade may or may not read as a transition. + WalkResult out{WalkStop::LIMIT, 0ms, 0}; + for (size_t walk = 0; walk < 3 && out.stop != WalkStop::BLOCKED; walk++){ + out = walk_until(stream, context, 0, -1, 8000ms, "out of the Pokemon Center"); + if (out.stop == WalkStop::DIALOG || out.stop == WalkStop::ARRIVED){ + break; + } + } + if (out.stop != WalkStop::BLOCKED){ + fail(stream, std::string("Jubilife Pokemon Center: walking out ended with ") + walk_stop_name(out.stop) + ", not at the girls below the door."); + } + + step(2); + // Jubilife's plaza has too few walls the grid gets right to plan on (a + // leg to a wall the map shows walked on 19 tiles). These are the holds + // that worked on the console: west onto the main road, north to the + // street east to Route 203, and back a couple of tiles. + route_turn(stream, context, Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, 720ms, 300ms); + route_turn(stream, context, Direction::NORTH); + pbf_move_left_joystick(context, {0, 1}, 3960ms, 300ms); + route_turn(stream, context, Direction::SOUTH); + pbf_move_left_joystick(context, {0, -1}, 600ms, 300ms); + context.wait_for_all_requests(); + // East along the street. The opening to the route is a few rows high; + // Barry stops us a little way in. A wall means we are a row off it: try + // rows to the south, then to the north. + static const int ROW_CHANGES[] = {0, 1, 1, -3, -1}; + bool barry = false; + for (int change : ROW_CHANGES){ + if (barry){ + break; + } + for (int i = 0; i < std::abs(change); i++){ + const Direction dir = change > 0 ? Direction::SOUTH : Direction::NORTH; + route_turn(stream, context, dir); + pbf_move_left_joystick(context, {0, change > 0 ? -1.0 : 1.0}, route_open_hold(dir, 1), 200ms); + } + context.wait_for_all_requests(); + WalkResult walk{WalkStop::LIMIT, 0ms, 0}; + for (size_t stretch = 0; stretch < 3 && walk.stop == WalkStop::LIMIT; stretch++){ + walk = walk_until(stream, context, 1, 0, 9000ms, "east to Route 203"); + } + barry = walk.stop == WalkStop::DIALOG; + } + if (!barry){ + fail(stream, "Route 203: Barry did not stop us at the entrance."); + } + if (!talk_or_fight(stream, context, "Barry", true)){ + fail(stream, "Route 203: Barry talked but did not battle."); + } +} + +void route203_to_oreburgh(VideoStream& stream, ProControllerContext& context){ + StepLog step(stream, "01.02", "From Barry on Route 203 to Oreburgh City", { + "Walk east along Route 203 into the Oreburgh Gate, fighting the trainers who see us", + "Listen to the Hiker, who gives HM Rock Smash", + "Walk east through the Gate past Curtis and Diana, out to Oreburgh City", + "Listen to the man who meets us at the city's entrance", + }); + + step(1); + // Route 203 only goes east, over three stretches joined by stairs, and + // the Gate's opening is in the cliff at its north-east end. Who stops us + // differs from run to run (a trainer's sight depends on our row), so + // there is no fixed list: walk east, talk or fight whoever stops us, and + // at a wall find a row from which east goes on. + size_t fights = 0; + bool inside = false; + // On Route 203 only trainers start a talk; one with no battle is the + // Hiker, who meets us inside the Gate (whose fade may not read as a + // transition). + bool met_hiker = false; + auto stopped_by = [&](){ + if (talk_or_fight(stream, context, "Route 203 trainer " + std::to_string(fights + 1))){ + fights++; + }else{ + route_status(stream, "No battle: that was the Hiker, inside the Oreburgh Gate."); + met_hiker = true; + inside = true; + } + }; + for (size_t leg = 0; leg < 40 && !inside; leg++){ + const WalkResult walk = walk_until(stream, context, 1, 0, 10000ms, "east along Route 203"); + if (walk.stop == WalkStop::TRANSITION){ + inside = true; + break; + } + if (walk.stop == WalkStop::LIMIT){ + continue; + } + if (walk.stop == WalkStop::DIALOG || dialogue_opens(stream, context, 1500ms)){ + stopped_by(); + continue; + } + // A wall. Find the row from which east goes on: north to the top wall + // first (a step north there does not move us, so rows are counted + // from it), then a row at a time south. At the route's end this is the + // corner by the second sign, with the Gate's opening two rows down. + walk_until(stream, context, 0, 1, 6000ms, "north to the top wall"); + bool moved_on = false; + for (size_t row = 1; row <= 10 && !moved_on; row++){ + step_south(stream, context); + const WalkResult next = walk_until(stream, context, 1, 0, 10000ms, "east along Route 203, row " + std::to_string(row) + " below the top"); + if (next.stop == WalkStop::TRANSITION){ + inside = true; + } + if (next.stop == WalkStop::DIALOG || (next.stop == WalkStop::BLOCKED && dialogue_opens(stream, context, 1500ms))){ + stopped_by(); + } + // Two still chunks are the least a wall takes; more is progress. + moved_on = inside || next.stop != WalkStop::BLOCKED || next.walked > 1200ms; + } + if (!moved_on){ + fail(stream, "Route 203: walls east on every row down to ten below the top wall."); + } + } + if (!inside){ + fail(stream, "Route 203: did not reach the Oreburgh Gate."); + } + route_status(stream, "Route 203: " + std::to_string(fights) + " trainer battle(s) on the way."); + context.wait_for(1500ms); + + step(2); + // The Hiker steps up to us as we walk east. + if (!met_hiker){ + const WalkResult to_hiker = walk_until(stream, context, 1, 0, 4000ms, "to the Hiker"); + if (to_hiker.stop != WalkStop::DIALOG && !dialogue_opens(stream, context, 2000ms)){ + fail(stream, "Oreburgh Gate: the Hiker did not stop us."); + } + finish_dialogue(stream, context, BUTTON_B, "Hiker"); + } + // Then a "Hidden Moves" tutorial card, closed with B. It is no dialogue + // box, and it can come up a moment after his last line. + pbf_wait(context, 1500ms); + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + context.wait_for_all_requests(); + + step(3); + // East: Camper Curtis and Picnicker Diana see us; past them the exit's + // white fade, and the man in Oreburgh stops us right outside. + fights = 0; + bool city = false; + size_t stuck = 0; + for (size_t leg = 0; leg < 12 && !city; leg++){ + const WalkResult walk = walk_until(stream, context, 1, 0, 10000ms, "east through the Oreburgh Gate"); + if (walk.stop == WalkStop::LIMIT || walk.stop == WalkStop::TRANSITION){ + continue; + } + if (walk.stop != WalkStop::DIALOG && !dialogue_opens(stream, context, 2000ms)){ + // A card or menu left up holds the screen still: B once, then retry. + if (++stuck > 1){ + fail(stream, "Oreburgh Gate: a wall stopped us walking east."); + } + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + continue; + } + stuck = 0; + if (fights >= 2){ + city = true; + break; + } + fights += talk_or_fight(stream, context, "Oreburgh Gate trainer " + std::to_string(fights + 1)) ? 1 : 0; + } + if (!city){ + fail(stream, "Oreburgh Gate: did not reach Oreburgh City."); + } + + step(4); + // He walks us to the Pokemon Center between his lines, and Barry joins + // there: more lines after each walk. + finish_dialogue(stream, context, BUTTON_B, "Man at Oreburgh's entrance"); + for (size_t scene = 0; scene < 6 && dialogue_opens(stream, context, 6000ms); scene++){ + finish_dialogue(stream, context, BUTTON_B, "Oreburgh scene"); + } + context.wait_for_all_requests(); +} + +// Holds the stick toward `dir` for `tiles` tiles, turning first. +void hold_tiles(VideoStream& stream, ProControllerContext& context, Direction dir, size_t tiles){ + route_turn(stream, context, dir); + const double x = dir == Direction::EAST ? 1 : dir == Direction::WEST ? -1 : 0; + const double y = dir == Direction::NORTH ? 1 : dir == Direction::SOUTH ? -1 : 0; + pbf_move_left_joystick(context, {x, y}, route_open_hold(dir, tiles), 200ms); + context.wait_for_all_requests(); +} + +WalkResult walk_dir(VideoStream& stream, ProControllerContext& context, Direction dir, Milliseconds limit, const std::string& label){ + const double x = dir == Direction::EAST ? 1 : dir == Direction::WEST ? -1 : 0; + const double y = dir == Direction::NORTH ? 1 : dir == Direction::SOUTH ? -1 : 0; + return walk_until(stream, context, x, y, limit, label); +} + +void oreburgh_to_roark(VideoStream& stream, ProControllerContext& context){ + // What 01.03 does, stepped through on the console before writing it. + StepLog step(stream, "01.03", "From the Oreburgh Pokemon Center to Roark in the mine", { + "Walk past Barry at the Center's door to the stone ledge, then east to the fence below the Poke Mart", + "South to the right-hand conveyor pillar, west to the left-hand pillars, then south into the mine", + "Through the mine's entrance hall to the rocky corridor", + "Walk the planned route down the corridor and round the coal block, and talk to Roark", + "Walk back to the corridor's top and out of the mine", + }); + + step(1); + // The save stands us in front of the Center, two tiles west of Barry at + // its door. These are the holds that worked on the console. + hold_tiles(stream, context, Direction::EAST, 3); + walk_dir(stream, context, Direction::NORTH, 3000ms, "north to the Center's front"); + hold_tiles(stream, context, Direction::EAST, 2); + walk_dir(stream, context, Direction::NORTH, 2500ms, "north beside Barry"); + // Roark answers in the mine only after Barry has told us he is there + // (2026-10-29: he ignored A in r101 and r104 and by hand, and answered at + // once after a talk with Barry). Barry is just east of us. + route_turn(stream, context, Direction::EAST); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + if (!dialogue_opens(stream, context, 3000ms)){ + fail(stream, "Oreburgh: Barry did not talk at the gym door."); + } + finish_dialogue(stream, context, BUTTON_B, "Barry at the gym door"); + if (walk_dir(stream, context, Direction::SOUTH, 8000ms, "south to the stone ledge").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking south did not stop at the stone ledge."); + } + // A townsperson wandering across the row can stop us a tile or two short + // (r95), so walk east again until a walk stops without moving. + bool at_fence = false; + for (size_t attempt = 0; attempt < 5 && !at_fence; attempt++){ + const WalkResult east = walk_dir(stream, context, Direction::EAST, 10000ms, "east to the fence below the Poke Mart"); + if (east.stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking east did not stop at the fence below the Poke Mart."); + } + at_fence = attempt > 0 && east.walked <= 800ms; + if (!at_fence){ + context.wait_for(1500ms); + } + } + if (!at_fence){ + fail(stream, "Oreburgh: something kept stopping the walk east to the fence below the Poke Mart."); + } + + step(2); + // The mine's stairs are straight south of here, two columns west of + // the fence (Bulbapedia's Oreburgh map: column 46, between the conveyor + // pillars at 42-44 and 48-50). One walk south goes down the yard's + // stairs, under the conveyors and into the mine; on the console it ran + // on through the entrance hall into the rocky corridor. A worker walks + // about on the sand path: wait for him, then try the columns beside. + // Timed holds of a tile or two move one tile or two unpredictably, so + // the column comes from walls. Down the path along its east fence ends + // against the right-hand conveyor pillar in the yard (column 48); from + // there west stops against the left-hand pillars, on column 45, which + // runs clear south into the mine. The belts keep the screen moving, so + // these stops read as the walk's time limit, not as a wall. + // + // The worker by the fence wanders onto the path and stops the walk south + // as a wall would (r95, r96): wait for him to move and walk on. + WalkResult down{WalkStop::BLOCKED, 0ms, 0}; + for (size_t attempt = 0; attempt < 5; attempt++){ + down = walk_dir(stream, context, Direction::SOUTH, 12000ms, "south to the right-hand pillar"); + if (down.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, "Someone on the way to the mine"); + }else if (down.stop != WalkStop::BLOCKED){ + break; + } + context.wait_for(2000ms); + } + bool in_mine = down.stop == WalkStop::TRANSITION; // already in the mine's column + if (!in_mine && down.stop != WalkStop::LIMIT){ + fail(stream, "Oreburgh: something kept stopping the walk south to the conveyor pillars."); + } + // West stops against the left-hand pillars, on the column of the sign + // above the mine's stairs (on the console, r96): the stairs are open a + // tile either side of it, so step back one tile east and walk south. + auto step_tiles = [&](Direction side, size_t tiles){ + route_turn(stream, context, side); + for (size_t tile = 0; tile < tiles; tile++){ + pbf_move_left_joystick(context, {side == Direction::EAST ? 1.0 : -1.0, 0}, 120ms, 300ms); + } + context.wait_for_all_requests(); + }; + if (!in_mine){ + walk_dir(stream, context, Direction::WEST, 2500ms, "west to the left-hand pillars"); + step_tiles(Direction::EAST, 1); + in_mine = walk_dir(stream, context, Direction::SOUTH, 15000ms, "south into the mine").stop == WalkStop::TRANSITION; + } + if (!in_mine){ + step_tiles(Direction::WEST, 2); // the sign's other side + in_mine = walk_dir(stream, context, Direction::SOUTH, 5000ms, "south down the mine's stairs").stop == WalkStop::TRANSITION; + } + if (!in_mine){ + fail(stream, "Oreburgh: no column south of the Center led into the mine."); + } + context.wait_for(1500ms); + + step(3); + // If the fade into the mine was missed, we are already in the corridor + // and this walk stops at its boulder; otherwise it crosses the hall. + const WalkResult hall = walk_dir(stream, context, Direction::SOUTH, 10000ms, "through the mine's entrance hall"); + if (hall.stop == WalkStop::TRANSITION){ + context.wait_for(1500ms); + } + + step(4); + // Planned on the coal room's tile grid (Data/OreburghMine): the + // corridor's rocks zigzag a row at a time, the room's second row passes + // under the worker on the first, and the east side leads down to the + // bottom row below Roark. + const Pokemon::MapSet& maps = oreburgh_mine_maps(); + auto world = [&](const Pokemon::TilePos& pos){ return maps.to_world(MAP_OREBURGH_MINE, pos); }; + std::vector no_trainers; + auto in_the_way = [&](const std::string& who){ talk_or_fight(stream, context, who); }; + std::vector at; + for (const Pokemon::TilePos& pos : OreburghMine::corridor_top()){ + at.push_back(world(pos)); + } + // Roark stands with a rock on each side, so the rock east of him is the + // anchor: plan to his row's open stretch east of the rock, walk north to + // the wall above it and west until the rock stops us, two tiles east of + // him (on the console, r97). Then south to the bottom wall and two tiles + // west puts us below him. + const Pokemon::TilePos roark = world(OreburghMine::ROARK); + at = walk_route(stream, context, maps, at, + [&](const Pokemon::TilePos& pos){ return pos.y >= roark.y && pos.y <= roark.y + 1 && pos.x >= roark.x + 2 && pos.x <= roark.x + 7; }, + no_trainers, in_the_way, "To the stretch east of Roark"); + walk_dir(stream, context, Direction::NORTH, 2000ms, "north to the wall on Roark's row"); + // A wild battle on the way eats into the walk's time (r100): walk again. + WalkStop west = WalkStop::LIMIT; + for (size_t attempt = 0; attempt < 3 && west == WalkStop::LIMIT; attempt++){ + west = walk_dir(stream, context, Direction::WEST, 6000ms, "west to the rock beside Roark").stop; + } + if (west != WalkStop::BLOCKED){ + fail(stream, "Oreburgh Mine: walking west along Roark's row did not stop at his rock."); + } + if (walk_dir(stream, context, Direction::SOUTH, 2000ms, "south to the bottom wall").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh Mine: walking south from Roark's rock did not stop at the bottom wall."); + } + route_turn(stream, context, Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, 120ms, 300ms); + pbf_move_left_joystick(context, {-1, 0}, 120ms, 300ms); + route_turn(stream, context, Direction::NORTH); + context.wait_for_all_requests(); + // Movement is by whole tiles: a sideways nudge only steps onto his rock's + // tile (r105, r106 read the rock's line as his). Turn to him and press A + // once; the camera zooms in and his first line, "OK, stand back and watch + // this.", can take some seconds (r98). + bool talked = false; + for (size_t attempt = 0; attempt < 3 && !talked; attempt++){ + route_turn(stream, context, Direction::EAST); + route_turn(stream, context, Direction::NORTH); + context.wait_for(500ms); + if (VideoSnapshot screen = stream.video().snapshot()){ + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/route/"; + Filesystem::create_directories(Filesystem::Path(folder)); + screen.frame->save(folder + now_to_filestring() + "-below-roark-" + std::to_string(attempt) + ".png"); + } + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + talked = dialogue_opens(stream, context, 8000ms); + } + if (!talked){ + fail(stream, "Oreburgh Mine: no talk opened below Roark."); + } + finish_dialogue(stream, context, BUTTON_B, "Roark"); + // His scene ends with him smashing his rock and leaving: his tile is + // free now. Step onto it and back, or fail if he is still there. + if (walk_dir(stream, context, Direction::NORTH, 2000ms, "onto Roark's tile").walked <= 800ms){ + fail(stream, "Oreburgh Mine: Roark is still there after the talk."); + } + walk_dir(stream, context, Direction::SOUTH, 2000ms, "back to the bottom wall"); + at = {roark.next(Direction::SOUTH)}; + + step(5); + // Back up the corridor, then north out through the hall. A route leg + // north to the top's wall walks on through it into the hall (r99), and + // no wall stops us a row short of it, so the route ends on the corner + // at (14,11), where that leg started, and the walk does the rest. + const Pokemon::TilePos exit = world(OreburghMine::CORRIDOR_EXIT_START); + at = walk_route(stream, context, maps, at, + [&](const Pokemon::TilePos& pos){ return pos == exit; }, + no_trainers, in_the_way, "Back to the corridor's top"); + // Up into the entrance hall (a transition, as on the way in), then on + // north out of the mine: in r103 that walk left the mine without a + // transition we saw and ended against the sign above the stairs, which + // the moving belts make read as the time limit. + if (walk_dir(stream, context, Direction::NORTH, 10000ms, "into the entrance hall").stop != WalkStop::TRANSITION){ + fail(stream, "Oreburgh Mine: walking north from the corridor did not reach the entrance hall."); + } + context.wait_for(1500ms); + if (walk_dir(stream, context, Direction::NORTH, 10000ms, "out of the mine").stop == WalkStop::DIALOG){ + fail(stream, "Oreburgh Mine: someone stopped us on the way out of the mine."); + } + context.wait_for(1500ms); +} + + +// A rough check that we stand in a Pokemon Center: its floor fills the lower +// half of the screen with warm yellow, where the gym is grey and routes are +// green. After a battle it tells a loss (a whiteout ends at the counter) +// from a win. +bool in_pokemon_center(VideoStream& stream){ + const VideoSnapshot frame = stream.video().snapshot(); + if (!frame){ + return false; + } + const ImageRGB32 small = frame.frame->scale_to(192, 108); + size_t warm = 0, total = 0; + for (size_t y = 54; y < 108; y++){ + for (size_t x = 0; x < 192; x++){ + const uint32_t pixel = small.pixel(x, y); + const int r = (pixel >> 16) & 0xff, g = (pixel >> 8) & 0xff, b = pixel & 0xff; + total++; + if (r > 200 && g > 150 && b < 150 && r - b > 70){ + warm++; + } + } + } + const bool inside = warm * 10 > total * 3; + stream.log("[Center check] warm floor " + std::to_string(warm * 100 / total) + " %: " + + (inside ? "in a Pokemon Center." : "not in a Pokemon Center.")); + return inside; +} + +// For a player inside a Pokemon Center after a whiteout, at the counter: +// out through the door. The door is straight south of the nurse in most +// Centers; in Oreburgh's it was two tiles east of where we stood (by hand). +void walk_out_of_center(VideoStream& stream, ProControllerContext& context, const std::string& label){ + finish_dialogue(stream, context, BUTTON_B, label + ": the nurse"); + const int shifts[] = {0, 2, -4}; + for (int shift : shifts){ + if (shift != 0){ + hold_tiles(stream, context, shift > 0 ? Direction::EAST : Direction::WEST, (size_t)std::abs(shift)); + } + if (walk_dir(stream, context, Direction::SOUTH, 8000ms, label + ": out of the Center").stop == WalkStop::TRANSITION){ + context.wait_for(2000ms); + return; + } + } + fail(stream, label + ": could not find the Pokemon Center's door."); +} + +// Oreburgh, from the street in front of the Pokemon Center (its door or its +// front wall) into the gym, as walked by hand on 2026-10-29: west to the +// cliff, south to the posts, a row up, then 17 tiles east is the gym door's +// column. The hold is checked by the door itself: walk north, and if the +// gym's wall stops us, try the columns either side. +void oreburgh_street_to_gym(VideoStream& stream, ProControllerContext& context){ + if (walk_dir(stream, context, Direction::WEST, 15000ms, "west to the cliff").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking west from the Center did not stop at the cliff."); + } + if (walk_dir(stream, context, Direction::SOUTH, 3000ms, "south to the posts").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking south did not stop at the posts."); + } + hold_tiles(stream, context, Direction::NORTH, 1); + hold_tiles(stream, context, Direction::EAST, 17); + // Cumulative steps: here, a tile east, a tile west, two east, two west. + const int steps[] = {0, 1, -2, 3, -4}; + for (int step : steps){ + if (step != 0){ + hold_tiles(stream, context, step > 0 ? Direction::EAST : Direction::WEST, (size_t)std::abs(step)); + } + const WalkResult door = walk_dir(stream, context, Direction::NORTH, 4000ms, "north into the gym"); + // The door's fade can read as the time limit rather than a black + // screen (by hand: "limit after 4000ms", and we were inside). + if (door.stop == WalkStop::TRANSITION || door.stop == WalkStop::LIMIT){ + context.wait_for(1000ms); + return; + } + if (door.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, "Someone outside the gym"); + } + } + fail(stream, "Oreburgh: no column near the gym's door went in."); +} + +// Inside the Oreburgh Gym: straight up the middle, fighting the trainers +// who stop us, to Roark on the top tier. Returns whether Roark was beaten; +// false after a whiteout, which leaves us in the Pokemon Center. +// Walking straight up meets both gym trainers (one at the entrance, one on +// the second tier); their experience helps an under-levelled team, and +// after a whiteout they stay beaten. Butters' route goes round them. +bool oreburgh_gym_battles(VideoStream& stream, ProControllerContext& context){ + // The entry camera pans for about 7.5 s (Butters' reference video). + context.wait_for(8000ms); + if (dialogue_opens(stream, context, 3000ms)){ + if (talk_or_fight(stream, context, "Gym trainer at the entrance") && in_pokemon_center(stream)){ + return false; + } + } + size_t stuck = 0; + for (size_t leg = 0; leg < 10; leg++){ + const WalkResult up = walk_dir(stream, context, Direction::NORTH, 8000ms, "north up the gym"); + if (up.stop == WalkStop::DIALOG){ + if (talk_or_fight(stream, context, "Gym trainer") && in_pokemon_center(stream)){ + return false; + } + continue; + } + if (up.stop != WalkStop::BLOCKED){ + continue; + } + // Roark stands at the top facing us; a wall or a stair edge says + // nothing to A. Only Roark starts a battle from our A here. + route_turn(stream, context, Direction::NORTH); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + if (dialogue_opens(stream, context, 3000ms)){ + if (talk_or_fight(stream, context, "Roark")){ + if (in_pokemon_center(stream)){ + return false; + } + finish_dialogue(stream, context, BUTTON_B, "Roark after the battle"); + return true; + } + continue; + } + if (up.walked <= 800ms && ++stuck >= 3){ + fail(stream, "Oreburgh Gym: stuck on the way up, and nobody answers A."); + } + } + fail(stream, "Oreburgh Gym: did not reach Roark."); +} + +void oreburgh_gym(VideoStream& stream, ProControllerContext& context){ + StepLog step(stream, "01.04", "From the Oreburgh Mine to the Coal Badge", { + "North to the Pokemon Center's front", + "West to the cliff, south to the posts, east along the street into the gym", + "Up the gym, fighting its trainers, and beat Roark (after a loss: from the Center, again)", + "Out of the gym", + }); + step(1); + // 01.03 leaves us below the mine's sign; a tile east is the column + // straight up to the Center (by hand: blocked at its front after 6.4 s). + hold_tiles(stream, context, Direction::EAST, 1); + if (walk_dir(stream, context, Direction::NORTH, 12000ms, "north to the Center's front").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking north from the mine did not stop at the Pokemon Center."); + } + + // Losing costs some money and keeps the experience, so each try comes + // back stronger: Roark won twice by hand against Piplup Lv 10 and 14 and + // lost the third time. + bool won = false; + for (size_t attempt = 0; attempt < 5 && !won; attempt++){ + step(2); + oreburgh_street_to_gym(stream, context); + step(3); + won = oreburgh_gym_battles(stream, context); + if (!won){ + stream.log("[01.04] Lost in the gym (try " + std::to_string(attempt + 1) + "); back from the Center.", COLOR_ORANGE); + walk_out_of_center(stream, context, "Oreburgh"); + } + } + if (!won){ + fail(stream, "Oreburgh Gym: lost to Roark five times."); + } + + step(4); + // Back down the middle and out of the door. + for (size_t leg = 0; leg < 4; leg++){ + const WalkResult down = walk_dir(stream, context, Direction::SOUTH, 10000ms, "south out of the gym"); + if (down.stop == WalkStop::TRANSITION){ + context.wait_for(2000ms); + return; + } + if (down.stop == WalkStop::DIALOG){ + finish_dialogue(stream, context, BUTTON_B, "Someone in the gym"); + } + } + fail(stream, "Oreburgh Gym: did not leave by its door."); +} + +// West until a transition, by the user's rule for a wall in the way: walk +// north to the top wall, which pins the row, then try the rows south of it +// one at a time. Trainers who stop us are fought. Returns at the first +// transition (a building, a cave, a gate). +void west_until_transition(VideoStream& stream, ProControllerContext& context, const std::string& label, size_t max_legs){ + for (size_t leg = 0; leg < max_legs; leg++){ + const WalkResult west = walk_dir(stream, context, Direction::WEST, 20000ms, label); + if (west.stop == WalkStop::TRANSITION){ + context.wait_for(2000ms); + return; + } + if (west.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, label + ": someone in the way"); + continue; + } + if (west.stop != WalkStop::BLOCKED){ + continue; + } + walk_dir(stream, context, Direction::NORTH, 4000ms, label + ": north to the top wall"); + bool moved = false; + for (size_t row = 0; row < 10 && !moved; row++){ + hold_tiles(stream, context, Direction::SOUTH, 1); + const WalkResult probe = walk_dir(stream, context, Direction::WEST, 1500ms, label + ": west on the next row"); + moved = probe.stop != WalkStop::BLOCKED || probe.walked > 1200ms; + if (probe.stop == WalkStop::TRANSITION){ + context.wait_for(2000ms); + return; + } + if (probe.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, label + ": someone in the way"); + } + } + if (!moved){ + fail(stream, label + ": no row below the top wall leads west."); + } + } + fail(stream, label + ": no transition after " + std::to_string(max_legs) + " legs."); +} + +void oreburgh_to_floaroma(VideoStream& stream, ProControllerContext& context){ + StepLog step(stream, "01.05", "From the Oreburgh Gym to Floaroma Town", { + "Up to the platform by Oreburgh Gate and into it (Barry bumps into us there)", + "Through the Gate and west along Route 203 and Jubilife to the Route 218 gate", + "Back east to Jubilife's north road and up it (Rowan, Dawn and two Galactic grunts: a tag battle)", + "North on Route 204 into the Ravaged Path", + "Through the Ravaged Path, smashing its rocks", + "Route 204 north on its grid, past the twins, into Floaroma", + "Heal at the Floaroma Pokemon Center", + }); + + step(1); + // From the gym door, as walked by hand on 2026-10-29. + hold_tiles(stream, context, Direction::SOUTH, 1); + hold_tiles(stream, context, Direction::NORTH, 1); + walk_dir(stream, context, Direction::WEST, 20000ms, "west to the stairs' cliff"); + walk_dir(stream, context, Direction::NORTH, 10000ms, "north along the cliff"); + hold_tiles(stream, context, Direction::EAST, 2); + walk_dir(stream, context, Direction::NORTH, 5000ms, "north up the stairs"); + walk_dir(stream, context, Direction::WEST, 6000ms, "west along the platform"); + hold_tiles(stream, context, Direction::SOUTH, 1); + // Barry runs into us on the platform ("*thud*") the first time; his lines + // end with no battle. + west_until_transition(stream, context, "Into Oreburgh Gate", 8); + + step(2); + // The Gate's cave, Route 203 (trainers stop us) and Jubilife's road all + // run west; the first door at the end is the Route 218 gate on + // Jubilife's west edge (by hand: twice). + west_until_transition(stream, context, "West to the Route 218 gate", 16); + + step(3); + // Back out east into Jubilife: its north road is columns 43 to 46 of the + // Jubilife grid, about 40 tiles east of the gate. Rowan's scene starts + // on the way up it; a wall in a few tiles means we are off its column. + if (walk_dir(stream, context, Direction::EAST, 6000ms, "back out of the gate").stop != WalkStop::TRANSITION){ + fail(stream, "Jubilife: did not leave the Route 218 gate eastwards."); + } + context.wait_for(2000ms); + hold_tiles(stream, context, Direction::EAST, 40); + const int steps[] = {0, 1, -2, 3, -4, 5, -6}; + bool scene = false; + for (int s_step : steps){ + if (s_step != 0){ + hold_tiles(stream, context, s_step > 0 ? Direction::EAST : Direction::WEST, (size_t)std::abs(s_step)); + } + const WalkResult up = walk_dir(stream, context, Direction::NORTH, 15000ms, "north up Jubilife's north road"); + if (up.stop == WalkStop::DIALOG){ + scene = true; + break; + } + walk_dir(stream, context, Direction::SOUTH, 15000ms, "back down to the street"); + } + if (!scene){ + fail(stream, "Jubilife: no column led up the north road to Rowan."); + } + if (!talk_or_fight(stream, context, "Galactic grunts with Dawn", true, true)){ + fail(stream, "Jubilife: Rowan's scene ended without the tag battle."); + } + if (in_pokemon_center(stream)){ + fail(stream, "Jubilife: lost the tag battle with Dawn."); + } + finish_dialogue(stream, context, BUTTON_B, "Rowan and Dawn after the battle"); + + step(4); + // Route 204's south half: a trainer just past the sign, then the cave in + // the cliff a column west of the path. + for (size_t leg = 0; leg < 6; leg++){ + const WalkResult up = walk_dir(stream, context, Direction::NORTH, 15000ms, "north on Route 204"); + if (up.stop == WalkStop::TRANSITION){ + break; + } + if (up.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, "Route 204 trainer"); + continue; + } + if (up.stop == WalkStop::BLOCKED){ + hold_tiles(stream, context, Direction::WEST, 1); + } + if (leg == 5){ + fail(stream, "Route 204: did not reach the Ravaged Path."); + } + } + context.wait_for(2000ms); + + step(5); + // On the traced grid (Data/PokemonBDSP_AutoStory_RavagedPath): from the + // south door up the stairs to the tile west of one of the two rocks + // across the lower corridor, smash it, then east and south to the + // corridor's east end; the way out is a tile west of it, south. + { + std::vector no_trainers; + auto in_the_way = [&](const std::string& who){ talk_or_fight(stream, context, who); }; + std::vector at{RavagedPath::ENTRANCE}; + const Pokemon::MapSet rocks_in = ravaged_path_maps(); + at = walk_route(stream, context, rocks_in, at, + [&](const Pokemon::TilePos& pos){ + const auto& before = RavagedPath::before_rocks(); + return std::find(before.begin(), before.end(), pos) != before.end(); + }, + no_trainers, in_the_way, "To a rock across the corridor"); + route_turn(stream, context, Direction::EAST); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + if (!dialogue_opens(stream, context, 2000ms)){ + fail(stream, "Ravaged Path: no Rock Smash offer facing the rock east of us."); + } + // "Would you like to use Rock Smash?": A for Yes, the animation, then + // B for anything left (by hand, 2026-10-29). + pbf_press_button(context, BUTTON_A, 80ms, 1500ms); + pbf_press_button(context, BUTTON_A, 80ms, 4000ms); + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + context.wait_for_all_requests(); + // We may be on either row; the rock east of the other stays, but no + // leg from our row crosses it. + std::vector smashed; + for (const Pokemon::TilePos& pos : RavagedPath::before_rocks()){ + smashed.push_back(pos.next(Direction::EAST)); + } + const Pokemon::MapSet rocks_out = ravaged_path_maps(smashed); + at = walk_route(stream, context, rocks_out, at, + [&](const Pokemon::TilePos& pos){ return pos == RavagedPath::EAST_END; }, + no_trainers, in_the_way, "To the corridor's east end"); + hold_tiles(stream, context, Direction::WEST, 1); + const WalkResult out = walk_dir(stream, context, Direction::SOUTH, 4000ms, "south out of the Ravaged Path"); + if (out.stop != WalkStop::TRANSITION && out.stop != WalkStop::LIMIT){ + fail(stream, "Ravaged Path: the walk south from a tile west of the corridor's end did not leave the cave."); + } + context.wait_for(2000ms); + } + + step(6); + // On the traced grid (Data/PokemonBDSP_AutoStory_Route204): from the + // cave's door west and up the stairs, round the pocket the ledge closes, + // east and north through the tall grass, and west below the flower beds, + // where the twins see us; then north between the beds into Floaroma. + { + std::vector twins = Route204::north_trainer_sights(); + auto fight = [&](const std::string& who){ + const bool doubles = who.find("Twins") != std::string::npos || who.find("someone") != std::string::npos; + talk_or_fight(stream, context, who, false, doubles); + }; + std::vector at{Route204::CAVE_EXIT}; + at = walk_route(stream, context, route204_north_maps(), at, Route204::in_floaroma_path, + twins, fight, "To the path into Floaroma"); + // Floaroma's arrival did not read as a black screen by hand ("limit + // after 20000ms", and we stood in the town), so either will do. + const WalkResult in = walk_dir(stream, context, Direction::NORTH, 20000ms, "north into Floaroma"); + if (in.stop == WalkStop::BLOCKED || in.stop == WalkStop::DIALOG){ + fail(stream, "Route 204: the walk north into Floaroma was stopped."); + } + context.wait_for(2000ms); + } + + step(7); + // We arrive in front of a house; the Center is a few tiles south-east. + walk_dir(stream, context, Direction::SOUTH, 1500ms, "south off the house's path"); + hold_tiles(stream, context, Direction::EAST, 4); + heal_at_pokemon_center(stream, context, "Floaroma Pokemon Center"); +} + +void checkpoint_0101( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + jubilife_to_barry(env.console, context); + }, + true + ); +} + +void checkpoint_0103( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + oreburgh_to_roark(env.console, context); + }, + true + ); +} + +void checkpoint_0102( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + route203_to_oreburgh(env.console, context); + }, + true + ); +} + +void checkpoint_0104( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + oreburgh_gym(env.console, context); + }, + true + ); +} + +void checkpoint_0105( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + oreburgh_to_floaroma(env.console, context); + }, + true + ); +} +} + + +std::string AutoStory_Segment_01::name() const { return "01: Jubilife to Floaroma"; } +std::string AutoStory_Segment_01::start_text() const { return "Start: Standing in front of the Jubilife City Pokemon Center PC."; } +std::string AutoStory_Segment_01::end_text() const { return "End: In the Floaroma Town Pokemon Center, with the Coal Badge."; } + +void AutoStory_Segment_01::run_segment( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + stats.m_segment++; + env.update_stats(); + env.console.log("Start Segment " + name(), COLOR_ORANGE); + AutoStory_Checkpoint_0101().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0102().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0103().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0104().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0105().run_checkpoint(env, context, options, stats); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + +std::string AutoStory_Checkpoint_0101::name() const { return "01.01 - Jubilife to Barry"; } +std::string AutoStory_Checkpoint_0101::start_text() const { return "Standing in front of the Jubilife City Pokemon Center PC."; } +std::string AutoStory_Checkpoint_0101::end_text() const { return "On Route 203 by its sign, after beating Barry."; } + +void AutoStory_Checkpoint_0101::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0101(env, context, options, stats); +} + +std::string AutoStory_Checkpoint_0102::name() const { return "01.02 - Route 203 to Oreburgh"; } +std::string AutoStory_Checkpoint_0102::start_text() const { return AutoStory_Checkpoint_0101().end_text(); } +std::string AutoStory_Checkpoint_0102::end_text() const { return "In front of the Oreburgh Pokemon Center, after the man's tour and Barry."; } + +void AutoStory_Checkpoint_0102::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0102(env, context, options, stats); +} + + + +std::string AutoStory_Checkpoint_0103::name() const { return "01.03 - Oreburgh Mine to Roark"; } +std::string AutoStory_Checkpoint_0103::start_text() const { return AutoStory_Checkpoint_0102().end_text(); } +std::string AutoStory_Checkpoint_0103::end_text() const { return "Outside the Oreburgh Mine, after talking to Roark."; } + +void AutoStory_Checkpoint_0103::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0103(env, context, options, stats); +} + + + +std::string AutoStory_Checkpoint_0104::name() const { return "01.04 - Oreburgh Gym"; } +std::string AutoStory_Checkpoint_0104::start_text() const { return AutoStory_Checkpoint_0103().end_text(); } +std::string AutoStory_Checkpoint_0104::end_text() const { return "Outside the Oreburgh Gym's door, with the Coal Badge."; } + +void AutoStory_Checkpoint_0104::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0104(env, context, options, stats); +} + +std::string AutoStory_Checkpoint_0105::name() const { return "01.05 - Oreburgh to Floaroma"; } +std::string AutoStory_Checkpoint_0105::start_text() const { return AutoStory_Checkpoint_0104().end_text(); } +std::string AutoStory_Checkpoint_0105::end_text() const { return "In the Floaroma Town Pokemon Center, healed."; } + +void AutoStory_Checkpoint_0105::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0105(env, context, options, stats); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h index a9cd8d6a76..c237106102 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h @@ -1,131 +1,100 @@ -/* BDSP AutoStory - Segment 01 - * From after choosing the starter Pokemon until after acquiring the first badge. - * - * From: https://github.com/PokemonAutomation/ - * - */ - -#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment01_H -#define PokemonAutomation_PokemonBDSP_AutoStory_Segment01_H - -#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" - -namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - - -// Route ownership: Oreburgh navigation and isolated route tests belong to Segment 01. -enum class MineRouteStage { - ALIGN_ENTRANCE, - FIRST_ROCK, - RIGHT_OF_FIRST_ROCK, - BELOW_FIRST_ROCK, - CENTER_GAP, - POSITION_07, - POSITION_08, - POSITION_09, - ITEM_AT_14, - ROARK_AT_19 -}; - -struct MineRouteTestOptions { - MineRouteStage first = MineRouteStage::ALIGN_ENTRANCE; - MineRouteStage last = MineRouteStage::ROARK_AT_19; - - // False: stop the test on failure. - // True: let the checkpoint retry handler reset the game. - bool reset_on_failure = false; -}; - -void jubilife_to_oreburgh_gate(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -void oreburgh_gate_nav(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -void oreburgh_gate_to_mine(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -void oreburgh_mine_nav(VideoStream& stream, ProControllerContext& context); - -void oreburgh_mine_to_gym(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -void gym_Puzzle_01(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -// Starts inside the mine at an entrance position; stops outside after -// talking to Roark and using Escape Rope. Does not reset on failure. -bool oreburgh_mine_route_and_escape_test(VideoStream& stream, ProControllerContext& context); - -// Shared stage runner for production navigation and isolated route tests. -bool oreburgh_mine_route(VideoStream& stream, ProControllerContext& context, const MineRouteTestOptions& options); - -bool oreburgh_mine_first_leg_test(VideoStream& stream, - ProControllerContext& context, - const MineRouteTestOptions& options = MineRouteTestOptions()); - -class AutoStory_Segment_01 : public AutoStory_Segment { -public: - virtual std::string name() const override; - virtual std::string start_text() const override; - virtual std::string end_text() const override; - virtual void run_segment(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const override; -}; - -class AutoStory_checkpoint_0101 : public AutoStory_Checkpoint { -public: - virtual std::string name() const override; - virtual std::string start_text() const override; - virtual std::string end_text() const override; - virtual void run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const override; -}; - -// start: starter Pokemon taken out of PC at level 100 and ready to be used in battle -// end: first Gym Badge acquired (Oreburgh City) -void checkpoint_0101(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats); -class AutoStory_checkpoint_0102 : public AutoStory_Checkpoint { -public: - virtual std::string name() const override; - virtual std::string start_text() const override; - virtual std::string end_text() const override; - virtual void run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const override; -}; - -// start: starter Pokemon taken out of PC at level 100 and ready to be used in battle -// end: first Gym Badge acquired (Oreburgh City) -void checkpoint_0102(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats); - -class AutoStory_checkpoint_0103 : public AutoStory_Checkpoint { -public: - virtual std::string name() const override; - virtual std::string start_text() const override; - virtual std::string end_text() const override; - virtual void run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const override; -}; - -// start: starter Pokemon taken out of PC at level 100 and ready to be used in battle -// end: first Gym Badge acquired (Oreburgh City) -void checkpoint_0103(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats); - -} -} -} -#endif +/* BDSP AutoStory - Segment 01 + * From the Jubilife Pokemon Center to Oreburgh City. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment01_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment01_H + +#include "../PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +class AutoStory_Segment_01 : public AutoStory_Segment{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_segment( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0101 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0102 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0103 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0104 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0105 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp new file mode 100644 index 0000000000..c29b2c0e4a --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp @@ -0,0 +1,105 @@ +/* BDSP AutoStory - Location + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/Language.h" +#include "CommonTools/Images/ImageFilter.h" +#include "CommonTools/OCR/OCR_RawTesseractOCR.h" +#include "PokemonBDSP_AutoStory_Location.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ + +// The location banner: a white box at the top left that names a map for a +// few seconds after the player enters it. Left of the centred name it is plain +// white (251 on console B, against 27-75 for the trees there otherwise). +const ImageFloatBox LOCATION_BANNER_WHITE{0.035, 0.035, 0.085, 0.050}; +const ImageFloatBox LOCATION_BANNER_TEXT{0.100, 0.025, 0.300, 0.080}; + +// The save card's header: the map's name in salmon (255, 152, 140) on near +// white (245), top left, right of the card's red border. Too faint for OCR +// as it is: the reddish pixels are made black on white first. +const ImageFloatBox SAVE_SCREEN_LOCATION{0.036, 0.045, 0.274, 0.070}; +const uint32_t SAVE_SCREEN_TEXT_MIN = 0xff960000; +const uint32_t SAVE_SCREEN_TEXT_MAX = 0xffffa9a9; + +std::string digits_of(const std::string& text){ + std::string out; + for (char ch : text){ + if (std::isdigit((unsigned char)ch)){ + out.push_back(ch); + } + } + return out; +} + +} + + +std::string normalize_location_name(const std::string& text){ + std::string out; + for (unsigned char ch : text){ + if (std::isalnum(ch)){ + out.push_back((char)std::tolower(ch)); + } + } + return out; +} + +std::string read_location_banner(const ImageViewRGB32& screen){ + const ImageStats white = image_stats(extract_box_reference(screen, LOCATION_BANNER_WHITE)); + if (white.average.sum() < 690 || white.stddev.sum() > 30){ + return ""; + } + return normalize_location_name(OCR::tesseract_ocr_read(Language::English, + extract_box_reference(screen, LOCATION_BANNER_TEXT))); +} + +std::string read_save_screen_location(const ImageViewRGB32& screen){ + const ImageRGB32 text = to_blackwhite_rgb32_range( + extract_box_reference(screen, SAVE_SCREEN_LOCATION), true, SAVE_SCREEN_TEXT_MIN, SAVE_SCREEN_TEXT_MAX); + return normalize_location_name(OCR::tesseract_ocr_read(Language::English, text)); +} + +bool location_banner_matches(const std::string& read, const std::string& expected){ + const std::string a = normalize_location_name(read); + const std::string b = normalize_location_name(expected); + if (a.empty() || b.empty()){ + return false; + } + if (digits_of(a) != digits_of(b)){ + return false; + } + // Levenshtein distance. + std::vector row(b.size() + 1); + for (size_t j = 0; j <= b.size(); j++){ + row[j] = j; + } + for (size_t i = 1; i <= a.size(); i++){ + size_t diagonal = row[0]; + row[0] = i; + for (size_t j = 1; j <= b.size(); j++){ + const size_t above = row[j]; + row[j] = std::min({row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] == b[j - 1] ? 0 : 1)}); + diagonal = above; + } + } + return row[b.size()] <= std::max(1, b.size() / 4); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h new file mode 100644 index 0000000000..a17dd863d6 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h @@ -0,0 +1,45 @@ +/* BDSP AutoStory - Location + * + * From: https://github.com/PokemonAutomation/ + * + * Where the game says the player is: the location banner shown on entering + * a map, and the map named on the save screen's card. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Location_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Location_H + +#include +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Lower-case letters and digits only: "Route 202" and "ROUTE 2O2 " compare +// by "route202". +std::string normalize_location_name(const std::string& text); + +// The location banner (the white box at the top left that names a map for a +// few seconds after the player enters it), read by OCR and normalized; +// empty when no banner shows. +std::string read_location_banner(const ImageViewRGB32& screen); + +// The map named at the top left of the save screen's card ("Sandgem Town", +// "Route 202"), read by OCR and normalized; empty if nothing reads. +std::string read_save_screen_location(const ImageViewRGB32& screen); + +// Whether the location banner's OCR `read` names the map `expected`. Both +// are compared as lower-case letters and digits. OCR drops or swaps letters +// ("andgemltown" for Sandgem Town on r109), so up to a quarter of the name's +// letters may differ; the digits must match exactly, so Route 201 is never +// read as Route 202. +bool location_banner_matches(const std::string& read, const std::string& expected); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp new file mode 100644 index 0000000000..ac311dcba9 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp @@ -0,0 +1,286 @@ +/* BDSP AutoStory - New Game + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "CommonFramework/ImageTools/ImageDiff.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/ScreenMachine/ScreenMachine.h" +#include "CommonTools/ScreenMachine/StepTrace.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Inference/NintendoSwitch_HomeMenuDetector.h" +#include "NintendoSwitch/Inference/NintendoSwitch_StartGameUserSelectDetector.h" +#include "NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h" +#include "NintendoSwitch/Programs/NintendoSwitch_GameEntry.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h" +#include "PokemonBDSP_AutoStory_NewGame.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; + + +std::string classify_intro_screen(ConsoleHandle* console, const ImageViewRGB32& screen){ + // The Switch's own screens come first: BDSP's dialogue detector also fires on them. + if (console != nullptr && StartGameUserSelectDetector(*console).detect(screen)){ + return "user-select"; + } + if (console != nullptr && HomeMenuDetector(*console, COLOR_RED).detect(screen)){ + return "home"; + } + if (SystemKeyboardDetector().detect(screen)){ + return "keyboard"; + } + switch (NameListDetector().selected_row(screen)){ + case -1: + break; + case 0: + return "rival-list-tell"; + default: + return "rival-list-preset"; + } + if (MenuDetector().detect(screen)){ + return "menu"; + } + // Includes the appearance grid, whose prompt sits in a dialogue box. + if (ShortDialogDetector().detect(screen)){ + return "dialog"; + } + // Language, cutscenes, fades, the bedroom with no menu open. + return "other"; +} + +IntroStep decide_intro_step(const std::string& screen, const IntroProgress& progress){ + if (screen == "user-select") return IntroStep::SELECT_USER; + if (screen == "home") return IntroStep::FAIL_NOT_IN_GAME; + if (screen == "keyboard"){ + return progress.player_named ? IntroStep::CANCEL_RIVAL_KEYBOARD : IntroStep::ENTER_PLAYER_NAME; + } + if (screen == "rival-list-tell") return IntroStep::MOVE_TO_PRESET; + if (screen == "rival-list-preset") return IntroStep::PICK_PRESET; + if (screen == "menu") return IntroStep::CLOSE_MENU_AND_FINISH; + if (screen == "dialog") return IntroStep::ADVANCE; + // The bedroom comes only after the rival is named. Before that, every unknown + // screen (language, the appearance preview, cutscenes) takes A. + return progress.rival_named ? IntroStep::PROBE_FOR_MENU : IntroStep::ADVANCE; +} +bool advance_after_probe(bool screen_changed){ + return !screen_changed; +} + +size_t run_intro_screen_tests(Logger& logger, const std::string& folder){ + size_t passed = 0; + size_t failed = 0; + for (const Filesystem::Path& dir : Filesystem::list_directory(Filesystem::Path(folder))){ + if (!Filesystem::is_directory(dir)){ + continue; + } + const std::string expected = dir.filename().string(); + for (const Filesystem::Path& file : Filesystem::list_directory(dir)){ + if (file.extension().string() != ".png"){ + continue; + } + const ImageRGB32 image(file.string()); + // 1080p and 720p. At 960x540 the keyboard's and the name list's checks + // do not hold (blurred key text, an arrow below the object size limit). + for (size_t width : {size_t(1920), size_t(1280)}){ + const std::string actual = classify_intro_screen(nullptr, image.scale_to(width, width * 9 / 16)); + if (actual == expected){ + passed++; + continue; + } + failed++; + logger.log("[Intro tests] FAIL " + expected + "/" + file.filename().string() + + " at " + std::to_string(width) + "px: read as " + actual, COLOR_RED); + } + } + } + logger.log("[Intro tests] " + std::to_string(passed) + " passed, " + std::to_string(failed) + " failed.", + failed == 0 ? COLOR_BLUE : COLOR_RED); + return failed; +} + + +namespace{ + +// Waits until the keyboard is gone (or, with `gone` false, has appeared). +bool wait_for_keyboard(VideoStream& stream, ProControllerContext& context, bool gone, Milliseconds timeout){ + context.wait_for_all_requests(); + const WallClock deadline = current_time() + timeout; + while (current_time() < deadline){ + VideoSnapshot screen = stream.video().snapshot(); + if (screen && SystemKeyboardDetector().detect(screen) != gone){ + return true; + } + context.wait_for(100ms); + } + return false; +} + +void enter_player_name(VideoStream& stream, ProControllerContext& context, StepTrace& trace, const std::string& name){ + // Let the keyboard finish sliding in: inputs during that are dropped. + pbf_wait(context, 800ms); + context.wait_for_all_requests(); + // B deletes a character, and closes the keyboard on an empty field, so delete only + // while there is text (e.g. after a retry). + for (size_t c = 0; c < 14; c++){ + VideoSnapshot screen = stream.video().snapshot(); + if (!screen || !system_keyboard_field_has_text(screen)){ + break; + } + trace.action("Name field holds text; deleting a character."); + pbf_press_button(context, BUTTON_B, 60ms, 250ms); + context.wait_for_all_requests(); + } + trace.action("Typing the player's name: " + name); + type_on_system_keyboard(stream, context, name); + pbf_press_button(context, BUTTON_PLUS, 80ms, 0ms); + if (!wait_for_keyboard(stream, context, true, 5s)){ + trace.fail("The keyboard stayed open after PLUS (OK)."); + } + trace.action("Name confirmed."); +} + +} + + + +void play_new_game_intro( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const std::string& player_name +){ + VideoStream& stream = env.console; + StepTrace trace(stream, "Intro"); + + // Only close and reopen the game: the states below take it from the user select + // on. reset_game_from_home() waits for a loaded save's black screen to end, and a + // new game's first screen is a dialog on black, so it would sit there until its + // timeout. + trace.action("Restarting the game."); + go_home(env.console, context); + from_home_close_and_reopen_game(env.console, context, true); + + IntroProgress progress; + size_t x_without_menu = 0; + auto press = [&](Button button){ + pbf_press_button(context, button, 80ms, 0ms); + }; + + // Carries out what decide_intro_step() chose for `screen`. + auto act = [&](const char* screen){ + switch (decide_intro_step(screen, progress)){ + case IntroStep::SELECT_USER: + // The cursor starts on the user that launched the game. + press(BUTTON_A); + pbf_wait(context, 1000ms); + return; + case IntroStep::FAIL_NOT_IN_GAME: + trace.fail("The Switch HOME menu is showing: the game is not running in front."); + case IntroStep::ENTER_PLAYER_NAME: + enter_player_name(stream, context, trace, player_name); + progress.player_named = true; + return; + case IntroStep::CANCEL_RIVAL_KEYBOARD: + // Opened from "I'll tell you..."; inputs during the slide-in are dropped. + pbf_wait(context, 800ms); + press(BUTTON_X); + return; + case IntroStep::MOVE_TO_PRESET: + pbf_press_dpad(context, DPAD_DOWN, 100ms, 300ms); + return; + case IntroStep::PICK_PRESET: + press(BUTTON_A); + pbf_wait(context, 500ms); + progress.rival_named = true; + return; + case IntroStep::CLOSE_MENU_AND_FINISH:{ + trace.action("Main menu is open: the player is in control in the bedroom. Closing it."); + press(BUTTON_B); + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + VideoSnapshot screen_now = stream.video().snapshot(); + if (!screen_now || MenuDetector().detect(screen_now)){ + trace.fail("The main menu did not close."); + } + return; + } + case IntroStep::ADVANCE: + pbf_press_button(context, BUTTON_A, 60ms, 400ms); + return; + case IntroStep::PROBE_FOR_MENU:{ + context.wait_for_all_requests(); + VideoSnapshot before = stream.video().snapshot(); + press(BUTTON_X); + context.wait_for_all_requests(); + MenuWatcher menu; + if (wait_until(stream, context, 3s, {{menu}}) == 0){ + return; // The next read sees the menu and finishes. + } + VideoSnapshot after = stream.video().snapshot(); + const bool changed = !before || !after || ImageMatch::pixel_RMSD(before, after) > 10; + if (!advance_after_probe(changed)){ + trace.action("X changed the screen but no known menu opened; not pressing A."); + if (++x_without_menu >= 10){ + trace.fail("X keeps changing the screen without a known menu opening."); + } + return; + } + x_without_menu = 0; + pbf_press_button(context, BUTTON_A, 60ms, 400ms); + return; + } + } + }; + + auto screen_is = [&](const ImageViewRGB32& screen, const char* name){ + return classify_intro_screen(&env.console, screen) == name; + }; + // The machine's states: the screens classify_intro_screen() tells apart, with the + // keyboard split by whose name it asks for. Each state's step is act(). + const std::vector states{ + {"user-select", + [&](const ImageViewRGB32& s){ return screen_is(s, "user-select"); }, + [&]{ act("user-select"); }, {}, 3}, + {"home", + [&](const ImageViewRGB32& s){ return screen_is(s, "home"); }, + [&]{ act("home"); }, {}, 1}, + {"player-keyboard", + [&](const ImageViewRGB32& s){ return !progress.player_named && screen_is(s, "keyboard"); }, + [&]{ act("keyboard"); }, {"dialog", "other"}, 1}, + {"rival-keyboard", + [&](const ImageViewRGB32& s){ return progress.player_named && screen_is(s, "keyboard"); }, + [&]{ act("keyboard"); }, {"rival-list-tell", "dialog", "other"}, 3}, + {"rival-list-tell", + [&](const ImageViewRGB32& s){ return screen_is(s, "rival-list-tell"); }, + [&]{ act("rival-list-tell"); }, {"rival-list-preset", "other"}, 6}, + {"rival-list-preset", + [&](const ImageViewRGB32& s){ return screen_is(s, "rival-list-preset"); }, + [&]{ act("rival-list-preset"); }, {"dialog", "other"}, 3}, + {"menu", + [&](const ImageViewRGB32& s){ return screen_is(s, "menu"); }, + [&]{ act("menu"); }, {}, 2, true}, + {"dialog", + [&](const ImageViewRGB32& s){ return screen_is(s, "dialog"); }, + [&]{ act("dialog"); }, {}, 400}, + {"other", + [](const ImageViewRGB32&){ return true; }, + [&]{ act("other"); }, {}, 300, false, 60}, + }; + run_screen_machine(stream, context, trace, states, std::chrono::minutes(15)); + trace.action("Intro done."); +} + + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h new file mode 100644 index 0000000000..7ab9e4fb5c --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h @@ -0,0 +1,71 @@ +/* BDSP AutoStory - New Game + * + * From: https://github.com/PokemonAutomation/ + * + * Plays a new game's intro (language, Professor Rowan, appearance, names) up to + * the bedroom with the player in control, which is where the Tutorial starts. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_NewGame_H +#define PokemonAutomation_PokemonBDSP_AutoStory_NewGame_H + +#include +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "NintendoSwitch/NintendoSwitch_SingleSwitchProgram.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Which intro screen `screen` shows, as the intro's steps tell them apart: +// "user-select", "home", "keyboard", "rival-list-tell" (the rival's name list on +// "I'll tell you..."), "rival-list-preset" (on a preset name), "menu", "dialog" or +// "other". The Switch's own screens need `console`; without it they read as +// whatever else they look like. +std::string classify_intro_screen(ConsoleHandle* console, const ImageViewRGB32& screen); + +// What the intro does on a screen. Decided from the screen and how far the intro has +// got only, so it is tested without a console. +enum class IntroStep{ + SELECT_USER, // A on the Switch's user select + FAIL_NOT_IN_GAME, // HOME is showing + ENTER_PLAYER_NAME, // type the name on the first keyboard + CANCEL_RIVAL_KEYBOARD, // X: back to the rival's name list + MOVE_TO_PRESET, // Down: off "I'll tell you..." + PICK_PRESET, // A on a preset name + CLOSE_MENU_AND_FINISH, // B: the menu opened in the bedroom, the intro is over + ADVANCE, // A: dialogue, language, appearance, cutscenes + PROBE_FOR_MENU, // X, to see whether the player is in control +}; +struct IntroProgress{ + bool player_named = false; + bool rival_named = false; +}; +IntroStep decide_intro_step(const std::string& screen, const IntroProgress& progress); + +// After PROBE_FOR_MENU found no menu: whether A may follow. Only when X changed +// nothing, since A on something X opened would pick from it (the Bag). +bool advance_after_probe(bool screen_changed); + +// Checks classify_intro_screen() on //*.png at 1920 and +// 1280 px wide. Logs each mismatch; returns the number of them. +size_t run_intro_screen_tests(Logger& logger, const std::string& folder); + +// From the title screen or any intro screen of a game with no save. Restarts the +// game first, so it always starts from the title. Names the player `player_name` +// and picks the rival's default name (Barry) from the list. Each step reads the +// screen and waits for the expected next one; an unexpected screen fails with a +// screenshot and the recent screens. +void play_new_game_intro( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const std::string& player_name +); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp new file mode 100644 index 0000000000..25ae5762a8 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp @@ -0,0 +1,311 @@ +/* BDSP AutoStory - Pokemon Center + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP_AutoStory_RouteWalk.h" +#include "PokemonBDSP_AutoStory_Walk.h" +#include "PokemonBDSP_AutoStory_PokemonCenter.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; +using namespace Pokemon; + + +namespace{ + +// How many tiles the 1080p view spans across: a tile is about 125 px. +constexpr double SCREEN_WIDTH_TILES = 1920.0 / 125; +// The camera keeps the player in the middle of the screen across, and the +// centre of his tile a little above the middle down (r121a and r122a: his +// feet at y = 590 of 1080). +constexpr double PLAYER_X = 0.5; +constexpr double PLAYER_Y = 0.505; +// How much of the screen's height one row of tiles takes near the player: +// the doorway straight ahead of him (r121a) is centred this far above his +// tile's centre. Rows further north look shorter, so this undercounts them. +constexpr double SCREEN_ROW_HEIGHT = 0.12; +// Doorways are looked for down to here, 0 to 1: a door the player can walk +// into is north of him. +constexpr double DOORWAY_SEARCH_BOTTOM = 0.67; +// A doorway found within this many rows of straight ahead is at the front +// row; further, the walk to the door ended short. +constexpr double DOORWAY_FRONT_ROWS = 1.5; + +// The doorway is looked for on a view this size: 4 px of 1080p per pixel. +constexpr size_t SMALL_WIDTH = 480; +constexpr size_t SMALL_HEIGHT = 270; +// A doorway's lit columns run at least this many small rows (the Mart's 12, +// the Center's 15); the buildings' lit roof bands 3 to 5. +constexpr size_t DOORWAY_COLUMN_ROWS = 8; +// A doorway has this many lit small pixels (the Mart's 260 to 350); a roof +// band's run 60 to 75. +constexpr double DOORWAY_MIN_MASS = 80; + +// Whether a pixel is the pale yellow-white of a lit doorway: the Mart's is +// (235, 243, 205), the Center's (255, 255, 185). The buildings' white roof +// bands are bluer, their orange bands and the light they throw on the +// ground outside are darker. +bool lit_doorway_pixel(uint32_t pixel){ + const int r = (pixel >> 16) & 0xff; + const int g = (pixel >> 8) & 0xff; + const int b = pixel & 0xff; + return r >= 215 && g >= 215 && b >= 150 && b <= g - 15 && r >= g - 25; +} + +// Each column of the small view: how many lit doorway pixels it has in the +// search band, and the sum of their rows. +struct LitColumns{ + std::vector count; + std::vector row_sum; +}; +LitColumns lit_columns(const ImageViewRGB32& screen){ + const ImageRGB32 small = screen.scale_to(SMALL_WIDTH, SMALL_HEIGHT); + LitColumns columns{std::vector(small.width(), 0), std::vector(small.width(), 0)}; + const size_t bottom = (size_t)(DOORWAY_SEARCH_BOTTOM * (double)small.height()); + for (size_t y = 0; y < bottom; y++){ + for (size_t x = 0; x < small.width(); x++){ + if (lit_doorway_pixel(small.pixel(x, y))){ + columns.count[x]++; + columns.row_sum[x] += (double)y; + } + } + } + return columns; +} + +// A run of lit columns: its lit pixels, and the sums of their columns and rows. +struct LitRun{ + double mass = 0; + double column_sum = 0; + double row_sum = 0; +}; +// The run of columns with at least DOORWAY_COLUMN_ROWS lit that starts at +// `x`, allowing gaps of up to 3 columns (the door's frame). Moves `x` past it. +LitRun lit_run(const LitColumns& columns, size_t& x){ + LitRun run; + size_t gap = 0; + for (; x < columns.count.size() && gap <= 3; x++){ + if (columns.count[x] < DOORWAY_COLUMN_ROWS){ + gap++; + continue; + } + run.mass += (double)columns.count[x]; + run.column_sum += (double)columns.count[x] * (double)x; + run.row_sum += columns.row_sum[x]; + gap = 0; + } + return run; +} + +// Where a building's lit doorway is across and down the screen, 0 to 1: the +// biggest run of lit columns within 0.35 of the middle (a second building's +// door may show further off). Nothing if no doorway shows. +std::optional> lit_doorway_position(const ImageViewRGB32& screen){ + const LitColumns columns = lit_columns(screen); + const double width = (double)columns.count.size(); + std::optional> best; + double best_mass = 0; + for (size_t x = 0; x < columns.count.size(); ){ + if (columns.count[x] < DOORWAY_COLUMN_ROWS){ + x++; + continue; + } + const LitRun run = lit_run(columns, x); + const double center = run.column_sum / run.mass / width; + if (run.mass < DOORWAY_MIN_MASS || std::abs(center - PLAYER_X) >= 0.35 || run.mass <= best_mass){ + continue; + } + best = {center, run.row_sum / run.mass / (double)SMALL_HEIGHT}; + best_mass = run.mass; + } + return best; +} + +// Whether the whole view is a different scene from "before". +bool scene_changed_since(VideoStream& stream, ProControllerContext& context, const VideoSnapshot& before){ + context.wait_for_all_requests(); + VideoSnapshot now = stream.video().snapshot(); + return before && now && walk_screen_change(*before.frame, *now.frame) > WALK_SCENE_CHANGE; +} + +// Moves across by "tiles", east if positive, west if negative. +void step_across(VideoStream& stream, ProControllerContext& context, double tiles){ + const Direction side = tiles > 0 ? Direction::EAST : Direction::WEST; + route_turn(stream, context, side); + const Milliseconds hold = std::max(Milliseconds(40), + Milliseconds((int64_t)(std::abs(tiles) * ROUTE_MS_PER_TILE_ACROSS.count()))); + pbf_move_left_joystick(context, {tiles > 0 ? 1.0 : -1.0, 0}, hold, 300ms); +} + +// Walks north into the door straight ahead, `doorway` away. Returns whether +// we are inside. +bool walk_into_door( + VideoStream& stream, ProControllerContext& context, + const VideoSnapshot& outside, const LitDoorway& doorway, const std::string& label +){ + // Time for the rows to the door (none when it is straight ahead), the + // door opening, and a start delay. + const double rows = std::max(0.0, doorway.rows_up - 1); + const Milliseconds limit = 1500ms + Milliseconds((int64_t)(rows * ROUTE_MS_PER_TILE_UP_DOWN.count())); + if (walk_until(stream, context, 0, 1, limit, label + ": into the door").stop == WalkStop::TRANSITION){ + pbf_wait(context, 2000ms); + return true; + } + // The door slides open before it takes us in, and its fade is not always + // black enough to read as a transition, and may finish after the walk; a + // whole new scene says we are in. Not after a walk of several rows, which + // scrolls the view: the next look decides. + pbf_wait(context, 2000ms); + if (doorway.rows_up > DOORWAY_FRONT_ROWS || !scene_changed_since(stream, context, outside)){ + return false; + } + stream.log("[Route] " + label + ": the scene changed; inside."); + return true; +} + +// Where the doorway is on screen, in the log. +void log_doorway(VideoStream& stream, const std::string& label, const LitDoorway& doorway){ + stream.log("[Route] " + label + ": doorway " + std::to_string(doorway.tiles_across) + + " tiles across, " + std::to_string(doorway.rows_up) + " rows up (at " + + std::to_string(doorway.x) + ", " + std::to_string(doorway.y) + " of the screen)."); +} + +} + + +std::optional find_lit_doorway(const ImageViewRGB32& screen){ + const std::optional> position = lit_doorway_position(screen); + if (!position){ + return std::nullopt; + } + return LitDoorway{ + position->first, + position->second, + (position->first - PLAYER_X) * SCREEN_WIDTH_TILES, + (PLAYER_Y - position->second) / SCREEN_ROW_HEIGHT, + }; +} + +std::vector tiles_short_of_door( + VideoStream& stream, ProControllerContext& context, + const TileGrid& grid, const TilePos& door, const std::string& label +){ + context.wait_for_all_requests(); + VideoSnapshot screen = stream.video().snapshot(); + const std::optional doorway = screen ? find_lit_doorway(*screen.frame) : std::nullopt; + if (!doorway){ + stream.log("[Route] " + label + ": no lit doorway on screen; not walking again."); + return {}; + } + log_doorway(stream, label, *doorway); + if (doorway->rows_up <= DOORWAY_FRONT_ROWS){ + return {}; + } + return tiles_by_lit_doorway(*doorway, grid, door); +} + +std::vector tiles_by_lit_doorway(const LitDoorway& doorway, const TileGrid& grid, const TilePos& door){ + const TilePos guess{door.x - (int)std::lround(doorway.tiles_across), door.y + (int)std::lround(doorway.rows_up)}; + std::vector tiles; + for (int y = guess.y - 1; y <= guess.y + 1; y++){ + for (int x = guess.x - 1; x <= guess.x + 1; x++){ + if (grid.standable(TilePos{x, y})){ + tiles.push_back(TilePos{x, y}); + } + } + } + return tiles; +} + +void enter_lit_door( + VideoStream& stream, ProControllerContext& context, + const std::string& label +){ + // Movement is not locked to tiles, and the door takes us in only when we + // are lined up with it. Its doorway is lit: find it on screen, where the + // camera keeps the player in the middle, move across by its offset, and + // walk in once it is straight ahead, looking again after every move. + route_status(stream, label + ": lining up with the door."); + VideoSnapshot last_outside; + for (size_t tries = 0; tries < 12; tries++){ + context.wait_for_all_requests(); + VideoSnapshot outside = stream.video().snapshot(); + if (!outside){ + continue; + } + const std::optional doorway = find_lit_doorway(*outside.frame); + if (!doorway){ + // The door may have taken us in after all, more slowly than we watched. + pbf_wait(context, 1500ms); + if (!scene_changed_since(stream, context, last_outside)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": no lit doorway on screen.", stream); + } + stream.log("[Route] " + label + ": the scene changed; inside."); + pbf_wait(context, 1500ms); + return; + } + last_outside = outside; + log_doorway(stream, label, *doorway); + if (std::abs(doorway->tiles_across) > 0.25){ + step_across(stream, context, doorway->tiles_across); + continue; + } + if (walk_into_door(stream, context, outside, *doorway, label)){ + pbf_wait(context, 1500ms); + return; + } + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": not through the door after 12 tries.", stream); +} + +void heal_at_pokemon_center( + VideoStream& stream, ProControllerContext& context, + const std::string& label +){ + enter_lit_door(stream, context, label); + + // The nurse's counter is straight ahead of the door. + route_status(stream, label + ": talking to the nurse."); + if (walk_to_talk(stream, context, 0, 1, 6000ms, label + ": to the nurse").stop != WalkStop::DIALOG){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": nurse not reached.", stream); + } + // A through her lines; the cursor starts on "Yes". Stop once no dialogue + // has shown for a while, so A does not start the talk over. + size_t quiet = 0; + for (size_t presses = 0; presses < 30 && quiet < 2; presses++){ + ShortDialogWatcher dialog(COLOR_YELLOW); + if (wait_until(stream, context, 1500ms, {{dialog}}) == 0){ + quiet = 0; + pbf_press_button(context, BUTTON_A, 80ms, 700ms); + }else{ + quiet++; + } + } + context.wait_for_all_requests(); + + route_status(stream, label + ": healed; walking out."); + leave_door_south(stream, context, label + ": out the door"); +} + + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h new file mode 100644 index 0000000000..c49c21f16d --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h @@ -0,0 +1,80 @@ +/* BDSP AutoStory - Pokemon Center + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_PokemonCenter_H +#define PokemonAutomation_PokemonBDSP_AutoStory_PokemonCenter_H + +#include +#include +#include +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// A building's lit doorway seen on the overworld, relative to the player, +// whom the camera keeps in the middle of the screen. +struct LitDoorway{ + double x; // centre across the screen, 0 to 1 + double y; // centre down the screen, 0 to 1 + double tiles_across; // east of the player if positive, west if negative + double rows_up; // rows north of the player's tile, roughly: 1 straight ahead +}; + +// Finds a lit doorway (a Pokemon Center's or a Poke Mart's) in the upper +// two thirds of `screen`, the pale yellow-white of its open door. Nothing if +// none shows there. +std::optional find_lit_doorway(const ImageViewRGB32& screen); + +// The tiles a player who sees `doorway` (the door on world tile `door`) may +// stand on: the one its offset says, give or take one each way, since the +// rows are a rough count. Only standable tiles of `grid`. +std::vector tiles_by_lit_doorway( + const LitDoorway& doorway, const Pokemon::TileGrid& grid, const Pokemon::TilePos& door +); + +// For a player who walked to the row below the door on world tile `door`: +// if its lit doorway shows further up the screen than straight ahead, the +// walk ended short (someone in the way stops an open leg, and the walk +// cannot tell), and the tiles he may stand on instead, read off the +// doorway's offset, are returned to walk from again. Empty when he is at +// the front row, or no doorway shows. +std::vector tiles_short_of_door( + VideoStream& stream, ProControllerContext& context, + const Pokemon::TileGrid& grid, const Pokemon::TilePos& door, const std::string& label +); + +// Goes in through a building's door (a Pokemon Center's or a Poke Mart's). +// Starts with its lit doorway on screen, within a few tiles: lines up with +// the doorway by sight and walks in, looking again after every move. Whole- +// tile steps sideways miss it, since movement is not locked to tiles, and +// its fade may not read as black: a new scene also counts as inside. Fails +// with a screenshot when no doorway shows, or after 12 moves. +void enter_lit_door( + VideoStream& stream, ProControllerContext& context, + const std::string& label +); + +// Heals the party at a Pokemon Center. Starts in front of the Center with its +// lit doorway on screen, within a few tiles of it: lines up with the doorway +// by sight, walks in, talks to the nurse, accepts, and walks back out. Ends +// on the tile below the door. +void heal_at_pokemon_center( + VideoStream& stream, ProControllerContext& context, + const std::string& label +); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp new file mode 100644 index 0000000000..e2ead0b6c8 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -0,0 +1,581 @@ +/* BDSP AutoStory - Route Walk + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "Common/Cpp/PrettyPrint.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP_AutoStory_Location.h" +#include "PokemonBDSP_AutoStory_Walk.h" +#include "PokemonBDSP_AutoStory_RouteWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; +using namespace Pokemon; + + +namespace{ + +bool across(Direction direction){ + return direction == Direction::EAST || direction == Direction::WEST; +} + +Milliseconds ms_per_tile(Direction direction){ + return across(direction) ? ROUTE_MS_PER_TILE_ACROSS : ROUTE_MS_PER_TILE_UP_DOWN; +} + +// The left stick for a direction. Row 0 is the top of a map, and the stick's +// y is positive upward. +void stick_for(Direction direction, double& x, double& y){ + x = 0; + y = 0; + switch (direction){ + case Direction::NORTH: y = 1; break; + case Direction::EAST: x = 1; break; + case Direction::SOUTH: y = -1; break; + case Direction::WEST: x = -1; break; + } +} + +std::string describe(const MapSet& maps, const std::vector& tiles){ + std::string out; + for (const TilePos& pos : tiles){ + out += (out.empty() ? "" : ", ") + maps.describe(pos); + } + return out; +} + +// The tiles the player passes when walking `tiles` tiles from `from`, the +// first one after `from` first. Stops early where the player is stopped. +std::vector leg_path(const TileGrid& grid, TilePos from, Direction direction, size_t tiles){ + std::vector path; + size_t travelled = 0; + while (travelled < tiles){ + const std::optional next = grid.step(from, direction); + if (!next){ + break; + } + travelled += std::abs(next->x - from.x) + std::abs(next->y - from.y); + from = *next; + path.push_back(from); + } + return path; +} + +// The map all of `tiles` are on, or empty if they are on several or none. +std::string map_of(const MapSet& maps, const std::vector& tiles){ + std::string name; + for (const TilePos& pos : tiles){ + const std::optional tile = maps.to_local(pos); + if (!tile || (!name.empty() && tile->map != name)){ + return ""; + } + name = tile->map; + } + return name; +} + +} + + +bool route_wall_readable(const TileGrid& grid, const std::vector& after){ + return std::none_of(after.begin(), after.end(), [&](const TilePos& pos){ + return grid.at(pos) == TileType::TALL_GRASS; + }); +} + +void route_status(VideoStream& stream, const std::string& text){ + stream.log("[Route] " + text, COLOR_BLUE); + add_wrapped_log(stream.overlay(), text, COLOR_WHITE); +} + +Milliseconds route_open_hold(Direction direction, size_t tiles){ + const Milliseconds step = ms_per_tile(direction); + return step * tiles - step / 2; +} + +void route_turn(VideoStream& stream, ProControllerContext& context, Direction direction){ + double x, y; + stick_for(direction, x, y); + stream.log(std::string("[Route] Turning ") + direction_name(direction) + "."); + pbf_move_left_joystick(context, {x, y}, ROUTE_TURN_TAP, 300ms); + context.wait_for_all_requests(); +} + +std::optional talk_to_adjacent_npc( + VideoStream& stream, ProControllerContext& context, + const std::vector& directions, + const std::string& label, Milliseconds wait +){ + for (Direction direction : directions){ + route_turn(stream, context, direction); + pbf_press_button(context, BUTTON_A, 80ms, 0ms); + context.wait_for_all_requests(); + ShortDialogWatcher dialog(COLOR_CYAN); + if (wait_until(stream, context, wait, {{dialog}}) == 0){ + stream.log("[Route] " + label + ": answered from the " + direction_name(direction) + "."); + return direction; + } + } + stream.log("[Route] " + label + ": nobody answered next to us."); + return std::nullopt; +} + +std::vector walk_route( + VideoStream& stream, ProControllerContext& context, + const MapSet& maps, + std::vector from, + const std::function& goal, + std::vector& trainers, + const RouteTrainerFight& fight, + const std::string& label, + const std::vector& avoid +){ + // Our own copy, so things found in the way can be added to it. + TileGrid grid = maps.world(); + for (const TilePos& pos : avoid){ + if (std::find(from.begin(), from.end(), pos) == from.end()){ + grid.set(pos, TileType::BLOCKED); + } + } + // Which way the player faces, once a leg has set it. + std::optional facing; + // Every banner read on the way, to check the map we arrive on against. + const std::string map_start = map_of(maps, from); + std::vector all_banners; + for (size_t plans = 0; plans < 8; plans++){ + const std::optional> legs = plan_robust_route(grid, from, goal); + if (!legs){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": no plan from " + describe(maps, from) + ".", stream); + } + route_status(stream, label + ": " + std::to_string(legs->size()) + " legs planned"); + std::string text = "[Route] " + label + ": plan from " + describe(maps, from) + ":"; + for (const PlannedLeg& leg : *legs){ + text += "\n " + to_string(leg.leg) + " to " + describe(maps, leg.after); + } + stream.log(text); + + bool replanned = false; + size_t leg_number = 0; + for (const PlannedLeg& planned : *legs){ + const TileLeg& leg = planned.leg; + leg_number++; + route_status(stream, "Leg " + std::to_string(leg_number) + "/" + std::to_string(legs->size()) + ": " + + to_string(leg) + " to " + describe(maps, planned.after)); + double x, y; + stick_for(leg.direction, x, y); + const std::string leg_label = label + ": " + to_string(leg); + + // Note the location banners the walk passes, to check map changes. + // A wild battle or a second walk to the wall may follow the + // banner: any banner seen during the leg counts. + const std::string map_before = map_of(maps, from); + const std::string map_after = map_of(maps, planned.after); + std::vector banners; + auto banner_ok = [&]{ + return std::any_of(banners.begin(), banners.end(), [&](const std::string& read){ + return location_banner_matches(read, map_after); + }); + }; + auto watch_banner = [&](const ImageViewRGB32& screen){ + if (banners.empty() || (!map_after.empty() && !banner_ok() && banners.size() < 8)){ + const std::string read = read_location_banner(screen); + if (!read.empty() && (banners.empty() || banners.back() != read)){ + banners.push_back(read); + } + } + return false; + }; + + WalkResult result; + if (leg.ends_at_wall){ + // Long enough to reach the wall from the farthest tile, with room + // for a slow start; past the wall the player stands still. + const Milliseconds limit = ROUTE_START_DELAY + ms_per_tile(leg.direction) * leg.tiles * 5 / 4 + 1000ms; + result = walk_until(stream, context, x, y, limit, leg_label, watch_banner); + // A wild battle may end the walk early. Walking to a wall again + // ends in the same place, so finish the leg. + const bool interrupted = result.wild_battles > 0; + for (size_t again = 0; again < 3 && result.wild_battles > 0 && + (result.stop == WalkStop::LIMIT || result.stop == WalkStop::BLOCKED); again++ + ){ + stream.log("[Route] " + leg_label + ": a wild battle on the way; walking to the wall again."); + result = walk_until(stream, context, x, y, limit, leg_label, watch_banner); + } + // A wall well before the nearest one the map has: something the + // map does not know is in the way. Work out where we stopped from + // the time walked, put it on our map and plan again from there. + size_t nearest = SIZE_MAX; + for (const TilePos& pos : from){ + nearest = std::min(nearest, walk_to_wall(grid, pos, leg.direction).tiles); + } + auto tiles_moved = [&](const WalkResult& walk){ + const Milliseconds moving = walk.walked - ROUTE_START_DELAY - + (walk.stop == WalkStop::BLOCKED ? 800ms : 0ms); + return moving.count() <= 0 ? (size_t)0 : (size_t)(moving / ms_per_tile(leg.direction)); + }; + size_t walked_tiles = tiles_moved(result); + // Someone walking about may stand in the way for a moment: wait + // and walk to the wall again before calling it an obstacle. + for (size_t again = 0; !interrupted && again < 2 && + result.stop == WalkStop::BLOCKED && walked_tiles + 2 < nearest; again++ + ){ + stream.log("[Route] " + leg_label + ": stopped after about " + std::to_string(walked_tiles) + + " tiles, short of the wall; waiting for the way to clear.", COLOR_ORANGE); + pbf_wait(context, 2500ms); + context.wait_for_all_requests(); + result = walk_until(stream, context, x, y, limit, leg_label, watch_banner); + walked_tiles += tiles_moved(result); + } + if (!interrupted && result.stop == WalkStop::BLOCKED && walked_tiles + 2 < nearest){ + // Someone who stays put: talk to them. A trainer we walked up + // to from the side starts the battle this way. + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + { + ShortDialogWatcher dialog(COLOR_YELLOW); + if (wait_until(stream, context, 2000ms, {{dialog}}) == 0){ + route_status(stream, leg_label + ": someone is in the way; talking to them."); + fight("someone in the way"); + } + } + std::vector stopped; + for (const TilePos& pos : from){ + const std::vector path = leg_path(grid, pos, leg.direction, SIZE_MAX); + for (size_t k = walked_tiles == 0 ? 0 : walked_tiles - 1; k <= walked_tiles + 1 && k <= path.size(); k++){ + stopped.push_back(k == 0 ? pos : path[k - 1]); + } + } + std::sort(stopped.begin(), stopped.end(), [](const TilePos& a, const TilePos& b){ + return a.y != b.y ? a.y < b.y : a.x < b.x; + }); + stopped.erase(std::unique(stopped.begin(), stopped.end()), stopped.end()); + if (stopped.empty()){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": stopped early, and no tile fits.", stream); + } + if (stopped.size() == 1){ + const TilePos blocker = stopped[0].next(leg.direction); + grid.set(blocker, TileType::BLOCKED); + stream.log("[Route] " + leg_label + ": something the map lacks is at " + + maps.describe(blocker) + "; marked it.", COLOR_ORANGE); + } + stream.log("[Route] " + leg_label + ": stopped early after about " + std::to_string(walked_tiles) + + " tiles, at " + describe(maps, stopped) + "; planning again.", COLOR_ORANGE); + facing = leg.direction; + from = stopped; + replanned = true; + break; + } + }else{ + // The hold is timed for a player facing the way to go. + if (facing != leg.direction){ + route_turn(stream, context, leg.direction); + } + result = walk_for(stream, context, x, y, route_open_hold(leg.direction, leg.tiles), leg_label); + if (result.wild_battles > 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": a wild battle cut an open leg short; position unknown.", stream); + } + } + facing = leg.direction; + { + context.wait_for_all_requests(); + if (VideoSnapshot screen = stream.video().snapshot()){ + watch_banner(*screen.frame); + } + } + std::string banner_text; + for (const std::string& read : banners){ + banner_text += (banner_text.empty() ? "\"" : ", \"") + read + "\""; + } + all_banners.insert(all_banners.end(), banners.begin(), banners.end()); + // Entering another map shows its name; a missing or wrong name means + // the walk did not end where the plan says. From tiles on several + // maps (a leg not confirmed) the banner may already have shown. + if (!map_before.empty() && !map_after.empty() && map_after != map_before && !banner_ok()){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": expected to enter " + map_after + ", but the location banner read " + + (banner_text.empty() ? "nothing" : banner_text) + ".", stream); + } + if (!banner_text.empty()){ + stream.log("[Route] " + leg_label + ": location banner " + banner_text + "."); + } + // Where each leg ended, to compare with the plan after a run. + if (VideoSnapshot screen = stream.video().snapshot()){ + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/route/"; + Filesystem::create_directories(Filesystem::Path(folder)); + screen.frame->save(folder + now_to_filestring() + "-" + normalize_location_name(leg_label) + ".png"); + } + if (result.stop == WalkStop::TRANSITION){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": unexpected transition.", stream); + } + // A trainer who sees us freezes the player and walks over; the + // dialogue may open after the walk has ended. + const std::vector> paths = [&]{ + std::vector> out; + for (const TilePos& pos : from){ + out.push_back(leg_path(grid, pos, leg.direction, + leg.ends_at_wall ? SIZE_MAX : leg.tiles + 1)); + } + return out; + }(); + std::vector watching; + for (size_t t = 0; t < trainers.size(); t++){ + const std::vector sight = sight_tiles(grid, trainers[t]); + for (const std::vector& path : paths){ + if (std::any_of(path.begin(), path.end(), [&](const TilePos& pos){ + return std::find(sight.begin(), sight.end(), pos) != sight.end(); + })){ + watching.push_back(t); + break; + } + } + } + bool talking = result.stop == WalkStop::DIALOG; + if (!talking){ + ShortDialogWatcher dialog(COLOR_YELLOW); + const Milliseconds wait = watching.empty() ? 500ms : 4000ms; + talking = wait_until(stream, context, wait, {{dialog}}) == 0; + } + if (!talking){ + from = planned.after; + continue; + } + + if (watching.empty()){ + // A trainer the map does not have. Fight, then plan again from + // anywhere the leg could have stopped. + std::vector anywhere = from; + for (const std::vector& path : paths){ + anywhere.insert(anywhere.end(), path.begin(), path.end()); + } + std::sort(anywhere.begin(), anywhere.end(), [](const TilePos& a, const TilePos& b){ + return a.y != b.y ? a.y < b.y : a.x < b.x; + }); + anywhere.erase(std::unique(anywhere.begin(), anywhere.end()), anywhere.end()); + route_status(stream, label + ": a trainer the map lacks stopped us; fighting."); + fight("unknown trainer"); + from = anywhere; + replanned = true; + break; + } + // Each possible start tile was stopped on the first tile of its path + // inside a watching trainer's sight. + const TrainerSight trainer = trainers[watching[0]]; + std::vector stopped; + for (const std::vector& path : paths){ + for (const TilePos& pos : path){ + bool seen = false; + for (size_t t : watching){ + const std::vector sight = sight_tiles(grid, trainers[t]); + seen |= std::find(sight.begin(), sight.end(), pos) != sight.end(); + } + if (seen){ + stopped.push_back(pos); + break; + } + } + } + if (stopped.empty()){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": " + trainer.name + " talked, but no path enters their sight.", stream); + } + stream.log("[Route] " + label + ": " + trainer.name + " stopped us at " + describe(maps, stopped) + "."); + route_status(stream, "Fighting " + trainer.name + "."); + fight(trainer.name); + trainers.erase(trainers.begin() + watching[0]); + from = stopped; + replanned = true; + break; + } + if (!replanned){ + // Legs whose ends span two maps skip the per-leg check; the walk + // as a whole must still have seen the banner of the map it ends on. + const std::string map_end = map_of(maps, from); + const bool changed_map = !map_start.empty() && !map_end.empty() && map_end != map_start; + if (changed_map && std::none_of(all_banners.begin(), all_banners.end(), [&](const std::string& read){ + return location_banner_matches(read, map_end); + })){ + std::string seen; + for (const std::string& read : all_banners){ + seen += (seen.empty() ? "\"" : ", \"") + read + "\""; + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": arrived at " + describe(maps, from) + " on the map, but " + map_end + + "'s location banner never showed (read " + (seen.empty() ? "nothing" : seen) + ").", stream); + } + stream.log("[Route] " + label + ": arrived at " + describe(maps, from) + "."); + return from; + } + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": too many plans.", stream); +} + + + +void talk_to_npc( + VideoStream& stream, ProControllerContext& context, + const MapSet& maps, + std::vector& at, + const TilePos& npc, + const std::vector& sides, + std::vector& trainers, + const RouteTrainerFight& fight, + const std::string& label +){ + for (Direction side : sides){ + const TilePos stand = npc.next(side); + if (!maps.world().standable(stand)){ + continue; + } + at = walk_route(stream, context, maps, at, [&](const TilePos& pos){ return pos == stand; }, + trainers, fight, label); + // Face the NPC: the opposite of the side we stand on. + const Direction face = + side == Direction::NORTH ? Direction::SOUTH : + side == Direction::SOUTH ? Direction::NORTH : + side == Direction::EAST ? Direction::WEST : Direction::EAST; + route_turn(stream, context, face); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + context.wait_for_all_requests(); + ShortDialogWatcher dialog(COLOR_CYAN); + if (wait_until(stream, context, 2000ms, {{dialog}}) == 0){ + route_status(stream, label + ": talking from " + maps.describe(stand) + "."); + return; + } + route_status(stream, label + ": nobody answered from " + maps.describe(stand) + "; trying another side."); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": no side of " + maps.describe(npc) + " started a dialogue.", stream); +} + +void finish_dialogue( + VideoStream& stream, ProControllerContext& context, + Button button, const std::string& label, Milliseconds timeout +){ + // No line counts: talks differ in length (r115a: 8 A left the Poke Mart + // lady's Potions talk open). The box blinks off between lines and during + // "obtained" jingles, so only a stable absence ends the talk. + const WallClock none = WallClock::max(); + ShortDialogDetector dialog; + OverworldDetector overworld; + const WallClock deadline = current_time() + timeout; + WallClock quiet_since = none; + WallClock unknown_since = none; + while (current_time() < deadline){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + const WallClock now = current_time(); + const bool talking = frame && dialog.detect(frame); + if (frame && !talking && overworld.detect(frame)){ + unknown_since = none; + if (quiet_since == none){ + quiet_since = now; + }else if (now - quiet_since >= 1500ms){ + return; + } + context.wait_for(200ms); + continue; + } + quiet_since = none; + // Press only on a box (an A on the overworld talks again), or on a + // screen that is neither for 3 s. + if (talking || (unknown_since != none && now - unknown_since >= 3000ms)){ + unknown_since = none; + pbf_press_button(context, button, 80ms, 1200ms); + continue; + } + if (unknown_since == none){ + unknown_since = now; + } + context.wait_for(200ms); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": the dialogue did not end within " + + std::to_string(std::chrono::duration_cast(timeout).count()) + " s.", stream); +} + +void enter_door_north(VideoStream& stream, ProControllerContext& context, const std::string& label){ + // Sideways steps to try after a blocked walk: east, then back west and + // one more. + const Direction steps[] = {Direction::EAST, Direction::WEST, Direction::WEST}; + for (size_t attempt = 0; attempt <= 3; attempt++){ + const WalkResult walk = walk_until(stream, context, 0, 1, 2500ms, label); + if (walk.stop == WalkStop::TRANSITION){ + context.wait_for(1500ms); + return; + } + if (attempt == 3){ + break; + } + route_status(stream, label + ": the door frame stopped us; one tile " + + (steps[attempt] == Direction::EAST ? "east." : "west.")); + route_turn(stream, context, steps[attempt]); + const double x = steps[attempt] == Direction::EAST ? 1 : -1; + pbf_move_left_joystick(context, {x, 0}, route_open_hold(steps[attempt], 1), 200ms); + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": could not go through the door.", stream); +} + +void leave_door_south(VideoStream& stream, ProControllerContext& context, const std::string& label){ + // A last line of talk may still be up, and it would stop the walk. + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + VideoSnapshot inside = stream.video().snapshot(); + // The door's fade changes the whole screen between two looks, as walking + // never does (16 to 102 per look inside a Center; the fades read 121 to + // 192). Stop the walk there: a stick held through the fade-in walks on + // outside, and r124a stood ten rows south of the Mart's door after + // "walking out" until a wall stopped it. + ImageRGB32 last; + bool have_last = false; + auto fading = [&](const ImageViewRGB32& screen){ + const bool fade = have_last && walk_screen_change(last, screen) > WALK_SCENE_CHANGE; + last = screen.copy(); + have_last = true; + return fade; + }; + bool out = false; + for (size_t tries = 0; tries < 3 && !out; tries++){ + const WalkResult walk = walk_until(stream, context, 0, -1, 6000ms, label, fading); + if (walk.stop == WalkStop::DIALOG){ + pbf_mash_button(context, BUTTON_B, 2000ms); + } + // The fade-in finishes after the walk has stopped. + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + VideoSnapshot now = stream.video().snapshot(); + out = walk.stop == WalkStop::TRANSITION || + (inside && now && walk_screen_change(*inside.frame, *now.frame) > WALK_SCENE_CHANGE); + } + if (!out){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": did not get back out.", stream); + } +} + + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h new file mode 100644 index 0000000000..d41688408b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -0,0 +1,138 @@ +/* BDSP AutoStory - Route Walk + * + * From: https://github.com/PokemonAutomation/ + * + * Walks a plan made on the story maps: legs to a wall are held until the + * player stops, open legs for their length in time. Tracks every tile the + * player may be on, and plans again after a trainer stops the walk. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_RouteWalk_H +#define PokemonAutomation_PokemonBDSP_AutoStory_RouteWalk_H + +#include +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "Pokemon/Navigation/Pokemon_MapSet.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Walking time per tile, measured on the console from the post-catch save: +// a row is deeper than a column is wide under the tilted camera. +constexpr Milliseconds ROUTE_MS_PER_TILE_ACROSS = Milliseconds(160); // east or west +constexpr Milliseconds ROUTE_MS_PER_TILE_UP_DOWN = Milliseconds(240); // north or south +// From pressing the stick to the first step, when standing still. +constexpr Milliseconds ROUTE_START_DELAY = Milliseconds(250); + +// How long to hold the stick for an open leg of `tiles` tiles, for a player +// already facing that way: a step once started is always finished, so stop +// half a tile early. On the console a 1290ms hold west went 9 tiles and an +// 800ms hold south 4: the player starts at once and finishes the step. +Milliseconds route_open_hold(Pokemon::Direction direction, size_t tiles); + +// A tap this short turns a player who faces another way, without a step: on +// the console a 150ms tap south after walking north only turned the player. +constexpr Milliseconds ROUTE_TURN_TAP = Milliseconds(100); + +// Turns the player to face `direction` without moving. Only for a player who +// faces another way: one already facing it may take a step. +void route_turn(VideoStream& stream, ProControllerContext& context, Pokemon::Direction direction); + +// Talks to someone on a tile next to ours, without trusting the tile we +// counted ourselves onto: for each of `directions` in order, turns that way, +// presses A and waits up to `wait` for a dialogue. Returns the direction that +// answered, its dialogue open, or none if nobody did. Turns are taps (see +// route_turn()), so do not list the way the player already faces first. +std::optional talk_to_adjacent_npc( + VideoStream& stream, ProControllerContext& context, + const std::vector& directions, + const std::string& label, Milliseconds wait = Milliseconds(1500) +); + +// Whether a walk to the wall that ends on any of `after` can tell it has +// stopped: not in tall grass, whose sway keeps the screen changing at a +// wall. Elsewhere, a walk to the wall that stops confirms where it ended. +bool route_wall_readable(const Pokemon::TileGrid& grid, const std::vector& after); + +// Shows what the route walk is doing in the log and the video overlay. +void route_status(VideoStream& stream, const std::string& text); + +// Called when a trainer's dialogue has opened, or when someone standing in +// the way was talked to. Fights the battle if one starts, and returns to the +// overworld either way. +using RouteTrainerFight = std::function; + +// Walks from any of the world tiles `from` of `maps` until every tile the +// player may be on is `goal`, and returns those tiles. Trainers in +// `trainers` who see the player are fought with `fight` and taken off the +// list. Tiles in `avoid` (where someone wanders) are planned as walls. +// Fails with a screenshot +// on a transition, a dialogue no trainer explains, a wild battle during an +// open leg, or when no plan exists. A walk from one map to another must see +// the second map's location banner on the way: a walk whose legs all ended +// on walls can still end in the wrong place (r124a saved in Sandgem Town +// after "arriving" on Route 202). +std::vector walk_route( + VideoStream& stream, ProControllerContext& context, + const Pokemon::MapSet& maps, + std::vector from, + const std::function& goal, + std::vector& trainers, + const RouteTrainerFight& fight, + const std::string& label, + const std::vector& avoid = {} +); + +// Walks next to the NPC on world tile `npc`, faces them and presses A, trying +// the sides in `sides` (the side of the NPC to stand on) in order until a +// dialogue opens. `at` is where the player may be, and is updated. Fails with +// a screenshot if no side starts a dialogue. A press from a diagonal tile, +// or from a tile a row off, talks to nobody, so each side is tried in turn. +void talk_to_npc( + VideoStream& stream, ProControllerContext& context, + const Pokemon::MapSet& maps, + std::vector& at, + const Pokemon::TilePos& npc, + const std::vector& sides, + std::vector& trainers, + const RouteTrainerFight& fight, + const std::string& label +); + +// Finishes a dialogue that has opened, however many lines it has: presses +// `button` while a dialogue box shows, and returns once the overworld has +// shown with no box for 1.5 s (the box blinks off between lines and during +// "obtained" jingles). Use A where the talk asks Yes/No or gives an item (A +// answers Yes and closes "obtained" lines; B would answer No), B elsewhere +// (B never starts a new talk). Fails with a screenshot after `timeout`. +void finish_dialogue( + VideoStream& stream, ProControllerContext& context, + Button button, const std::string& label, + Milliseconds timeout = std::chrono::seconds(40) +); + +// For a player on the tile below a door: walks north through it. If a wall +// stops the walk (the door frame, a tile off the opening), steps a tile east, +// then west, and tries again. Fails with a screenshot if no step goes in. +void enter_door_north(VideoStream& stream, ProControllerContext& context, const std::string& label); + +// For a player inside a building whose door is straight south: closes any +// talk still up and walks south out of it. The door's fade is not always +// black enough to read as a transition, so a whole new scene also counts. +// Fails with a screenshot if three walks south get nowhere. +void leave_door_south(VideoStream& stream, ProControllerContext& context, const std::string& label); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp index c7843b41cb..554347d66b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp @@ -6,15 +6,25 @@ * */ + +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "CommonFramework/GlobalAutoPaths.h" #include -#include -#include #include +#include +#include +#include + +#include "PokemonBDSP_AutoStory_RouteWalk.h" +#include "PokemonBDSP_AutoStory_Segment_00_tools.h" +#include "PokemonBDSP_Autostory_Segment_01_tools.h" #include "CommonFramework/Exceptions/OperationFailedException.h" #include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" -#include "CommonFramework/GlobalAutoPaths.h" +#include "Common/Cpp/PrettyPrint.h" +#include "Common/Cpp/Filesystem/Filesystem.h" #include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonTools/OCR/OCR_RawTesseractOCR.h" #include "CommonFramework/Tools/ErrorDumper.h" #include "CommonFramework/VideoPipeline/VideoFeed.h" #include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" @@ -25,2017 +35,2044 @@ #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "NintendoSwitch/NintendoSwitch_Settings.h" + #include "NintendoSwitch/Programs/DateManip/NintendoSwitch_DateManip.h" #include "NintendoSwitch/Programs/DateSpam/NintendoSwitch_HomeToDateTime.h" #include "NintendoSwitch/Programs/NintendoSwitch_GameEntry.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_EndBattleDetector.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_ExperienceGainDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" #include "PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_ReceivePokemonDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" #include "PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h" -#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_EndBattleDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_ReceivePokemonDetector.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_ExperienceGainDetector.h" + #include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" + +#include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" #include "PokemonBDSP/Programs/PokemonBDSP_BasicCatcher.h" #include "PokemonBDSP/Programs/PokemonBDSP_GameEntry.h" -#include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" - -#include "PokemonBDSP_Autostory_Segment_01_tools.h" - -#include "PokemonBDSP_AutoStory_Segment_00_tools.h" using namespace std::chrono_literals; namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - -// --------------------------------------------------------------------------- -// BattleDialogWatcher -// --------------------------------------------------------------------------- - -BattleDialogWatcher::BattleDialogWatcher(Color color) - : DetectorToFinder("BattleDialogWatcher", std::chrono::milliseconds(250), color){ -} - -// --------------------------------------------------------------------------- -// Poketech Clown dialog helper -// --------------------------------------------------------------------------- - -int detect_clown_choice_arrow(VideoOverlay& overlay, VideoSnapshot snapshot){ - SelectionArrowFinder top_arrow(overlay, {0.62, 0.37, 0.08, 0.10}, COLOR_RED); - - SelectionArrowFinder bottom_arrow(overlay, {0.62, 0.48, 0.08, 0.10}, COLOR_RED); - - if (top_arrow.detect(snapshot)){ - return 0; - } - - if (bottom_arrow.detect(snapshot)){ - return 1; - } - - return -1; -} + namespace NintendoSwitch { + namespace PokemonBDSP { + + // --------------------------------------------------------------------------- + // BattleDialogWatcher + // --------------------------------------------------------------------------- + + BattleDialogWatcher::BattleDialogWatcher(Color color) + : DetectorToFinder( + "BattleDialogWatcher", + std::chrono::milliseconds(250), + color + ) + { + } -void clown_dialog_helper(VideoStream& stream, ProControllerContext& context, DpadPosition direction){ - stream.log("[AutoStory] Starting clown dialog helper...", COLOR_BLUE); + // --------------------------------------------------------------------------- + // Poketech Clown dialog helper + // --------------------------------------------------------------------------- + + int detect_clown_choice_arrow( + VideoOverlay& overlay, + VideoSnapshot snapshot + ) { + SelectionArrowFinder top_arrow( + overlay, + { 0.62, 0.37, 0.08, 0.10 }, + COLOR_RED + ); + + SelectionArrowFinder bottom_arrow( + overlay, + { 0.62, 0.48, 0.08, 0.10 }, + COLOR_RED + ); + + if (top_arrow.detect(snapshot)) { + return 0; + } - const auto overall_deadline = std::chrono::steady_clock::now() + 30s; + if (bottom_arrow.detect(snapshot)) { + return 1; + } - // ------------------------------------------------- - // Phase 1: Move + interact until dialog is detected. - // ------------------------------------------------- - ShortDialogWatcher dialog(COLOR_YELLOW); - bool dialog_started = false; + return -1; + } - while (!dialog_started){ + void clown_dialog_helper( + VideoStream& stream, + ProControllerContext& context, + DpadPosition direction + ) { + stream.log( + "[AutoStory] Starting clown dialog helper...", + COLOR_BLUE + ); + + const auto overall_deadline = + std::chrono::steady_clock::now() + 30s; + + // ------------------------------------------------- + // Phase 1: Move + interact until dialog is detected. + // ------------------------------------------------- + ShortDialogWatcher dialog(COLOR_YELLOW); + bool dialog_started = false; + + while (!dialog_started) { + + if (std::chrono::steady_clock::now() > overall_deadline) { + stream.log( + "[AutoStory] Clown helper timed out finding dialog.", + COLOR_RED + ); + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "[AutoStory] Clown helper timed out finding dialog.", stream); + } - if (std::chrono::steady_clock::now() > overall_deadline){ - stream.log("[AutoStory] Clown helper timed out finding dialog.", COLOR_RED); - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "[AutoStory] Clown helper timed out finding dialog.", - stream); - } + context.wait_for_all_requests(); + pbf_press_dpad(context, direction, 80ms, 200ms); + pbf_press_button(context, BUTTON_A, 80ms, 500ms); - context.wait_for_all_requests(); - pbf_press_dpad(context, direction, 80ms, 200ms); - pbf_press_button(context, BUTTON_A, 80ms, 500ms); + if (wait_until(stream, context, 1500ms, { {dialog} }) == 0) { + dialog_started = true; + stream.log("[AutoStory] Dialog detected.", COLOR_GREEN); + } + } - if (wait_until(stream, context, 1500ms, {{dialog}}) == 0){ - dialog_started = true; - stream.log("[AutoStory] Dialog detected.", COLOR_GREEN); - } - } + // ------------------------------------------------- + // Phase 2: A mash for 2 seconds. + // ------------------------------------------------- + stream.log("[AutoStory] Mashing A to advance dialog...", COLOR_BLUE); + pbf_mash_button(context, BUTTON_A, 4000ms); + context.wait_for_all_requests(); - // ------------------------------------------------- - // Phase 2: A mash for 2 seconds. - // ------------------------------------------------- - stream.log("[AutoStory] Mashing A to advance dialog...", COLOR_BLUE); - pbf_mash_button(context, BUTTON_A, 4000ms); - context.wait_for_all_requests(); + // ------------------------------------------------- + // Phase 3: Clear remaining dialog. + // ------------------------------------------------- + pbf_mash_button(context, BUTTON_B, 4000ms); - // ------------------------------------------------- - // Phase 3: Clear remaining dialog. - // ------------------------------------------------- - pbf_mash_button(context, BUTTON_B, 4000ms); + context.wait_for_all_requests(); - context.wait_for_all_requests(); + stream.log( + "[AutoStory] Clown dialog helper complete.", + COLOR_GREEN + ); + } + // --------------------------------------------------------------------------- + // Time helper + // --------------------------------------------------------------------------- + + // Options chrome and position detectors are independent of setting values. + class TutorialOptionsDetector { + public: + bool detect(const ImageViewRGB32& screen) const { + ImageViewRGB32 panel = extract_box_reference(screen, ImageFloatBox{0.04, 0.065, 0.93, 0.70}); + size_t white = 0; + for (size_t y = 0; y < panel.height(); y += 4) + for (size_t x = 0; x < panel.width(); x += 4) { + uint32_t p = panel.pixel(x, y); + if (((p >> 16) & 255) > 225 && ((p >> 8) & 255) > 225 && (p & 255) > 225) white++; + } + double samples = (double)(((panel.width() + 3) / 4) * ((panel.height() + 3) / 4)); + // Distinguish Options from the icon menu and bag using its + // blue heading and full-width white settings panel. + ImageViewRGB32 heading = extract_box_reference(screen, ImageFloatBox{0.01, 0.005, 0.20, 0.025}); + size_t blue = 0, total = 0; + for (size_t y = 0; y < heading.height(); y++) + for (size_t x = 0; x < heading.width(); x++) { + uint32_t p = heading.pixel(x, y); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + blue += b > r + 20 && g > r + 10; + total++; + } + return samples > 0 && white / samples > 0.70 && total > 0 && blue > total / 4; + } + }; - stream.log("[AutoStory] Clown dialog helper complete.", COLOR_GREEN); -} -// --------------------------------------------------------------------------- -// Time helper -// --------------------------------------------------------------------------- - -// Options chrome and position detectors are independent of setting values. -class TutorialOptionsDetector { -public: - bool detect(const ImageViewRGB32& screen) const { - ImageViewRGB32 panel = extract_box_reference(screen, ImageFloatBox{0.04, 0.065, 0.93, 0.70}); - size_t white = 0; - for (size_t y = 0; y < panel.height(); y += 4){ - for (size_t x = 0; x < panel.width(); x += 4){ - uint32_t p = panel.pixel(x, y); - if (((p >> 16) & 255) > 225 && ((p >> 8) & 255) > 225 && (p & 255) > 225){ - white++; + // Options fades in after A on its icon: poll instead of judging one frame. + static bool wait_for_options_screen(VideoStream& stream, ProControllerContext& context, + const TutorialOptionsDetector& options_screen, Milliseconds timeout) { + context.wait_for_all_requests(); + const WallClock deadline = current_time() + timeout; + while (current_time() < deadline) { + auto frame = stream.video().snapshot(); + if (frame && options_screen.detect(frame)) return true; + context.wait_for(100ms); } + return false; } - } - double samples = (double)(((panel.width() + 3) / 4) * ((panel.height() + 3) / 4)); - // Distinguish Options from the icon menu and bag using its - // blue heading and full-width white settings panel. - ImageViewRGB32 heading = extract_box_reference(screen, ImageFloatBox{0.01, 0.005, 0.20, 0.025}); - size_t blue = 0, total = 0; - for (size_t y = 0; y < heading.height(); y++){ - for (size_t x = 0; x < heading.width(); x++){ - uint32_t p = heading.pixel(x, y); - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - blue += b > r + 20 && g > r + 10; - total++; - } - } - return samples > 0 && white / samples > 0.70 && total > 0 && blue > total / 4; - } -}; - -class TutorialOptionPositionDetector { -public: - // Locate the two long red horizontal lines surrounding the - // selected row. Scrolling changes their position automatically. - double selected_row(const ImageViewRGB32& screen) const { - int first = -1, last = -1; - for (size_t y = (size_t)(screen.height() * 0.08); y < screen.height() * 0.75; y++){ - size_t red = 0, total = 0; - for (size_t x = (size_t)(screen.width() * 0.08); x < screen.width() * 0.90; x += 2){ - uint32_t p = screen.pixel(x, y); - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - red += r > 150 && r > g + 40 && r > b + 40; - total++; - } - if (total && red > total * 0.70){ - if (first < 0){ - first = (int)y; + + class TutorialOptionPositionDetector { + public: + // Locate the two long red horizontal lines surrounding the + // selected row. Scrolling changes their position automatically. + double selected_row(const ImageViewRGB32& screen) const { + int first = -1, last = -1; + for (size_t y = (size_t)(screen.height() * 0.08); y < screen.height() * 0.75; y++) { + size_t red = 0, total = 0; + for (size_t x = (size_t)(screen.width() * 0.08); x < screen.width() * 0.90; x += 2) { + uint32_t p = screen.pixel(x, y); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + red += r > 150 && r > g + 40 && r > b + 40; + total++; + } + if (total && red > total * 0.70) { + if (first < 0) first = (int)y; + last = (int)y; + } + } + if (first < 0 || last - first < screen.height() * 0.04 || last - first > screen.height() * 0.12) return -1; + return (first + last) / (2.0 * screen.height()); } - last = (int)y; - } - } - if (first < 0 || last - first < screen.height() * 0.04 || last - first > screen.height() * 0.12){ - return -1; - } - return (first + last) / (2.0 * screen.height()); - } - // Locate selected red value text separately from the row border. - bool selected_value(const ImageViewRGB32& screen, double y) const { - ImageViewRGB32 value = extract_box_reference(screen, ImageFloatBox{0.33, y - 0.025, 0.54, 0.05}); - size_t red = 0; - for (size_t py = 0; py < value.height(); py++){ - for (size_t px = 0; px < value.width(); px++){ - uint32_t p = value.pixel(px, py); - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - red += r > g + 20 && r > b + 20; - } - } - return red > value.width() * value.height() * 0.0005; - } -}; - - -// Compare binary visual shapes with small alignment tolerance. -// No text recognition is needed for fixed English option labels. -static double -option_image_score(const ImageViewRGB32& screen, const ImageFloatBox& box, const ImageRGB32& reference, bool red_only){ - ImageRGB32 actual = extract_box_reference(screen, box).scale_to(384, 32); - ImageRGB32 expected = reference.scale_to(384, 32); - auto ink = [red_only](uint32_t p){ - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - return red_only ? r > g + 20 && r > b + 20 : r + g + b < 500; - }; - double best = 0; - for (int dy = -2; dy <= 2; dy++){ - for (int dx = -2; dx <= 2; dx++){ - size_t overlap = 0, count = 0; - for (int y = 2; y < 30; y++){ - for (int x = 2; x < 382; x++){ - bool a = ink(actual.pixel(x + dx, y + dy)); - bool b = ink(expected.pixel(x, y)); - overlap += a && b; - count += a + b; + // Locate selected red value text separately from the row border. + bool selected_value(const ImageViewRGB32& screen, double y) const { + ImageViewRGB32 value = extract_box_reference(screen, ImageFloatBox{0.33, y - 0.025, 0.54, 0.05}); + size_t red = 0; + for (size_t py = 0; py < value.height(); py++) + for (size_t px = 0; px < value.width(); px++) { + uint32_t p = value.pixel(px, py); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + red += r > g + 20 && r > b + 20; + } + return red > value.width() * value.height() * 0.0005; } + }; + + + // Compare binary visual shapes with small alignment tolerance. + // No text recognition is needed for fixed English option labels. + static double option_image_score(const ImageViewRGB32& screen, + const ImageFloatBox& box, const ImageRGB32& reference, bool red_only) { + ImageRGB32 actual = extract_box_reference(screen, box).scale_to(384, 32); + ImageRGB32 expected = reference.scale_to(384, 32); + auto ink = [red_only](uint32_t p){ + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + return red_only ? r > g + 20 && r > b + 20 : r + g + b < 500; + }; + double best = 0; + for (int dy = -2; dy <= 2; dy++) for (int dx = -2; dx <= 2; dx++) { + size_t overlap = 0, count = 0; + for (int y = 2; y < 30; y++) for (int x = 2; x < 382; x++) { + bool a = ink(actual.pixel(x + dx, y + dy)); + bool b = ink(expected.pixel(x, y)); + overlap += a && b; + count += a + b; + } + if (count) best = std::max(best, 2.0 * overlap / count); + } + return best; } - if (count){ - best = std::max(best, 2.0 * overlap / count); - } - } - } - return best; -} -static bool autosave_off_highlight(const ImageViewRGB32& screen, double y){ - auto red_count = [&](const ImageFloatBox& box){ - ImageViewRGB32 image = extract_box_reference(screen, box); - size_t red = 0; - for (size_t py = 0; py < image.height(); py++){ - for (size_t px = 0; px < image.width(); px++){ - uint32_t p = image.pixel(px, py); - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - red += r > g + 30 && r > b + 30; + static bool autosave_off_highlight(const ImageViewRGB32& screen, double y) { + auto red_count = [&](const ImageFloatBox& box){ + ImageViewRGB32 image = extract_box_reference(screen, box); + size_t red = 0; + for (size_t py = 0; py < image.height(); py++) + for (size_t px = 0; px < image.width(); px++) { + uint32_t p = image.pixel(px, py); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + red += r > g + 30 && r > b + 30; + } + return red; + }; + size_t off = red_count({0.63, y - 0.022, 0.055, 0.044}); + size_t on = red_count({0.34, y - 0.022, 0.055, 0.044}); + // Require a clear Off highlight and reject any competing On highlight. + return off >= 10 && on * 10 < off; } - } - return red; - }; - size_t off = red_count({0.63, y - 0.022, 0.055, 0.044}); - size_t on = red_count({0.34, y - 0.022, 0.055, 0.044}); - // Require a clear Off highlight and reject any competing On highlight. - return off >= 10 && on * 10 < off; -} -static void verify_tutorial_settings_once(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - MenuDetector menu; - VideoOverlaySet detector_overlays(stream.overlay()); - menu.make_overlays(detector_overlays); - detector_overlays.add(COLOR_CYAN, {0.04, 0.065, 0.93, 0.70}, ""); - detector_overlays.add(COLOR_BLUE, {0.01, 0.005, 0.20, 0.025}, ""); - detector_overlays.add(COLOR_RED, {0.08, 0.08, 0.82, 0.67}, ""); - auto initial = stream.video().snapshot(); - if (!initial || !menu.detect(initial)){ - pbf_press_button(context, BUTTON_X, 80ms, 1000ms); - context.wait_for_all_requests(); - } - MenuWatcher menu_open; - if (wait_until(stream, context, 5s, {{menu_open}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Main menu not detected.", - stream); - } - stream.log("[Tutorial settings] Main menu detected; settling for 500ms before Options navigation."); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - TutorialOptionsDetector options_screen; - TutorialOptionPositionDetector position; - // Only two icons are available at this initial checkpoint. - // Verify the main menu before navigating to the neighboring icon. - bool options_open = false; - for (size_t attempt = 0; attempt < 2; attempt++){ - pbf_press_dpad(context, attempt == 0 ? DPAD_RIGHT : DPAD_LEFT, 80ms, 350ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - context.wait_for_all_requests(); - auto frame = stream.video().snapshot(); - if (frame && options_screen.detect(frame)){ - options_open = true; - break; - } - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - context.wait_for_all_requests(); - MenuWatcher back_to_menu; - if (wait_until(stream, context, 5s, {{back_to_menu}}) != 0){ - break; - } - } - if (!options_open){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Options screen not detected.", - stream); - } - stream.log("[Tutorial settings] Options screen detected."); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - struct Setting { - const char* label; - const char* value; - }; - const Setting settings[] = { - {"textspeed", "fast"}, - {"changewindow", "type1"}, - {"battleeffects", "off"}, - {"battlestyle", "set"}, - {"sendtoboxes", "automatic"}, - {"givenicknames", "dontgive"}, - {"gyroscope", "use"}, - {"autosave", "off"}, - {"shownicknames", "show"}, - {"backgroundmusic", "8"}, - {"soundeffects", "8"}, - }; - for (size_t row = 0; row < sizeof(settings) / sizeof(settings[0]); row++){ - // Detect the selected row afresh after every navigation/correction. - double y = -1; - const std::string base = RESOURCE_PATH() + "PokemonBDSP/Autostory/Options/" + settings[row].label; - const ImageRGB32 label_reference(base + "_label.png"); - const ImageRGB32 value_reference(base + "_value.png"); - bool slider_reset = false; - bool correct = false; - for (size_t attempt = 0; attempt < 30; attempt++){ - context.wait_for_all_requests(); - auto frame = stream.video().snapshot(); - if (frame && options_screen.detect(frame)){ - y = position.selected_row(frame); - }else{ - y = -1; - } - const bool value_visible = y >= 0 && position.selected_value(frame, y); - const double label_score = - y >= 0 ? option_image_score(frame, {0.08, y - 0.025, 0.24, 0.05}, label_reference, false) : 0; - stream.log("[Tutorial settings] Expected row=" + std::string(settings[row].label) + - " row_y=" + std::to_string(y) + " highlighted_value=" + std::to_string(value_visible) + - " label_match=" + std::to_string(label_score)); - if (y < 0 || !value_visible || label_score < 0.80){ - // A menu can be detected while its row/text is still - // fading in. Re-read without directional inputs first. - if (attempt < 4){ - pbf_wait(context, 200ms); + static void verify_tutorial_settings_once(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); + MenuDetector menu; + VideoOverlaySet detector_overlays(stream.overlay()); + menu.make_overlays(detector_overlays); + detector_overlays.add(COLOR_CYAN, {0.04, 0.065, 0.93, 0.70}, ""); + detector_overlays.add(COLOR_BLUE, {0.01, 0.005, 0.20, 0.025}, ""); + detector_overlays.add(COLOR_RED, {0.08, 0.08, 0.82, 0.67}, ""); + auto initial = stream.video().snapshot(); + if (!initial || !menu.detect(initial)) { + pbf_press_button(context, BUTTON_X, 80ms, 1000ms); context.wait_for_all_requests(); - continue; } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Unable to confirm expected row " + std::string(settings[row].label), - stream); - } - // These are regular video-overlay boxes, so the existing - // overlay display control can show/hide every active ROI. - OverlayBoxScope row_overlay(stream.overlay(), COLOR_RED, {0.07, y - 0.04, 0.84, 0.08}, ""); - OverlayBoxScope label_overlay(stream.overlay(), COLOR_YELLOW, {0.08, y - 0.025, 0.24, 0.05}, ""); - OverlayBoxScope value_overlay(stream.overlay(), COLOR_GREEN, {0.33, y - 0.025, 0.54, 0.05}, ""); - VideoOverlaySet autosave_rois(stream.overlay()); - if (row == 7){ - autosave_rois.add(COLOR_GREEN, {0.63, y - 0.022, 0.055, 0.044}); - autosave_rois.add(COLOR_RED, {0.34, y - 0.022, 0.055, 0.044}); - } - double score; - if (row >= 9){ - // Compare the numeric value rather than the long slider: - // neighboring slider levels otherwise look too similar. - ImageViewRGB32 number = value_reference.sub_image(value_reference.width() * 82 / 100, - 0, - value_reference.width() - value_reference.width() * 82 / 100, - value_reference.height()); - ImageRGB32 number_reference = number.scale_to(96, 32); - score = option_image_score(frame, {0.7728, y - 0.025, 0.0972, 0.05}, number_reference, true); - }else{ - score = option_image_score(frame, {0.33, y - 0.025, 0.54, 0.05}, value_reference, true); - } - stream.log( - "[Tutorial settings] " + std::string(settings[row].label) + " target match=" + std::to_string(score)); - // The reference recording uses inactive pale-red text; - // the focused live row has a thicker dark-red glyph mask. - // Verified Fast scores 0.734 while Slow/Normal score 0. - if (score >= 0.65 && (row != 7 || autosave_off_highlight(frame, y))){ - if (row == 7){ - for (size_t confirmation = 0; confirmation < 3; confirmation++){ - pbf_wait(context, 200ms); + MenuWatcher menu_open; + if (wait_until(stream, context, 5s, {{menu_open}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Settings checkpoint: Main menu not detected.", stream); + } + stream.log("[Tutorial settings] Main menu detected; settling for 500ms before Options navigation."); + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + TutorialOptionsDetector options_screen; + TutorialOptionPositionDetector position; + // Only two icons are available at this initial checkpoint. + // Verify the main menu before navigating to the neighboring icon. + bool options_open = false; + for (size_t attempt = 0; attempt < 2; attempt++) { + pbf_press_dpad(context, attempt == 0 ? DPAD_RIGHT : DPAD_LEFT, 80ms, 350ms); + pbf_press_button(context, BUTTON_A, 80ms, 0ms); + if (wait_for_options_screen(stream, context, options_screen, 5s)) { + options_open = true; + break; + } + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + context.wait_for_all_requests(); + MenuWatcher back_to_menu; + if (wait_until(stream, context, 5s, {{back_to_menu}}) != 0) break; + } + if (!options_open) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Settings checkpoint: Options screen not detected.", stream); + } + stream.log("[Tutorial settings] Options screen detected."); + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + struct Setting { const char* label; const char* value; }; + const Setting settings[] = { + {"textspeed", "fast"}, {"changewindow", "type1"}, + {"battleeffects", "off"}, {"battlestyle", "set"}, + {"sendtoboxes", "automatic"}, {"givenicknames", "dontgive"}, + {"gyroscope", "use"}, {"autosave", "off"}, + {"shownicknames", "show"}, {"backgroundmusic", "8"}, + {"soundeffects", "8"}, + }; + for (size_t row = 0; row < sizeof(settings) / sizeof(settings[0]); row++) { + // Detect the selected row afresh after every navigation/correction. + double y = -1; + const std::string base = RESOURCE_PATH() + "PokemonBDSP/Autostory/Options/" + settings[row].label; + const ImageRGB32 label_reference(base + "_label.png"); + const ImageRGB32 value_reference(base + "_value.png"); + bool slider_reset = false; + bool correct = false; + for (size_t attempt = 0; attempt < 30; attempt++) { context.wait_for_all_requests(); - auto check = stream.video().snapshot(); - double check_y = check ? position.selected_row(check) : -1; - if (!check || !options_screen.detect(check) || check_y < 0 || - option_image_score(check, {0.08, check_y - 0.025, 0.24, 0.05}, label_reference, false) < - 0.80 || - !autosave_off_highlight(check, check_y)){ + auto frame = stream.video().snapshot(); + if (frame && options_screen.detect(frame)) y = position.selected_row(frame); + else y = -1; + const bool value_visible = y >= 0 && position.selected_value(frame, y); + const double label_score = y >= 0 ? option_image_score(frame, + {0.08, y - 0.025, 0.24, 0.05}, label_reference, false) : 0; + stream.log("[Tutorial settings] Expected row=" + std::string(settings[row].label) + + " row_y=" + std::to_string(y) + " highlighted_value=" + std::to_string(value_visible) + + " label_match=" + std::to_string(label_score)); + if (y < 0 || !value_visible || label_score < 0.80) { + // A menu can be detected while its row/text is still + // fading in. Re-read without directional inputs first. + if (attempt < 4) { + pbf_wait(context, 200ms); + context.wait_for_all_requests(); + continue; + } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Autosave Off could not be confirmed. Stopping before movement.", - stream); + "Settings checkpoint: Unable to confirm expected row " + std::string(settings[row].label), stream); + } + // These are regular video-overlay boxes, so the existing + // overlay display control can show/hide every active ROI. + OverlayBoxScope row_overlay(stream.overlay(), COLOR_RED, + {0.07, y - 0.04, 0.84, 0.08}, ""); + OverlayBoxScope label_overlay(stream.overlay(), COLOR_YELLOW, + {0.08, y - 0.025, 0.24, 0.05}, ""); + OverlayBoxScope value_overlay(stream.overlay(), COLOR_GREEN, + {0.33, y - 0.025, 0.54, 0.05}, ""); + VideoOverlaySet autosave_rois(stream.overlay()); + if (row == 7) { + autosave_rois.add(COLOR_GREEN, {0.63, y - 0.022, 0.055, 0.044}); + autosave_rois.add(COLOR_RED, {0.34, y - 0.022, 0.055, 0.044}); + } + double score; + if (row >= 9) { + // Compare the numeric value rather than the long slider: + // neighboring slider levels otherwise look too similar. + ImageViewRGB32 number = value_reference.sub_image( + value_reference.width() * 82 / 100, 0, + value_reference.width() - value_reference.width() * 82 / 100, + value_reference.height()); + ImageRGB32 number_reference = number.scale_to(96, 32); + score = option_image_score(frame, + {0.7728, y - 0.025, 0.0972, 0.05}, number_reference, true); + } else { + score = option_image_score(frame, + {0.33, y - 0.025, 0.54, 0.05}, value_reference, true); } + stream.log("[Tutorial settings] " + std::string(settings[row].label) + + " target match=" + std::to_string(score)); + // The reference recording uses inactive pale-red text; + // the focused live row has a thicker dark-red glyph mask. + // Verified Fast scores 0.734 while Slow/Normal score 0. + if (score >= 0.65 && (row != 7 || autosave_off_highlight(frame, y))) { + if (row == 7) { + for (size_t confirmation = 0; confirmation < 3; confirmation++) { + pbf_wait(context, 200ms); + context.wait_for_all_requests(); + auto check = stream.video().snapshot(); + double check_y = check ? position.selected_row(check) : -1; + if (!check || !options_screen.detect(check) || check_y < 0 || + option_image_score(check, {0.08, check_y - 0.025, 0.24, 0.05}, label_reference, false) < 0.80 || + !autosave_off_highlight(check, check_y)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Autosave Off could not be confirmed. Stopping before movement.", stream); + } + } + } + stream.log("[Tutorial settings] Verified " + std::string(settings[row].label) + "=" + settings[row].value); + correct = true; + break; + } + stream.log("[Tutorial settings] Correcting " + std::string(settings[row].label)); + if (row >= 9 && !slider_reset) { + // Sliders clamp at zero. Establish a known endpoint, + // then increment; verify the target image afterward. + for (size_t step = 0; step < 12; step++) + pbf_press_dpad(context, DPAD_LEFT, 80ms, 100ms); + slider_reset = true; + } else { + pbf_press_dpad(context, DPAD_RIGHT, 80ms, 350ms); + } + context.wait_for_all_requests(); + } + if (!correct) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Settings checkpoint: Could not apply " + std::string(settings[row].label), stream); } + if (row + 1 < sizeof(settings) / sizeof(settings[0])) + pbf_press_dpad(context, DPAD_DOWN, 80ms, 350ms); } - stream.log( - "[Tutorial settings] Verified " + std::string(settings[row].label) + "=" + settings[row].value); - correct = true; - break; - } - stream.log("[Tutorial settings] Correcting " + std::string(settings[row].label)); - if (row >= 9 && !slider_reset){ - // Sliders clamp at zero. Establish a known endpoint, - // then increment; verify the target image afterward. - for (size_t step = 0; step < 12; step++){ - pbf_press_dpad(context, DPAD_LEFT, 80ms, 100ms); + // Confirm settings before exiting: A A A A B B. + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1200ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + context.wait_for_all_requests(); + OverworldWatcher overworld; + if (wait_until(stream, context, 10s, {{overworld}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Settings checkpoint: Did not return to the overworld after saving options.", stream); } - slider_reset = true; - }else{ - pbf_press_dpad(context, DPAD_RIGHT, 80ms, 350ms); - } - context.wait_for_all_requests(); - } - if (!correct){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Could not apply " + std::string(settings[row].label), - stream); - } - if (row + 1 < sizeof(settings) / sizeof(settings[0])){ - pbf_press_dpad(context, DPAD_DOWN, 80ms, 350ms); - } - } - // Confirm settings before exiting: A A A A B B. - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1200ms); - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - context.wait_for_all_requests(); - OverworldWatcher overworld; - if (wait_until(stream, context, 10s, {{overworld}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Did not return to the overworld after saving options.", - stream); - } - // Reopen the saved Options screen and independently verify Autosave. - // No route movement is permitted until this persisted-value check passes. - pbf_press_button(context, BUTTON_X, 80ms, 1000ms); - context.wait_for_all_requests(); - MenuWatcher verify_menu; - if (wait_until(stream, context, 5s, {{verify_menu}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Autosave verification: Main menu not detected.", - stream); - } - bool reopened = false; - for (size_t attempt = 0; attempt < 2; attempt++){ - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - context.wait_for_all_requests(); - auto check = stream.video().snapshot(); - if (check && options_screen.detect(check)){ - reopened = true; - break; - } - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - context.wait_for_all_requests(); - MenuWatcher return_menu; - if (wait_until(stream, context, 5s, {{return_menu}}) != 0){ - break; - } - pbf_press_dpad(context, DPAD_RIGHT, 80ms, 350ms); - } - if (!reopened){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Autosave verification: Could not reopen saved Options.", - stream); - } - // Options starts on Text Speed; Autosave is seven rows below it. - for (size_t step = 0; step < 7; step++){ - pbf_press_dpad(context, DPAD_DOWN, 80ms, 350ms); - } - context.wait_for_all_requests(); - const ImageRGB32 autosave_label(RESOURCE_PATH() + "PokemonBDSP/Autostory/Options/autosave_label.png"); - for (size_t confirmation = 0; confirmation < 3; confirmation++){ - auto check = stream.video().snapshot(); - double check_y = check ? position.selected_row(check) : -1; - if (!check || !options_screen.detect(check) || check_y < 0 || - option_image_score(check, {0.08, check_y - 0.025, 0.24, 0.05}, autosave_label, false) < 0.80 || - !autosave_off_highlight(check, check_y)){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Saved Autosave setting is not verified Off. Stopping before movement.", - stream); - } - pbf_wait(context, 200ms); - context.wait_for_all_requests(); - } - stream.log("[Tutorial settings] Autosave = Off: confirmed after reopening saved Options (3 frames)."); - // Confirm settings before exiting: A A A A B B. - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1200ms); - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - context.wait_for_all_requests(); - OverworldWatcher verified_exit; - if (wait_until(stream, context, 10s, {{verified_exit}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Autosave verification: Could not return to the overworld.", - stream); - } - // Report inferred values explicitly: these are visual matches, - // allowing the user to compare our conclusions with the screen. - stream.log("[Tutorial settings] Completed. Values believed applied (visual matches before saving):"); - stream.log( - "[Tutorial settings] Text Speed = Fast; Change Window = Type 1; Battle Effects = Off; Battle Style = Set."); - stream.log("[Tutorial settings] Send to Boxes = Automatic; Give Nicknames = Don't give; Gyroscope = Use."); - stream.log("[Tutorial settings] Autosave = Off; Show Nicknames = Show; Background Music = 8; Sound Effects = 8."); -} - - -void verify_tutorial_settings(VideoStream& stream, ProControllerContext& context){ - // Settings-only recovery: initial attempt plus three retries. - // Never permit route movement unless the complete check succeeds. - constexpr size_t MAX_RETRIES = 3; - for (size_t attempt = 0; attempt <= MAX_RETRIES; attempt++){ - stream.log("[Tutorial settings] Attempt " + std::to_string(attempt + 1) + "/4."); - try { - verify_tutorial_settings_once(stream, context); - return; - } catch (OperationFailedException& error){ - stream.log("[Tutorial settings] Attempt failed: " + error.message(), COLOR_ORANGE); - if (attempt == MAX_RETRIES){ - stream.log("[Tutorial settings] All three retries exhausted; stopping before movement.", COLOR_RED); - throw; - } - // Back out of Options, save prompts, bag, or menu without - // moving the player or resetting/loading the game. - stream.log("[Tutorial settings] Returning to overworld before retry."); - context.wait_for_all_requests(); - TutorialOptionsDetector options_screen; - const auto failed_frame = stream.video().snapshot(); - if (failed_frame && options_screen.detect(failed_frame)){ - // Finish the known Options save/exit sequence before - // observing state; never cancel it on an Options frame - // misclassified as overworld by the generic detector. - for (size_t press = 0; press < 4; press++){ - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + // Reopen the saved Options screen and independently verify Autosave. + // No route movement is permitted until this persisted-value check passes. + pbf_press_button(context, BUTTON_X, 80ms, 1000ms); + context.wait_for_all_requests(); + MenuWatcher verify_menu; + if (wait_until(stream, context, 5s, {{verify_menu}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Autosave verification: Main menu not detected.", stream); } - } - pbf_mash_button(context, BUTTON_B, 3000ms); - context.wait_for_all_requests(); - OverworldDetector overworld; - MenuDetector menu; - BlackScreenDetector black; - bool recovered = false; - size_t confirmations = 0; - for (size_t check = 0; check < 15; check++){ - const auto frame = stream.video().snapshot(); - const bool clear = frame && !options_screen.detect(frame) && !menu.detect(frame) && - !black.detect(frame) && bag_pocket_position(frame) == 0 && overworld.detect(frame); - if (clear){ - if (++confirmations >= 3){ - recovered = true; - break; + bool reopened = false; + for (size_t attempt = 0; attempt < 2; attempt++) { + pbf_press_button(context, BUTTON_A, 80ms, 0ms); + if (wait_for_options_screen(stream, context, options_screen, 5s)) { reopened = true; break; } + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + context.wait_for_all_requests(); + MenuWatcher return_menu; + if (wait_until(stream, context, 5s, {{return_menu}}) != 0) break; + pbf_press_dpad(context, DPAD_RIGHT, 80ms, 350ms); + } + if (!reopened) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Autosave verification: Could not reopen saved Options.", stream); + } + // Options starts on Text Speed; Autosave is seven rows below it. + for (size_t step = 0; step < 7; step++) + pbf_press_dpad(context, DPAD_DOWN, 80ms, 350ms); + context.wait_for_all_requests(); + const ImageRGB32 autosave_label(RESOURCE_PATH() + "PokemonBDSP/Autostory/Options/autosave_label.png"); + for (size_t confirmation = 0; confirmation < 3; confirmation++) { + auto check = stream.video().snapshot(); + double check_y = check ? position.selected_row(check) : -1; + if (!check || !options_screen.detect(check) || check_y < 0 || + option_image_score(check, {0.08, check_y - 0.025, 0.24, 0.05}, autosave_label, false) < 0.80 || + !autosave_off_highlight(check, check_y)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Saved Autosave setting is not verified Off. Stopping before movement.", stream); } pbf_wait(context, 200ms); - }else{ - confirmations = 0; - pbf_mash_button(context, BUTTON_B, 500ms); + context.wait_for_all_requests(); } + stream.log("[Tutorial settings] Autosave = Off: confirmed after reopening saved Options (3 frames)."); + // Confirm settings before exiting: A A A A B B. + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1200ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); context.wait_for_all_requests(); + OverworldWatcher verified_exit; + if (wait_until(stream, context, 10s, {{verified_exit}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Autosave verification: Could not return to the overworld.", stream); + } + // Report inferred values explicitly: these are visual matches, + // allowing the user to compare our conclusions with the screen. + stream.log("[Tutorial settings] Completed. Values believed applied (visual matches before saving):"); + stream.log("[Tutorial settings] Text Speed = Fast; Change Window = Type 1; Battle Effects = Off; Battle Style = Set."); + stream.log("[Tutorial settings] Send to Boxes = Automatic; Give Nicknames = Don't give; Gyroscope = Use."); + stream.log("[Tutorial settings] Autosave = Off; Show Nicknames = Show; Background Music = 8; Sound Effects = 8."); } - stream.log(recovered ? "[Tutorial settings] Recovery confirmed outside Options/menu; retrying." - : "[Tutorial settings] Recovery not confirmed; next attempt will recheck the screen."); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - } - } -} - -void set_time_fast(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - const DateTime& target, - bool preserve_date){ - env.log("[AutoStory] Fast date set starting...", COLOR_ORANGE); - - // 1) Home - NintendoSwitch::go_home(env.console, context); - - // 2) Navigate to Date/Time settings quickly. - NintendoSwitch::home_to_date_time(env.console, context, true); - - // 3) Enter date editor. - pbf_press_button(context, BUTTON_A, 80ms, 240ms); - // 4) Wait for date screen and set exact time. - NintendoSwitch::DateChangeWatcher date_reader(env.console); - int ret = wait_until(env.console, context, 10s, {date_reader}); - if (ret < 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Failed to enter Date Change screen.", - env.console); - } - { - VideoOverlaySet overlays(env.console.overlay()); - date_reader.make_overlays(overlays); - DateTime effective_target = target; - if (preserve_date){ - context.wait_for_all_requests(); - const auto snapshot = env.console.video().snapshot(); - if (!snapshot){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Clock-only setup: No date-editor frame.", - env.console); - } - const DateTime current = date_reader.read_date(env.console, snapshot.frame).second; - if (current.year < 2000 || current.year > 2060 || current.month < 1 || current.month > 12 || - current.day < 1 || current.day > 31){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Clock-only setup: Could not reliably read the current console date.", - env.console); + void verify_tutorial_settings(VideoStream& stream, ProControllerContext& context) { + // Settings-only recovery: initial attempt plus three retries. + // Never permit route movement unless the complete check succeeds. + constexpr size_t MAX_RETRIES = 3; + for (size_t attempt = 0; attempt <= MAX_RETRIES; attempt++) { + stream.log("[Tutorial settings] Attempt " + std::to_string(attempt + 1) + "/4."); + try { + verify_tutorial_settings_once(stream, context); + return; + } catch (OperationFailedException& error) { + stream.log("[Tutorial settings] Attempt failed: " + error.message(), COLOR_ORANGE); + if (attempt == MAX_RETRIES) { + stream.log("[Tutorial settings] All three retries exhausted; stopping before movement.", COLOR_RED); + throw; + } + // Back out of Options, save prompts, bag, or menu without + // moving the player or resetting/loading the game. + stream.log("[Tutorial settings] Returning to overworld before retry."); + context.wait_for_all_requests(); + TutorialOptionsDetector options_screen; + const auto failed_frame = stream.video().snapshot(); + if (failed_frame && options_screen.detect(failed_frame)) { + // Finish the known Options save/exit sequence before + // observing state; never cancel it on an Options frame + // misclassified as overworld by the generic detector. + for (size_t press = 0; press < 4; press++) + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + } + pbf_mash_button(context, BUTTON_B, 3000ms); + context.wait_for_all_requests(); + OverworldDetector overworld; + MenuDetector menu; + BlackScreenDetector black; + bool recovered = false; + size_t confirmations = 0; + for (size_t check = 0; check < 15; check++) { + const auto frame = stream.video().snapshot(); + const bool clear = frame && !options_screen.detect(frame) && + !menu.detect(frame) && !black.detect(frame) && bag_pocket_position(frame) == 0 && overworld.detect(frame); + if (clear) { + if (++confirmations >= 3) { recovered = true; break; } + pbf_wait(context, 200ms); + } else { + confirmations = 0; + pbf_mash_button(context, BUTTON_B, 500ms); + } + context.wait_for_all_requests(); + } + stream.log(recovered ? "[Tutorial settings] Recovery confirmed outside Options/menu; retrying." + : "[Tutorial settings] Recovery not confirmed; next attempt will recheck the screen."); + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + } + } } - effective_target.year = current.year; - effective_target.month = current.month; - effective_target.day = current.day; - env.log("[AutoStory] Clock-only target=" + std::to_string((int)target.hour) + ":" + - std::to_string((int)target.minute) + "; preserving date=" + std::to_string(current.year) + "-" + - std::to_string((int)current.month) + "-" + std::to_string((int)current.day) + "."); - } - date_reader.set_date(env.program_info(), env.console, context, effective_target); - } - // 5) Commit date. - pbf_press_button(context, BUTTON_A, 160ms, 240ms); + void set_time_fast( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const DateTime& target, + bool preserve_date + ) { + env.log("[AutoStory] Fast date set starting...", COLOR_ORANGE); + + // 1) Home + NintendoSwitch::go_home(env.console, context); + + // 2) Navigate to Date/Time settings quickly. + NintendoSwitch::home_to_date_time(env.console, context, true); + + // 3) Enter date editor. + pbf_press_button(context, BUTTON_A, 80ms, 240ms); + + // 4) Wait for date screen and set exact time. + NintendoSwitch::DateChangeWatcher date_reader(env.console); + int ret = wait_until( + env.console, context, 10s, + { date_reader } + ); + if (ret < 0) { + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "Failed to enter Date Change screen.", + env.console + ); + } - // 6) Back to game. - pbf_press_button(context, BUTTON_HOME, 160ms, ConsoleSettings::instance().SETTINGS_TO_HOME_DELAY0); - NintendoSwitch::resume_game_from_home(env.console, context, false); + { + VideoOverlaySet overlays(env.console.overlay()); + date_reader.make_overlays(overlays); + DateTime effective_target = target; + if (preserve_date) { + context.wait_for_all_requests(); + const auto snapshot = env.console.video().snapshot(); + if (!snapshot) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Clock-only setup: No date-editor frame.", env.console); + const DateTime current = date_reader.read_date(env.console, snapshot.frame).second; + if (current.year < 2000 || current.year > 2060 || current.month < 1 || current.month > 12 || + current.day < 1 || current.day > 31) + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Clock-only setup: Could not reliably read the current console date.", env.console); + effective_target.year = current.year; + effective_target.month = current.month; + effective_target.day = current.day; + env.log("[AutoStory] Clock-only target=" + std::to_string((int)target.hour) + ":" + + std::to_string((int)target.minute) + "; preserving date=" + std::to_string(current.year) + "-" + + std::to_string((int)current.month) + "-" + std::to_string((int)current.day) + "."); + } + date_reader.set_date(env.program_info(), env.console, context, effective_target); + } - context.wait_for_all_requests(); - context.wait_for(500ms); -} + // 5) Commit date. + pbf_press_button(context, BUTTON_A, 160ms, 240ms); + // 6) Back to game. + pbf_press_button( + context, BUTTON_HOME, 160ms, + ConsoleSettings::instance().SETTINGS_TO_HOME_DELAY0 + ); + NintendoSwitch::resume_game_from_home(env.console, context, false); -// --------------------------------------------------------------------------- -// Natalie battle -// --------------------------------------------------------------------------- -static void select_battle_move_slot(VideoStream& stream, - ProControllerContext& context, - BattleType battle_type, - size_t target, - const std::string& label, - bool confirm_default_target = false, - size_t current_move = 1){ - BattleMenuDetector commands(battle_type); - VideoOverlaySet overlays(stream.overlay()); - commands.make_overlays(overlays); - overlays.add(COLOR_GREEN, {0.90, 0.48, 0.075, 0.505}); - // Reenter Fight from an already-open list without assuming - // that this resets the remembered move cursor. - if (confirm_default_target){ - context.wait_for_all_requests(); - const auto frame = stream.video().snapshot(); - if (frame && detect_battle_move_menu(frame).move_count){ - pbf_press_button(context, BUTTON_B, 80ms, 500ms); - } - } - bool fight = false; - size_t confirmed = 0; - for (size_t check = 0; check < 30; check++){ - context.wait_for_all_requests(); - auto frame = stream.video().snapshot(); - BattleMenuOption selected = frame ? commands.selected_option(frame) : BattleMenuOption::UNKNOWN; - stream.log("[" + label + "] Command cursor=" + std::to_string((int)selected + 1) + "; target Battle=1."); - if (selected == BattleMenuOption::BATTLE){ - if (++confirmed >= 3){ - fight = true; - break; - } - pbf_wait(context, 100ms); - }else{ - confirmed = 0; - if (selected == BattleMenuOption::UNKNOWN){ - pbf_wait(context, 150ms); - }else{ - pbf_press_dpad(context, DPAD_UP, 80ms, 250ms); - } - } - } - if (!fight){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Could not confirm Battle command; A not pressed.", - stream); - } - pbf_press_button(context, BUTTON_A, 80ms, confirm_default_target ? 1000ms : 500ms); - if (confirm_default_target){ - // Reuse the farming move/target confirmation sequence, but - // navigate relative to this battle's retained move cursor. - // Reopening Fight does not reset it to move 1. - size_t available = 0, stable = 0; - for (size_t check = 0; check < 20 && stable < 3; check++){ - context.wait_for_all_requests(); - const auto frame = stream.video().snapshot(); - const auto moves = frame ? detect_battle_move_menu(frame) : BattleMoveMenuState{}; - if (moves.move_count && moves.move_count == available){ - stable++; - }else{ - available = moves.move_count; - stable = moves.move_count ? 1 : 0; - } - if (stable < 3){ - pbf_wait(context, 100ms); - } - } - if (stable < 3 || available < target || available < current_move){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Requested move position is not available.", - stream); - } - stream.log("[" + label + "] Retained move cursor " + std::to_string(current_move) + " -> " + - std::to_string(target) + "."); - for (size_t slot = current_move; slot < target; slot++){ - pbf_press_dpad(context, DPAD_DOWN, 80ms, 400ms); - } - for (size_t slot = current_move; slot > target; slot--){ - pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); - } - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 3000ms); - return; - } - confirmed = 0; - size_t previous_move_count = 0, stable_move_count = 0; - for (size_t check = 0; check < 30; check++){ - context.wait_for_all_requests(); - auto frame = stream.video().snapshot(); - const BattleMoveMenuState moves = frame ? detect_battle_move_menu(frame) : BattleMoveMenuState{}; - const int slot = (int)moves.selected_slot; - VideoOverlaySet move_overlays(stream.overlay()); - for (size_t i = 0; i < moves.move_count; i++){ - move_overlays.add(moves.selected_slot == i + 1 ? COLOR_GREEN : COLOR_YELLOW, moves.buttons[i]); - } - stream.log("[" + label + "] Available moves=" + std::to_string(moves.move_count) + - "; selected slot=" + std::to_string(slot) + "/4; target=" + std::to_string(target) + "."); - if (moves.move_count == previous_move_count && moves.move_count > 0){ - stable_move_count++; - }else{ - previous_move_count = moves.move_count; - stable_move_count = 1; - confirmed = 0; - } - if (moves.move_count > 0 && moves.move_count < target && stable_move_count >= 3){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Move slot " + std::to_string(target) + " is absent; no move confirmed.", - stream); - } - if (slot == (int)target && stable_move_count >= 3){ - if (++confirmed >= 3){ - stream.log("[" + label + "] Move " + std::to_string(target) + " confirmed; using it."); - pbf_press_button(context, BUTTON_A, 80ms, 3000ms); - return; - } - pbf_wait(context, 100ms); - }else{ - confirmed = 0; - if (slot == 0 || slot == (int)target){ - pbf_wait(context, 150ms); - }else{ - pbf_press_dpad(context, slot < (int)target ? DPAD_DOWN : DPAD_UP, 80ms, 250ms); + context.wait_for_all_requests(); + context.wait_for(500ms); } - } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Could not confirm move " + std::to_string(target) + "; A not pressed.", - stream); -} - -static void select_natalie_move_three(VideoStream& stream, ProControllerContext& context){ - select_battle_move_slot(stream, context, BattleType::TRAINER, 3, "Natalie"); -} -void fight_Natalie(VideoStream& stream, ProControllerContext& context){ - stream.log("[AutoStory] Starting Natalie battle...", COLOR_BLUE); + + // --------------------------------------------------------------------------- + // Natalie battle + // --------------------------------------------------------------------------- + // Early moves the starters and their foes have, as type, power and + // most PP. A status move has power 0. + struct KnownMove { const char* name; const char* type; int power; int pp; }; + static const KnownMove KNOWN_MOVES[] = { + {"pound", "normal", 40, 35}, {"tackle", "normal", 40, 35}, {"scratch", "normal", 40, 35}, + {"quickattack", "normal", 40, 30}, {"headbutt", "normal", 70, 15}, + {"watergun", "water", 40, 25}, {"bubble", "water", 40, 30}, {"bubblebeam", "water", 65, 20}, + {"ember", "fire", 40, 25}, {"flamewheel", "fire", 60, 25}, {"machpunch", "fighting", 40, 30}, + {"absorb", "grass", 20, 25}, {"razorleaf", "grass", 55, 25}, {"bite", "dark", 60, 25}, + {"peck", "flying", 35, 35}, {"wingattack", "flying", 60, 35}, {"metalclaw", "steel", 50, 35}, + {"growl", "normal", 0, 40}, {"leer", "normal", 0, 30}, {"tailwhip", "normal", 0, 30}, + {"withdraw", "water", 0, 40}, {"taunt", "dark", 0, 20}, + }; + + // A move button's PP left, "8/25" at its right end; -1 if it cannot be + // read. The text is dark, or yellow to red as PP runs low, so text is + // whatever differs from the button's own color. `max_pp` helps when + // the OCR reads the slash as a digit. + static int read_move_pp(const ImageViewRGB32& screen, const ImageFloatBox& button, int max_pp) { + const ImageViewRGB32 box = extract_box_reference(screen, ImageFloatBox{ + button.x + button.width * 0.75, button.y + button.height * 0.10, + button.width * 0.21, button.height * 0.80}); + if (box.width() == 0 || box.height() == 0) return -1; + std::vector pixels; + for (size_t y = 0; y < box.height(); y++) for (size_t x = 0; x < box.width(); x++) pixels.push_back(box.pixel(x, y)); + auto brightness = [](uint32_t p) { return ((p >> 16) & 255) + ((p >> 8) & 255) + (p & 255); }; + std::vector sorted = pixels; + std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end(), + [&](uint32_t a, uint32_t b) { return brightness(a) < brightness(b); }); + const uint32_t background = sorted[sorted.size() / 2]; + const size_t pad = 8; + ImageRGB32 text(box.width() + 2 * pad, box.height() + 2 * pad); + text.fill(0xffffffff); + for (size_t y = 0; y < box.height(); y++) { + for (size_t x = 0; x < box.width(); x++) { + const uint32_t p = box.pixel(x, y); + int distance = 0; + for (int shift : {16, 8, 0}) distance += std::abs((int)((p >> shift) & 255) - (int)((background >> shift) & 255)); + if (distance > 120) text.pixel(x + pad, y + pad) = 0xff000000; + } + } + const std::string read = OCR::tesseract_ocr_read(Language::English, text); + const size_t slash = read.find('/'); + std::string digits; + for (size_t i = 0; i < read.size() && i < slash; i++) if (std::isdigit((unsigned char)read[i])) digits.push_back(read[i]); + if (slash == std::string::npos && max_pp > 0) { + // "29135" for 29/35: drop the most PP and the misread slash. + const std::string max = std::to_string(max_pp); + if (digits.size() > max.size() + 1 && digits.compare(digits.size() - max.size(), max.size(), max) == 0) { + digits.resize(digits.size() - max.size() - 1); + } else { + return -1; + } + } + if (digits.empty() || digits.size() > 2) return -1; + return std::stoi(digits); + } - // Allow battle transition to finish. - pbf_wait(context, 5000ms); + // The best move on the move menu: the most powerful one we know, and + // on a tie one that is not Normal (an early starter's own type). + // Reads the names with OCR; 1 if none is known. + static size_t pick_best_move(VideoStream& stream, const ImageViewRGB32& screen, + const BattleMoveMenuState& moves, const std::string& label) { + // Keep the menu as it looked, to check the reads against. + { + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/moves/"; + Filesystem::create_directories(Filesystem::Path(folder)); + screen.save(folder + now_to_filestring() + "-" + label + ".png"); + } + size_t best = 1; + int best_score = -1; + bool any_attack = false; + size_t last_not_status = 1; + for (size_t i = 0; i < moves.move_count; i++) { + // The name is dark text on a light button (grey, or light blue under the + // cursor). Take the box right of the type icon and left of the PP, tall + // enough for a status move's name, which sits lower: it has no "Effective" + // line under it. + const ImageFloatBox& button = moves.buttons[i]; + const ImageFloatBox name_box{button.x + button.width * 0.17, button.y + button.height * 0.08, + button.width * 0.53, button.height * 0.80}; + const ImageViewRGB32 box = extract_box_reference(screen, name_box); + // Text is darker than halfway between the background and the darkest pixel; + // a fixed cut thins the strokes on the blue button. + std::vector gray(box.width() * box.height()); + for (size_t y = 0; y < box.height(); y++) { + for (size_t x = 0; x < box.width(); x++) { + const uint32_t pixel = box.pixel(x, y); + gray[y * box.width() + x] = (int)(((pixel >> 16) & 0xff) + ((pixel >> 8) & 0xff) + (pixel & 0xff)) / 3; + } + } + std::vector sorted = gray; + std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end()); + const int background = sorted[sorted.size() / 2]; + const int darkest = *std::min_element(gray.begin(), gray.end()); + const int threshold = (background + darkest) / 2; + // The name is the tallest band of text rows; "Effective" is smaller. + size_t band_top = 0, band_bottom = 0; + for (size_t y = 0; y < box.height();) { + auto has_text = [&](size_t row) { + for (size_t x = 0; x < box.width(); x++) if (gray[row * box.width() + x] < threshold) return true; + return false; + }; + if (!has_text(y)) { y++; continue; } + size_t end = y; + while (end < box.height() && has_text(end)) end++; + if (end - y > band_bottom - band_top) { band_top = y; band_bottom = end; } + y = end; + } + // Black text on white, with a margin, for the OCR. + const size_t pad = 6; + const size_t top = band_top >= 4 ? band_top - 4 : 0; + const size_t bottom = std::min(box.height(), band_bottom + 4); + ImageRGB32 text(box.width() + 2 * pad, bottom - top + 2 * pad); + text.fill(0xffffffff); + for (size_t y = top; y < bottom; y++) { + for (size_t x = 0; x < box.width(); x++) { + if (gray[y * box.width() + x] < threshold) text.pixel(x + pad, y - top + pad) = 0xff000000; + } + } + std::string name; + for (unsigned char ch : OCR::tesseract_ocr_read(Language::English, text)) { + if (std::isalpha(ch)) name.push_back((char)std::tolower(ch)); + } + // A slot read well once keeps its name when a later read fails. + static std::array last_read; + const KnownMove* known = nullptr; + for (int pass = 0; pass < 2 && !known; pass++) { + if (pass == 1) name = last_read[i]; + for (const KnownMove& move : KNOWN_MOVES) { + // The longest name that appears wins: "bubblebeam" over "bubble". + if (name.find(move.name) != std::string::npos && + (!known || std::string(move.name).size() > std::string(known->name).size())) { + known = &move; + } + } + } + if (known) last_read[i] = known->name; + // A move with no PP left cannot be chosen; the game says so and + // keeps the menu up. + const int pp = read_move_pp(screen, button, known ? known->pp : 0); + stream.log("[" + label + "] Move " + std::to_string(i + 1) + " PP left: " + (pp < 0 ? std::string("unread") : std::to_string(pp)) + "."); + if (pp == 0) { + continue; + } + // An unreadable move counts as a plain attack, ahead of known + // status moves. + const int score = known ? known->power * 2 + (std::string(known->type) != "normal" ? 1 : 0) : 40 * 2 - 1; + stream.log("[" + label + "] Move " + std::to_string(i + 1) + " reads \"" + name + "\"" + + (known ? std::string(" = ") + known->name + " (" + known->type + ", " + std::to_string(known->power) + ")" : " (unknown)") + "."); + if (score > best_score) { best_score = score; best = i + 1; } + if (known && known->power > 0) any_attack = true; + if (!known || known->power > 0) last_not_status = i + 1; + } + // No attack read: the newest move is the starter's own-type attack + // (Water Gun, Ember, Absorb), unless it is a status move. + if (!any_attack) best = last_not_status; + stream.log("[" + label + "] Best move: " + std::to_string(best) + "."); + return best; + } - bool battle_menu_seen = false; + static void select_battle_move_slot(VideoStream& stream, ProControllerContext& context, + BattleType battle_type, size_t target, const std::string& label, + bool confirm_default_target = false, size_t current_move = 1) { + BattleMenuDetector commands(battle_type); + VideoOverlaySet overlays(stream.overlay()); + commands.make_overlays(overlays); + overlays.add(COLOR_GREEN, {0.90, 0.48, 0.075, 0.505}); + // Reenter Fight from an already-open list without assuming + // that this resets the remembered move cursor. + if (confirm_default_target) { + context.wait_for_all_requests(); + const auto frame=stream.video().snapshot(); + if (frame && detect_battle_move_menu(frame).move_count) { + pbf_press_button(context,BUTTON_B,80ms,500ms); + } + } + bool fight = false; + size_t confirmed = 0; + for (size_t check = 0; check < 30; check++) { + context.wait_for_all_requests(); + auto frame = stream.video().snapshot(); + BattleMenuOption selected = frame ? commands.selected_option(frame) : BattleMenuOption::UNKNOWN; + stream.log("[" + label + "] Command cursor=" + std::to_string((int)selected + 1) + "; target Battle=1."); + if (selected == BattleMenuOption::BATTLE) { + if (++confirmed >= 3) { fight = true; break; } + pbf_wait(context, 100ms); + } else { + confirmed = 0; + if (selected == BattleMenuOption::UNKNOWN) pbf_wait(context, 150ms); + else pbf_press_dpad(context, DPAD_UP, 80ms, 250ms); + } + } + if (!fight) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Could not confirm Battle command; A not pressed.", stream); + pbf_press_button(context,BUTTON_A,80ms,confirm_default_target ? 1000ms : 500ms); + if (confirm_default_target) { + // Reuse the farming move/target confirmation sequence, but + // navigate relative to this battle's retained move cursor. + // Reopening Fight does not reset it to move 1. + size_t available=0,stable=0; + for (size_t check=0;check<20 && stable<3;check++) { + context.wait_for_all_requests(); + const auto frame=stream.video().snapshot(); + const auto moves=frame ? detect_battle_move_menu(frame) : BattleMoveMenuState{}; + if (moves.move_count && moves.move_count==available) stable++; + else {available=moves.move_count;stable=moves.move_count ? 1:0;} + if (stable<3) pbf_wait(context,100ms); + } + if (stable<3 || available "+std::to_string(target)+"."); + for (size_t slot=current_move;slottarget;slot--) pbf_press_dpad(context,DPAD_UP,80ms,400ms); + pbf_press_button(context,BUTTON_A,80ms,1000ms); + pbf_press_button(context,BUTTON_A,80ms,3000ms); + return; + } + confirmed = 0; + size_t previous_move_count = 0, stable_move_count = 0; + for (size_t check = 0; check < 30; check++) { + context.wait_for_all_requests(); + auto frame = stream.video().snapshot(); + const BattleMoveMenuState moves = frame ? detect_battle_move_menu(frame) : BattleMoveMenuState{}; + const int slot = (int)moves.selected_slot; + VideoOverlaySet move_overlays(stream.overlay()); + for (size_t i = 0; i < moves.move_count; i++) + move_overlays.add(moves.selected_slot == i + 1 ? COLOR_GREEN : COLOR_YELLOW, moves.buttons[i]); + stream.log("[" + label + "] Available moves=" + std::to_string(moves.move_count) + + "; selected slot=" + std::to_string(slot) + "/4; target=" + std::to_string(target) + "."); + if (moves.move_count == previous_move_count && moves.move_count > 0) stable_move_count++; + else { previous_move_count = moves.move_count; stable_move_count = 1; confirmed = 0; } + // Target 0: pick the best move once the menu has settled. + if (target == 0 && stable_move_count >= 3 && frame) { + target = pick_best_move(stream, *frame.frame, moves, label); + } + if (moves.move_count > 0 && moves.move_count < target && stable_move_count >= 3) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Move slot " + std::to_string(target) + " is absent; no move confirmed.", stream); + } + if (slot == (int)target && stable_move_count >= 3) { + if (++confirmed >= 3) { + stream.log("[" + label + "] Move " + std::to_string(target) + " confirmed; using it."); + pbf_press_button(context, BUTTON_A, 80ms, 3000ms); + return; + } + pbf_wait(context, 100ms); + } else { + confirmed = 0; + if (slot == 0 || slot == (int)target) pbf_wait(context, 150ms); + else pbf_press_dpad(context, slot < (int)target ? DPAD_DOWN : DPAD_UP, 80ms, 250ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Could not confirm move " + std::to_string(target) + "; A not pressed.", stream); + } - // State machine loop. - while (true){ + // --------------------------------------------------------------------------- + // Starly battle + // --------------------------------------------------------------------------- - context.wait_for_all_requests(); + void fight_starly(VideoStream& stream, ProControllerContext& context) { + stream.log("[AutoStory] Starting Starly battle...", COLOR_BLUE); - BattleMenuWatcher battle_menu(BattleType::TRAINER); - EndBattleWatcher end_battle; + // Declare OUTSIDE the loop so accumulated detector state + // (trigger counts, black-screen transitions) is preserved across frames. + // STARTER type: player has no Pokeballs, so ball-count areas won't + // be white. STARTER skips that check and only verifies status bars + // and the Fight button color. + BattleMenuWatcher battle_menu(BattleType::STARTER, COLOR_YELLOW); + EndBattleWatcher end_battle; - // Detect move-learning arrows. - SelectionArrowFinder learn_move(stream.overlay(), {0.50, 0.62, 0.40, 0.18}, COLOR_YELLOW); + while (true) { + context.wait_for_all_requests(); - int ret = run_until(stream, - context, - [](ProControllerContext& context){ - // Continuously clear: - // EXP gain - // level up - // faint text - // send-out text - // battle narration - pbf_mash_button(context, BUTTON_B, 120000ms); - }, + // Phase 1: wait for either battle menu or battle end. + int ret = run_until( + stream, context, + [](ProControllerContext& ctx) { + pbf_mash_button(ctx, BUTTON_B, 120000ms); + }, { {battle_menu}, - battle_menu_seen ? PeriodicInferenceCallback{end_battle} : PeriodicInferenceCallback{}, - {learn_move}, - }); - - switch (ret){ - - // ------------------------------------------------- - // Battle menu detected. - // ------------------------------------------------- - case 0: { - stream.log("[AutoStory] Battle menu detected.", COLOR_BLUE); + {end_battle}, + } + ); - battle_menu_seen = true; + switch (ret) { + case 0: + { + stream.log("[AutoStory] Battle menu detected. Selecting move...", COLOR_BLUE); - select_natalie_move_three(stream, context); + // Select Fight, then move slot 1. + pbf_press_button(context, BUTTON_A, 80ms, 500ms); + pbf_press_button(context, BUTTON_A, 80ms, 3000ms); + // Loop back - mash B through animations until next menu or end. + break; + } + case 1: + { + stream.log("[AutoStory] Starly battle complete.", COLOR_GREEN); + + // Mash through XP / level-up / dialog screens back to overworld. + OverworldWatcher overworld; + int cleanup_ret = run_until( + stream, context, + [](ProControllerContext& ctx) { + pbf_mash_button(ctx, BUTTON_B, 120000ms); + }, + { {overworld} } + ); + if (cleanup_ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Starly battle: Overworld cleanup timed out.", stream); + } + return; + } + default: + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Starly battle timed out.", stream); + } + } + } - break; - } + // --------------------------------------------------------------------------- + // Tristan battle + // --------------------------------------------------------------------------- + // Two grass rows at 240 ms each, with room to spare: the wall stops us. + static constexpr Milliseconds CATCH_GRASS_WALK = 900ms; - // ------------------------------------------------- - // Battle finished. - // ------------------------------------------------- - case 1: { - stream.log("[AutoStory] Natalie battle complete.", COLOR_GREEN); + static void encounter_one_wild_pokemon(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + bool encounter_found = false; + const auto deadline = std::chrono::steady_clock::now() + 180s; + + // Walk up and down the catch grass until a battle starts. Its three rows + // sit between tree walls, so each walk ends at a wall, on the same tile + // every time, after two new grass tiles. Short sideways steps drift and + // can rock across one tile edge without entering new grass. + while (!encounter_found && std::chrono::steady_clock::now() < deadline) { + BlackScreenWatcher battle_start; + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + + route_turn(stream, context, Pokemon::Direction::NORTH); + pbf_move_left_joystick(context, {0, 1}, CATCH_GRASS_WALK, 200ms); + context.wait_for_all_requests(); - pbf_mash_button(context, BUTTON_B, 5000ms); + int ret = wait_until( + stream, + context, + 1500ms, + { + {battle_start}, + {battle_menu}, + {battle_dialog}, + } + ); - return; - } + if (ret >= 0) { + encounter_found = true; + break; + } - // ------------------------------------------------- - // Move learning detected. - // ------------------------------------------------- - case 2: { - stream.log("[AutoStory] Learn move detected.", COLOR_ORANGE); + route_turn(stream, context, Pokemon::Direction::SOUTH); + pbf_move_left_joystick(context, {0, -1}, CATCH_GRASS_WALK, 200ms); + context.wait_for_all_requests(); - // Always refuse move learning. - pbf_move_right_joystick(context, {0, -1}, 160ms, 840ms); + ret = wait_until( + stream, + context, + 1500ms, + { + {battle_start}, + {battle_menu}, + {battle_dialog}, + } + ); - pbf_press_button(context, BUTTON_A, 160ms, 840ms); + if (ret >= 0) { + encounter_found = true; + break; + } + } - break; - } + if (!encounter_found) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Wild encounter: walking up and down the grass found no battle within three minutes.", stream); + stream.log("[AutoStory] Wild battle detected!", COLOR_GREEN); - // ------------------------------------------------- - // Timeout. - // ------------------------------------------------- - default: { - stream.log("[AutoStory] Natalie battle timed out.", COLOR_RED); + // Wait until the actual battle menu appears. + BattleMenuWatcher confirm_menu(BattleType::STANDARD); - pbf_mash_button(context, BUTTON_B, 5000ms); + int menu_ret = wait_until( + stream, + context, + 15s, + { + {confirm_menu}, + } + ); - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "[AutoStory] Natalie battle timed out.", - stream); - } - } - } -} + if (menu_ret < 0) { + stream.log( + "[AutoStory] Failed to detect battle menu.", + COLOR_RED + ); + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "[AutoStory] Failed to detect battle menu.", stream); + } -// --------------------------------------------------------------------------- -// Starly battle -// --------------------------------------------------------------------------- - -void fight_starly(VideoStream& stream, ProControllerContext& context){ - stream.log("[AutoStory] Starting Starly battle...", COLOR_BLUE); - - // Declare OUTSIDE the loop so accumulated detector state - // (trigger counts, black-screen transitions) is preserved across frames. - // STARTER type: player has no Pokeballs, so ball-count areas won't - // be white. STARTER skips that check and only verifies status bars - // and the Fight button color. - BattleMenuWatcher battle_menu(BattleType::STARTER, COLOR_YELLOW); - EndBattleWatcher end_battle; - - while (true){ - context.wait_for_all_requests(); - - // Phase 1: wait for either battle menu or battle end. - int ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - { - {battle_menu}, - {end_battle}, - }); + stream.log("[AutoStory] Battle menu detected.", COLOR_GREEN); - switch (ret){ - case 0: { - stream.log("[AutoStory] Battle menu detected. Selecting move...", COLOR_BLUE); + context.wait_for_all_requests(); + pbf_wait(context, 500ms); - // Select Fight, then move slot 1. - pbf_press_button(context, BUTTON_A, 80ms, 500ms); - pbf_press_button(context, BUTTON_A, 80ms, 3000ms); - // Loop back - mash B through animations until next menu or end. - break; - } - case 1: { - stream.log("[AutoStory] Starly battle complete.", COLOR_GREEN); - - // Mash through XP / level-up / dialog screens back to overworld. - OverworldWatcher overworld; - int cleanup_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - {{overworld}}); - if (cleanup_ret != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Starly battle: Overworld cleanup timed out.", - stream); } - return; - } - default: - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Starly battle timed out.", - stream); - } - } -} -// --------------------------------------------------------------------------- -// Tristan battle -// --------------------------------------------------------------------------- -static void encounter_one_wild_pokemon(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - pbf_wait(context, 1000ms); - - bool encounter_found = false; - const auto deadline = std::chrono::steady_clock::now() + 180s; - - // Wiggle left/right until battle starts. - while (!encounter_found && std::chrono::steady_clock::now() < deadline){ - BlackScreenWatcher battle_start; - BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); - BattleDialogWatcher battle_dialog(COLOR_YELLOW); - - // Turn left. - pbf_move_left_joystick(context, {-1, 0}, 120ms, 200ms); - context.wait_for_all_requests(); - - int ret = wait_until(stream, - context, - 1500ms, - { - {battle_start}, - {battle_menu}, - {battle_dialog}, - }); + static void confirm_battle_overworld(VideoStream& stream, ProControllerContext& context, + const std::string& label) { + OverworldDetector overworld; + ShortDialogDetector dialogue(COLOR_YELLOW); + BlackScreenDetector black; + VideoOverlaySet overlays(stream.overlay()); + overworld.make_overlays(overlays); + dialogue.make_overlays(overlays); + black.make_overlays(overlays); + const auto deadline = std::chrono::steady_clock::now() + 180s; + auto quiet_since = std::chrono::steady_clock::now(); + bool finished = false; + while (std::chrono::steady_clock::now() < deadline) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No video frame.", stream); + const auto now = std::chrono::steady_clock::now(); + if (dialogue.detect(frame) || black.detect(frame) || !overworld.detect(frame)) { + quiet_since = now; + } else if (now - quiet_since >= 4s) { + finished = true; + break; + } + pbf_mash_button(context, BUTTON_B, 100ms); + context.wait_for_all_requests(); + } + if (!finished) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Dialogue-free overworld not confirmed for four seconds.", stream); + context.wait_for_all_requests(); + } - if (ret >= 0){ - encounter_found = true; - break; - } + // Our HP bar in the battle's bottom-left panel, as a fraction; -1 if + // no bar is there. Measured on 1920x1080 frames: the bar runs from + // x 88 to 466 on rows 961 to 965. + static double battle_player_hp(const ImageViewRGB32& frame) { + const ImageViewRGB32 bar = extract_box_reference(frame, ImageFloatBox{0.046, 0.890, 0.197, 0.004}); + size_t filled = 0; + for (size_t x = 0; x < bar.width(); x++) { + size_t color = 0; + for (size_t y = 0; y < bar.height(); y++) { + const uint32_t p = bar.pixel(x, y); + const int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + const bool green = g > r + 30 && g > b + 20; + const bool yellow = r > 150 && g > 120 && b < 100; + const bool red = r > 150 && r > g + 50 && r > b + 50; + color += green || yellow || red; + } + filled += color > bar.height() / 2; + } + return bar.width() && filled ? (double)filled / bar.width() : -1; + } - // Turn right. - pbf_move_left_joystick(context, {1, 0}, 120ms, 200ms); - context.wait_for_all_requests(); + enum class BattleHeal { NOT_NEEDED, USED, NONE_LEFT }; - ret = wait_until(stream, - context, - 1500ms, - { - {battle_start}, - {battle_menu}, - {battle_dialog}, - }); + // At the battle menu: if our HP is under `heal_below`, use a Potion + // from the Bag on the lead, which takes the turn. Otherwise the + // battle menu is left up. + static BattleHeal battle_heal_if_low(VideoStream& stream, ProControllerContext& context, + BattleType battle_type, double heal_below, const std::string& label) { + context.wait_for_all_requests(); + double hp = -1; + for (size_t check = 0; check < 5 && hp < 0; check++) { + const auto frame = stream.video().snapshot(); + if (frame) hp = battle_player_hp(*frame.frame); + if (hp < 0) pbf_wait(context, 150ms); + context.wait_for_all_requests(); + } + stream.log("[" + label + "] Our HP bar reads " + std::to_string((int)(hp * 100)) + "%."); + if (hp < 0 || hp >= heal_below) return BattleHeal::NOT_NEEDED; + BattleMenuDetector commands(battle_type); + bool on_bag = false; + for (size_t check = 0; check < 30 && !on_bag; check++) { + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + const BattleMenuOption selected = frame ? commands.selected_option(frame) : BattleMenuOption::UNKNOWN; + if (selected == BattleMenuOption::BAG) on_bag = true; + else if (selected == BattleMenuOption::UNKNOWN) pbf_wait(context, 150ms); + else pbf_press_dpad(context, (int)selected < (int)BattleMenuOption::BAG ? DPAD_DOWN : DPAD_UP, 80ms, 300ms); + } + if (!on_bag) { + stream.log("[" + label + "] Could not reach the Bag command; fighting on.", COLOR_ORANGE); + return BattleHeal::NONE_LEFT; + } + pbf_press_button(context, BUTTON_A, 80ms, 2000ms); + if (!find_bag_item_in_open_bag(stream, context, "potion", 1)) { + stream.log("[" + label + "] No Potion left; fighting on.", COLOR_ORANGE); + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + context.wait_for_all_requests(); + return BattleHeal::NONE_LEFT; + } + // Potion, then the lead in the party list, then Use. + stream.log("[" + label + "] Using a Potion on the lead.", COLOR_BLUE); + pbf_press_button(context, BUTTON_A, 80ms, 1500ms); + pbf_press_button(context, BUTTON_A, 80ms, 1500ms); + pbf_press_button(context, BUTTON_A, 80ms, 2500ms); + context.wait_for_all_requests(); + return BattleHeal::USED; + } - if (ret >= 0){ - encounter_found = true; - break; - } - } + static void fight_using_move_list( + VideoStream& stream, ProControllerContext& context, + BattleType battle_type, const std::vector& move_slots, + const std::string& label, bool confirm_default_target, bool post_battle_dialogue_cleanup, + bool doubles = false, bool switch_on_faint = false + ) { + if (move_slots.empty() || std::any_of(move_slots.begin(),move_slots.end(), + [](size_t slot) { return slot>4; })) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Supply a nonempty move list containing only positions 0 (best) through 4.",stream); + } + size_t move_index=0; + // The first Fight menu starts at move 1. Later turns retain + // this Pokemon's previous selection; a new battle resets it. + size_t current_move=1; + stream.log( + "[AutoStory] Starting " + label + " battle...", + COLOR_BLUE + ); - if (!encounter_found){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Wild encounter: Left/right wiggle did not find a battle within three minutes.", - stream); - } - stream.log("[AutoStory] Wild battle detected!", COLOR_GREEN); - - // Wait until the actual battle menu appears. - BattleMenuWatcher confirm_menu(BattleType::STANDARD); - - int menu_ret = wait_until(stream, - context, - 15s, - { - {confirm_menu}, - }); - - if (menu_ret < 0){ - stream.log("[AutoStory] Failed to detect battle menu.", COLOR_RED); - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "[AutoStory] Failed to detect battle menu.", - stream); - } + // Scripted battles wait on the menu detector below, so no + // extra fixed delay is needed after a caller confirms it. + // Preserve the existing early-game transition delay. + if (!confirm_default_target) pbf_wait(context,5000ms); - stream.log("[AutoStory] Battle menu detected.", COLOR_GREEN); + bool battle_menu_seen = false; + bool out_of_potions = false; + size_t potions_used = 0; + size_t turns = 0; + size_t faint_switches = 0; + const bool watch_learn_move = confirm_default_target || doubles || switch_on_faint; + EndBattleWatcher end_battle; - context.wait_for_all_requests(); - pbf_wait(context, 500ms); -} + while (true) { -static void confirm_battle_overworld(VideoStream& stream, ProControllerContext& context, const std::string& label){ - OverworldDetector overworld; - ShortDialogDetector dialogue(COLOR_YELLOW); - BlackScreenDetector black; - VideoOverlaySet overlays(stream.overlay()); - overworld.make_overlays(overlays); - dialogue.make_overlays(overlays); - black.make_overlays(overlays); - const auto deadline = std::chrono::steady_clock::now() + 180s; - auto quiet_since = std::chrono::steady_clock::now(); - bool finished = false; - while (std::chrono::steady_clock::now() < deadline){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": No video frame.", - stream); - } - const auto now = std::chrono::steady_clock::now(); - if (dialogue.detect(frame) || black.detect(frame) || !overworld.detect(frame)){ - quiet_since = now; - }else if (now - quiet_since >= 4s){ - finished = true; - break; - } - pbf_mash_button(context, BUTTON_B, 100ms); - context.wait_for_all_requests(); - } - if (!finished){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Dialogue-free overworld not confirmed for four seconds.", - stream); - } - context.wait_for_all_requests(); -} + context.wait_for_all_requests(); -static void fight_using_move_list(VideoStream& stream, - ProControllerContext& context, - BattleType battle_type, - const std::vector& move_slots, - const std::string& label, - bool confirm_default_target, - bool post_battle_dialogue_cleanup){ - if (move_slots.empty() || std::any_of(move_slots.begin(), move_slots.end(), [](size_t slot){ - return slot < 1 || slot > 4; - })){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Supply a nonempty move list containing only positions 1 through 4.", - stream); - } - size_t move_index = 0; - // The first Fight menu starts at move 1. Later turns retain - // this Pokemon's previous selection; a new battle resets it. - size_t current_move = 1; - stream.log("[AutoStory] Starting " + label + " battle...", COLOR_BLUE); - - // Scripted battles wait on the menu detector below, so no - // extra fixed delay is needed after a caller confirms it. - // Preserve the existing early-game transition delay. - if (!confirm_default_target){ - pbf_wait(context, 5000ms); - } + BattleMenuWatcher battle_menu(battle_type); + // Reuse the farming handlers' move-learning prompt recovery. + SelectionArrowFinder learn_move(stream.overlay(),{0.50,0.62,0.40,0.18},COLOR_YELLOW); + + int ret = run_until( + stream, + context, + [&](ProControllerContext& context) { + // Clear narration with B while watching for commands. + // A here can open Fight before selection begins. + // With faint switching, a quiet 30 s means the party + // screen after a faint, which B cannot leave. + pbf_mash_button(context,BUTTON_B,switch_on_faint ? 30000ms : 120000ms); + }, + { + {battle_menu}, + battle_menu_seen + ? PeriodicInferenceCallback{end_battle} + : PeriodicInferenceCallback{}, + watch_learn_move ? PeriodicInferenceCallback{learn_move} : PeriodicInferenceCallback{}, + } + ); + + switch (ret) { + + // ------------------------------------------------- + // Battle menu detected. + // ------------------------------------------------- + case 0: + battle_menu_seen = true; + // Early battles take a few turns; this many means the chosen + // move is not being used (no PP, a misread menu). + if (++turns > 25) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": still at the battle menu after 25 turns.", stream); + } - bool battle_menu_seen = false; - EndBattleWatcher end_battle; + stream.log( + "[AutoStory] Battle menu detected.", + COLOR_BLUE + ); + + // A trainer battle we may lose: a Potion when HP is low. + if (battle_type == BattleType::TRAINER && !out_of_potions && potions_used < 4) { + const BattleHeal heal = battle_heal_if_low(stream, context, battle_type, 0.35, label); + if (heal == BattleHeal::USED) { + potions_used++; + break; + } + out_of_potions = heal == BattleHeal::NONE_LEFT; + } + stream.log("["+label+"] Move list position "+std::to_string(move_index+1)+ + ": use move "+std::to_string(move_slots[move_index])+"."); + select_battle_move_slot(stream, context, battle_type, move_slots[move_index], label, + confirm_default_target,current_move); + current_move=move_slots[move_index]; + // A double battle asks for the target next; B would + // back out of it. The default target is an opponent. + if (doubles) { + pbf_wait(context,1200ms); + pbf_press_button(context,BUTTON_A,80ms,1500ms); + } + // Advance once per move selection; retain the final entry. + if (move_index+1(stream, context, + [](ProControllerContext& ctx) { + pbf_mash_button(ctx, BUTTON_B, 120000ms); + }, {{overworld}}); + if (cleanup_ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Post-battle cleanup timed out.", stream); + } + context.wait_for_all_requests(); + confirm_battle_overworld(stream, context, label + " cleanup"); + return; + } - BattleMenuWatcher battle_menu(battle_type); - // Reuse the farming handlers' move-learning prompt recovery. - SelectionArrowFinder learn_move(stream.overlay(), {0.50, 0.62, 0.40, 0.18}, COLOR_YELLOW); + case 2: + stream.log("["+label+"] Declining move learning; preserving scripted move positions."); + pbf_move_right_joystick(context,{0,-1},160ms,840ms); + pbf_press_button(context,BUTTON_ZL,160ms,840ms); + break; - int ret = run_until(stream, - context, - [](ProControllerContext& context){ - // Clear narration with B while watching for commands. - // A here can open Fight before selection begins. - pbf_mash_button(context, BUTTON_B, 120000ms); - }, - { - {battle_menu}, - battle_menu_seen ? PeriodicInferenceCallback{end_battle} : PeriodicInferenceCallback{}, - confirm_default_target ? PeriodicInferenceCallback{learn_move} : PeriodicInferenceCallback{}, - }); - - switch (ret){ - - // ------------------------------------------------- - // Battle menu detected. - // ------------------------------------------------- - case 0: - battle_menu_seen = true; - - stream.log("[AutoStory] Battle menu detected.", COLOR_BLUE); - - stream.log("[" + label + "] Move list position " + std::to_string(move_index + 1) + ": use move " + - std::to_string(move_slots[move_index]) + "."); - select_battle_move_slot(stream, - context, - battle_type, - move_slots[move_index], - label, - confirm_default_target, - current_move); - current_move = move_slots[move_index]; - // Advance once per move selection; retain the final entry. - if (move_index + 1 < move_slots.size()){ - move_index++; - } + default: + // After a faint the game shows the party: Down to the + // next Pokemon, A, then A on "Send out" (by hand, + // 2026-10-29 against Roark). + if (switch_on_faint && faint_switches < 5) { + faint_switches++; + stream.log("[" + label + "] No battle menu for 30 s: a faint; sending out the next Pokemon.", COLOR_ORANGE); + pbf_press_dpad(context, DPAD_DOWN, 80ms, 500ms); + pbf_press_button(context, BUTTON_A, 80ms, 1200ms); + pbf_press_button(context, BUTTON_A, 80ms, 2000ms); + break; + } + stream.log( + "[AutoStory] " + label + " battle timeout.", + COLOR_RED + ); - break; + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "[AutoStory] " + label + " battle timeout.", stream); + } + } + } - // ------------------------------------------------- - // End battle detected. - // ------------------------------------------------- - case 1: { - stream.log("[AutoStory] " + label + " battle complete.", COLOR_GREEN); + // Preserve the existing early-game callers and their B text cleanup. + static void fight_using_move_slot(VideoStream& stream, ProControllerContext& context, + BattleType battle_type, size_t move_slot, const std::string& label) { + fight_using_move_list(stream,context,battle_type,{move_slot},label,false,true); + } - // EndBattleWatcher has confirmed the completed exit transition. - // Ordinary trainers need no scene-wide dialogue-free gate. - if (!post_battle_dialogue_cleanup){ + void clear_post_battle_dialogue(VideoStream& stream, ProControllerContext& context, + Button button, Milliseconds quiet_period, + const char* instruction_card) { context.wait_for_all_requests(); - return; - } - // Story NPCs and gym leaders opt into the existing cleanup. - OverworldWatcher overworld; - int cleanup_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - {{overworld}}); - if (cleanup_ret != 0){ + std::unique_ptr card; + if (instruction_card) card = std::make_unique( + std::make_shared(RESOURCE_PATH()+instruction_card), + ImageFloatBox{.04,.32,.92,.34},30.0); + ShortDialogDetector dialogue(COLOR_YELLOW); + BlackScreenDetector black; + VideoOverlaySet overlays(stream.overlay()); + dialogue.make_overlays(overlays); + black.make_overlays(overlays); + const auto deadline = std::chrono::steady_clock::now() + 120s; + auto absent_since = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() < deadline) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Post-battle dialogue: No video frame.", stream); + const auto now = std::chrono::steady_clock::now(); + const bool dialogue_visible = dialogue.detect(*frame.frame); + const bool card_visible = card && card->detect(*frame.frame); + if (dialogue_visible || card_visible || black.detect(*frame.frame)) absent_since = now; + else if (now - absent_since >= quiet_period) { + stream.log("[Post-battle dialogue] " + std::to_string(quiet_period.count()) + + "ms without dialogue confirmed."); + return; + } + // A can interact again once dialogue disappears. Only confirm + // visible prompts; B retains the existing post-battle behavior. + if (card_visible) pbf_press_button(context, BUTTON_B, 80ms, 100ms); + else if (button == BUTTON_A && !dialogue_visible) pbf_wait(context, 100ms); + else pbf_mash_button(context, button, 100ms); + context.wait_for_all_requests(); + } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Post-battle cleanup timed out.", - stream); + "Post-battle dialogue: Three clear seconds not confirmed.", stream); } - context.wait_for_all_requests(); - confirm_battle_overworld(stream, context, label + " cleanup"); - return; - } - - case 2: - stream.log("[" + label + "] Declining move learning; preserving scripted move positions."); - pbf_move_right_joystick(context, {0, -1}, 160ms, 840ms); - pbf_press_button(context, BUTTON_ZL, 160ms, 840ms); - break; - - default: - stream.log("[AutoStory] " + label + " battle timeout.", COLOR_RED); - - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "[AutoStory] " + label + " battle timeout.", - stream); - } - } -} - -// Preserve the existing early-game callers and their B text cleanup. -static void fight_using_move_slot(VideoStream& stream, - ProControllerContext& context, - BattleType battle_type, - size_t move_slot, - const std::string& label){ - fight_using_move_list(stream, context, battle_type, {move_slot}, label, false, true); -} - -void clear_post_battle_dialogue(VideoStream& stream, - ProControllerContext& context, - Button button, - Milliseconds quiet_period, - const char* instruction_card){ - context.wait_for_all_requests(); - std::unique_ptr card; - if (instruction_card){ - card = std::make_unique(std::make_shared(RESOURCE_PATH() + instruction_card), - ImageFloatBox{.04, .32, .92, .34}, - 30.0); - } - ShortDialogDetector dialogue(COLOR_YELLOW); - BlackScreenDetector black; - VideoOverlaySet overlays(stream.overlay()); - dialogue.make_overlays(overlays); - black.make_overlays(overlays); - const auto deadline = std::chrono::steady_clock::now() + 120s; - auto absent_since = std::chrono::steady_clock::now(); - while (std::chrono::steady_clock::now() < deadline){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Post-battle dialogue: No video frame.", - stream); - } - const auto now = std::chrono::steady_clock::now(); - const bool dialogue_visible = dialogue.detect(*frame.frame); - const bool card_visible = card && card->detect(*frame.frame); - if (dialogue_visible || card_visible || black.detect(*frame.frame)){ - absent_since = now; - }else if (now - absent_since >= quiet_period){ - stream.log( - "[Post-battle dialogue] " + std::to_string(quiet_period.count()) + "ms without dialogue confirmed."); - return; - } - // A can interact again once dialogue disappears. Only confirm - // visible prompts; B retains the existing post-battle behavior. - if (card_visible){ - pbf_press_button(context, BUTTON_B, 80ms, 100ms); - }else if (button == BUTTON_A && !dialogue_visible){ - pbf_wait(context, 100ms); - }else{ - pbf_mash_button(context, button, 100ms); - } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Post-battle dialogue: Three clear seconds not confirmed.", - stream); -} -void align_to_overworld_landmarks(VideoStream& stream, - ProControllerContext& context, - OverworldMatchResult (*matcher)(const ImageRGB32&), - const std::string& label, - bool allow_story_start, - bool horizontal_first, - bool search_down_first, - OverworldMatchResult (*entry_matcher)(const ImageRGB32&), - bool* entry_detected, - double arrival_tolerance_x, - double arrival_tolerance_y, - double moderate_entry_confidence, - bool* rock_acquisition_timed_out){ - if (rock_acquisition_timed_out){ - *rock_acquisition_timed_out = false; - } - auto missing_rocks_since = std::chrono::steady_clock::time_point::max(); - if (entry_detected){ - *entry_detected = false; - } - context.wait_for_all_requests(); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - ShortDialogDetector dialogue(COLOR_YELLOW); - BattleMenuDetector battle(BattleType::TRAINER, COLOR_GREEN); - size_t confirmations = 0, missing = 0; - bool ace_bypass = false; - const bool route215_renewal = matcher == match_route215_bridge_renewal || matcher == match_route215_ace_renewal || - matcher == match_route215_derek_stairs_renewal || matcher == match_route215_derek_stair_top_renewal; - Milliseconds correction_movement = 0ms; - for (size_t attempt = 0; attempt < 80; attempt++){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": No video frame.", - stream); - } - if (entry_matcher){ - BlackScreenDetector transition; - if (entry_matcher(*frame.frame).located || transition.detect(*frame.frame)){ - BlackScreenWatcher visible(COLOR_RED, - {0.1, 0.1, 0.8, 0.8}, - 100, - 10, - BlackScreenWatcher::FinderType::GONE, - 500ms); - if (wait_until(stream, context, 30s, {{visible}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Building transition did not finish.", - stream); - } - const auto inside = stream.video().snapshot(); - if (!inside || !entry_matcher(*inside.frame).located){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Expected interior not confirmed after transition.", - stream); - } - if (entry_detected){ - *entry_detected = true; - } - stream.log("[" + label + "] Expected interior confirmed; ending outdoor alignment.", COLOR_GREEN); - return; - } - } - // The story can start before the exact reference point. Hand off - // to battle preparation instead of steering during dialogue. - if (dialogue.detect(*frame.frame) || battle.detect(*frame.frame)){ - if (allow_story_start){ - stream.log("[" + label + "] Story dialogue/battle started; handing off without further movement."); - return; - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Unexpected dialogue/battle during alignment.", - stream); - } - const auto result = matcher(*frame.frame); - if (result.located){ - missing_rocks_since = std::chrono::steady_clock::time_point::max(); - } - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); - } - stream.log("[" + label + "] " + landmark.name + " score=" + std::to_string(landmark.score) + - ", margin=" + std::to_string(landmark.margin) + ", offset=(" + std::to_string(landmark.dx) + "," + - std::to_string(landmark.dy) + ")"); - } - stream.log("[" + label + "] located=" + std::to_string(result.located) + - ", coarse=" + std::to_string(result.coarse) + ", at_target=" + std::to_string(result.at_target) + - ", consensus offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")"); - // The blue-haired NPC patrols the narrow eastern row. Shift - // one tile north on the clear road before crossing west; - // return to the original row only after clearing his column. - if (matcher == match_route215_ace_renewal && result.located){ - if (result.dx < -1.0 / 12){ - ace_bypass = true; - }else if (result.dx >= -1.0 / 32){ - ace_bypass = false; - } - } - const bool ace_row_recovery = ace_bypass && std::abs(result.dy - 70.0 / 1080) > 1.0 / 108; - const bool ace_central_row = matcher == match_route215_ace_renewal && !ace_bypass && result.coarse && - std::abs(result.dx) <= 1.0 / 12 && std::abs(result.dy) > 1.0 / 54; - const double entry_confidence = moderate_entry_confidence > 0 ? overworld_entry_confidence(result) : 0; - const bool moderate_arrival = moderate_entry_confidence > 0 && entry_confidence >= moderate_entry_confidence; - if (moderate_entry_confidence > 0){ - stream.log("[" + label + "] Exterior entry confidence=" + std::to_string(entry_confidence) + - ", moderate threshold=" + std::to_string(moderate_entry_confidence) + - ", strict target=" + std::to_string(result.at_target)); - } - // Explicit loose bounds are for non-interacting route handoffs. - // Default callers still require the matcher's precise arrival. - const bool rough_arrival = arrival_tolerance_x > 0 && arrival_tolerance_y > 0 && result.located && - std::abs(result.dx) <= arrival_tolerance_x && std::abs(result.dy) <= arrival_tolerance_y; - if (result.at_target || rough_arrival || moderate_arrival){ - if (++confirmations >= 3){ - stream.log("[" + label + "] Position confirmed."); - return; - } - pbf_wait(context, 250ms); - }else if (result.located){ - confirmations = 0; - missing = 0; - // Wait at an aligned pose while strict visual support - // recovers, rather than stepping away under a cloud. - if ((matcher == match_galactic_hq_jupiter || matcher == match_route208_hannah_departure || - matcher == match_route208_stair_top) && - result.coarse && std::abs(result.dx) <= 1.0 / 128 && std::abs(result.dy) <= 1.0 / 54){ + void align_to_overworld_landmarks(VideoStream& stream, ProControllerContext& context, + OverworldMatchResult (*matcher)(const ImageRGB32&), const std::string& label, bool allow_story_start, bool horizontal_first, + bool search_down_first, + OverworldMatchResult (*entry_matcher)(const ImageRGB32&), + bool* entry_detected, + double arrival_tolerance_x, double arrival_tolerance_y, + double moderate_entry_confidence, bool* rock_acquisition_timed_out) { + if (rock_acquisition_timed_out) *rock_acquisition_timed_out=false; + auto missing_rocks_since=std::chrono::steady_clock::time_point::max(); + if (entry_detected) *entry_detected=false; + context.wait_for_all_requests(); pbf_wait(context, 500ms); context.wait_for_all_requests(); - continue; - } - // A coarse match cannot confirm position, but two accepted - // anchors can establish a safe horizontal direction when both - // offsets are well outside the uncertainty in their spacing. - bool coarse_horizontal = false; - if (horizontal_first && result.coarse && result.landmarks.size() == 2){ - const auto& a = result.landmarks[0]; - const auto& b = result.landmarks[1]; - const double nearest = std::min(std::abs(a.dx), std::abs(b.dx)); - coarse_horizontal = a.accepted && b.accepted && a.dx * b.dx > 0 && nearest > 1.0 / 64 && - nearest > 2 * std::abs(a.dx - b.dx); - } - const bool plaza_anchor_horizontal = matcher == match_jubilife_plaza_approach && result.coarse && - std::abs(result.dx) > 1.0 / 50 && std::abs(result.dy) <= 1.0 / 54; - // A uniquely matched Eterna ball may correct the door - // column while the sign/bush enter the frame. Arrival - // still requires the matcher's full three-anchor check. - const bool eterna_anchor_horizontal = - (matcher == match_eterna_center_entrance || matcher == match_eterna_underground_house || - matcher == match_eterna_underground_man) && - result.coarse && std::abs(result.dx) > 1.0 / 128; - // Recover the gym's doorway row before moving right if - // the south approach overshot toward the fence/cliff. - const bool gym_anchor_horizontal = matcher == match_eterna_gym_entrance && result.coarse && - std::abs(result.dx) > 1.0 / 128 && std::abs(result.dy) <= .06; - const bool gym_row_recovery = - matcher == match_eterna_gym_entrance && result.coarse && std::abs(result.dy) > .06; - // Reach the row north of Jupiter before entering her - // column. From above that row, align sideways before - // moving south so the NPC cannot block the correction. - const bool jupiter_row_recovery = matcher == match_galactic_hq_jupiter && result.dy < -1.0 / 54; - const bool jupiter_anchor_horizontal = matcher == match_galactic_hq_jupiter && !jupiter_row_recovery && - result.coarse && std::abs(result.dx) > 1.0 / 128; - // Stay south of the Contest Hall wall/sign before crossing to Fantina. - const bool contest_row_recovery = matcher == match_hearthome_contest_fantina && result.dy > 1.0 / 54; - // Keep the stair approach horizontal-first until close to - // its column. Recorded fine poses otherwise spent repeated - // 16ms sideways turns while a larger vertical error waited. - // Fine correction changes axis priority, never arrival gates. - const bool stair_fine_correction = - matcher == match_route208_stair_top && !result.coarse && std::abs(result.dx) <= 1.0 / 64; - const bool horizontal = !ace_row_recovery && !ace_central_row && - (ace_bypass || - (!contest_row_recovery && !gym_row_recovery && !jupiter_row_recovery && - (jupiter_anchor_horizontal || coarse_horizontal || plaza_anchor_horizontal || - eterna_anchor_horizontal || gym_anchor_horizontal || - (!result.coarse && - ((horizontal_first && !stair_fine_correction && std::abs(result.dx) > 1.0 / 128) || - std::abs(result.dx) >= std::abs(result.dy) * 9.0 / 16))))); - if (stair_fine_correction && horizontal_first && !horizontal && std::abs(result.dx) > 1.0 / 128){ - stream.log("[" + label + "] Near stair column; correcting the larger vertical error first."); - } - if (ace_row_recovery){ - stream.log("[" + label + "] Clearing the NPC row before crossing west."); - }else if (ace_central_row){ - stream.log("[" + label + "] Both road bends support restoring the arrival row."); - }else if (coarse_horizontal && horizontal){ - stream.log("[" + label + - "] Both landmarks agree on a large horizontal offset; correcting the street lane first."); - } - const double error = horizontal ? result.dx : (result.dy - (ace_bypass ? 70.0 / 1080 : 0.0)) * 9.0 / 16; - auto duration = std::abs(error) > 1.0 / 24 ? 80ms : std::abs(error) > 1.0 / 64 ? 40ms : 16ms; - // A 16ms turn may not complete a step. Use a bounded - // full correction while reacquiring the second plaza anchor. - if (matcher == match_jubilife_plaza_approach && result.coarse){ - duration = std::max(duration, 60ms); - } - if (matcher == match_galactic_hq_jupiter){ - for (const auto& landmark : result.landmarks){ - // The player stands around y=.74. Do not steer - // sideways/north when the stair opening is close - // to that row; a southward correction moves away. - if (landmark.name == "StairBoundary" && landmark.accepted && - landmark.found.y + landmark.found.height > .60 && (horizontal || result.dy < 0)){ + ShortDialogDetector dialogue(COLOR_YELLOW); + BattleMenuDetector battle(BattleType::TRAINER, COLOR_GREEN); + size_t confirmations = 0, missing = 0; + for (size_t attempt = 0; attempt < 80; attempt++) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No video frame.", stream); + if (entry_matcher){ + BlackScreenDetector transition; + if (entry_matcher(*frame.frame).located || transition.detect(*frame.frame)){ + BlackScreenWatcher visible(COLOR_RED,{0.1,0.1,0.8,0.8}, + 100,10,BlackScreenWatcher::FinderType::GONE,500ms); + if (wait_until(stream,context,30s,{{visible}})!=0) + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Building transition did not finish.",stream); + const auto inside=stream.video().snapshot(); + if (!inside || !entry_matcher(*inside.frame).located) + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Expected interior not confirmed after transition.",stream); + if(entry_detected)*entry_detected=true; + stream.log("["+label+"] Expected interior confirmed; ending outdoor alignment.",COLOR_GREEN); + return; + } + } + // The story can start before the exact reference point. Hand off + // to battle preparation instead of steering during dialogue. + if (dialogue.detect(*frame.frame) || battle.detect(*frame.frame)) { + if (allow_story_start) { + stream.log("[" + label + "] Story dialogue/battle started; handing off without further movement."); + return; + } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Correction would approach the stair boundary.", - stream); + label + ": Unexpected dialogue/battle during alignment.", stream); } - } - } - if (route215_renewal){ - if (correction_movement + duration > 1500ms){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Renewal alignment movement budget exhausted.", - stream); - } - correction_movement += duration; - } - pbf_move_left_joystick(context, - {horizontal ? (result.dx > 0 ? 1.0 : -1.0) : 0.0, horizontal ? 0.0 : (error < 0 ? 1.0 : -1.0)}, - duration, - 0ms); - pbf_wait(context, 500ms); - }else{ - confirmations = 0; - if (matcher == match_hearthome_center_entrance || matcher == match_hearthome_contest_fantina || - matcher == match_veilstone_city_passage || matcher == match_veilstone_dawn_stairs || route215_renewal){ - // Do not sweep into a different doorway or around the Hall's - // side, or onto a Veilstone terrace, while required anchors are hidden. - if (++missing > 20){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Scene anchors missing; stationary reacquisition exhausted.", - stream); - } - // Recorded expiry endpoints can be north of the passage. - // Acquire its buildings along the clear south-only strip; - // cap total search motion, then remain stationary. - if (matcher == match_veilstone_city_passage && search_down_first && correction_movement < 1800ms){ - pbf_move_left_joystick(context, {0.0, -1.0}, 100ms, 0ms); - correction_movement += 100ms; - } - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - continue; - } - if (matcher == match_galactic_hq_jupiter || matcher == match_route208_stair_top){ - // Keep generic missing-anchor behavior unchanged. Route208's - // explicit caller can instead request its recorded recovery. - const bool stair_rocks = matcher == match_route208_stair_top; - if (stair_rocks && rock_acquisition_timed_out){ - const auto now = std::chrono::steady_clock::now(); - if (missing_rocks_since == std::chrono::steady_clock::time_point::max()){ - missing_rocks_since = now; + const auto result = matcher(*frame.frame); + if (result.located) + missing_rocks_since=std::chrono::steady_clock::time_point::max(); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[" + label + "] " + landmark.name + " score=" + + std::to_string(landmark.score) + ", margin=" + std::to_string(landmark.margin) + + ", offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); } - // Matcher processing can take seconds per observation; - // measure real elapsed time rather than counting waits. - if (now - missing_rocks_since >= 10s){ - context.wait_for_all_requests(); - *rock_acquisition_timed_out = true; - stream.log("[" + label + - "] Rock acquisition timed out after 10s; handing off to the requested cliff recovery."); - return; + stream.log("[" + label + "] located=" + std::to_string(result.located) + + ", coarse=" + std::to_string(result.coarse) + ", at_target=" + std::to_string(result.at_target) + + ", consensus offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")"); + const double entry_confidence=moderate_entry_confidence>0 ? overworld_entry_confidence(result) : 0; + const bool moderate_arrival=moderate_entry_confidence>0 && entry_confidence>=moderate_entry_confidence; + if(moderate_entry_confidence>0) stream.log("["+label+"] Exterior entry confidence="+ + std::to_string(entry_confidence)+", moderate threshold="+std::to_string(moderate_entry_confidence)+ + ", strict target="+std::to_string(result.at_target)); + // Explicit loose bounds are for non-interacting route handoffs. + // Default callers still require the matcher's precise arrival. + const bool rough_arrival=arrival_tolerance_x>0 && arrival_tolerance_y>0 && + result.located && std::abs(result.dx)<=arrival_tolerance_x && + std::abs(result.dy)<=arrival_tolerance_y; + if (result.at_target || rough_arrival || moderate_arrival) { + if (++confirmations >= 3) { stream.log("[" + label + "] Position confirmed."); return; } + pbf_wait(context, 250ms); + } else if (result.located) { + confirmations = 0; + missing = 0; + // Wait at an aligned pose while strict visual support + // recovers, rather than stepping away under a cloud. + if ((matcher==match_galactic_hq_jupiter || + matcher==match_route208_hannah_departure || matcher==match_route208_stair_top) && result.coarse && + std::abs(result.dx)<=1.0/128 && std::abs(result.dy)<=1.0/54){ + pbf_wait(context,500ms); + context.wait_for_all_requests(); + continue; + } + // A coarse match cannot confirm position, but two accepted + // anchors can establish a safe horizontal direction when both + // offsets are well outside the uncertainty in their spacing. + bool coarse_horizontal = false; + if (horizontal_first && result.coarse && result.landmarks.size() == 2) { + const auto& a = result.landmarks[0]; + const auto& b = result.landmarks[1]; + const double nearest = std::min(std::abs(a.dx), std::abs(b.dx)); + coarse_horizontal = a.accepted && b.accepted && a.dx*b.dx > 0 && + nearest > 1.0/64 && nearest > 2*std::abs(a.dx-b.dx); + } + const bool plaza_anchor_horizontal = matcher == match_jubilife_plaza_approach && + result.coarse && std::abs(result.dx)>1.0/50 && std::abs(result.dy)<=1.0/54; + // A uniquely matched Eterna ball may correct the door + // column while the sign/bush enter the frame. Arrival + // still requires the matcher's full three-anchor check. + const bool eterna_anchor_horizontal = (matcher == match_eterna_center_entrance || + matcher == match_eterna_underground_house || matcher == match_eterna_underground_man) && + result.coarse && std::abs(result.dx)>1.0/128; + // Recover the gym's doorway row before moving right if + // the south approach overshot toward the fence/cliff. + const bool gym_anchor_horizontal=matcher==match_eterna_gym_entrance && + result.coarse && std::abs(result.dx)>1.0/128 && std::abs(result.dy)<=.06; + const bool gym_row_recovery=matcher==match_eterna_gym_entrance && + result.coarse && std::abs(result.dy)>.06; + // Reach the row north of Jupiter before entering her + // column. From above that row, align sideways before + // moving south so the NPC cannot block the correction. + const bool jupiter_row_recovery=matcher==match_galactic_hq_jupiter && + result.dy < -1.0/54; + const bool jupiter_anchor_horizontal=matcher==match_galactic_hq_jupiter && + !jupiter_row_recovery && result.coarse && std::abs(result.dx)>1.0/128; + // Stay south of the Contest Hall wall/sign before crossing to Fantina. + const bool contest_row_recovery=matcher==match_hearthome_contest_fantina && result.dy>1.0/54; + // Keep the stair approach horizontal-first until close to + // its column. Recorded fine poses otherwise spent repeated + // 16ms sideways turns while a larger vertical error waited. + // Fine correction changes axis priority, never arrival gates. + const bool stair_fine_correction=matcher==match_route208_stair_top && + !result.coarse && std::abs(result.dx)<=1.0/64; + const bool horizontal = !contest_row_recovery && !gym_row_recovery && !jupiter_row_recovery && (jupiter_anchor_horizontal || coarse_horizontal || plaza_anchor_horizontal || eterna_anchor_horizontal || gym_anchor_horizontal || (!result.coarse && + ((horizontal_first && !stair_fine_correction && std::abs(result.dx)>1.0/128) || + std::abs(result.dx)>=std::abs(result.dy)*9.0/16))); + if (stair_fine_correction && horizontal_first && !horizontal && std::abs(result.dx)>1.0/128) + stream.log("["+label+"] Near stair column; correcting the larger vertical error first."); + if (coarse_horizontal) stream.log("[" + label + + "] Both landmarks agree on a large horizontal offset; correcting the street lane first."); + const double error = horizontal ? result.dx : result.dy*9.0/16; + auto duration = std::abs(error) > 1.0/24 ? 80ms : std::abs(error) > 1.0/64 ? 40ms : 16ms; + // A 16ms turn may not complete a step. Use a bounded + // full correction while reacquiring the second plaza anchor. + if (matcher == match_jubilife_plaza_approach && result.coarse) + duration = std::max(duration, 60ms); + if (matcher==match_galactic_hq_jupiter){ + for (const auto& landmark:result.landmarks){ + // The player stands around y=.74. Do not steer + // sideways/north when the stair opening is close + // to that row; a southward correction moves away. + if (landmark.name=="StairBoundary" && landmark.accepted && + landmark.found.y+landmark.found.height>.60 && + (horizontal || result.dy<0)) + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Correction would approach the stair boundary.",stream); + } + } + pbf_move_left_joystick(context, + {horizontal ? (result.dx > 0 ? 1.0 : -1.0) : 0.0, + horizontal ? 0.0 : (result.dy < 0 ? 1.0 : -1.0)}, duration, 0ms); + pbf_wait(context, 500ms); + } else { + confirmations = 0; + if (matcher==match_hearthome_center_entrance || matcher==match_hearthome_contest_fantina){ + // Do not sweep into a different doorway or around the Hall's + // side when a cloud, NPC or fade hides the required anchors. + if (++missing>20) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Scene anchors missing; stationary reacquisition exhausted.",stream); + pbf_wait(context,500ms); + context.wait_for_all_requests(); + continue; + } + if (matcher==match_galactic_hq_jupiter || matcher==match_route208_stair_top){ + // Keep generic missing-anchor behavior unchanged. Route208's + // explicit caller can instead request its recorded recovery. + const bool stair_rocks=matcher==match_route208_stair_top; + if (stair_rocks && rock_acquisition_timed_out) { + const auto now=std::chrono::steady_clock::now(); + if (missing_rocks_since==std::chrono::steady_clock::time_point::max()) + missing_rocks_since=now; + // Matcher processing can take seconds per observation; + // measure real elapsed time rather than counting waits. + if (now-missing_rocks_since>=10s) { + context.wait_for_all_requests(); + *rock_acquisition_timed_out=true; + stream.log("["+label+"] Rock acquisition timed out after 10s; handing off to the requested cliff recovery."); + return; + } + } else if (++missing>(stair_rocks ? 20 : 6)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+(stair_rocks ? ": Rock anchors missing; no blind search near stairs." : + ": Chair/stair anchors missing; no blind search near stairs."),stream); + } + pbf_wait(context,500ms); + context.wait_for_all_requests(); + continue; + } + // Search right first, then across the starting point to the + // left and back. Each complete sweep returns to its origin. + // Try that sweep at the initial height, above it, and below it; + // no missing/weak landmark ever confirms the destination. + if (++missing > (search_down_first ? 78 : 74)) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Landmarks missing during bounded bidirectional search.", stream); + if (search_down_first && missing <= 4) { + stream.log("[" + label + "] Post-battle acquisition: moving down before lateral search."); + pbf_move_left_joystick(context, {0.0, -1.0}, 100ms, 0ms); + } else { + const size_t search_step = missing - 1 - (search_down_first ? 4 : 0); + const size_t level = search_step / 25; + const size_t slot = search_step % 25; + const bool lateral = slot < 24; + const double direction = slot < 6 || slot >= 18 ? 1.0 : -1.0; + const auto search_duration = lateral ? 100ms : level == 0 ? 600ms : 1200ms; + stream.log("[" + label + "] Acquisition search step=" + std::to_string(missing) + + ", direction=" + (lateral ? (direction>0 ? "right" : "left") : (level==0 ? "up" : "down"))); + // On the street, a northward search from the east + // edge can stop at the building/NPC collision row. + // Step west around it before changing height, then + // return to the same lane for the next bounded sweep. + const bool street_height_search=!lateral && matcher==match_jubilife_street_approach; + if (street_height_search) pbf_move_left_joystick(context,{-1.0,0.0},150ms,0ms); + pbf_move_left_joystick(context, + {lateral ? direction : 0.0, lateral ? 0.0 : (level == 0 ? 1.0 : -1.0)}, search_duration, 0ms); + if (street_height_search) pbf_move_left_joystick(context,{1.0,0.0},150ms,0ms); + } + pbf_wait(context, 500ms); } - }else if (++missing > (stair_rocks ? 20 : 6)){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + - (stair_rocks ? ": Rock anchors missing; no blind search near stairs." - : ": Chair/stair anchors missing; no blind search near stairs."), - stream); + context.wait_for_all_requests(); } - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - continue; - } - // Search right first, then across the starting point to the - // left and back. Each complete sweep returns to its origin. - // Try that sweep at the initial height, above it, and below it; - // no missing/weak landmark ever confirms the destination. - if (++missing > (search_down_first ? 78 : 74)){ OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Landmarks missing during bounded bidirectional search.", - stream); + label + ": Could not confirm the reference position.", stream); } - if (search_down_first && missing <= 4){ - stream.log("[" + label + "] Post-battle acquisition: moving down before lateral search."); - pbf_move_left_joystick(context, {0.0, -1.0}, 100ms, 0ms); - }else{ - const size_t search_step = missing - 1 - (search_down_first ? 4 : 0); - const size_t level = search_step / 25; - const size_t slot = search_step % 25; - const bool lateral = slot < 24; - const double direction = slot < 6 || slot >= 18 ? 1.0 : -1.0; - const auto search_duration = lateral ? 100ms : level == 0 ? 600ms : 1200ms; - stream.log("[" + label + "] Acquisition search step=" + std::to_string(missing) + - ", direction=" + (lateral ? (direction > 0 ? "right" : "left") : (level == 0 ? "up" : "down"))); - // On the street, a northward search from the east - // edge can stop at the building/NPC collision row. - // Step west around it before changing height, then - // return to the same lane for the next bounded sweep. - const bool street_height_search = !lateral && matcher == match_jubilife_street_approach; - if (street_height_search){ - pbf_move_left_joystick(context, {-1.0, 0.0}, 150ms, 0ms); - } - pbf_move_left_joystick(context, - {lateral ? direction : 0.0, lateral ? 0.0 : (level == 0 ? 1.0 : -1.0)}, - search_duration, - 0ms); - if (street_height_search){ - pbf_move_left_joystick(context, {1.0, 0.0}, 150ms, 0ms); - } - } - pbf_wait(context, 500ms); - } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Could not confirm the reference position.", - stream); -} -void fight_trainer_using_moves(VideoStream& stream, - ProControllerContext& context, - const std::vector& moves, - const std::string& label, - bool post_battle_dialogue_cleanup){ - fight_using_move_list(stream, context, BattleType::TRAINER, moves, label, true, post_battle_dialogue_cleanup); -} - -void fight_trainer_using_moves(VideoStream& stream, - ProControllerContext& context, - size_t move, - const std::string& label, - bool post_battle_dialogue_cleanup){ - fight_trainer_using_moves(stream, context, std::vector{move}, label, post_battle_dialogue_cleanup); -} + void fight_trainer_using_moves(VideoStream& stream, ProControllerContext& context, + const std::vector& moves, const std::string& label, bool post_battle_dialogue_cleanup) { + fight_using_move_list(stream,context,BattleType::TRAINER,moves,label,true,post_battle_dialogue_cleanup); + } -void fight_trainer_using_move_slot(VideoStream& stream, - ProControllerContext& context, - size_t move_slot, - const std::string& label){ - fight_using_move_slot(stream, context, BattleType::TRAINER, move_slot, label); -} + void fight_trainer_using_moves(VideoStream& stream, ProControllerContext& context, + size_t move, const std::string& label, bool post_battle_dialogue_cleanup) { + fight_trainer_using_moves(stream,context,std::vector{move},label,post_battle_dialogue_cleanup); + } -void fight_tristan(VideoStream& stream, ProControllerContext& context){ - // All starters use move slot 3. Confirm its cursor position - // visually without reading the move name; fail if slot 3 is absent. - fight_trainer_using_move_slot(stream, context, 3, "Tristan (move slot 3)"); -} + void fight_trainer_using_move_slot(VideoStream& stream, ProControllerContext& context, + size_t move_slot, const std::string& label) { + fight_using_move_slot(stream,context,BattleType::TRAINER,move_slot,label); + } -void battle_1_pokemon(VideoStream& stream, ProControllerContext& context){ - stream.log("[Training] Finding one wild Pokemon using the catch encounter wiggle.", COLOR_BLUE); - encounter_one_wild_pokemon(stream, context); - // Use the same battle loop as Tristan with move slot 1. - fight_using_move_slot(stream, context, BattleType::STANDARD, 1, "Training (move slot 1)"); - stream.log("[Training] Fight and level-up cleanup complete; verifying Potion healing."); - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - stream.log("[Training] One wild battle completed; slot 1 healing verified.", COLOR_GREEN); -} + void fight_story_battle(VideoStream& stream, ProControllerContext& context, const std::string& label, bool doubles) { + // The best move each turn; the next Pokemon after a faint; no new moves. + fight_using_move_list(stream, context, BattleType::TRAINER, {0}, label, false, true, doubles, true); + } -// --------------------------------------------------------------------------- -// Catch 1 Pokemon -// --------------------------------------------------------------------------- - -void catch_1_pokemon(VideoStream& stream, ProControllerContext& context){ - stream.log("[AutoStory] Starting catch_1_pokemon helper...", COLOR_BLUE); - - encounter_one_wild_pokemon(stream, context); - - // Catch the pokemon. - stream.log("[AutoStory] Throwing Poke Ball (manual bag open)...", COLOR_BLUE); - ReceivePokemonDetector received_pokemon; - // Preserve exit-transition state from the ball outcome through - // receipt/experience cleanup; do not recreate it between phases. - EndBattleWatcher battle_ended; - bool caught = false; - bool exit_seen = false; - for (int throw_attempt = 0; throw_attempt < 10; throw_attempt++){ - // Open bag from battle menu (X), confirm first slot (ZL). - pbf_press_button(context, BUTTON_X, 160ms, 840ms); - context.wait_for_all_requests(); - pbf_press_button(context, BUTTON_ZL, 160ms, 1000ms); - context.wait_for_all_requests(); - - // Wait to see if the battle menu comes back (ball failed) - // or if the battle ends (caught or fainted). - BattleMenuWatcher menu_back(BattleType::STANDARD, COLOR_YELLOW); - int throw_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - {{menu_back}, {received_pokemon}, {battle_ended}}); - - if (throw_ret == 1 || (throw_ret == 2 && received_pokemon.received())){ - caught = true; - exit_seen = throw_ret == 2; - stream.log("[AutoStory] Pokemon receipt verified.", COLOR_GREEN); - break; - } - if (throw_ret < 0 || throw_ret == 2){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Catch failed: No verified Pokemon receipt before battle ended or timed out.", - stream); - } - stream.log("[AutoStory] Ball failed, retrying (attempt " + std::to_string(throw_attempt + 2) + ")...", - COLOR_ORANGE); - } + void fight_trainer(VideoStream& stream, ProControllerContext& context, const std::string& label) { + // Move 0: the best move on the menu each turn, whatever the starter. + fight_using_move_slot(stream, context, BattleType::TRAINER, 0, label); + } - context.wait_for_all_requests(); + void battle_1_pokemon(VideoStream& stream, ProControllerContext& context) { + stream.log("[Training] Finding one wild Pokemon using the catch encounter wiggle.", COLOR_BLUE); + encounter_one_wild_pokemon(stream, context); + // The best move on the menu, whatever the starter. + fight_using_move_slot(stream, context, BattleType::STANDARD, 0, "Training"); + context.wait_for_all_requests(); + stream.log("[Training] One wild battle completed.", COLOR_GREEN); + } - if (!caught){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Catch failed: Exhausted ten ball attempts.", - stream); - } - // Receipt alone is not an overworld return: clear experience, - // automatic move learning, dex registration and nickname screens - // until the same completed-black-transition watcher used in battles fires. - if (!exit_seen){ - stream.log("[Catch cleanup] Receipt confirmed; waiting for battle exit transition."); - const int cleanup_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - {{battle_ended}}); - if (cleanup_ret != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Catch cleanup: Battle exit transition did not finish.", - stream); - } - } - context.wait_for_all_requests(); - stream.log("[Catch cleanup] Battle exit transition confirmed; checking stable overworld."); - confirm_battle_overworld(stream, context, "Catch cleanup"); - stream.log("[Catch cleanup] Verified receipt, completed battle exit, and four seconds of clear overworld.", - COLOR_GREEN); -} + // --------------------------------------------------------------------------- + // Catch 1 Pokemon + // --------------------------------------------------------------------------- + + void catch_1_pokemon( + VideoStream& stream, + ProControllerContext& context + ) { + stream.log("[AutoStory] Starting catch_1_pokemon helper...", COLOR_BLUE); + + encounter_one_wild_pokemon(stream, context); + + // Catch the pokemon. + stream.log("[AutoStory] Throwing Poke Ball (manual bag open)...", COLOR_BLUE); + ReceivePokemonDetector received_pokemon; + // Preserve exit-transition state from the ball outcome through + // receipt/experience cleanup; do not recreate it between phases. + EndBattleWatcher battle_ended; + bool caught = false; + bool exit_seen = false; + for (int throw_attempt = 0; throw_attempt < 10; throw_attempt++) { + // Open bag from battle menu (X), confirm first slot (ZL). + pbf_press_button(context, BUTTON_X, 160ms, 840ms); + context.wait_for_all_requests(); + pbf_press_button(context, BUTTON_ZL, 160ms, 1000ms); + context.wait_for_all_requests(); -// --------------------------------------------------------------------------- -// catch_1_pokemon_v2 - burst wiggle variant for testing. -// Uses WIGGLES_PER_BURST consecutive left/right moves before checking for a -// battle, reducing total wait time compared to checking after every move. -// Commented out - enable for testing when ready. -// --------------------------------------------------------------------------- -// void catch_1_pokemon_v2( -// SingleSwitchProgramEnvironment& env, -// VideoStream& stream, -// ProControllerContext& context -// ) { -// stream.log("[AutoStory] Starting catch_1_pokemon_v2 helper...", COLOR_BLUE); -// -// // Save once before attempting to catch. On failure the game -// // resets back to this save, so the save lives outside the loop. -// stream.log("[AutoStory] Saving game before catch attempt...", COLOR_BLUE); -// { -// static constexpr int SAVE_MAX_RETRIES = 3; -// bool save_succeeded = false; -// for (int save_attempt = 0; save_attempt < SAVE_MAX_RETRIES; save_attempt++) { -// try { -// if (save_attempt > 0) { -// stream.log( -// "[AutoStory] Retrying save (attempt " + std::to_string(save_attempt + 1) + ")...", -// COLOR_ORANGE -// ); -// pbf_mash_button(context, BUTTON_B, 1000ms); -// context.wait_for_all_requests(); -// } -// save_game(stream, context); -// save_succeeded = true; -// break; -// } -// catch (OperationFailedException& e) { -// stream.log( -// "[AutoStory] Save attempt " + std::to_string(save_attempt + 1) + " failed: " + e.message(), -// COLOR_RED -// ); -// context.wait_for(1000ms); -// } -// } -// if (!save_succeeded) { -// stream.log("[AutoStory] All save attempts failed. Using blind fallback save.", COLOR_ORANGE); -// pbf_mash_button(context, BUTTON_B, 1000ms); -// save_game(context); -// pbf_mash_button(context, BUTTON_B, 2000ms); -// } -// } -// context.wait_for_all_requests(); -// pbf_wait(context, 1000ms); -// -// // ------------------------------------------------------- -// // Mini-checkpoint loop: retry from the save above if we -// // run out of balls or our Pokemon faints. -// // ------------------------------------------------------- -// while (true) { -// -// BlackScreenWatcher battle_start; -// BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); -// BattleDialogWatcher battle_dialog(COLOR_YELLOW); -// -// bool encounter_found = false; -// -// // Wiggle left/right in bursts, checking for a battle after each burst. -// // Doing multiple moves before checking avoids waiting after every step. -// static constexpr int WIGGLES_PER_BURST = 3; -// while (!encounter_found) { -// for (int i = 0; i < WIGGLES_PER_BURST; i++) { -// pbf_move_left_joystick(context, {-1, 0}, 120ms, 80ms); -// pbf_move_left_joystick(context, { 1, 0}, 120ms, 80ms); -// } -// context.wait_for_all_requests(); -// -// int ret = wait_until( -// stream, -// context, -// 1500ms, -// { -// {battle_start}, -// {battle_menu}, -// {battle_dialog}, -// } -// ); -// -// if (ret >= 0) { -// encounter_found = true; -// } -// } -// -// stream.log("[AutoStory] Wild battle detected!", COLOR_GREEN); -// -// // Wait until the actual battle menu appears. -// BattleMenuWatcher confirm_menu(BattleType::STANDARD); -// -// int menu_ret = wait_until( -// stream, -// context, -// 15s, -// { -// {confirm_menu}, -// } -// ); -// -// if (menu_ret < 0) { -// stream.log( -// "[AutoStory] Failed to detect battle menu. Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// } -// -// stream.log("[AutoStory] Battle menu detected.", COLOR_GREEN); -// -// context.wait_for_all_requests(); -// pbf_wait(context, 500ms); -// -// // --- Throw balls --- -// CatchResults result = basic_catcher( -// stream, -// context, -// Language::English, -// "poke-ball", -// 10 -// ); -// -// switch (result.result) { -// case CatchResult::POKEMON_CAUGHT: -// stream.log( -// "[AutoStory] Pokemon successfully caught after " + -// std::to_string(result.balls_used) + " ball(s).", -// COLOR_GREEN -// ); -// { -// OverworldWatcher overworld; -// run_until( -// stream, context, -// [](ProControllerContext& ctx) { -// pbf_mash_button(ctx, BUTTON_B, 120000ms); -// }, -// {{overworld}} -// ); -// } -// return; -// -// case CatchResult::POKEMON_FAINTED: -// stream.log("[AutoStory] Wild Pokemon fainted. Exiting battle.", COLOR_ORANGE); -// { -// OverworldWatcher overworld; -// run_until( -// stream, context, -// [](ProControllerContext& ctx) { -// pbf_mash_button(ctx, BUTTON_B, 120000ms); -// }, -// {{overworld}} -// ); -// } -// return; -// -// case CatchResult::OUT_OF_BALLS: -// stream.log( -// "[AutoStory] Ran out of Poke Balls after " + -// std::to_string(result.balls_used) + " throw(s). Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// -// case CatchResult::OWN_FAINTED: -// stream.log( -// "[AutoStory] Own Pokemon fainted. Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// -// case CatchResult::BALL_LIMIT_REACHED: -// stream.log( -// "[AutoStory] Ball limit reached (" + -// std::to_string(result.balls_used) + " throw(s)). Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// -// case CatchResult::CANNOT_THROW_BALL: -// stream.log( -// "[AutoStory] Cannot throw ball (menu timing issue). Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// -// default: -// stream.log( -// "[AutoStory] Catch timed out after " + -// std::to_string(result.balls_used) + " ball(s). Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// } -// -// } // end while(true) -// } - -// --------------------------------------------------------------------------- -// Movement Helpers -// --------------------------------------------------------------------------- - -bool detect_black_screen_after_movement(VideoStream& stream, ProControllerContext& context, Milliseconds timeout){ - BlackScreenWatcher black_screen; - return wait_until(stream, context, timeout, {{black_screen}}) == 0; -} + // Wait to see if the battle menu comes back (ball failed) + // or if the battle ends (caught or fainted). + BattleMenuWatcher menu_back(BattleType::STANDARD, COLOR_YELLOW); + int throw_ret = run_until( + stream, context, + [](ProControllerContext& ctx) { + pbf_mash_button(ctx, BUTTON_B, 120000ms); + }, + {{menu_back}, {received_pokemon}, {battle_ended}} + ); + + if (throw_ret == 1 || (throw_ret == 2 && received_pokemon.received())) { + caught = true; + exit_seen = throw_ret == 2; + stream.log("[AutoStory] Pokemon receipt verified.", COLOR_GREEN); + break; + } + if (throw_ret < 0 || throw_ret == 2) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Catch failed: No verified Pokemon receipt before battle ended or timed out.", stream); + } + stream.log( + "[AutoStory] Ball failed, retrying (attempt " + std::to_string(throw_attempt + 2) + ")...", + COLOR_ORANGE + ); + } -bool try_right_down_exit_search(VideoStream& stream, ProControllerContext& context, size_t iterations){ - for (size_t i = 0; i < iterations; i++){ - pbf_move_left_joystick(context, {1.0, 0.0}, 80ms, 0ms); - pbf_move_left_joystick(context, {0.0, -1.0}, 220ms, 0ms); + context.wait_for_all_requests(); - if (detect_black_screen_after_movement(stream, context, 1000ms)){ - return true; - } - } - return false; -} + if (!caught) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Catch failed: Exhausted ten ball attempts.", stream); + } + // Receipt alone is not an overworld return: clear experience, + // automatic move learning, dex registration and nickname screens + // until the same completed-black-transition watcher used in battles fires. + if (!exit_seen) { + stream.log("[Catch cleanup] Receipt confirmed; waiting for battle exit transition."); + const int cleanup_ret = run_until(stream, context, + [](ProControllerContext& ctx) { pbf_mash_button(ctx, BUTTON_B, 120000ms); }, + {{battle_ended}}); + if (cleanup_ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Catch cleanup: Battle exit transition did not finish.", stream); + } + } + context.wait_for_all_requests(); + stream.log("[Catch cleanup] Battle exit transition confirmed; checking stable overworld."); + confirm_battle_overworld(stream, context, "Catch cleanup"); + stream.log("[Catch cleanup] Verified receipt, completed battle exit, and four seconds of clear overworld.", COLOR_GREEN); + } -void leave_pokemon_center_before_barry_1(VideoStream& stream, ProControllerContext& context){ - // Original down movement to leave Pokemon Center. - pbf_move_left_joystick(context, {0.0, -1.0}, 814ms, 0ms); + // --------------------------------------------------------------------------- + // catch_1_pokemon_v2 - burst wiggle variant for testing. + // Uses WIGGLES_PER_BURST consecutive left/right moves before checking for a + // battle, reducing total wait time compared to checking after every move. + // Commented out - enable for testing when ready. + // --------------------------------------------------------------------------- + // void catch_1_pokemon_v2( + // SingleSwitchProgramEnvironment& env, + // VideoStream& stream, + // ProControllerContext& context + // ) { + // stream.log("[AutoStory] Starting catch_1_pokemon_v2 helper...", COLOR_BLUE); + // + // // Save once before attempting to catch. On failure the game + // // resets back to this save, so the save lives outside the loop. + // stream.log("[AutoStory] Saving game before catch attempt...", COLOR_BLUE); + // { + // static constexpr int SAVE_MAX_RETRIES = 3; + // bool save_succeeded = false; + // for (int save_attempt = 0; save_attempt < SAVE_MAX_RETRIES; save_attempt++) { + // try { + // if (save_attempt > 0) { + // stream.log( + // "[AutoStory] Retrying save (attempt " + std::to_string(save_attempt + 1) + ")...", + // COLOR_ORANGE + // ); + // pbf_mash_button(context, BUTTON_B, 1000ms); + // context.wait_for_all_requests(); + // } + // save_game(stream, context); + // save_succeeded = true; + // break; + // } + // catch (OperationFailedException& e) { + // stream.log( + // "[AutoStory] Save attempt " + std::to_string(save_attempt + 1) + " failed: " + e.message(), + // COLOR_RED + // ); + // context.wait_for(1000ms); + // } + // } + // if (!save_succeeded) { + // stream.log("[AutoStory] All save attempts failed. Using blind fallback save.", COLOR_ORANGE); + // pbf_mash_button(context, BUTTON_B, 1000ms); + // save_game(context); + // pbf_mash_button(context, BUTTON_B, 2000ms); + // } + // } + // context.wait_for_all_requests(); + // pbf_wait(context, 1000ms); + // + // // ------------------------------------------------------- + // // Mini-checkpoint loop: retry from the save above if we + // // run out of balls or our Pokemon faints. + // // ------------------------------------------------------- + // while (true) { + // + // BlackScreenWatcher battle_start; + // BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + // BattleDialogWatcher battle_dialog(COLOR_YELLOW); + // + // bool encounter_found = false; + // + // // Wiggle left/right in bursts, checking for a battle after each burst. + // // Doing multiple moves before checking avoids waiting after every step. + // static constexpr int WIGGLES_PER_BURST = 3; + // while (!encounter_found) { + // for (int i = 0; i < WIGGLES_PER_BURST; i++) { + // pbf_move_left_joystick(context, {-1, 0}, 120ms, 80ms); + // pbf_move_left_joystick(context, { 1, 0}, 120ms, 80ms); + // } + // context.wait_for_all_requests(); + // + // int ret = wait_until( + // stream, + // context, + // 1500ms, + // { + // {battle_start}, + // {battle_menu}, + // {battle_dialog}, + // } + // ); + // + // if (ret >= 0) { + // encounter_found = true; + // } + // } + // + // stream.log("[AutoStory] Wild battle detected!", COLOR_GREEN); + // + // // Wait until the actual battle menu appears. + // BattleMenuWatcher confirm_menu(BattleType::STANDARD); + // + // int menu_ret = wait_until( + // stream, + // context, + // 15s, + // { + // {confirm_menu}, + // } + // ); + // + // if (menu_ret < 0) { + // stream.log( + // "[AutoStory] Failed to detect battle menu. Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // } + // + // stream.log("[AutoStory] Battle menu detected.", COLOR_GREEN); + // + // context.wait_for_all_requests(); + // pbf_wait(context, 500ms); + // + // // --- Throw balls --- + // CatchResults result = basic_catcher( + // stream, + // context, + // Language::English, + // "poke-ball", + // 10 + // ); + // + // switch (result.result) { + // case CatchResult::POKEMON_CAUGHT: + // stream.log( + // "[AutoStory] Pokemon successfully caught after " + + // std::to_string(result.balls_used) + " ball(s).", + // COLOR_GREEN + // ); + // { + // OverworldWatcher overworld; + // run_until( + // stream, context, + // [](ProControllerContext& ctx) { + // pbf_mash_button(ctx, BUTTON_B, 120000ms); + // }, + // {{overworld}} + // ); + // } + // return; + // + // case CatchResult::POKEMON_FAINTED: + // stream.log("[AutoStory] Wild Pokemon fainted. Exiting battle.", COLOR_ORANGE); + // { + // OverworldWatcher overworld; + // run_until( + // stream, context, + // [](ProControllerContext& ctx) { + // pbf_mash_button(ctx, BUTTON_B, 120000ms); + // }, + // {{overworld}} + // ); + // } + // return; + // + // case CatchResult::OUT_OF_BALLS: + // stream.log( + // "[AutoStory] Ran out of Poke Balls after " + + // std::to_string(result.balls_used) + " throw(s). Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // + // case CatchResult::OWN_FAINTED: + // stream.log( + // "[AutoStory] Own Pokemon fainted. Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // + // case CatchResult::BALL_LIMIT_REACHED: + // stream.log( + // "[AutoStory] Ball limit reached (" + + // std::to_string(result.balls_used) + " throw(s)). Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // + // case CatchResult::CANNOT_THROW_BALL: + // stream.log( + // "[AutoStory] Cannot throw ball (menu timing issue). Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // + // default: + // stream.log( + // "[AutoStory] Catch timed out after " + + // std::to_string(result.balls_used) + " ball(s). Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // } + // + // } // end while(true) + // } + + // --------------------------------------------------------------------------- + // Movement Helpers + // --------------------------------------------------------------------------- + + bool detect_black_screen_after_movement( + VideoStream& stream, + ProControllerContext& context, + Milliseconds timeout + ) { + BlackScreenWatcher black_screen; + return wait_until(stream, context, timeout, {{black_screen}}) == 0; + } - if (detect_black_screen_after_movement(stream, context, 1200ms)){ - pbf_wait(context, 2340ms); - return; - } + bool try_right_down_exit_search( + VideoStream& stream, + ProControllerContext& context, + size_t iterations + ) { + for (size_t i = 0; i < iterations; i++) { + pbf_move_left_joystick(context, { 1.0, 0.0 }, 80ms, 0ms); + pbf_move_left_joystick(context, { 0.0, -1.0 }, 220ms, 0ms); + + if (detect_black_screen_after_movement(stream, context, 1000ms)) { + return true; + } + } + return false; + } - stream.log("[AutoStory] Pokemon Center exit black screen not detected. Searching with right/down movement.", - COLOR_ORANGE); + void leave_pokemon_center_before_barry_1( + VideoStream& stream, + ProControllerContext& context + ) { + // Original down movement to leave Pokemon Center. + pbf_move_left_joystick(context, { 0.0, -1.0 }, 814ms, 0ms); - if (try_right_down_exit_search(stream, context, 20)){ - pbf_wait(context, 2340ms); - return; - } + if (detect_black_screen_after_movement(stream, context, 1200ms)) { + pbf_wait(context, 2340ms); + return; + } - stream.log("[AutoStory] First exit search failed. Repositioning left and retrying.", COLOR_ORANGE); - pbf_move_left_joystick(context, {-1.0, 0.0}, 5000ms, 0ms); + stream.log("[AutoStory] Pokemon Center exit black screen not detected. Searching with right/down movement.", COLOR_ORANGE); - if (try_right_down_exit_search(stream, context, 30)){ - pbf_wait(context, 2340ms); - return; - } + if (try_right_down_exit_search(stream, context, 20)) { + pbf_wait(context, 2340ms); + return; + } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Failed to find Pokemon Center exit black screen before Barry 1. Triggering checkpoint retry.", - stream); -} + stream.log("[AutoStory] First exit search failed. Repositioning left and retrying.", COLOR_ORANGE); + pbf_move_left_joystick(context, { -1.0, 0.0 }, 5000ms, 0ms); -// --------------------------------------------------------------------------- -// Generic Movement Helper - Walk through grass in specified direction for -// specified steps, attempting to run from any battles that are encountered. -// --------------------------------------------------------------------------- -void walk_through_grass(VideoStream& stream, - ProControllerContext& context, - int8_t x, - int8_t y, - size_t steps, - const std::string& direction_name, - BattleType battle_type, - Milliseconds movement_duration, - OverworldMatchResult (*destination)(const ImageRGB32&)){ - size_t movements = 0, confirmations = 0; - while (true){ - context.wait_for_all_requests(); - // Destination checks happen before input and after each step/escape. - // With no matcher, retain the original fixed-step behavior. - VideoOverlaySet overlays(stream.overlay()); - OverworldMatchResult location; - if (destination){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Grass destination: No video frame.", - stream); - } - location = destination(*frame.frame); - for (const auto& landmark : location.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); - } - stream.log("[Grass destination] " + direction_name + " " + landmark.name + - " score=" + std::to_string(landmark.score) + " margin=" + std::to_string(landmark.margin) + - " accepted=" + std::to_string(landmark.accepted)); - } - stream.log("[Grass destination] located=" + std::to_string(location.located) + - " at_target=" + std::to_string(location.at_target) + " screen-relative offset=(" + - std::to_string(location.dx) + "," + std::to_string(location.dy) + ")"); - if (location.at_target){ - if (++confirmations >= 3){ - stream.log("[Grass] Destination confirmed; stopped " + direction_name + " after " + - std::to_string(movements) + "/" + std::to_string(steps) + " movements.", - COLOR_GREEN); + if (try_right_down_exit_search(stream, context, 30)) { + pbf_wait(context, 2340ms); return; } - pbf_wait(context, 250ms); - continue; + + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "Failed to find Pokemon Center exit black screen before Barry 1. Triggering checkpoint retry.", + stream + ); } - confirmations = 0; - } - if (movements >= steps){ - if (destination){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Grass destination not confirmed within movement limit: " + direction_name, - stream); + + // --------------------------------------------------------------------------- + // Generic Movement Helper - Walk through grass in specified direction for + // specified steps, attempting to run from any battles that are encountered. + // --------------------------------------------------------------------------- + void walk_through_grass( + VideoStream& stream, ProControllerContext& context, + int8_t x, int8_t y, size_t steps, const std::string& direction_name, + BattleType battle_type, Milliseconds movement_duration, + OverworldMatchResult (*destination)(const ImageRGB32&) + ) { + size_t movements = 0, confirmations = 0; + while (true) { + context.wait_for_all_requests(); + // Destination checks happen before input and after each step/escape. + // With no matcher, retain the original fixed-step behavior. + VideoOverlaySet overlays(stream.overlay()); + OverworldMatchResult location; + if (destination) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Grass destination: No video frame.", stream); + location = destination(*frame.frame); + for (const auto& landmark : location.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[Grass destination] " + direction_name + " " + landmark.name + + " score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin) + + " accepted=" + std::to_string(landmark.accepted)); + } + stream.log("[Grass destination] located=" + std::to_string(location.located) + + " at_target=" + std::to_string(location.at_target) + + " screen-relative offset=(" + std::to_string(location.dx) + + "," + std::to_string(location.dy) + ")"); + if (location.at_target) { + if (++confirmations >= 3) { + stream.log("[Grass] Destination confirmed; stopped " + direction_name + + " after " + std::to_string(movements) + "/" + std::to_string(steps) + " movements.", COLOR_GREEN); + return; + } + pbf_wait(context, 250ms); + continue; + } + confirmations = 0; + } + if (movements >= steps) { + if (destination) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Grass destination not confirmed within movement limit: " + direction_name, stream); + stream.log("[Grass] Completed " + direction_name + " (" + std::to_string(steps) + " steps).", COLOR_GREEN); + return; + } + int8_t move_x=x, move_y=y; + Milliseconds duration=movement_duration; + if (destination && location.located && !location.coarse && + std::abs(location.dx)<=0.10 && std::abs(location.dy)<=0.12) { + // Small corrections prevent stepping past a narrow stop target. + const bool horizontal=std::abs(location.dx)>=std::abs(location.dy)*9.0/16; + const double error=horizontal ? location.dx : location.dy*9.0/16; + duration=std::min(duration, std::abs(error)>0.03125 ? 80ms : + std::abs(error)>0.015625 ? 40ms : 16ms); + move_x=horizontal ? (location.dx>0 ? 1 : -1) : 0; + move_y=horizontal ? 0 : (location.dy<0 ? 1 : -1); + stream.log("[Grass destination] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + + " axis for " + std::to_string(duration.count()) + "ms."); + } + // Each movement gets fresh watcher state. A completed white + // transition from a prior encounter must not trigger again. + BlackScreenWatcher battle_start; + WhiteScreenOverWatcher white_screen(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); + BattleMenuWatcher battle_menu(battle_type, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + pbf_move_left_joystick(context, {(double)move_x, (double)move_y}, duration, 0ms); + movements++; + context.wait_for_all_requests(); + int ret = wait_until(stream, context, 2500ms, + {{white_screen}, {battle_start}, {battle_dialog}, {battle_menu}}); + if (ret >= 0) { + confirmations = 0; + stream.log("[Grass] " + direction_name + " step " + std::to_string(movements) + + "/" + std::to_string(steps) + ": encounter trigger=" + std::to_string(ret) + + "; using shared confirmed-Run escape."); + escape_wild_battle(stream, context, battle_type); + context.wait_for_all_requests(); + stream.log("[Grass] Escape confirmed; resuming " + direction_name + "."); + } + } } - stream.log("[Grass] Completed " + direction_name + " (" + std::to_string(steps) + " steps).", COLOR_GREEN); - return; - } - int8_t move_x = x, move_y = y; - Milliseconds duration = movement_duration; - if (destination && location.located && !location.coarse && std::abs(location.dx) <= 0.10 && - std::abs(location.dy) <= 0.12){ - // Small corrections prevent stepping past a narrow stop target. - const bool horizontal = std::abs(location.dx) >= std::abs(location.dy) * 9.0 / 16; - const double error = horizontal ? location.dx : location.dy * 9.0 / 16; - duration = std::min(duration, std::abs(error) > 0.03125 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms); - move_x = horizontal ? (location.dx > 0 ? 1 : -1) : 0; - move_y = horizontal ? 0 : (location.dy < 0 ? 1 : -1); - stream.log("[Grass destination] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + - " axis for " + std::to_string(duration.count()) + "ms."); - } - // Each movement gets fresh watcher state. A completed white - // transition from a prior encounter must not trigger again. - BlackScreenWatcher battle_start; - WhiteScreenOverWatcher white_screen(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); - BattleMenuWatcher battle_menu(battle_type, COLOR_YELLOW); - BattleDialogWatcher battle_dialog(COLOR_YELLOW); - pbf_move_left_joystick(context, {(double)move_x, (double)move_y}, duration, 0ms); - movements++; - context.wait_for_all_requests(); - int ret = wait_until(stream, context, 2500ms, {{white_screen}, {battle_start}, {battle_dialog}, {battle_menu}}); - if (ret >= 0){ - confirmations = 0; - stream.log("[Grass] " + direction_name + " step " + std::to_string(movements) + "/" + - std::to_string(steps) + ": encounter trigger=" + std::to_string(ret) + - "; using shared confirmed-Run escape."); - escape_wild_battle(stream, context, battle_type); - context.wait_for_all_requests(); - stream.log("[Grass] Escape confirmed; resuming " + direction_name + "."); + } } } - -} -} -} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h index 47a1de720e..351ed5ab5b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h @@ -1,139 +1,161 @@ #pragma once +#include "PokemonBDSP_OverworldMatchers.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "../StorySegments/PokemonBDSP_AutoStory_Segment_00.h" #include "CommonTools/Async/InferenceRoutines.h" #include "NintendoSwitch/Programs/DateManip/NintendoSwitch_DateManip.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" #include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" -#include "PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.h" - -#include "PokemonBDSP_OverworldMatchers.h" namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - -// --------------------------------------------------------------------------- -// Utility Watchers -// --------------------------------------------------------------------------- - -class BattleDialogWatcher : public DetectorToFinder { -public: - BattleDialogWatcher(Color color = COLOR_RED); -}; - -// --------------------------------------------------------------------------- -// Time / setup helpers -// --------------------------------------------------------------------------- - -// Shared visual alignment and dialogue cleanup used by story routes. -void clear_post_battle_dialogue(VideoStream& stream, - ProControllerContext& context, - Button button = BUTTON_B, - Milliseconds quiet_period = Milliseconds(3000), - const char* instruction_card = nullptr); -void align_to_overworld_landmarks(VideoStream& stream, - ProControllerContext& context, - OverworldMatchResult (*matcher)(const ImageRGB32&), - const std::string& label, - bool allow_story_start = false, - bool horizontal_first = false, - bool search_down_first = false, - OverworldMatchResult (*entry_matcher)(const ImageRGB32&) = nullptr, - bool* entry_detected = nullptr, - double arrival_tolerance_x = 0, - double arrival_tolerance_y = 0, - double moderate_entry_confidence = 0, - bool* rock_acquisition_timed_out = nullptr); - -void verify_tutorial_settings(VideoStream& stream, ProControllerContext& context); - -void set_time_fast(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - const DateTime& target, - bool preserve_date = false // Keep current year/month/day; set only target time. -); - -// --------------------------------------------------------------------------- -// Battles -// --------------------------------------------------------------------------- - -void fight_starly(VideoStream& stream, ProControllerContext& context); - -// Select the requested 1-based move slot using visual cursor confirmation. -void fight_trainer_using_move_slot(VideoStream& stream, - ProControllerContext& context, - size_t move_slot, - const std::string& label); - -void fight_tristan(VideoStream& stream, ProControllerContext& context); - -void fight_Natalie(VideoStream& stream, ProControllerContext& context); - -// --------------------------------------------------------------------------- -// Catching / healing -// --------------------------------------------------------------------------- - -void catch_1_pokemon(VideoStream& stream, ProControllerContext& context); - -// One wild encounter using move slot 1, followed by verified Potion healing. -void battle_1_pokemon(VideoStream& stream, ProControllerContext& context); - -// Burst wiggle variant of catch_1_pokemon for testing. -// Commented out in the .cpp - uncomment both to enable. -// void catch_1_pokemon_v2( -// SingleSwitchProgramEnvironment& env, -// VideoStream& stream, -// ProControllerContext& context -// ); - -void use_potion_first_pokemon(VideoStream& stream, ProControllerContext& context); - -// --------------------------------------------------------------------------- -// Grass movement helpers -// --------------------------------------------------------------------------- - -void walk_through_grass(VideoStream& stream, - ProControllerContext& context, - int8_t x, - int8_t y, - size_t steps, - const std::string& direction_name, - BattleType battle_type = BattleType::STANDARD, - Milliseconds movement_duration = Milliseconds(200), - OverworldMatchResult (*destination)(const ImageRGB32&) = nullptr); - -bool detect_black_screen_after_movement(VideoStream& stream, ProControllerContext& context, Milliseconds timeout); - -bool try_right_down_exit_search(VideoStream& stream, ProControllerContext& context, size_t iterations); - -void leave_pokemon_center_before_barry_1(VideoStream& stream, ProControllerContext& context); -// --------------------------------------------------------------------------- -// Helper function to navigate the poketech challenge clown dialogue. -// Example usage -// Move up and talk to clown. -// clown_dialog_helper( -// stream, -// context, -// DPAD_UP -// ); - -// Move left and talk to clown. -// clown_dialog_helper( -// stream, -// context, -// DPAD_LEFT -// ); - -// Move right and talk to clown. -// clown_dialog_helper( -// stream, -// context, -// DPAD_RIGHT -// ); -// ---------------------------------------------------------------------------- -void clown_dialog_helper(VideoStream& stream, ProControllerContext& context, DpadPosition direction); -} -} + namespace NintendoSwitch { + namespace PokemonBDSP { + + // --------------------------------------------------------------------------- + // Utility Watchers + // --------------------------------------------------------------------------- + + class BattleDialogWatcher : public DetectorToFinder { + public: + BattleDialogWatcher(Color color = COLOR_RED); + }; + + // --------------------------------------------------------------------------- + // Time / setup helpers + // --------------------------------------------------------------------------- + + // Shared visual alignment and dialogue cleanup used by story routes. + void clear_post_battle_dialogue(VideoStream& stream, ProControllerContext& context, + Button button = BUTTON_B, Milliseconds quiet_period = Milliseconds(3000), + const char* instruction_card = nullptr); + void align_to_overworld_landmarks(VideoStream& stream, ProControllerContext& context, + OverworldMatchResult (*matcher)(const ImageRGB32&), const std::string& label, bool allow_story_start = false, bool horizontal_first = false, + bool search_down_first = false, + OverworldMatchResult (*entry_matcher)(const ImageRGB32&) = nullptr, + bool* entry_detected = nullptr, + double arrival_tolerance_x = 0, double arrival_tolerance_y = 0, + double moderate_entry_confidence = 0, + bool* rock_acquisition_timed_out = nullptr); + + void verify_tutorial_settings(VideoStream& stream, ProControllerContext& context); + + void set_time_fast( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const DateTime& target, + bool preserve_date = false // Keep current year/month/day; set only target time. + ); + + // --------------------------------------------------------------------------- + // Battles + // --------------------------------------------------------------------------- + + void fight_starly( + VideoStream& stream, + ProControllerContext& context + ); + + // Select the requested 1-based move slot using visual cursor confirmation. + void fight_trainer_using_move_slot(VideoStream& stream, ProControllerContext& context, + size_t move_slot, const std::string& label); + + // Any trainer battle, whatever the starter: uses move slot 1 until the + // battle ends. Slot 1 is a damaging move (Tackle, Scratch or Pound) for + // every starter. + void fight_trainer(VideoStream& stream, ProControllerContext& context, const std::string& label); + + // A story battle that may go badly: the best move each turn, Potions + // under 35 %, the next Pokemon when one faints, and "Keep old moves" + // on level-up prompts. `doubles` confirms the default target each + // turn (tag and twin battles). Returns once the battle has ended, + // won or lost; after a loss we stand in the last Pokemon Center. + void fight_story_battle(VideoStream& stream, ProControllerContext& context, const std::string& label, bool doubles = false); + + // --------------------------------------------------------------------------- + // Catching / healing + // --------------------------------------------------------------------------- + + void catch_1_pokemon( + VideoStream& stream, + ProControllerContext& context + ); + + // Finds one wild Pokemon in the grass by stepping left and right, and + // fights it with the best move on the menu. Does not heal. + void battle_1_pokemon(VideoStream& stream, ProControllerContext& context); + + // Burst wiggle variant of catch_1_pokemon for testing. + // Commented out in the .cpp - uncomment both to enable. + // void catch_1_pokemon_v2( + // SingleSwitchProgramEnvironment& env, + // VideoStream& stream, + // ProControllerContext& context + // ); + + // --------------------------------------------------------------------------- + // Grass movement helpers + // --------------------------------------------------------------------------- + + void walk_through_grass( + VideoStream& stream, + ProControllerContext& context, + int8_t x, + int8_t y, + size_t steps, + const std::string& direction_name, + BattleType battle_type = BattleType::STANDARD, + Milliseconds movement_duration = Milliseconds(200), + OverworldMatchResult (*destination)(const ImageRGB32&) = nullptr + ); + + bool detect_black_screen_after_movement( + VideoStream& stream, + ProControllerContext& context, + Milliseconds timeout + ); + + bool try_right_down_exit_search( + VideoStream& stream, + ProControllerContext& context, + size_t iterations + ); + + void leave_pokemon_center_before_barry_1( + VideoStream& stream, + ProControllerContext& context + ); + // --------------------------------------------------------------------------- + // Helper function to navigate the poketech challenge clown dialogue. + // Example usage + // Move up and talk to clown. + // clown_dialog_helper( + // stream, + // context, + // DPAD_UP + // ); + + // Move left and talk to clown. + // clown_dialog_helper( + // stream, + // context, + // DPAD_LEFT + // ); + + // Move right and talk to clown. + // clown_dialog_helper( + // stream, + // context, + // DPAD_RIGHT + // ); + // ---------------------------------------------------------------------------- + void clown_dialog_helper( + VideoStream& stream, + ProControllerContext& context, + DpadPosition direction + ); + } + } } \ No newline at end of file diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp new file mode 100644 index 0000000000..0703dcbece --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp @@ -0,0 +1,365 @@ +/* BDSP AutoStory - Walk + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTools/ImageDiff.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP_AutoStory_Segment_00_tools.h" +#include "PokemonBDSP_Autostory_Segment_01_tools.h" +#include "PokemonBDSP_AutoStory_Walk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; + + +const char* walk_stop_name(WalkStop stop){ + switch (stop){ + case WalkStop::ARRIVED: return "arrived"; + case WalkStop::DIALOG: return "dialog"; + case WalkStop::TRANSITION: return "transition"; + case WalkStop::BLOCKED: return "blocked"; + case WalkStop::LIMIT: return "limit"; + } + return "?"; +} + +double walk_screen_change(const ImageViewRGB32& before, const ImageViewRGB32& after){ + // Small copies: only the whole-view scroll matters, not detail. + return ImageMatch::pixel_RMSD(before.scale_to(192, 108), after.scale_to(192, 108)); +} + + +namespace{ + +VideoSnapshot snapshot_or_fail(VideoStream& stream, const std::string& label){ + VideoSnapshot frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No video frame.", stream); + } + return frame; +} + +// After a black screen: a wild battle if a battle screen shows up, else a transition. +bool black_screen_was_battle(VideoStream& stream, ProControllerContext& context){ + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + BlackScreenWatcher gone(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 100, 10, + BlackScreenWatcher::FinderType::GONE, 500ms); + const int ret = wait_until(stream, context, 10s, {{battle_menu}, {battle_dialog}, {gone}}); + if (ret == 2){ + // The battle intro also starts from black; give its first screen a moment. + BattleMenuWatcher late_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher late_dialog(COLOR_YELLOW); + return wait_until(stream, context, 2s, {{late_menu}, {late_dialog}}) >= 0; + } + return ret >= 0; +} + +// A grass encounter opens with a leaf animation, neither white nor black, and +// shows the battle's text box a few seconds later. After a change of the +// whole screen that walking cannot make, wait for the battle to show. +bool battle_follows(VideoStream& stream, ProControllerContext& context){ + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + return wait_until(stream, context, 5s, {{battle_menu}, {battle_dialog}}) >= 0; +} + +// A step in tall grass that was started before the stick was released still +// finishes, and its encounter starts after the walk has ended. Watch a little +// longer, and run from it. Returns whether there was one. +bool run_from_late_encounter(VideoStream& stream, ProControllerContext& context, const std::string& label){ + VideoSnapshot before = stream.video().snapshot(); + WhiteScreenOverWatcher encounter_flash(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); + BlackScreenWatcher black(COLOR_RED); + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + const int ret = wait_until(stream, context, 1200ms, {{encounter_flash}, {black}, {battle_menu}, {battle_dialog}}); + bool battle = ret == 0 || ret == 2 || ret == 3 || (ret == 1 && black_screen_was_battle(stream, context)); + if (ret < 0){ + VideoSnapshot after = stream.video().snapshot(); + battle = before && after && walk_screen_change(*before.frame, *after.frame) > WALK_SCENE_CHANGE && + battle_follows(stream, context); + } + if (!battle){ + return false; + } + stream.log("[Walk] " + label + ": wild battle after the walk; running."); + escape_wild_battle(stream, context, BattleType::STANDARD); + context.wait_for_all_requests(); + return true; +} + +// Samples the screen every 100ms for probe_walk() and stops it once the view +// has been still for 800ms. +class WalkProbeSampler : public VisualInferenceCallback{ +public: + WalkProbeSampler(WalkProbe& result, double moving_change, WallClock start) + : VisualInferenceCallback("WalkProbeSampler") + , m_result(result) + , m_moving_change(moving_change) + , m_start(start) + {} + virtual void make_overlays(VideoOverlaySet&) const override{} + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override{ + if (m_has_last && timestamp - m_last_time < 100ms){ + return false; + } + const Milliseconds at = std::chrono::duration_cast(timestamp - m_start); + ImageRGB32 small = frame.scale_to(192, 108); + if (m_has_last){ + const double change = ImageMatch::pixel_RMSD(m_last, small); + m_result.samples.push_back({at, change}); + if (change >= m_moving_change){ + if (m_result.first_move < 0ms){ + m_result.first_move = at; + } + m_result.last_move = at; + } + } + m_last = std::move(small); + m_last_time = timestamp; + m_has_last = true; + const Milliseconds still_since = m_result.last_move < 0ms ? 0ms : m_result.last_move; + return at - still_since >= 800ms; + } + +private: + WalkProbe& m_result; + double m_moving_change; + WallClock m_start; + ImageRGB32 m_last; + WallClock m_last_time; + bool m_has_last = false; +}; + +} + + +WalkResult walk_until( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label, + const std::function& arrived +){ + constexpr Milliseconds CHUNK = 400ms; + WalkResult result{WalkStop::LIMIT, 0ms, 0}; + size_t unchanged = 0; + + context.wait_for_all_requests(); + VideoSnapshot before = snapshot_or_fail(stream, label); + auto finish = [&](WalkStop stop){ + result.stop = stop; + if ((stop == WalkStop::LIMIT || stop == WalkStop::BLOCKED) && + run_from_late_encounter(stream, context, label) + ){ + result.wild_battles++; + } + stream.log("[Walk] " + label + ": " + walk_stop_name(stop) + " after " + + std::to_string(result.walked.count()) + "ms, " + + std::to_string(result.wild_battles) + " wild battle(s)."); + return result; + }; + + while (true){ + if (arrived && arrived(*before.frame)){ + return finish(WalkStop::ARRIVED); + } + if (result.walked >= limit){ + return finish(WalkStop::LIMIT); + } + const Milliseconds chunk = std::min(CHUNK, limit - result.walked); + + WhiteScreenOverWatcher encounter_flash(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); + BlackScreenWatcher black(COLOR_RED); + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + ShortDialogWatcher dialog(COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + const int ret = run_until( + stream, context, + [&](ProControllerContext& context){ + pbf_move_left_joystick(context, {x, y}, chunk, 0ms); + }, + {{encounter_flash}, {black}, {battle_menu}, {dialog}, {battle_dialog}} + ); + result.walked += chunk; + + bool battle = ret == 0 || ret == 2 || ret == 4; + if (ret == 1){ + battle = black_screen_was_battle(stream, context); + if (!battle){ + return finish(WalkStop::TRANSITION); + } + } + if (battle){ + result.wild_battles++; + stream.log("[Walk] " + label + ": wild battle; running."); + escape_wild_battle(stream, context, BattleType::STANDARD); + context.wait_for_all_requests(); + before = snapshot_or_fail(stream, label); + unchanged = 0; + continue; + } + if (ret == 3){ + return finish(WalkStop::DIALOG); + } + + context.wait_for_all_requests(); + VideoSnapshot after = snapshot_or_fail(stream, label); + const double change = walk_screen_change(*before.frame, *after.frame); + stream.log("[Walk] " + label + ": " + std::to_string(result.walked.count()) + + "ms, screen change " + std::to_string(change) + "."); + if (change > WALK_SCENE_CHANGE && battle_follows(stream, context)){ + result.wild_battles++; + stream.log("[Walk] " + label + ": wild battle (leaf opening); running."); + escape_wild_battle(stream, context, BattleType::STANDARD); + context.wait_for_all_requests(); + before = snapshot_or_fail(stream, label); + unchanged = 0; + continue; + } + // Two still chunks in a row, so one dropped frame does not stop us. A + // frame exactly the same is a stalled video or a game hitch, not a wall: + // standing at a wall still animates. + if (change == 0){ + before = std::move(after); + continue; + } + unchanged = change < WALK_BLOCKED_CHANGE ? unchanged + 1 : 0; + if (unchanged >= 2){ + return finish(WalkStop::BLOCKED); + } + before = std::move(after); + } +} + +WalkResult walk_for( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds duration, const std::string& label +){ + WalkResult result{WalkStop::LIMIT, duration, 0}; + context.wait_for_all_requests(); + WhiteScreenOverWatcher encounter_flash(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); + BlackScreenWatcher black(COLOR_RED); + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + ShortDialogWatcher dialog(COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + const int ret = run_until( + stream, context, + [&](ProControllerContext& context){ + pbf_move_left_joystick(context, {x, y}, duration, 0ms); + }, + {{encounter_flash}, {black}, {battle_menu}, {dialog}, {battle_dialog}} + ); + bool battle = ret == 0 || ret == 2 || ret == 4; + if (ret == 1){ + battle = black_screen_was_battle(stream, context); + if (!battle){ + result.stop = WalkStop::TRANSITION; + } + } + if (battle){ + result.wild_battles = 1; + stream.log("[Walk] " + label + ": wild battle; running."); + escape_wild_battle(stream, context, BattleType::STANDARD); + context.wait_for_all_requests(); + } + if (ret == 3){ + result.stop = WalkStop::DIALOG; + } + // An encounter after the hold leaves us where the walk ended, so it does + // not count as cutting the walk short. + if (ret < 0){ + run_from_late_encounter(stream, context, label); + } + stream.log("[Walk] " + label + ": " + walk_stop_name(result.stop) + " after a " + + std::to_string(duration.count()) + "ms hold, " + std::to_string(result.wild_battles) + " wild battle(s)."); + return result; +} + +WalkResult walk_to_talk( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label +){ + WalkResult total{WalkStop::LIMIT, 0ms, 0}; + while (total.walked < limit){ + const WalkResult step = walk_until(stream, context, x, y, + std::min(400ms, limit - total.walked), label); + total.walked += step.walked; + total.wild_battles += step.wild_battles; + if (step.stop == WalkStop::DIALOG || step.stop == WalkStop::TRANSITION){ + total.stop = step.stop; + return total; + } + ShortDialogWatcher dialog(COLOR_YELLOW); + const int ret = run_until( + stream, context, + [](ProControllerContext& context){ + pbf_press_button(context, BUTTON_A, 80ms, 720ms); + }, + {{dialog}} + ); + if (ret == 0){ + total.stop = WalkStop::DIALOG; + stream.log("[Walk] " + label + ": talking after " + std::to_string(total.walked.count()) + "ms."); + return total; + } + } + stream.log("[Walk] " + label + ": nobody to talk to within " + std::to_string(limit.count()) + "ms."); + return total; +} + +WalkProbe probe_walk( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, + double moving_change, const std::string& label +){ + WalkProbe result; + context.wait_for_all_requests(); + WalkProbeSampler sampler(result, moving_change, current_time()); + run_until( + stream, context, + [&](ProControllerContext& context){ + pbf_move_left_joystick(context, {x, y}, limit, 0ms); + }, + {{sampler}} + ); + context.wait_for_all_requests(); + stream.log("[Walk] " + label + ": probe moved from " + std::to_string(result.first_move.count()) + + "ms to " + std::to_string(result.last_move.count()) + "ms."); + return result; +} + +WalkResult walk_expecting( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label, + WalkStop expected, + const std::function& arrived +){ + const WalkResult result = walk_until(stream, context, x, y, limit, label, arrived); + if (result.stop != expected){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": expected " + walk_stop_name(expected) + ", got " + + walk_stop_name(result.stop) + ".", stream); + } + return result; +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h new file mode 100644 index 0000000000..77deca0893 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h @@ -0,0 +1,110 @@ +/* BDSP AutoStory - Walk + * + * From: https://github.com/PokemonAutomation/ + * + * Walking that reads the screen instead of trusting a duration: hold a + * direction and stop on whatever the screen says happened. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Walk_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Walk_H + +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +enum class WalkStop{ + ARRIVED, // `arrived` matched. + DIALOG, // A dialogue box opened: an NPC or a trainer stopped us. + TRANSITION, // The screen went black and came back without a battle: a door or a new area. + BLOCKED, // The screen stopped changing: we are walking into a wall. + LIMIT, // Walked `limit` without any of the above. +}; +const char* walk_stop_name(WalkStop stop); + +struct WalkResult{ + WalkStop stop; + Milliseconds walked; + size_t wild_battles; +}; + +// Holds the left stick at (x, y) until one of the WalkStop events. Wild battles on +// the way are run from, and walking resumes. Does not throw for any WalkStop; the +// caller decides which ones are expected. +WalkResult walk_until( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label, + const std::function& arrived = nullptr +); + +// Holds the left stick at (x, y) for `duration` in one go, for a walk of a +// set length: walk_until() checks the screen between short holds, and each +// break can cost a step. Stops early for a dialog or a transition. A wild +// battle is run from and ends the walk with `wild_battles` 1, at a place the +// caller cannot know. +WalkResult walk_for( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds duration, const std::string& label +); + +// walk_until() that requires `expected`. Fails with the screenshot otherwise. +WalkResult walk_expecting( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label, + WalkStop expected, + const std::function& arrived = nullptr +); + +// Walks toward someone to talk to: after each short stretch, presses A and stops +// once a dialogue opens. Use it where walking into the person does not read as +// BLOCKED, e.g. in tall grass, whose sway keeps the screen changing. +WalkResult walk_to_talk( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label +); + +// How much the screen changed between two frames, 0 for identical. Walking scrolls +// the whole view: 16 to 85 per 400ms on Route 202, against about 6 for running in +// place against trees. +double walk_screen_change(const ImageViewRGB32& before, const ImageViewRGB32& after); +constexpr double WALK_BLOCKED_CHANGE = 10.0; +// More than walking changes in 400ms: a new scene, such as a battle opening. +constexpr double WALK_SCENE_CHANGE = 120.0; + + +// One straight walk timed finely, to check a map against the game: holds the +// stick at (x, y) and samples the screen every 100ms until the view has been +// still for 800ms or `limit` runs out. Times count from the start of the hold. +// A sample moved if it changed by at least `moving_change`: still is 4 to 7, +// walking 16 to 85. Handles no battles or dialogs: use it on open ground, +// away from tall grass. +struct WalkProbe{ + Milliseconds first_move{-1}; // the first sample that moved; -1 if none did + Milliseconds last_move{-1}; // the last sample that moved; -1 if none did + struct Sample{ + Milliseconds at; + double change; // walk_screen_change() since the previous sample + }; + std::vector samples; +}; +WalkProbe probe_walk( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, + double moving_change, const std::string& label +); + + +} +} +} +#endif diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 904e94600f..f15a8ab4ae 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -338,6 +338,8 @@ file(GLOB CORE_LIBRARY_SOURCES Source/NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController_from_Keyboard.h Source/NintendoSwitch/Controllers/Procon/NintendoSwitch_ProControllerState.cpp Source/NintendoSwitch/Controllers/Procon/NintendoSwitch_ProControllerState.h + Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.cpp + Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h Source/NintendoSwitch/NintendoSwitch_Settings.cpp Source/NintendoSwitch/NintendoSwitch_Settings.h Source/NintendoSwitch/Options/NintendoSwitch_CodeEntrySettingsOption.cpp @@ -1515,6 +1517,36 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/Resources/Pokemon_PokemonNames.h Source/Pokemon/Resources/Pokemon_PokemonSlugs.cpp Source/Pokemon/Resources/Pokemon_PokemonSlugs.h + Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.cpp + Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.cpp Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.cpp