diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index 1c19573466..502c22e7e1 100644 --- a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp +++ b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp @@ -27,6 +27,7 @@ #include "NintendoSwitch/Inference/NintendoSwitch_UpdatePopupDetector.h" #include "NintendoSwitch/Programs/Typewriter/NintendoSwitch_Typewriter_Tests.h" +#include "PokemonBDSP/PokemonBDSP_Tests.h" #include "VideoGames/PokemonHome/PokemonHome_Tests.h" #include "VideoGames/PokemonSwSh/PokemonSwSh_Tests.h" #include "VideoGames/PokemonLA/PokemonLA_Tests.h" @@ -62,6 +63,7 @@ UnitTestDatabase make_UNIT_TESTS_ALL(){ NintendoSwitch::add_tests_FailedToConnectDetector(ret); NintendoSwitch::add_tests_UpdatePopupDetector(ret); NintendoSwitch::Typewriter::add_tests_TypewriterTextMapping(ret); + NintendoSwitch::PokemonBDSP::add_tests(ret); NintendoSwitch::PokemonFRLG::add_tests(ret); NintendoSwitch::PokemonRSE::add_tests(ret); NintendoSwitch::PokemonHome::add_tests(ret); diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.cpp index 536df70512..6abbeadb16 100644 --- a/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.cpp @@ -1,173 +1,303 @@ -/* Battle Menu Detector - * - * From: https://github.com/PokemonAutomation/ - * - */ - -#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" -#include "CommonTools/Images/SolidColorTest.h" -#include "PokemonBDSP_BattleMenuDetector.h" - -//#include -//using std::cout; -//using std::endl; - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonBDSP{ - - -BattleMenuDetector::BattleMenuDetector(BattleType battle_type, Color color) - : m_color(color) - , m_battle_type(battle_type) - , m_left0_status (0.140, 0.922, 0.100, 0.010) - , m_left1_status (0.140, 0.910, 0.100, 0.010) - , m_right_status (0.405, 0.925, 0.100, 0.010) -// , m_opponent_left (0.685, 0.065, 0.020, 0.030) -// , m_opponent_right (0.960, 0.065, 0.020, 0.030) - , m_opponent_left (0.708, 0.070, 0.005, 0.028) - , m_opponent_right (0.982, 0.070, 0.005, 0.028) - , m_ball_left (0.890, 0.475, 0.02, 0.03) - , m_ball_right (0.960, 0.475, 0.03, 0.03) - , m_menu_battle (0.817, 0.585 + 0 * 0.1075, 0.150, 0.070) - , m_menu_pokemon (0.817, 0.585 + 1 * 0.1075, 0.150, 0.070) - , m_menu_bag (0.817, 0.585 + 2 * 0.1075, 0.150, 0.070) - , m_menu_run (0.817, 0.585 + 3 * 0.1075, 0.150, 0.070) -{} -void BattleMenuDetector::make_overlays(VideoOverlaySet& items) const{ - items.add(m_color, m_left0_status); - items.add(m_color, m_left1_status); - items.add(m_color, m_right_status); - items.add(m_color, m_opponent_left); - items.add(m_color, m_opponent_right); - items.add(m_color, m_ball_left); - items.add(m_color, m_ball_right); - items.add(m_color, m_menu_battle); - items.add(m_color, m_menu_pokemon); - items.add(m_color, m_menu_bag); - items.add(m_color, m_menu_run); -} -bool BattleMenuDetector::is_battle_button(const ImageViewRGB32& screen) const{ - ImageStats stats = image_stats(extract_box_reference(screen, m_menu_battle)); -// cout << stats.average << stats.stddev << endl; - double stddev = stats.stddev.sum(); - if (stddev < 30 || stddev > 150){ - return false; - } - double average = stats.average.sum(); - FloatPixel actual = stats.average / average; - if (euclidean_distance(actual, {0.541961, 0.231317, 0.226722}) > 0.2){ - return false; - } - return true; -} -bool BattleMenuDetector::detect(const ImageViewRGB32& screen){ - { - bool left = is_white(extract_box_reference(screen, m_opponent_left)); - bool right = is_white(extract_box_reference(screen, m_opponent_right)); - if (!left && !right){ -// cout << "opp left or right" << endl; - return false; - } - } - { - bool left0 = is_white(extract_box_reference(screen, m_left0_status)); - bool left1 = is_white(extract_box_reference(screen, m_left1_status)); - bool right = is_white(extract_box_reference(screen, m_right_status)); - if (!left0 && !left1 && !right){ - return false; - } - } - - if (m_battle_type == BattleType::STARTER){ - return is_battle_button(screen); - } - - - if (m_battle_type == BattleType::STANDARD){ - if (!is_white(extract_box_reference(screen, m_ball_left))){ -// cout << "Not white" << endl; - return false; - } - if (!is_white(extract_box_reference(screen, m_ball_right))){ -// cout << "Not white" << endl; - return false; - } - } - - if (!is_battle_button(screen)){ - return false; - } -// cout << "Start Checking Pokemon" << endl; - { - ImageStats stats = image_stats(extract_box_reference(screen, m_menu_pokemon)); -// cout << stats.average << stats.stddev << endl; - double stddev = stats.stddev.sum(); - if (stddev < 30 || stddev > 150){ - return false; - } - double average = stats.average.sum(); - FloatPixel actual = stats.average / average; - if (euclidean_distance(actual, {0.255944, 0.582771, 0.161285}) > 0.2){ - return false; - } - } - // cout << "Start Checking bag" << endl; - { - ImageStats stats = image_stats(extract_box_reference(screen, m_menu_bag)); -// cout << stats.average << stats.stddev << endl; - double stddev = stats.stddev.sum(); - if (stddev < 30 || stddev > 100){ - return false; - } - double average = stats.average.sum(); - FloatPixel actual = stats.average / average; - if (euclidean_distance(actual, {0.485857, 0.414946, 0.099197}) > 0.2){ - return false; - } - } - { - ImageStats stats = image_stats(extract_box_reference(screen, m_menu_run)); -// cout << stats.average << stats.stddev << endl; - double stddev = stats.stddev.sum(); - if (stddev < 30 || stddev > 100){ - return false; - } - double average = stats.average.sum(); - FloatPixel actual = stats.average / average; - if (euclidean_distance(actual, {0.250977, 0.269868, 0.479155}) > 0.2){ - return false; - } - } - - return true; -} - - - - -BattleMenuWatcher::BattleMenuWatcher(BattleType battle_type, Color color) - : BattleMenuDetector(battle_type, color) - , VisualInferenceCallback("BattleMenuWatcher") -{} -void BattleMenuWatcher::make_overlays(VideoOverlaySet& items) const{ - BattleMenuDetector::make_overlays(items); -} -bool BattleMenuWatcher::process_frame(const ImageViewRGB32& frame, WallClock timestamp){ - // Need 5 consecutive successful detections. - if (!detect(frame)){ -// cout << "no detect" << endl; - m_trigger_count = 0; - return false; - } -// cout << "detected" << endl; - m_trigger_count++; - return m_trigger_count >= 5; -} - - - - -} -} -} +/* Battle Menu Detector + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" +#include "CommonTools/Images/SolidColorTest.h" +#include "PokemonBDSP_BattleMenuDetector.h" + +//#include +//using std::cout; +//using std::endl; + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +BattleMenuDetector::BattleMenuDetector(BattleType battle_type, Color color) + : m_color(color) + , m_battle_type(battle_type) + , m_left0_status (0.140, 0.922, 0.100, 0.010) + , m_left1_status (0.140, 0.910, 0.100, 0.010) + , m_right_status (0.405, 0.925, 0.100, 0.010) +// , m_opponent_left (0.685, 0.065, 0.020, 0.030) +// , m_opponent_right (0.960, 0.065, 0.020, 0.030) + , m_opponent_left (0.708, 0.070, 0.005, 0.028) + , m_opponent_right (0.982, 0.070, 0.005, 0.028) + , m_ball_left (0.890, 0.475, 0.02, 0.03) + , m_ball_right (0.960, 0.475, 0.03, 0.03) + , m_menu_battle (0.817, 0.585 + 0 * 0.1075, 0.150, 0.070) + , m_menu_pokemon (0.817, 0.585 + 1 * 0.1075, 0.150, 0.070) + , m_menu_bag (0.817, 0.585 + 2 * 0.1075, 0.150, 0.070) + , m_menu_run (0.817, 0.585 + 3 * 0.1075, 0.150, 0.070) +{} +void BattleMenuDetector::make_overlays(VideoOverlaySet& items) const{ + items.add(m_color, m_left0_status); + items.add(m_color, m_left1_status); + items.add(m_color, m_right_status); + items.add(m_color, m_opponent_left); + items.add(m_color, m_opponent_right); + items.add(m_color, m_ball_left); + items.add(m_color, m_ball_right); + items.add(m_color, m_menu_battle); + items.add(m_color, m_menu_pokemon); + items.add(m_color, m_menu_bag); + items.add(m_color, m_menu_run); +} +bool BattleMenuDetector::is_battle_button(const ImageViewRGB32& screen) const{ + ImageStats stats = image_stats(extract_box_reference(screen, m_menu_battle)); +// cout << stats.average << stats.stddev << endl; + double stddev = stats.stddev.sum(); + if (stddev < 30 || stddev > 150){ + return false; + } + double average = stats.average.sum(); + FloatPixel actual = stats.average / average; + if (euclidean_distance(actual, {0.541961, 0.231317, 0.226722}) > 0.2){ + return false; + } + return true; +} +bool BattleMenuDetector::detect(const ImageViewRGB32& screen){ + { + bool left = is_white(extract_box_reference(screen, m_opponent_left)); + bool right = is_white(extract_box_reference(screen, m_opponent_right)); + if (!left && !right){ +// cout << "opp left or right" << endl; + return false; + } + } + { + bool left0 = is_white(extract_box_reference(screen, m_left0_status)); + bool left1 = is_white(extract_box_reference(screen, m_left1_status)); + bool right = is_white(extract_box_reference(screen, m_right_status)); + if (!left0 && !left1 && !right){ + return false; + } + } + + if (m_battle_type == BattleType::STARTER){ + return is_battle_button(screen); + } + + + if (m_battle_type == BattleType::STANDARD){ + if (!is_white(extract_box_reference(screen, m_ball_left))){ +// cout << "Not white" << endl; + return false; + } + if (!is_white(extract_box_reference(screen, m_ball_right))){ +// cout << "Not white" << endl; + return false; + } + } + + if (!is_battle_button(screen)){ + return false; + } +// cout << "Start Checking Pokemon" << endl; + { + ImageStats stats = image_stats(extract_box_reference(screen, m_menu_pokemon)); +// cout << stats.average << stats.stddev << endl; + double stddev = stats.stddev.sum(); + if (stddev < 30 || stddev > 150){ + return false; + } + double average = stats.average.sum(); + FloatPixel actual = stats.average / average; + if (euclidean_distance(actual, {0.255944, 0.582771, 0.161285}) > 0.2){ + return false; + } + } + // cout << "Start Checking bag" << endl; + { + ImageStats stats = image_stats(extract_box_reference(screen, m_menu_bag)); +// cout << stats.average << stats.stddev << endl; + double stddev = stats.stddev.sum(); + if (stddev < 30 || stddev > 100){ + return false; + } + double average = stats.average.sum(); + FloatPixel actual = stats.average / average; + if (euclidean_distance(actual, {0.485857, 0.414946, 0.099197}) > 0.2){ + return false; + } + } + { + ImageStats stats = image_stats(extract_box_reference(screen, m_menu_run)); +// cout << stats.average << stats.stddev << endl; + double stddev = stats.stddev.sum(); + if (stddev < 30 || stddev > 100){ + return false; + } + double average = stats.average.sum(); + FloatPixel actual = stats.average / average; + if (euclidean_distance(actual, {0.250977, 0.269868, 0.479155}) > 0.2){ + return false; + } + } + + return true; +} + + + + +BattleMenuOption BattleMenuDetector::selected_option(const ImageViewRGB32& screen){ + if (!screen || !detect(screen)){ + return BattleMenuOption::UNKNOWN; + } + int selected = -1; + for (int row = 0; row < 4; row++){ + const double offset = row * 0.1075; + bool brackets = false; + // The corner brackets wiggle; all four must agree on one small offset. + const double offsets[] = {0.0, -0.003, 0.003, -0.006, 0.006}; + for (double dy : offsets){ + for (double dx : offsets){ + const ImageFloatBox corners[] = { + {0.793 + dx, 0.562 + offset + dy, 0.018, 0.023}, + {0.976 + dx, 0.562 + offset + dy, 0.018, 0.023}, + {0.793 + dx, 0.652 + offset + dy, 0.018, 0.023}, + {0.976 + dx, 0.652 + offset + dy, 0.018, 0.023}, + }; + bool candidate = true; + for (const ImageFloatBox& box : corners){ + const ImageViewRGB32 patch = extract_box_reference(screen, box); + size_t white = 0; + const size_t total = patch.width() * patch.height(); + for (size_t y = 0; y < patch.height(); y++){ + for (size_t x = 0; x < patch.width(); x++){ + const uint32_t pixel = patch.pixel(x, y); + const unsigned r = (pixel >> 16) & 255; + const unsigned g = (pixel >> 8) & 255; + const unsigned b = pixel & 255; + const unsigned low = std::min(r, std::min(g, b)); + const unsigned high = std::max(r, std::max(g, b)); + white += low > 210 && high - low < 35; + } + } + if (total == 0 || static_cast(white) / total < 0.15){ + candidate = false; + break; + } + } + if (candidate){ + brackets = true; + break; + } + } + if (brackets){ + break; + } + } + if (brackets){ + if (selected >= 0){ + return BattleMenuOption::UNKNOWN; + } + selected = row; + } + } + return static_cast(selected); +} + +BattleMoveMenuState detect_battle_move_menu(const ImageViewRGB32& screen){ + BattleMoveMenuState result; + if (!screen) return result; + // Every populated move has a white PP panel, irrespective of move type or + // selected row. Find connected panels rather than assume a 4-row layout. + const ImageFloatBox search{0.90, 0.48, 0.075, 0.505}; + const ImageViewRGB32 patch = extract_box_reference(screen, search); + cv::Mat white((int)patch.height(), (int)patch.width(), CV_8UC1); + for (size_t y = 0; y < patch.height(); y++) for (size_t x = 0; x < patch.width(); x++) { + uint32_t p = patch.pixel(x, y); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + white.at((int)y, (int)x) = std::min(r, std::min(g, b)) > 225 && + std::max(r, std::max(g, b)) - std::min(r, std::min(g, b)) < 30 ? 255 : 0; + } + cv::Mat labels, stats, centers; + int components = cv::connectedComponentsWithStats(white, labels, stats, centers); + std::vector panels; + for (int component = 1; component < components; component++) { + cv::Rect box(stats.at(component, cv::CC_STAT_LEFT), stats.at(component, cv::CC_STAT_TOP), + stats.at(component, cv::CC_STAT_WIDTH), stats.at(component, cv::CC_STAT_HEIGHT)); + if (box.width < patch.width() * 0.60 || box.height < screen.height() * 0.055 || + box.height > screen.height() * 0.13) continue; + double area = (double)stats.at(component, cv::CC_STAT_AREA); + if (area / box.area() < 0.55) continue; + panels.push_back(box); + } + if (panels.empty() || panels.size() > 4) return result; + std::sort(panels.begin(), panels.end(), [](const cv::Rect& a, const cv::Rect& b){ return a.y < b.y; }); + // Reject inconsistent/overlapping geometry rather than count noise as moves. + for (size_t i = 1; i < panels.size(); i++) { + if (panels[i].y <= panels[i - 1].y + panels[i - 1].height || + std::abs(panels[i].height - panels[0].height) > screen.height() * 0.025) return result; + } + result.move_count = panels.size(); + auto is_corner = [&](double x, double y){ + if (x < 0 || y < 0 || x + 0.018 > 1 || y + 0.023 > 1) return false; + ImageViewRGB32 corner = extract_box_reference(screen, ImageFloatBox{x, y, 0.018, 0.023}); + size_t white_count = 0, total = corner.width() * corner.height(); + for (size_t py = 0; py < corner.height(); py++) for (size_t px = 0; px < corner.width(); px++) { + uint32_t p = corner.pixel(px, py); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + white_count += std::min(r, std::min(g, b)) > 210 && + std::max(r, std::max(g, b)) - std::min(r, std::min(g, b)) < 35; + } + return total && (double)white_count / total >= 0.15; + }; + for (size_t slot = 0; slot < panels.size(); slot++) { + double top = search.y + (double)panels[slot].y / screen.height(); + double bottom = top + (double)panels[slot].height / screen.height(); + result.buttons[slot] = ImageFloatBox{0.665, std::max(0.0, top - 0.01), 0.333, + std::min(1.0, bottom + 0.01) - std::max(0.0, top - 0.01)}; + bool selected = false; + for (double dy : {0.0, -0.003, 0.003, -0.006, 0.006, -0.010, 0.010}) { + for (double dx : {0.0, -0.003, 0.003}) { + if (is_corner(0.663 + dx, top - 0.010 + dy) && + is_corner(0.976 + dx, top - 0.010 + dy) && + is_corner(0.663 + dx, bottom - 0.014 + dy) && + is_corner(0.976 + dx, bottom - 0.014 + dy)) { selected = true; break; } + } + if (selected) break; + } + if (selected) { + if (result.selected_slot) { result.selected_slot = 0; return result; } + result.selected_slot = slot + 1; + } + } + return result; +} + +BattleMenuWatcher::BattleMenuWatcher(BattleType battle_type, Color color) + : BattleMenuDetector(battle_type, color) + , VisualInferenceCallback("BattleMenuWatcher") +{} +void BattleMenuWatcher::make_overlays(VideoOverlaySet& items) const{ + BattleMenuDetector::make_overlays(items); +} +bool BattleMenuWatcher::process_frame(const ImageViewRGB32& frame, WallClock timestamp){ + // Need 5 consecutive successful detections. + if (!detect(frame)){ +// cout << "no detect" << endl; + m_trigger_count = 0; + return false; + } +// cout << "detected" << endl; + m_trigger_count++; + return m_trigger_count >= 5; +} + + + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h b/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h index 30a7d2249b..21c3e5f858 100644 --- a/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h +++ b/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h @@ -1,73 +1,97 @@ -/* Battle Menu Detector - * - * From: https://github.com/PokemonAutomation/ - * - */ - -#ifndef PokemonAutomation_PokemonBDSP_BattleMenuDetector_H -#define PokemonAutomation_PokemonBDSP_BattleMenuDetector_H - -#include "Common/Cpp/Color.h" -#include "CommonFramework/ImageTools/ImageBoxes.h" -#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" -#include "CommonTools/VisualDetector.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonBDSP{ - - -enum class BattleType{ - STARTER, - STANDARD, - GREAT_MARSH, - TRAINER, -}; - - -class BattleMenuDetector : public StaticScreenDetector{ -public: - BattleMenuDetector(BattleType battle_type, Color color = COLOR_RED); - - virtual void make_overlays(VideoOverlaySet& items) const override; - virtual bool detect(const ImageViewRGB32& screen) override; - -private: - bool is_battle_button(const ImageViewRGB32& screen) const; - -private: - Color m_color; - BattleType m_battle_type; - ImageFloatBox m_left0_status; - ImageFloatBox m_left1_status; - ImageFloatBox m_right_status; - ImageFloatBox m_opponent_left; - ImageFloatBox m_opponent_right; - ImageFloatBox m_ball_left; - ImageFloatBox m_ball_right; - ImageFloatBox m_menu_battle; - ImageFloatBox m_menu_pokemon; - ImageFloatBox m_menu_bag; - ImageFloatBox m_menu_run; -}; - - -class BattleMenuWatcher : public BattleMenuDetector, public VisualInferenceCallback{ -public: - BattleMenuWatcher(BattleType battle_type, Color color = COLOR_RED); - - virtual void make_overlays(VideoOverlaySet& items) const override; - virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override final; - using VisualInferenceCallback::process_frame; - - -private: - size_t m_trigger_count = 0; -}; - - - -} -} -} -#endif +/* Battle Menu Detector + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_BattleMenuDetector_H +#define PokemonAutomation_PokemonBDSP_BattleMenuDetector_H + +#include +#include "Common/Cpp/Color.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" +#include "CommonTools/VisualDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +enum class BattleType{ + STARTER, + STANDARD, + GREAT_MARSH, + TRAINER, +}; + + +// Vertical command order in the BDSP battle menu. +enum class BattleMenuOption{ + UNKNOWN = -1, + BATTLE = 0, + POKEMON = 1, + BAG = 2, + RUN = 3, +}; + +// Logical move slots are ordered top-to-bottom among populated move buttons. +// selected_slot is 1-4, or 0 when missing/ambiguous. Never guess on uncertainty. +struct BattleMoveMenuState { + size_t move_count = 0; + size_t selected_slot = 0; + std::array buttons; +}; +BattleMoveMenuState detect_battle_move_menu(const ImageViewRGB32& screen); + +class BattleMenuDetector : public StaticScreenDetector{ +public: + BattleMenuDetector(BattleType battle_type, Color color = COLOR_RED); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + + // Requires the battle menu and white selection brackets at all four corners. + // Missing or ambiguous brackets return UNKNOWN; never infer the cursor. + BattleMenuOption selected_option(const ImageViewRGB32& screen); + + +private: + bool is_battle_button(const ImageViewRGB32& screen) const; + +private: + Color m_color; + BattleType m_battle_type; + ImageFloatBox m_left0_status; + ImageFloatBox m_left1_status; + ImageFloatBox m_right_status; + ImageFloatBox m_opponent_left; + ImageFloatBox m_opponent_right; + ImageFloatBox m_ball_left; + ImageFloatBox m_ball_right; + ImageFloatBox m_menu_battle; + ImageFloatBox m_menu_pokemon; + ImageFloatBox m_menu_bag; + ImageFloatBox m_menu_run; +}; + + +class BattleMenuWatcher : public BattleMenuDetector, public VisualInferenceCallback{ +public: + BattleMenuWatcher(BattleType battle_type, Color color = COLOR_RED); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override final; + using VisualInferenceCallback::process_frame; + + +private: + size_t m_trigger_count = 0; +}; + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleOpponentNameReader.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleOpponentNameReader.cpp new file mode 100644 index 0000000000..5934464203 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleOpponentNameReader.cpp @@ -0,0 +1,75 @@ +/* BDSP Battle Opponent Name Reader + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "CommonTools/OCR/OCR_Routines.h" +#include "Pokemon/Inference/Pokemon_NameReader.h" +#include "PokemonBDSP_BattleOpponentNameReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace Pokemon; + + +// ⚠ These regions are estimated from BDSP's typical battle layout. +// Calibrate against a real capture if OCR reads are consistently wrong. +// +// In BDSP singles, the one opponent's info box is in the top-right quadrant. +// In BDSP doubles, two boxes sit side-by-side in the top half of the screen. +static const ImageFloatBox BOX_SINGLES_OPP (0.505, 0.010, 0.420, 0.085); +static const ImageFloatBox BOX_DOUBLES_LEFT (0.505, 0.010, 0.200, 0.085); +static const ImageFloatBox BOX_DOUBLES_RIGHT (0.710, 0.010, 0.260, 0.085); + + +static std::string ocr_name_box( + Logger& logger, + Language language, + const ImageViewRGB32& frame, + const ImageFloatBox& box +){ + ImageViewRGB32 cropped = extract_box_reference(frame, box); + OCR::StringMatchResult result = PokemonNameReader::instance().read_substring( + logger, + language, + cropped, + OCR::BLACK_OR_WHITE_TEXT_FILTERS() + ); + if (result.results.empty()){ + return ""; + } + // Use the best match (lowest log10p) if it meets the confidence threshold + double log10p = result.results.begin()->first; + if (log10p > PokemonNameReader::MAX_LOG10P){ + return ""; + } + return result.results.begin()->second.token; +} + + +std::vector read_battle_opponent_names( + Logger& logger, + Language language, + const ImageViewRGB32& frame, + BDSPBattleType battle_type +){ + std::vector names; + if (battle_type == BDSPBattleType::SINGLES){ + names.push_back(ocr_name_box(logger, language, frame, BOX_SINGLES_OPP)); + } else { + names.push_back(ocr_name_box(logger, language, frame, BOX_DOUBLES_LEFT)); + names.push_back(ocr_name_box(logger, language, frame, BOX_DOUBLES_RIGHT)); + } + return names; +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleOpponentNameReader.h b/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleOpponentNameReader.h new file mode 100644 index 0000000000..b1592c8705 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleOpponentNameReader.h @@ -0,0 +1,44 @@ +/* BDSP Battle Opponent Name Reader + * + * From: https://github.com/PokemonAutomation/ + * + * OCR-based detection of opponent pokemon names from the BDSP battle screen. + * Used by the outside-help battle handler to track which pokemon are currently + * on the field and update move selection accordingly. + * + * ⚠ Screen region coordinates are estimates and may require calibration + * against a real BDSP capture before they work reliably in production. + */ + +#ifndef PokemonAutomation_PokemonBDSP_BattleOpponentNameReader_H +#define PokemonAutomation_PokemonBDSP_BattleOpponentNameReader_H + +#include +#include +#include "CommonFramework/Language.h" +#include "PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.h" + +namespace PokemonAutomation{ + class Logger; + class ImageViewRGB32; +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Read the opponent pokemon name boxes at the top of the BDSP battle screen. +// +// Returns one slug per opponent slot (up to 1 for singles, 2 for doubles). +// An empty string in a slot means OCR confidence was too low to identify the pokemon. +// The caller should keep the previous state for any slot that returns empty string. +std::vector read_battle_opponent_names( + Logger& logger, + Language language, + const ImageViewRGB32& frame, + BDSPBattleType battle_type +); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_DialogDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_DialogDetector.cpp index c8636d99cb..c85aaced34 100644 --- a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_DialogDetector.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_DialogDetector.cpp @@ -20,9 +20,11 @@ namespace PokemonBDSP{ ShortDialogDetector::ShortDialogDetector(Color color) : m_color(color) // , m_bottom(0.50, 0.91, 0.29, 0.05) - , m_left_white(0.21, 0.835, 0.008, 0.12) + // Preserve the established inner-margin samples. detect() checks an + // alternate blank margin when a long Gardenia reward line overlaps one. + , m_left_white(0.221, 0.86, 0.002, 0.025) , m_left(0.18, 0.835, 0.02, 0.12) - , m_right_white(0.785, 0.835, 0.008, 0.12) + , m_right_white(0.798, 0.86, 0.002, 0.025) , m_right(0.822, 0.835, 0.02, 0.12) {} void ShortDialogDetector::make_overlays(VideoOverlaySet& items) const{ @@ -36,12 +38,23 @@ bool ShortDialogDetector::detect(const ImageViewRGB32& screen){ ImageStats left_white = image_stats(extract_box_reference(screen, m_left_white)); // cout << left_white.average << left_white.stddev << endl; if (!is_white(left_white)){ - return false; + left_white=image_stats(extract_box_reference(screen,ImageFloatBox{.208,.86,.002,.025})); + if (!is_white(left_white)) return false; } ImageStats right_white = image_stats(extract_box_reference(screen, m_right_white)); // cout << right_white.average << right_white.stddev << endl; if (!is_white(right_white)){ - return false; + // Gardenia's opening reward line can reach this strip. Retain the + // same white test in a second blank margin, below that long line. + right_white=image_stats(extract_box_reference(screen,ImageFloatBox{.790,.915,.002,.025})); + if (!is_white(right_white)){ + // Buneary's long second line reaches the inner strip, while the + // outer strip can land on the tinted border. The blank lower + // margin must pass the same uniform-white test; retain both + // edge-variation checks below and the existing left margin. + right_white=image_stats(extract_box_reference(screen,ImageFloatBox{.790,.940,.002,.018})); + if (!is_white(right_white)) return false; + } } // ImageStats bottom = image_stats(extract_box_reference(screen, m_bottom)); // if (!is_white(bottom)){ diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp index 12da43966e..3df61e3b68 100644 --- a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp @@ -4,6 +4,14 @@ * */ +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Globals.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" #include "CommonFramework/ImageTools/ImageStats.h" #include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" #include "CommonTools/Images/SolidColorTest.h" @@ -31,6 +39,17 @@ MenuDetector::MenuDetector(Color color, bool skip_new_banner) , m_line3(menu_stripe(3, 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_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) + , m_save_button(0.835, 0.960, 0.024, 0.038) + , m_close_button(0.915, 0.960, 0.024, 0.038) + , m_save_heading(0.920, 0.755, 0.070, 0.040) + , m_save_background(0.973, 0.820, 0.020, 0.065) + , m_save_shortcut(0.936, 0.826, 0.029, 0.053) {} void MenuDetector::make_overlays(VideoOverlaySet& items) const{ @@ -40,8 +59,159 @@ void MenuDetector::make_overlays(VideoOverlaySet& items) const{ items.add(m_color, m_line3); items.add(m_color, m_line4); items.add(m_color, m_cross); + items.add(m_color, m_panel_top); + items.add(m_color, m_panel_left); + items.add(m_color, m_panel_right); + items.add(m_color, m_footer); + items.add(m_color, m_confirm_button); + items.add(m_color, m_confirm_button_with_swap); + items.add(m_color, m_save_button); + items.add(m_color, m_close_button); + items.add(m_color, m_save_heading); + items.add(m_color, m_save_background); + items.add(m_color, m_save_shortcut); } +// A/R/B prompts and the Save shortcut have dark neutral button backgrounds +// and light lettering. Check their contrast without OCR or image resources. +static bool has_menu_button(const ImageViewRGB32& patch){ + size_t dark = 0; + size_t light = 0; + for (size_t y = 0; y < patch.height(); y++){ + for (size_t x = 0; x < patch.width(); x++){ + const uint32_t pixel = patch.pixel(x, y); + const int r = (pixel >> 16) & 0xff; + const int g = (pixel >> 8) & 0xff; + const int b = pixel & 0xff; + const int low = std::min(r, std::min(g, b)); + const int high = std::max(r, std::max(g, b)); + if (high - low < 35){ + dark += high < 160; + light += low > 200; + } + } + } + const double pixels = (double)patch.width() * patch.height(); + return pixels > 0 && dark / pixels >= 0.15 && light / pixels >= 0.08; +} + +bool MenuDetector::detect_menu_chrome(const ImageViewRGB32& screen) const{ + // The surrounding panel remains the same with six or eight unlocked icons. + 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; + } + } + const ImageStats footer = image_stats(extract_box_reference(screen, m_footer)); + if (footer.average.b < footer.average.r + 35 || + footer.average.g < footer.average.r + 20 || footer.stddev.sum() > 60){ + return false; + } + // Unlocking the Swap prompt shifts A Confirm left. Require the same + // button contrast at either documented footer position, with all other + // panel, footer and Save checks unchanged. + if (!has_menu_button(extract_box_reference(screen, m_confirm_button)) && + !has_menu_button(extract_box_reference(screen, m_confirm_button_with_swap))){ + return false; + } + for (const ImageFloatBox& box : {m_save_button, m_close_button, m_save_shortcut}){ + if (!has_menu_button(extract_box_reference(screen, box))){ + return false; + } + } + // Require both the light Save heading and its cyan background as well. + const ImageStats heading = image_stats(extract_box_reference(screen, m_save_heading)); + const ImageStats background = image_stats(extract_box_reference(screen, m_save_background)); + return heading.average.r > 180 && heading.average.g > 180 && heading.average.b > 180 && + heading.stddev.sum() > 30 && + background.average.g > background.average.r + 40 && + background.average.b > background.average.r + 40 && background.stddev.sum() < 90; +} + +static std::string fmt(double value){ + char buffer[32]; + snprintf(buffer, sizeof(buffer), "%.3f", value); + return buffer; +} +static std::string describe_stats(const ImageStats& stats){ + return "avg=(" + fmt(stats.average.r) + "," + fmt(stats.average.g) + "," + fmt(stats.average.b) + + ") stddev_sum=" + fmt(stats.stddev.sum()); +} +static std::string describe_button(const ImageViewRGB32& patch){ + size_t dark = 0; + size_t light = 0; + for (size_t y = 0; y < patch.height(); y++){ + for (size_t x = 0; x < patch.width(); x++){ + const uint32_t pixel = patch.pixel(x, y); + const int r = (pixel >> 16) & 0xff; + const int g = (pixel >> 8) & 0xff; + const int b = pixel & 0xff; + const int low = std::min(r, std::min(g, b)); + const int high = std::max(r, std::max(g, b)); + if (high - low < 35){ + dark += high < 160; + light += low > 200; + } + } + } + const double pixels = (double)patch.width() * patch.height(); + const double dark_ratio = pixels > 0 ? dark / pixels : 0; + const double light_ratio = pixels > 0 ? light / pixels : 0; + return "dark=" + fmt(dark_ratio) + " (need>=0.150) light=" + fmt(light_ratio) + " (need>=0.080)"; +} + +std::string MenuDetector::debug_report(const ImageViewRGB32& screen) const{ + std::string report = "MenuDetector report (" + std::to_string(screen.width()) + "x" + + std::to_string(screen.height()) + "):\n"; + auto line = [&](const std::string& name, bool ok, const std::string& detail){ + report += std::string(" [") + (ok ? "SEEN " : "MISSING") + "] " + name + ": " + detail + "\n"; + }; + const std::pair panels[] = { + {"panel top", &m_panel_top}, {"panel left", &m_panel_left}, {"panel right", &m_panel_right}, + }; + for (const auto& panel : panels){ + const ImageStats stats = image_stats(extract_box_reference(screen, *panel.second)); + line(panel.first, is_white(stats), describe_stats(stats)); + } + const ImageStats footer = image_stats(extract_box_reference(screen, m_footer)); + const bool footer_ok = !(footer.average.b < footer.average.r + 35 || + footer.average.g < footer.average.r + 20 || footer.stddev.sum() > 60); + line("footer (need b>=r+35, g>=r+20, stddev<=60)", footer_ok, describe_stats(footer)); + const std::pair buttons[] = { + {"confirm button (without Swap)", &m_confirm_button}, + {"confirm button (with Swap)", &m_confirm_button_with_swap}, + {"save button", &m_save_button}, + {"close button", &m_close_button}, {"save shortcut", &m_save_shortcut}, + }; + for (const auto& button : buttons){ + const ImageViewRGB32 patch = extract_box_reference(screen, *button.second); + line(button.first, has_menu_button(patch), describe_button(patch)); + } + const ImageStats heading = image_stats(extract_box_reference(screen, m_save_heading)); + const ImageStats background = image_stats(extract_box_reference(screen, m_save_background)); + line("save heading (need rgb>180, stddev>30)", + heading.average.r > 180 && heading.average.g > 180 && heading.average.b > 180 && heading.stddev.sum() > 30, + describe_stats(heading)); + line("save background (need g,b>=r+40, stddev<90)", + background.average.g > background.average.r + 40 && background.average.b > background.average.r + 40 && + background.stddev.sum() < 90, + describe_stats(background)); + line("chrome overall", detect_menu_chrome(screen), ""); + + const ImageFloatBox* stripes[] = {&m_line0, &m_line1, &m_line2, &m_line3, &m_line4}; + for (size_t c = 0; c < 5; c++){ + const ImageStats stats = image_stats(extract_box_reference(screen, *stripes[c])); + line("legacy stripe " + std::to_string(c), is_white(stats), describe_stats(stats)); + } + const ImageStats cross = image_stats(extract_box_reference(screen, m_cross)); + line("legacy cross (need stddev>=100)", cross.stddev.sum() >= 100, describe_stats(cross)); + return report; +} + bool MenuDetector::detect(const ImageViewRGB32& screen){ + if (detect_menu_chrome(screen)){ + return true; + } + // Retain the existing layout check for captures supported by the old detector. ImageStats stats0 = image_stats(extract_box_reference(screen, m_line0)); if (!is_white(stats0)){ return false; @@ -84,6 +254,87 @@ bool MenuWatcher::process_frame(const ImageViewRGB32& frame, WallClock timestamp + +std::string menu_detector_test_folder(){ + return UNIT_TEST_RESOURCE_PATH() + "PokemonBDSP/menu_detector"; +} + +bool run_menu_detector_tests(Logger& logger, const std::string& folder){ + std::vector files; + for (const auto& path : Filesystem::list_directory(folder)){ + if (Filesystem::is_regular_file(path) && path.extension().string() == ".png"){ + files.push_back(path); + } + } + if (files.empty()){ + logger.log("[Menu tests] No PNG screenshots in " + folder, COLOR_RED); + return false; + } + size_t passed = 0; + size_t failed = 0; + auto check = [&](bool actual, bool expected, const std::string& label){ + const bool success = actual == expected; + (success ? passed : failed)++; + logger.log(std::string(success ? "[PASS] " : "[FAIL] ") + label + + ": detected=" + (actual ? "true" : "false") + + ", expected=" + (expected ? "true" : "false"), success ? COLOR_GREEN : COLOR_RED); + }; + for (const auto& path : files){ + const std::string filename = path.filename().string(); + const bool expected = !filename.starts_with("non_menu_"); + try{ + const ImageRGB32 image(path.string()); + for (size_t width : {size_t(1920), size_t(1280), size_t(960)}){ + const ImageRGB32 scaled = image.scale_to(width, width * 9 / 16); + MenuDetector detector; + check(detector.detect(scaled), expected, + filename + " at " + std::to_string(width) + "px"); + } + }catch (const FileException& error){ + failed++; + logger.log("[FAIL] " + filename + ": " + error.to_str(), COLOR_RED); + }catch (const std::exception& error){ + failed++; + logger.log("[FAIL] " + filename + ": " + error.what(), COLOR_RED); + } + } + // Uniform bright/dark frames must not count as an open menu. + for (uint32_t color : {uint32_t(0xff000000), uint32_t(0xffffffff)}){ + ImageRGB32 blank(1280, 720); + for (size_t y = 0; y < blank.height(); y++){ + for (size_t x = 0; x < blank.width(); x++) blank.pixel(x, y) = color; + } + MenuDetector detector; + check(detector.detect(blank), false, color == 0xff000000 ? "Black frame" : "White frame"); + } + logger.log("[Menu tests] " + std::to_string(passed) + " passed, " + + std::to_string(failed) + " failed.", failed == 0 ? COLOR_GREEN : COLOR_RED); + return failed == 0; +} + +// Adapter for the existing filename-based command-line test map. +int test_MenuDetector(const ImageViewRGB32& image, bool expected){ + for (size_t width : {size_t(1920), size_t(1280), size_t(960)}){ + const ImageRGB32 scaled = image.scale_to(width, width * 9 / 16); + MenuDetector detector; + if (detector.detect(scaled) != expected) return 1; + } + return 0; +} + +class Test_MenuDetector : public UnitTest{ +public: + Test_MenuDetector() : UnitTest("PokemonBDSP::MenuDetector screenshot tests"){} + UnitTestResult run(Logger& logger, CancellableScope&) const override{ + return run_menu_detector_tests(logger, menu_detector_test_folder()); + } +}; + +void add_tests_MenuDetector(UnitTestDatabase& database){ + database.add(); +} + + } } } diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h index f7e5a0517f..a56bcc7bc1 100644 --- a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h @@ -8,6 +8,7 @@ #define PokemonAutomation_PokemonBDSP_MenuDetector_H #include "Common/Cpp/Color.h" +#include "Common/Cpp/TestRunners/UnitTest.h" #include "CommonFramework/ImageTools/ImageBoxes.h" #include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" #include "CommonTools/VisualDetector.h" @@ -17,6 +18,12 @@ namespace NintendoSwitch{ namespace PokemonBDSP{ +// Screenshot tests accept no controller context. non_menu_*.png are negative cases. +std::string menu_detector_test_folder(); +bool run_menu_detector_tests(Logger& logger, const std::string& folder); +void add_tests_MenuDetector(UnitTestDatabase& database); +int test_MenuDetector(const ImageViewRGB32& image, bool expected); + class MenuDetector : public StaticScreenDetector{ public: MenuDetector(Color color = COLOR_RED, bool skip_new_banner = false); @@ -24,7 +31,12 @@ class MenuDetector : public StaticScreenDetector{ virtual void make_overlays(VideoOverlaySet& items) const override; virtual bool detect(const ImageViewRGB32& screen) override; + // Multi-line description of every check: what passed, what failed, and the measured values. + std::string debug_report(const ImageViewRGB32& screen) const; + private: + bool detect_menu_chrome(const ImageViewRGB32& screen) const; + Color m_color; ImageFloatBox m_line0; ImageFloatBox m_line1; @@ -32,6 +44,17 @@ class MenuDetector : public StaticScreenDetector{ ImageFloatBox m_line3; ImageFloatBox m_line4; ImageFloatBox m_cross; + ImageFloatBox m_panel_top; + ImageFloatBox m_panel_left; + ImageFloatBox m_panel_right; + ImageFloatBox m_footer; + ImageFloatBox m_confirm_button; + ImageFloatBox m_confirm_button_with_swap; + ImageFloatBox m_save_button; + ImageFloatBox m_close_button; + ImageFloatBox m_save_heading; + ImageFloatBox m_save_background; + ImageFloatBox m_save_shortcut; }; diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.cpp new file mode 100644 index 0000000000..fc48e45fa5 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.cpp @@ -0,0 +1,273 @@ +/* Shop Detectors + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/VideoPipeline/VideoOverlay.h" +#include "CommonTools/Images/BinaryImage_FilterRgb32.h" +#include "CommonTools/Images/SolidColorTest.h" +#include "Kernels/Waterfill/Kernels_Waterfill_Session.h" +#include "Kernels/Waterfill/Kernels_Waterfill_Types.h" +#include "PokemonBDSP_ShopItemNameReader.h" +#include "PokemonBDSP_ShopItemPriceReader.h" +#include "PokemonBDSP_ShopDetectors.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// --------------------------------------------------------------------------- +// ShopBuyingWindowDetector +// --------------------------------------------------------------------------- + +// TODO: Fill in the actual coordinates of the blue bottom border strip. +// At 1280x720, the buying-list bottom bar appears near y ≈ 0.93. +// The colour ratio below matches BoxDetector's bottom bar ({0.190, 0.327, 0.482}). +static const ImageFloatBox SHOP_BUYING_WINDOW_BOX{0.020000, 0.960000, 0.750000, 0.035000}; +static const FloatPixel SHOP_BLUE_RATIO{0.190, 0.327, 0.483}; +static constexpr double SHOP_BLUE_MAX_DISTANCE = 0.10; +static constexpr double SHOP_BLUE_MAX_STDDEV = 15.0; + +ShopBuyingWindowDetector::ShopBuyingWindowDetector(Color color) + : m_color(color) + , m_box(SHOP_BUYING_WINDOW_BOX) +{} +void ShopBuyingWindowDetector::make_overlays(VideoOverlaySet& items) const{ + items.add(m_color, m_box); +} +bool ShopBuyingWindowDetector::detect(const ImageViewRGB32& screen){ + ImageStats stats = image_stats(extract_box_reference(screen, m_box)); + return is_solid(stats, SHOP_BLUE_RATIO, SHOP_BLUE_MAX_DISTANCE, SHOP_BLUE_MAX_STDDEV); +} + + +// --------------------------------------------------------------------------- +// ShopQuantityWindowDetector +// --------------------------------------------------------------------------- + +// TODO: Fill in the coordinates of the yellow quantity-input box. +// It typically appears in the centre-right of the screen when selecting quantity. +static const ImageFloatBox SHOP_QUANTITY_BOX{0.800000, 0.570000, 0.100000, 0.140000}; +// Expected colour ratio for the gold/yellow quantity box. +// High R, high G, low B — adjust if the box colour looks different in your capture. +static const FloatPixel SHOP_YELLOW_RATIO{0.58, 0.38, 0.04}; +static constexpr double SHOP_YELLOW_MAX_DISTANCE = 0.20; +static constexpr double SHOP_YELLOW_MAX_STDDEV = 20.0; + +ShopQuantityWindowDetector::ShopQuantityWindowDetector(Color color) + : m_color(color) + , m_dialog(color) + , m_qty_box(SHOP_QUANTITY_BOX) +{} +void ShopQuantityWindowDetector::make_overlays(VideoOverlaySet& items) const{ + m_dialog.make_overlays(items); + items.add(m_color, m_qty_box); +} +bool ShopQuantityWindowDetector::detect(const ImageViewRGB32& screen){ + if (!m_dialog.detect(screen)){ + return false; + } + ImageStats stats = image_stats(extract_box_reference(screen, m_qty_box)); + return is_solid(stats, SHOP_YELLOW_RATIO, SHOP_YELLOW_MAX_DISTANCE, SHOP_YELLOW_MAX_STDDEV); +} + + +// --------------------------------------------------------------------------- +// ShopItemHoverDetector +// --------------------------------------------------------------------------- + +// Color filter for the red hover rectangle. +static constexpr uint32_t RED_MIN = 0xff800000; // R≥128, G=0, B=0 +static constexpr uint32_t RED_MAX = 0xffff6060; // R≤255, G≤96, B≤96 + +// Minimum pixel area for a red blob to be considered (filters noise). +static constexpr size_t RED_MIN_AREA = 200; + +// The hover rectangle must span at least this fraction of the scan area width. +static constexpr double RED_MIN_WIDTH_RATIO = 0.80; + +// Minimum aspect ratio (width/height) — the row is much wider than tall. +static constexpr double RED_MIN_ASPECT = 4.0; + +// Minimum consecutive frames that must confirm before the detector fires. +static constexpr size_t HOVER_MIN_CONSECUTIVE = 3; + +// OCR region X offsets (as fractions of outline row width). +// Tune NAME_X_OFFSET if the first character of the item name is being cut off. +// Name: [ 5%, 70%] → x_offset=0.05, width_fraction=0.65 +// Price: [70%, 100%] → x_offset=0.70, width_fraction=0.30 +static constexpr double NAME_X_OFFSET = 0.08; +static constexpr double NAME_X_WIDTH = 0.65; +static constexpr double PRICE_X_OFFSET = 0.70; +static constexpr double PRICE_X_WIDTH = 0.30; +// Fixed OCR box height in normalized screen coordinates. The waterfill detects +// only the thin red border lines, so outline_box.height is just the border +// thickness. This constant covers the full item row height instead. +static constexpr double OCR_ROW_HEIGHT = 0.06; + +// Maps item slug → expected price slug. +static const std::map ITEM_PRICE_TABLE{ + {"poke-ball", "200"}, + {"great-ball", "600"}, + {"ultra-ball", "1200"}, + {"heal-ball", "300"}, + {"net-ball", "1000"}, + {"nest-ball", "1000"}, + {"dusk-ball", "1000"}, + {"quick-ball", "1000"}, + {"timer-ball", "1000"}, + {"repeat-ball", "1000"}, + {"luxury-ball", "1000"}, + {"potion", "300"}, + {"super-potion", "700"}, + {"hyper-potion", "1200"}, + {"max-potion", "2500"}, + {"full-restore", "3000"}, + {"revive", "1500"}, + {"antidote", "100"}, + {"paralyze-heal", "200"}, + {"awakening", "250"}, + {"burn-heal", "250"}, + {"ice-heal", "250"}, + {"full-heal", "600"}, + {"escape-rope", "550"}, + {"repel", "350"}, + {"super-repel", "500"}, + {"max-repel", "700"}, +}; + +ShopItemHoverDetector::ShopItemHoverDetector( + Logger& logger, + const ImageFloatBox& scan_area, + const std::string& item_slug, + Language language, + Color color +) + : VisualInferenceCallback("ShopItemHoverDetector") + , m_logger(logger) + , m_color(color) + , m_language(language) + , m_scan_area(scan_area) + , m_item_slug(item_slug) + , m_price_slug(ITEM_PRICE_TABLE.at(item_slug)) +{} + +bool ShopItemHoverDetector::find_outline(const ImageViewRGB32& screen, ImageFloatBox& out_box) const{ + using namespace Kernels::Waterfill; + + const ImageViewRGB32 region = extract_box_reference(screen, m_scan_area); + PackedBinaryMatrix matrix = compress_rgb32_to_binary_range(region, RED_MIN, RED_MAX); + + auto session = make_WaterfillSession(matrix); + auto iter = session->make_iterator(RED_MIN_AREA); + + WaterfillObject object; + while (iter->find_next(object, false)){ + if (object.aspect_ratio() < RED_MIN_ASPECT) continue; + if ((double)object.width() / region.width() < RED_MIN_WIDTH_RATIO) continue; + + out_box = ImageFloatBox{ + m_scan_area.x + (double)object.min_x / region.width() * m_scan_area.width, + m_scan_area.y + (double)object.min_y / region.height() * m_scan_area.height, + (double)object.width() / region.width() * m_scan_area.width, + (double)object.height() / region.height() * m_scan_area.height + }; + return true; + } + return false; +} + +bool ShopItemHoverDetector::match_ocr(const ImageViewRGB32& screen, const ImageFloatBox& outline_box) const{ + m_last_name_box = ImageFloatBox{ + outline_box.x + NAME_X_OFFSET * outline_box.width, + outline_box.y, + NAME_X_WIDTH * outline_box.width, + OCR_ROW_HEIGHT + }; + m_last_price_box = ImageFloatBox{ + outline_box.x + PRICE_X_OFFSET * outline_box.width, + outline_box.y, + PRICE_X_WIDTH * outline_box.width, + OCR_ROW_HEIGHT + }; + const ImageFloatBox& name_box = m_last_name_box; + const ImageFloatBox& price_box = m_last_price_box; + + const ShopItemNameReader& name_reader = ShopItemNameReader::instance(); + OCR::StringMatchResult name_result = name_reader.read_substring( + m_logger, m_language, extract_box_reference(screen, name_box) + ); + name_result.clear_beyond_log10p(ShopItemNameReader::MAX_LOG10P); + if (name_result.results.empty()) return false; + if (name_result.results.begin()->second.token != m_item_slug) return false; + + const ShopItemPriceReader& price_reader = ShopItemPriceReader::instance(); + OCR::StringMatchResult price_result = price_reader.read_substring( + m_logger, m_language, extract_box_reference(screen, price_box) + ); + price_result.clear_beyond_log10p(ShopItemPriceReader::MAX_LOG10P); + if (price_result.results.empty()) return false; + if (price_result.results.begin()->second.token != m_price_slug) return false; + + if (!m_ocr_logged){ + m_ocr_logged = true; + name_result.log(m_logger, ShopItemNameReader::MAX_LOG10P, "want: " + m_item_slug); + price_result.log(m_logger, ShopItemPriceReader::MAX_LOG10P, "want: " + m_price_slug); + } + return true; +} + +bool ShopItemHoverDetector::detect(const ImageViewRGB32& screen){ + ImageFloatBox outline_box; + if (!find_outline(screen, outline_box)){ + m_detected_box = ImageFloatBox{0, 0, 0, 0}; + m_last_name_box = ImageFloatBox{0, 0, 0, 0}; + m_last_price_box = ImageFloatBox{0, 0, 0, 0}; + m_ocr_logged = false; + return false; + } + if (!match_ocr(screen, outline_box)){ + m_last_name_box = ImageFloatBox{0, 0, 0, 0}; + m_last_price_box = ImageFloatBox{0, 0, 0, 0}; + return false; + } + m_detected_box = outline_box; + return true; +} + +void ShopItemHoverDetector::make_overlays(VideoOverlaySet& items) const{ + items.add(m_color, m_scan_area); + if (m_detected_box.width > 0){ + items.add(m_color, m_detected_box); + } + if (m_last_name_box.width > 0){ + items.add(COLOR_BLUE, m_last_name_box); + } + if (m_last_price_box.width > 0){ + items.add(COLOR_GREEN, m_last_price_box); + } +} + +bool ShopItemHoverDetector::process_frame(const ImageViewRGB32& frame, WallClock /*timestamp*/){ + // Only run OCR every 5th frame to reduce CPU load. + if (m_frame_count++ % 5 != 0){ + return false; + } + if (!detect(frame)){ + m_trigger_count = 0; + return false; + } + m_trigger_count++; + return m_trigger_count >= HOVER_MIN_CONSECUTIVE; +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.h new file mode 100644 index 0000000000..bfe3298e66 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.h @@ -0,0 +1,123 @@ +/* Shop Detectors + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_ShopDetectors_H +#define PokemonAutomation_PokemonBDSP_ShopDetectors_H + +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/Language.h" +#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" +#include "CommonTools/VisualDetector.h" +#include "PokemonBDSP_DialogDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Detects the blue decorative border at the bottom of the screen that appears +// when the mart buying list is open. +class ShopBuyingWindowDetector : public StaticScreenDetector{ +public: + // box: a thin strip covering the blue bottom border (user fills in coordinates). + ShopBuyingWindowDetector(Color color = COLOR_RED); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + +private: + Color m_color; + ImageFloatBox m_box; +}; +class ShopBuyingWindowWatcher : public DetectorToFinder{ +public: + ShopBuyingWindowWatcher(Color color = COLOR_RED) + : DetectorToFinder("ShopBuyingWindowWatcher", std::chrono::milliseconds(250), color) + {} +}; + + +// Detects the quantity-input overlay (yellow/gold box + ShortDialog) that appears +// after selecting an item to buy. +class ShopQuantityWindowDetector : public StaticScreenDetector{ +public: + // box: the area containing the yellow quantity-input box (user fills in coordinates). + ShopQuantityWindowDetector(Color color = COLOR_RED); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + +private: + Color m_color; + ShortDialogDetector m_dialog; + ImageFloatBox m_qty_box; +}; +class ShopQuantityWindowWatcher : public DetectorToFinder{ +public: + ShopQuantityWindowWatcher(Color color = COLOR_RED) + : DetectorToFinder("ShopQuantityWindowWatcher", std::chrono::milliseconds(250), color) + {} +}; + + +// Scans `scan_area` to dynamically locate the red selection outline, then +// verifies the highlighted item by running OCR on the item name and price +// regions within the row and comparing against the expected `item_slug`. +// Fires only when the outline is found AND both OCR checks pass. +class ShopItemHoverDetector : public VisualInferenceCallback{ +public: + // scan_area: normalized screen region that covers the entire shop item list. + // item_slug: identifies the expected item (e.g. "full-restore"). Must match + // a key in ShopItemNameOCR.json and have a known price in the + // internal lookup table. + // language: language used for OCR dictionary lookup. + ShopItemHoverDetector( + Logger& logger, + const ImageFloatBox& scan_area, + const std::string& item_slug, + Language language = Language::English, + Color color = COLOR_CYAN + ); + + bool detect(const ImageViewRGB32& screen); + + // Returns the last outline box confirmed by detect() / process_frame(). + // Valid only after a successful detection; width == 0 if nothing detected yet. + const ImageFloatBox& detected_box() const { return m_detected_box; } + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override; + +private: + // Scans scan_area for a horizontal band of red edges (left + right) that + // indicate the selection outline. Fills out_box with the outline's screen- + // normalized bounds and returns true when found. + bool find_outline(const ImageViewRGB32& screen, ImageFloatBox& out_box) const; + + // Runs OCR on the name and price sub-regions of outline_box. + // Returns true when both match the expected item slug and its price. + bool match_ocr(const ImageViewRGB32& screen, const ImageFloatBox& outline_box) const; + + Logger& m_logger; + Color m_color; + Language m_language; + ImageFloatBox m_scan_area; + std::string m_item_slug; + std::string m_price_slug; + mutable ImageFloatBox m_detected_box{0, 0, 0, 0}; + mutable ImageFloatBox m_last_name_box{0, 0, 0, 0}; + mutable ImageFloatBox m_last_price_box{0, 0, 0, 0}; + mutable bool m_ocr_logged = false; + size_t m_trigger_count = 0; + size_t m_frame_count = 0; +}; + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemNameReader.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemNameReader.cpp new file mode 100644 index 0000000000..96fa48eae0 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemNameReader.cpp @@ -0,0 +1,41 @@ +/* Shop Item Name Reader + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonTools/OCR/OCR_Routines.h" +#include "PokemonBDSP_ShopItemNameReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +ShopItemNameReader::ShopItemNameReader() + : SmallDictionaryMatcher("PokemonBDSP/ShopItemNameOCR.json") +{} + +const ShopItemNameReader& ShopItemNameReader::instance(){ + static ShopItemNameReader reader; + return reader; +} + +OCR::StringMatchResult ShopItemNameReader::read_substring( + Logger& logger, + Language language, + const ImageViewRGB32& image, + double min_text_ratio, double max_text_ratio +) const{ + return match_substring_from_image_multifiltered( + &logger, language, image, + OCR::BLACK_TEXT_FILTERS(), + MAX_LOG10P, MAX_LOG10P_SPREAD, + min_text_ratio, max_text_ratio + ); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemNameReader.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemNameReader.h new file mode 100644 index 0000000000..ae32b1dda5 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemNameReader.h @@ -0,0 +1,40 @@ +/* Shop Item Name Reader + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_ShopItemNameReader_H +#define PokemonAutomation_PokemonBDSP_ShopItemNameReader_H + +#include "CommonFramework/Language.h" +#include "CommonTools/OCR/OCR_SmallDictionaryMatcher.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +class ShopItemNameReader : public OCR::SmallDictionaryMatcher{ +public: + static constexpr double MAX_LOG10P = -1.40; + static constexpr double MAX_LOG10P_SPREAD = 0.50; + +public: + ShopItemNameReader(); + + static const ShopItemNameReader& instance(); + + OCR::StringMatchResult read_substring( + Logger& logger, + Language language, + const ImageViewRGB32& image, + double min_text_ratio = 0.01, double max_text_ratio = 0.50 + ) const; +}; + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemPriceReader.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemPriceReader.cpp new file mode 100644 index 0000000000..82d6cfba3d --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemPriceReader.cpp @@ -0,0 +1,41 @@ +/* Shop Item Price Reader + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonTools/OCR/OCR_Routines.h" +#include "PokemonBDSP_ShopItemPriceReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +ShopItemPriceReader::ShopItemPriceReader() + : SmallDictionaryMatcher("PokemonBDSP/ShopItemPriceOCR.json") +{} + +const ShopItemPriceReader& ShopItemPriceReader::instance(){ + static ShopItemPriceReader reader; + return reader; +} + +OCR::StringMatchResult ShopItemPriceReader::read_substring( + Logger& logger, + Language language, + const ImageViewRGB32& image, + double min_text_ratio, double max_text_ratio +) const{ + return match_substring_from_image_multifiltered( + &logger, language, image, + OCR::BLACK_TEXT_FILTERS(), + MAX_LOG10P, MAX_LOG10P_SPREAD, + min_text_ratio, max_text_ratio + ); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemPriceReader.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemPriceReader.h new file mode 100644 index 0000000000..0d5cc49303 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemPriceReader.h @@ -0,0 +1,40 @@ +/* Shop Item Price Reader + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_ShopItemPriceReader_H +#define PokemonAutomation_PokemonBDSP_ShopItemPriceReader_H + +#include "CommonFramework/Language.h" +#include "CommonTools/OCR/OCR_SmallDictionaryMatcher.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +class ShopItemPriceReader : public OCR::SmallDictionaryMatcher{ +public: + static constexpr double MAX_LOG10P = -1.40; + static constexpr double MAX_LOG10P_SPREAD = 0.50; + +public: + ShopItemPriceReader(); + + static const ShopItemPriceReader& instance(); + + OCR::StringMatchResult read_substring( + Logger& logger, + Language language, + const ImageViewRGB32& image, + double min_text_ratio = 0.01, double max_text_ratio = 0.50 + ) const; +}; + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.cpp new file mode 100644 index 0000000000..3655b5244d --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.cpp @@ -0,0 +1,36 @@ +#include "PokemonBDSP_TransitionWatchers.h" +namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonBDSP{ +WhiteScreenOverWatcher::WhiteScreenOverWatcher( + Color color, const ImageFloatBox& box, + double min_rgb_sum, + double max_stddev_sum, + std::chrono::milliseconds hold_duration, + std::chrono::milliseconds release_duration +) + : VisualInferenceCallback("WhiteScreenOverWatcher") + , m_on(color, box, min_rgb_sum, max_stddev_sum, WhiteScreenWatcher::FinderType::PRESENT, hold_duration) + , m_off(color, box, min_rgb_sum, max_stddev_sum, WhiteScreenWatcher::FinderType::GONE, release_duration) +{} +void WhiteScreenOverWatcher::make_overlays(VideoOverlaySet& items) const{ + m_on.make_overlays(items); +} +bool WhiteScreenOverWatcher::process_frame(const ImageViewRGB32& frame, WallClock timestamp){ + if (m_white_is_over.load(std::memory_order_acquire)){ + return true; + } + if (!m_has_been_white){ + m_has_been_white = m_on.process_frame(frame, timestamp); + return false; + } + bool is_over = m_off.process_frame(frame, timestamp); + if (!is_over){ + return false; + } + m_white_is_over.store(true, std::memory_order_release); + return true; +} +bool WhiteScreenOverWatcher::white_is_over(const ImageViewRGB32& frame){ + return m_white_is_over.load(std::memory_order_acquire); +} + +}}} diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h new file mode 100644 index 0000000000..ce5a34d2cb --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h @@ -0,0 +1,56 @@ +#pragma once +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonBDSP{ +// AutoStory-specific transition handling; shared watchers retain upstream behavior. +class WhiteScreenWatcher : public DetectorToFinder{ +public: + WhiteScreenWatcher( + Color color = COLOR_RED, + const ImageFloatBox& box = {0.1, 0.1, 0.8, 0.8}, + double min_rgb_sum = 500, + double max_stddev_sum = 10, + FinderType finder_type = FinderType::PRESENT, + std::chrono::milliseconds duration = std::chrono::milliseconds(100) + ) + : DetectorToFinder("WhiteScreenWatcher", finder_type, duration, color, box, min_rgb_sum, max_stddev_sum) + {} +}; + +class BlackScreenFlashWatcher : public BlackScreenOverWatcher{ +public: + BlackScreenFlashWatcher( + Color color = COLOR_RED, + const ImageFloatBox& box = {0.1, 0.1, 0.8, 0.8}, + double max_rgb_sum = 100, + double max_stddev_sum = 10, + std::chrono::milliseconds hold_duration = std::chrono::milliseconds(20), + std::chrono::milliseconds release_duration = std::chrono::milliseconds(100) + ) + : BlackScreenOverWatcher(color, box, max_rgb_sum, max_stddev_sum, hold_duration, release_duration) + {} +}; + +class WhiteScreenOverWatcher : public VisualInferenceCallback{ +public: + WhiteScreenOverWatcher( + Color color = COLOR_RED, + const ImageFloatBox& box = {0.1, 0.1, 0.8, 0.8}, + double min_rgb_sum = 500, + double max_stddev_sum = 10, + std::chrono::milliseconds hold_duration = std::chrono::milliseconds(100), + std::chrono::milliseconds release_duration = std::chrono::milliseconds(100) + ); + + bool white_is_over(const ImageViewRGB32& frame); + + virtual void make_overlays(VideoOverlaySet& items) const override; + + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override; + +private: + WhiteScreenWatcher m_on; + WhiteScreenWatcher m_off; + bool m_has_been_white = false; + std::atomic m_white_is_over = false; +}; +}}} diff --git a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Panels.cpp b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Panels.cpp index e2aa07801f..1d7a5b3132 100644 --- a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Panels.cpp +++ b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Panels.cpp @@ -7,6 +7,9 @@ #include "CommonFramework/StaticGlobals.h" #include "CommonFramework/GlobalAutoPaths.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h" +#include "PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.h" + #include "PokemonBDSP_Panels.h" #include "PokemonBDSP_Settings.h" @@ -91,6 +94,10 @@ std::vector PanelListFactory::make_panels() const{ ret.emplace_back(make_SingleSwitchProgram()); ret.emplace_back(make_SingleSwitchProgram()); + ret.emplace_back("---- Auto Story ----"); + ret.emplace_back(make_SingleSwitchProgram()); + ret.emplace_back(make_SingleSwitchProgram()); + ret.emplace_back("---- Glitches (v1.1.3) ----"); ret.emplace_back(make_SingleSwitchProgram()); ret.emplace_back(make_SingleSwitchProgram()); diff --git a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp index b8ff5e26a5..569d479276 100644 --- a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp @@ -1,23 +1,295 @@ -/* Pokemon BDSP Tests - * - * From: https://github.com/PokemonAutomation/ - * - */ - -#include "Common/Cpp/TestRunners/UnitTestDatabase.h" -#include "PokemonBDSP_Tests.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonBDSP{ - - - -void add_tests(UnitTestDatabase& database){ -} - - - -} -} -} +/* Pokemon BDSP Tests + * + * From: https://github.com/PokemonAutomation/ + */ + +#include "CommonFramework/GlobalAutoPaths.h" +#include +#include +#include +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/Json/JsonValue.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "Common/Cpp/Json/JsonArray.h" +#include "Common/Cpp/Color.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Globals.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP_Tests.h" +#include "Inference/PokemonBDSP_MenuDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace{ + +std::string action_name(BagMoveAction action){ + switch (action){ + case BagMoveAction::CONFIRM: return "Confirm"; + case BagMoveAction::UP: return "Up"; + case BagMoveAction::DOWN: return "Down"; + case BagMoveAction::PAGE_DOWN: return "Page down"; + case BagMoveAction::REWIND: return "Rewind"; + default: return "Unknown"; + } +} + +// A name ROI with no dark text is an empty row, not an unknown item. +bool empty_bag_row(const ImageViewRGB32& image, size_t row){ + const auto text = extract_box_reference(image, ImageFloatBox(0.492, 0.213 + (row - 1) * 0.075, 0.335, 0.052)); + size_t dark = 0; + for (size_t y = 0; y < text.height(); y++){ + for (size_t x = 0; x < text.width(); x++){ + uint32_t pixel = text.pixel(x, y); + dark += ((pixel >> 16) & 255) < 100 && ((pixel >> 8) & 255) < 100 && (pixel & 255) < 100; + } + } + return dark < text.width() * text.height() * 0.001; +} + +struct TestItemTemplate{ + BagItemTemplate item; + std::shared_ptr reference; +}; + +class Test_BagScreenshots : public UnitTest{ +public: + Test_BagScreenshots() : UnitTest("PokemonBDSP::Bag screenshot tests"){} + UnitTestResult run(Logger& logger, CancellableScope&) const override{ + return run_bag_screenshot_tests(logger, UNIT_TEST_RESOURCE_PATH() + "PokemonBDSP/itembagtests"); + } +}; + +} + +bool run_bag_screenshot_tests(Logger& logger, const std::string& folder){ + try{ + const std::string prefix = folder + "/"; + const auto doc = load_json_file(prefix + "bag_tests.json"); + const auto& manifest = doc.to_object_throw(); + const auto& template_json = manifest.get_array_throw("templates"); + const auto& cases = manifest.get_array_throw("cases"); + if (template_json.empty() || cases.empty()){ + logger.log("[Bag tests] Invalid or empty test manifest.", COLOR_RED); + return false; + } + std::map> images; + auto load_image = [&](const std::string& name){ + auto it = images.find(name); + if (it == images.end()){ + it = images.emplace(name, std::make_shared(prefix + name)).first; + } + return it->second; + }; + std::vector templates; + for (const auto& value : template_json){ + const auto& entry = value.to_object_throw(); + TestItemTemplate t; + t.item.label = entry.get_string_throw("label"); + const int64_t reference_row = entry.get_integer_throw("row"); + t.item.reference_row = reference_row >= 1 && reference_row <= 7 ? static_cast(reference_row) : 0; + const auto name = entry.get_string_throw("image"); + if (t.item.label.empty() || t.item.reference_row < 1 || t.item.reference_row > 7 || + !Filesystem::is_regular_file(prefix + name)){ + logger.log("[Bag tests] Invalid or missing item reference: " + name, COLOR_RED); + return false; + } + t.reference = load_image(name); + templates.emplace_back(std::move(t)); + } + + size_t passed = 0; + size_t failed = 0; + auto check = [&](bool result, const std::string& detail){ + (result ? passed : failed)++; + logger.log(std::string(result ? "[PASS] " : "[FAIL] ") + detail, result ? COLOR_GREEN : COLOR_RED); + }; + // All 64 routes, including wraparound and Right for opposite pockets. + for (int current = 1; current <= 8; current++){ + for (int target = 1; target <= 8; target++){ + const auto plan = plan_bag_pocket_move(current, target); + const int delta = target - current; + const int distance = std::abs(delta); + const size_t expected = static_cast(std::min(distance, 8 - distance)); + const auto direction = distance == 0 ? BagPocketAction::CONFIRM : + distance == 4 || (delta > 0 && distance < 4) || (delta < 0 && distance > 4) + ? BagPocketAction::RIGHT : BagPocketAction::LEFT; + check(plan.presses == expected && plan.action == direction, + "Pocket route " + std::to_string(current) + " to " + std::to_string(target)); + } + } + check(plan_bag_pocket_move(4, 5).action == BagPocketAction::RIGHT && + plan_bag_pocket_move(4, 5).presses == 1, "Dropped press: recalculate from pocket 4 to 5"); + check(plan_bag_pocket_move(0, 5).action == BagPocketAction::UNKNOWN && + plan_bag_pocket_move(1, 9).action == BagPocketAction::UNKNOWN, + "Unknown or invalid pocket gives no movement"); + // Independent expected values, including paging and unknown-cursor handling. + const std::array pages{13, 11, 10, 9, 8, 7, 1}; + for (int cursor = 1; cursor <= 7; cursor++){ + const auto plan = plan_bag_item_move(cursor, 0); + check(plan.presses == pages[cursor - 1] && plan.action == + (cursor == 7 ? BagMoveAction::REWIND : BagMoveAction::PAGE_DOWN), + "Absent item, cursor " + std::to_string(cursor) + ": " + action_name(plan.action) + + " " + std::to_string(plan.presses)); + } + check(plan_bag_item_move(0, 3).action == BagMoveAction::UNKNOWN, "Unknown cursor gives no movement"); + check(plan_bag_item_move(2, 8).action == BagMoveAction::UNKNOWN, "Invalid item row gives no movement"); + check(bag_cursor_position(ImageRGB32()) == 0, "Missing image gives unknown cursor"); + + // Exact expected displacements for every cursor/target combination. + const int displacement[7][7] = { + { 0, 1, 2, 3, 4, 5, 6}, + {-1, 0, 1, 2, 3, 4, 5}, + {-2, -1, 0, 1, 2, 3, 4}, + {-3, -2, -1, 0, 1, 2, 3}, + {-4, -3, -2, -1, 0, 1, 2}, + {-5, -4, -3, -2, -1, 0, 1}, + {-6, -5, -4, -3, -2, -1, 0} + }; + for (int cursor = 1; cursor <= 7; cursor++){ + for (int target = 1; target <= 7; target++){ + const int delta = displacement[cursor - 1][target - 1]; + const auto plan = plan_bag_item_move(cursor, target); + check(plan.action == (delta == 0 ? BagMoveAction::CONFIRM : + delta < 0 ? BagMoveAction::UP : BagMoveAction::DOWN) && + plan.presses == static_cast(delta < 0 ? -delta : delta), + "Cursor " + std::to_string(cursor) + " to item " + std::to_string(target) + + ": " + action_name(plan.action) + " " + std::to_string(plan.presses)); + } + } + ImageRGB32 blank(1920, 1080); + for (size_t y = 0; y < blank.height(); y++){ + for (size_t x = 0; x < blank.width(); x++){ + blank.pixel(x, y) = 0xffffffff; + } + } + check(bag_cursor_position(blank) == 0, "Blank screenshot has no cursor"); + for (const auto& t : templates){ + const auto matches = match_bag_item_rows(blank, *t.reference, t.item); + bool found = false; + for (const auto& m : matches){ + found |= m.matched; + } + check(!found, "Blank screenshot does not match " + t.item.label); + } + + for (const auto& case_value : cases){ + const auto& test = case_value.to_object_throw(); + const auto filename = test.get_string_throw("image"); + const auto& expected = test.get_array_throw("items"); + const int64_t expected_cursor = test.get_integer_throw("cursor"); + if (expected.size() != 7 || expected_cursor < 0 || expected_cursor > 7 || + !Filesystem::is_regular_file(prefix + filename)){ + check(false, "Invalid or missing screenshot case: " + filename); + continue; + } + const auto image = load_image(filename); + const int cursor = bag_cursor_position(*image); + check(cursor == expected_cursor, filename + ": cursor " + std::to_string(cursor) + + ", expected " + std::to_string(expected_cursor)); + // Scale the same screen to the minimum supported capture resolution. + const auto small = image->scale_to(1280, 720); + check(bag_cursor_position(small) == expected_cursor, filename + ": cursor at 720p"); + + std::array labels; + std::array best; + std::array second; + best.fill(std::numeric_limits::infinity()); + second = best; + for (const auto& t : templates){ + const auto matches = match_bag_item_rows(*image, *t.reference, t.item); + for (size_t row = 0; row < 7; row++){ + const auto& m = matches[row]; + if (!m.matched){ + continue; + } + const double score = m.icon_rmsd + m.name_rmsd; + if (score < best[row]){ + second[row] = best[row]; + best[row] = score; + labels[row] = t.item.label; + }else if (score < second[row]){ + second[row] = score; + } + } + } + for (size_t row = 0; row < 7; row++){ + if (labels[row].empty()){ + labels[row] = empty_bag_row(*image, row + 1) ? "" : "Unknown"; + }else if (second[row] - best[row] < 2.0){ + labels[row] = "Unknown"; + } + check(labels[row] == expected[row].to_string_throw(), + filename + ": row " + std::to_string(row + 1) + " = " + + (labels[row].empty() ? "Empty" : labels[row]) + ", expected " + + (expected[row].to_string_throw().empty() ? "Empty" : expected[row].to_string_throw())); + } + for (const auto& query_value : test.get_array_throw("queries")){ + const auto& query = query_value.to_object_throw(); + const auto wanted = query.get_string_throw("item"); + int item_row = 0; + for (size_t row = 0; row < 7; row++){ + if (labels[row] == wanted){ + item_row = static_cast(row + 1); + } + } + const auto plan = plan_bag_item_move(cursor, item_row); + check(action_name(plan.action) == query.get_string_throw("action") && + plan.presses == static_cast(query.get_integer_throw("presses")), + filename + ": looking for " + wanted + ", item row " + std::to_string(item_row) + + ", cursor row " + std::to_string(cursor) + ": " + action_name(plan.action) + + " " + std::to_string(plan.presses) + " (no input sent)"); + } + } + // Pocket fixtures are part of this suite and must be present. + const auto pocket_doc = load_json_file(prefix + "pocket_tests.json"); + const auto& pocket_cases = pocket_doc.to_object_throw().get_array_throw("cases"); + check(!pocket_cases.empty(), "Pocket test manifest is nonempty"); + for (const auto& value : pocket_cases){ + const auto& entry = value.to_object_throw(); + const auto& name = entry.get_string_throw("image"); + const int64_t expected_pocket = entry.get_integer_throw("pocket"); + const bool expected_empty = entry.get_boolean_throw("empty"); + if (expected_pocket < 0 || expected_pocket > 8 || + !Filesystem::is_regular_file(prefix + name)){ + check(false, "Invalid or missing pocket case: " + name); + continue; + } + const auto image = load_image(name); + for (size_t width : {size_t(1920), size_t(1280), size_t(960)}){ + const auto scaled = image->scale_to(width, width * 9 / 16); + check(bag_pocket_position(scaled) == expected_pocket && + bag_list_is_empty(scaled) == expected_empty, + name + ": pocket " + std::to_string(expected_pocket) + + ", empty=" + (expected_empty ? "true" : "false") + + " at width " + std::to_string(width)); + } + } + check(bag_pocket_position(blank) == 0 && !bag_list_is_empty(blank), + "Blank screenshot has no pocket and is not an empty Bag"); + check(bag_pocket_position(ImageRGB32()) == 0 && !bag_list_is_empty(ImageRGB32()), + "Missing screenshot has no pocket and is not an empty Bag"); + logger.log("[Bag tests] " + std::to_string(passed) + " passed, " + std::to_string(failed) + " failed.", + failed == 0 ? COLOR_GREEN : COLOR_RED); + return failed == 0; + }catch (const ParseException& error){ + logger.log("[Bag tests] " + error.to_str(), COLOR_RED); + return false; + }catch (const FileException& error){ + logger.log("[Bag tests] " + error.to_str(), COLOR_RED); + return false; + }catch (const std::exception& error){ + logger.log("[Bag tests] " + std::string(error.what()), COLOR_RED); + return false; + } +} + +void add_tests(UnitTestDatabase& database){ + database.add(); + add_tests_MenuDetector(database); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.h b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.h index 009a365035..272fc9f29d 100644 --- a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.h +++ b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.h @@ -1,25 +1,28 @@ -/* Pokemon BDSP Tests - * - * From: https://github.com/PokemonAutomation/ - * - */ - -#ifndef PokemonAutomation_PokemonBDSP_Tests_H -#define PokemonAutomation_PokemonBDSP_Tests_H - -#include "Common/Cpp/TestRunners/UnitTest.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonBDSP{ - - - -void add_tests(UnitTestDatabase& database); - - - -} -} -} -#endif +/* Pokemon BDSP Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_Tests_H +#define PokemonAutomation_PokemonBDSP_Tests_H + +#include "Common/Cpp/TestRunners/UnitTest.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + + +// No controller context is accepted; this function only reads images and logs. +bool run_bag_screenshot_tests(Logger& logger, const std::string& folder); + +void add_tests(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.cpp new file mode 100644 index 0000000000..f72ee6f780 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.cpp @@ -0,0 +1,250 @@ +/* BDSP AutoStory - Game Data + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include + +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Json/JsonArray.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "Common/Cpp/Json/JsonValue.h" + +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/Globals.h" + +#include "PokemonBDSP_AutoStory_BDSPData.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +static const std::string RESOURCE_DIR = "PokemonBDSP/AutoStory/"; + + +// --------------------------------------------------------------------------- +// Shared parsing helpers +// --------------------------------------------------------------------------- + +static PokemonType parse_type(const std::string& slug, const std::string& path){ + static const std::map TABLE{ + {"normal", PokemonType::NORMAL}, + {"fire", PokemonType::FIRE}, + {"fighting", PokemonType::FIGHTING}, + {"water", PokemonType::WATER}, + {"flying", PokemonType::FLYING}, + {"grass", PokemonType::GRASS}, + {"poison", PokemonType::POISON}, + {"electric", PokemonType::ELECTRIC}, + {"ground", PokemonType::GROUND}, + {"psychic", PokemonType::PSYCHIC}, + {"rock", PokemonType::ROCK}, + {"ice", PokemonType::ICE}, + {"bug", PokemonType::BUG}, + {"dragon", PokemonType::DRAGON}, + {"ghost", PokemonType::GHOST}, + {"dark", PokemonType::DARK}, + {"steel", PokemonType::STEEL}, + {"fairy", PokemonType::FAIRY}, + }; + auto iter = TABLE.find(slug); + if (iter == TABLE.end()){ + throw FileException(nullptr, PA_CURRENT_FUNCTION, "Unknown type: " + slug, path); + } + return iter->second; +} + +static MoveCategory parse_category(const std::string& slug, const std::string& path){ + if (slug == "physical"){ + return MoveCategory::PHYSICAL; + } + if (slug == "special"){ + return MoveCategory::SPECIAL; + } + if (slug == "status"){ + return MoveCategory::STATUS; + } + throw FileException(nullptr, PA_CURRENT_FUNCTION, "Unknown move category: " + slug, path); +} + + +// --------------------------------------------------------------------------- +// Move database +// --------------------------------------------------------------------------- + +struct BDSPMoveDatabase { + std::map data; + + static const BDSPMoveDatabase& instance(){ + static BDSPMoveDatabase db; + return db; + } + + BDSPMoveDatabase(){ + std::string path = RESOURCE_PATH() + RESOURCE_DIR + "bdsp_move_data.json"; + JsonValue json = load_json_file(path); + JsonObject& root = json.to_object_throw(path); + + for (auto& item : root){ + const std::string& slug = item.first; + JsonObject& obj = item.second.to_object_throw(path); + + BDSPMoveData& move = data[slug]; + move.category = parse_category(obj.get_string_throw("category", path), path); + move.type = parse_type(obj.get_string_throw("type", path), path); + move.base_power = (uint8_t)obj.get_integer_throw("base_power", path); + move.pp = (uint8_t)obj.get_integer_throw("pp", path); + move.spread = obj.get_boolean_throw("spread", path); + move.accuracy = (uint8_t)obj.get_integer_default("accuracy", 100); + } + } +}; + +const BDSPMoveData& bdsp_move(const std::string& slug){ + const auto& db = BDSPMoveDatabase::instance().data; + auto iter = db.find(slug); + if (iter == db.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown BDSP move slug: " + slug); + } + return iter->second; +} + + +// --------------------------------------------------------------------------- +// Pokemon database +// --------------------------------------------------------------------------- + +struct BDSPPokemonDatabase { + std::map data; + + static const BDSPPokemonDatabase& instance(){ + static BDSPPokemonDatabase db; + return db; + } + + BDSPPokemonDatabase(){ + std::string path = RESOURCE_PATH() + RESOURCE_DIR + "bdsp_pokemon_data.json"; + JsonValue json = load_json_file(path); + JsonObject& root = json.to_object_throw(path); + + for (auto& item : root){ + const std::string& slug = item.first; + JsonObject& obj = item.second.to_object_throw(path); + + BDSPPokemonData& mon = data[slug]; + mon.type[0] = PokemonType::NONE; + mon.type[1] = PokemonType::NONE; + { + JsonArray& type_arr = obj.get_array_throw("type", path); + if (type_arr.size() >= 1){ + mon.type[0] = parse_type(type_arr[0].to_string_throw(path), path); + } + if (type_arr.size() >= 2){ + mon.type[1] = parse_type(type_arr[1].to_string_throw(path), path); + } + } + { + JsonObject& stats = obj.get_object_throw("base_stats", path); + mon.hp = (uint8_t)stats.get_integer_throw("hp", path); + mon.attack = (uint8_t)stats.get_integer_throw("attack", path); + mon.defense = (uint8_t)stats.get_integer_throw("defense", path); + mon.spatk = (uint8_t)stats.get_integer_throw("spatk", path); + mon.spdef = (uint8_t)stats.get_integer_throw("spdef", path); + mon.speed = (uint8_t)stats.get_integer_throw("speed", path); + } + } + } +}; + +const BDSPPokemonData& bdsp_pokemon(const std::string& slug){ + const auto& db = BDSPPokemonDatabase::instance().data; + auto iter = db.find(slug); + if (iter == db.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown BDSP pokemon slug: " + slug); + } + return iter->second; +} + + +// --------------------------------------------------------------------------- +// Trainer database +// --------------------------------------------------------------------------- + +struct BDSPTrainerDatabase { + std::map data; + + static const BDSPTrainerDatabase& instance(){ + static BDSPTrainerDatabase db; + return db; + } + + BDSPTrainerDatabase(){ + std::string path = RESOURCE_PATH() + RESOURCE_DIR + "trainers.json"; + JsonValue json = load_json_file(path); + JsonObject& root = json.to_object_throw(path); + + for (auto& item : root){ + const std::string& battle_id = item.first; + JsonObject& obj = item.second.to_object_throw(path); + + BDSPTrainerData& battle = data[battle_id]; + { + const std::string& type_str = obj.get_string_throw("battle_type", path); + if (type_str == "2v2"){ + battle.battle_type = BDSPBattleType::DOUBLES; + }else{ + battle.battle_type = BDSPBattleType::SINGLES; + } + } + { + JsonArray& trainers_arr = obj.get_array_throw("trainers", path); + for (auto& t_val : trainers_arr){ + JsonObject& t_obj = t_val.to_object_throw(path); + BDSPTrainer trainer; + JsonArray& pkmn_arr = t_obj.get_array_throw("pokemon", path); + for (auto& p_val : pkmn_arr){ + JsonObject& p_obj = p_val.to_object_throw(path); + BDSPTrainerPokemon tp; + tp.species = p_obj.get_string_throw("species", path); + tp.level = (uint8_t)p_obj.get_integer_throw("level", path); + + auto parse_stat_set = [&](BDSPStatSet& set, const char* key){ + JsonObject* s = p_obj.get_object(key); + if (!s){ + return; + } + set.hp = (uint8_t)s->get_integer_default("hp", 0); + set.attack = (uint8_t)s->get_integer_default("attack", 0); + set.defense = (uint8_t)s->get_integer_default("defense", 0); + set.spatk = (uint8_t)s->get_integer_default("spatk", 0); + set.spdef = (uint8_t)s->get_integer_default("spdef", 0); + set.speed = (uint8_t)s->get_integer_default("speed", 0); + }; + parse_stat_set(tp.ivs, "ivs"); + parse_stat_set(tp.evs, "evs"); + tp.has_intimidate = p_obj.get_boolean_default("intimidate", false); + + trainer.pokemon.push_back(std::move(tp)); + } + battle.trainers.push_back(std::move(trainer)); + } + } + } + } +}; + +const BDSPTrainerData& bdsp_trainer(const std::string& battle_id){ + const auto& db = BDSPTrainerDatabase::instance().data; + auto iter = db.find(battle_id); + if (iter == db.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown BDSP battle ID: " + battle_id); + } + return iter->second; +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.h new file mode 100644 index 0000000000..a8a6bce913 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.h @@ -0,0 +1,104 @@ +/* BDSP AutoStory - Game Data + * + * From: https://github.com/PokemonAutomation/ + * + * Provides access to move metadata, pokemon base stats, and the trainer + * database used by the outside-help battle handler. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_BDSPData_H +#define PokemonAutomation_PokemonBDSP_AutoStory_BDSPData_H + +#include +#include + +#include "Pokemon/Pokemon_Types.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +using namespace Pokemon; + + +// --------------------------------------------------------------------------- +// Move data +// --------------------------------------------------------------------------- + +struct BDSPMoveData { + MoveCategory category; + PokemonType type; + uint8_t base_power; + uint8_t pp; + bool spread; + uint8_t accuracy; // 0 = never-miss (e.g. Swift); otherwise 1-100 +}; + +// Returns move data for the given slug. +// Throws InternalProgramError if the slug is not found. +const BDSPMoveData& bdsp_move(const std::string& slug); + + +// --------------------------------------------------------------------------- +// Pokemon base stats +// --------------------------------------------------------------------------- + +struct BDSPPokemonData { + PokemonType type[2]; // type[1] == NONE for mono-type pokemon + uint8_t hp; + uint8_t attack; + uint8_t defense; + uint8_t spatk; + uint8_t spdef; + uint8_t speed; +}; + +// Returns base stats for the given species slug (e.g. "beautifly"). +// Throws InternalProgramError if the slug is not found. +const BDSPPokemonData& bdsp_pokemon(const std::string& slug); + + +// --------------------------------------------------------------------------- +// Trainer database +// --------------------------------------------------------------------------- + +enum class BDSPBattleType { + SINGLES, + DOUBLES, +}; + +struct BDSPStatSet { + uint8_t hp = 0; + uint8_t attack = 0; + uint8_t defense = 0; + uint8_t spatk = 0; + uint8_t spdef = 0; + uint8_t speed = 0; +}; + +struct BDSPTrainerPokemon { + std::string species; + uint8_t level; + BDSPStatSet ivs; + BDSPStatSet evs; + bool has_intimidate = false; +}; + +struct BDSPTrainer { + std::vector pokemon; +}; + +struct BDSPTrainerData { + BDSPBattleType battle_type; + std::vector trainers; +}; + +// Returns trainer data for the given battle ID (e.g. "Veilstone_GalacticGrunts_2v2"). +// Throws InternalProgramError if the ID is not found. +const BDSPTrainerData& bdsp_trainer(const std::string& battle_id); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_MenuCursorDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_MenuCursorDetector.cpp new file mode 100644 index 0000000000..9cf2086703 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_MenuCursorDetector.cpp @@ -0,0 +1,292 @@ +/* BDSP AutoStory - Menu Cursor Detector + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include + +#include "CommonFramework/VideoPipeline/VideoFeed.h" + +#include "CommonTools/Images/ImageFilter.h" + +#include "PokemonBDSP_AutoStory_MenuCursorDetector.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +std::string menu_cursor_to_string(MenuCursorPosition pos){ + switch (pos){ + case MenuCursorPosition::POKEDEX: + return "POKEDEX"; + case MenuCursorPosition::POKEMON: + return "POKEMON"; + case MenuCursorPosition::BAG: + return "BAG"; + case MenuCursorPosition::CARD: + return "CARD"; + case MenuCursorPosition::OPTIONS: + return "OPTIONS"; + case MenuCursorPosition::MAP: + return "MAP"; + case MenuCursorPosition::CAPSULES: + return "CAPSULES"; + case MenuCursorPosition::MYSTERYGIFT: + return "MYSTERYGIFT"; + default: + return "UNKNOWN"; + } +} + + +MenuCursorDetector::MenuCursorDetector(Color color) : + m_color(color) + // ------------------------------------------------------------------------- + // Top row — left to right + // ------------------------------------------------------------------------- + // TODO: replace each ImageFloatBox(0,0,0,0) with the real normalized + // (x, y, width, height) for a small region that gains dark pixels when + // that icon is hovered. Use the test program overlays to tune coordinates. + // ------------------------------------------------------------------------- + , m_pokedex_box{ + ImageFloatBox(0.180000, 0.115000, 0.135000, 0.195000), + }, + m_pokemon_box{ + ImageFloatBox(0.347500, 0.115000, 0.135000, 0.195000), + }, + m_bag_box{ + ImageFloatBox(0.515000, 0.115000, 0.135000, 0.195000), + }, + m_card_box{ + ImageFloatBox(0.682500, 0.115000, 0.135000, 0.195000), + } // ------------------------------------------------------------------------- + // Bottom row — left to right + // 5-icon layout: OPTIONS is the only bottom icon (far-left position). + // 8-icon layout: MAP, CAPSULES, OPTIONS, MYSTERYGIFT from left to right. + // OPTIONS sits at a different x-position in each layout — use a separate + // MenuCursorDetector instance if you need to detect OPTIONS in both. + // ------------------------------------------------------------------------- + , m_options_box{ + ImageFloatBox(0.515000, 0.364000, 0.135000, 0.195000), + }, + m_map_box{ + ImageFloatBox(0.180000, 0.364000, 0.135000, 0.195000), + }, + m_capsules_box{ + ImageFloatBox(0.347500, 0.364000, 0.135000, 0.195000), + }, + m_mysterygift_box{ + ImageFloatBox(0.682500, 0.364000, 0.135000, 0.195000), + } // ------------------------------------------------------------------------- + // Approach 2 search boxes — one per icon, same area as the dark-pixel box. + // The template PNG is slid across this area at its native size. + // ------------------------------------------------------------------------- + , m_icon_match_box{ + ImageFloatBox(0.180000, 0.115000, 0.135000, 0.195000), // 0 POKEDEX + ImageFloatBox(0.347500, 0.115000, 0.135000, 0.195000), // 1 POKEMON + ImageFloatBox(0.515000, 0.115000, 0.135000, 0.195000), // 2 BAG + ImageFloatBox(0.682500, 0.115000, 0.135000, 0.195000), // 3 CARD + ImageFloatBox(0.515000, 0.364000, 0.135000, 0.195000), // 4 OPTIONS (8-icon layout) + ImageFloatBox(0.180000, 0.364000, 0.135000, 0.195000), // 5 MAP + ImageFloatBox(0.347500, 0.364000, 0.135000, 0.195000), // 6 CAPSULES + ImageFloatBox(0.682500, 0.364000, 0.135000, 0.195000), // 7 MYSTERYGIFT + } { +} + + +void MenuCursorDetector::make_overlays(VideoOverlaySet& items) const { + for (const ImageFloatBox* icon_boxes : { + m_pokedex_box, + m_pokemon_box, + m_bag_box, + m_card_box, + m_options_box, + m_map_box, + m_capsules_box, + m_mysterygift_box, + }){ + for (int i = 0; i < BOXES_PER_ICON; i++){ + if (icon_boxes[i].width > 0.0 && icon_boxes[i].height > 0.0){ + items.add(m_color, icon_boxes[i]); + } + } + } + for (int i = 0; i < MENU_CURSOR_NUM_ICONS; i++){ + if (m_icon_match_box[i].width > 0.0 && m_icon_match_box[i].height > 0.0){ + items.add(m_color, m_icon_match_box[i]); + } + } +} + + +namespace { + +// Count how many of `n` boxes contain >= MIN_DARK_PIXEL_COUNT pixels with +// R, G, B all <= BLACK_CHANNEL_MAX. Boxes with zero width are skipped. +int count_dark_boxes(const ImageViewRGB32& screen, const ImageFloatBox* boxes, int n){ + const uint32_t ch = uint32_t(MenuCursorDetector::BLACK_CHANNEL_MAX); + const uint32_t maxs = 0xff000000u | (ch << 16) | (ch << 8) | ch; + + int count = 0; + for (int i = 0; i < n; i++){ + if (boxes[i].width == 0.0){ + continue; + } + size_t dark_pixels = 0; + filter_rgb32_range(dark_pixels, + extract_box_reference(screen, boxes[i]), + 0xff000000u, + maxs, + Color(0xffffffff), + false); + if (dark_pixels >= MenuCursorDetector::MIN_DARK_PIXEL_COUNT){ + count++; + } + } + return count; +} + +// Pixel-wise RMSD between two same-size images over RGB channels only. +double compute_rmsd(const ImageViewRGB32& a, const ImageViewRGB32& b){ + size_t n = a.width() * a.height(); + if (n == 0){ + return 0.0; + } + double sum_sq = 0.0; + for (size_t y = 0; y < a.height(); y++){ + for (size_t x = 0; x < a.width(); x++){ + uint32_t pa = a.pixel(x, y); + uint32_t pb = b.pixel(x, y); + int dr = int((pa >> 16) & 0xff) - int((pb >> 16) & 0xff); + int dg = int((pa >> 8) & 0xff) - int((pb >> 8) & 0xff); + int db = int(pa & 0xff) - int(pb & 0xff); + sum_sq += double(dr * dr + dg * dg + db * db); + } + } + return std::sqrt(sum_sq / (3.0 * n)); +} + +// Slide `tmpl` (at its native pixel size) across the region of `screen` +// defined by `box`. Returns the minimum RMSD found over all window positions. +double template_match_rmsd(const ImageViewRGB32& screen, const ImageFloatBox& box, const ImageViewRGB32& tmpl){ + ImageViewRGB32 region = extract_box_reference(screen, box); + size_t rw = tmpl.width(), rh = tmpl.height(); + size_t lw = region.width(), lh = region.height(); + if (rw == 0 || rh == 0 || rw > lw || rh > lh){ + return 0.0; + } + + double min_rmsd = std::numeric_limits::max(); + for (size_t dy = 0; dy <= lh - rh; dy++){ + for (size_t dx = 0; dx <= lw - rw; dx++){ + double r = compute_rmsd(region.sub_image(dx, dy, rw, rh), tmpl); + if (r < min_rmsd){ + min_rmsd = r; + } + } + } + return min_rmsd; +} + +} // anonymous namespace + + +MenuCursorPosition MenuCursorDetector::detect_dark_pixels(const ImageViewRGB32& screen) const { + struct IconEntry { + MenuCursorPosition position; + const ImageFloatBox* boxes; + }; + const IconEntry icons[] = { + {MenuCursorPosition::POKEDEX, m_pokedex_box}, + {MenuCursorPosition::POKEMON, m_pokemon_box}, + {MenuCursorPosition::BAG, m_bag_box}, + {MenuCursorPosition::CARD, m_card_box}, + {MenuCursorPosition::OPTIONS, m_options_box}, + {MenuCursorPosition::MAP, m_map_box}, + {MenuCursorPosition::CAPSULES, m_capsules_box}, + {MenuCursorPosition::MYSTERYGIFT, m_mysterygift_box}, + }; + + MenuCursorPosition best_pos = MenuCursorPosition::UNKNOWN; + int best_count = 0; + + for (const auto& entry : icons){ + int count = count_dark_boxes(screen, entry.boxes, BOXES_PER_ICON); + if (count > best_count){ + best_count = count; + best_pos = entry.position; + } + } + + return (best_count >= REQUIRED_VOTES) ? best_pos : MenuCursorPosition::UNKNOWN; +} + + +MenuCursorPosition MenuCursorDetector::detect_image_match(const ImageViewRGB32& screen) const { + MenuCursorPosition best_pos = MenuCursorPosition::UNKNOWN; + double best_rmsd = MATCH_RMSD_THRESHOLD; // must exceed this + + for (int i = 0; i < MENU_CURSOR_NUM_ICONS; i++){ + if (!m_icon_references[i].has_value()){ + continue; + } + if (m_icon_match_box[i].width == 0.0){ + continue; + } + double r = template_match_rmsd(screen, m_icon_match_box[i], *m_icon_references[i]); + if (r > best_rmsd){ + best_rmsd = r; + best_pos = static_cast(i); + } + } + return best_pos; +} + + +MenuCursorPosition MenuCursorDetector::detect(const ImageViewRGB32& screen) const { + MenuCursorPosition p1 = detect_dark_pixels(screen); + MenuCursorPosition p2 = detect_image_match(screen); + + if (p1 == p2){ + return p1; // both agree (or both UNKNOWN) + } + if (p1 == MenuCursorPosition::UNKNOWN){ + return p2; // only approach 2 fired + } + if (p2 == MenuCursorPosition::UNKNOWN){ + return p1; // only approach 1 fired + } + return p1; // conflict: prefer approach 1 +} + + +void MenuCursorDetector::set_icon_reference(MenuCursorPosition icon, ImageRGB32 reference){ + m_icon_references[static_cast(icon)].emplace(std::move(reference)); +} + + +double MenuCursorDetector::icon_rmsd(const ImageViewRGB32& screen, MenuCursorPosition icon) const { + int idx = static_cast(icon); + if (!m_icon_references[idx].has_value() || m_icon_match_box[idx].width == 0.0){ + return -1.0; + } + return template_match_rmsd(screen, m_icon_match_box[idx], *m_icon_references[idx]); +} + + +MenuCursorPosition detect_menu_cursor(VideoStream& stream, ProControllerContext& /*context*/){ + VideoSnapshot screen = stream.video().snapshot(); + if (!screen){ + return MenuCursorPosition::UNKNOWN; + } + MenuCursorDetector detector; + return detector.detect(screen); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_MenuCursorDetector.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_MenuCursorDetector.h new file mode 100644 index 0000000000..e7090deafb --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_MenuCursorDetector.h @@ -0,0 +1,146 @@ +/* BDSP AutoStory - Menu Cursor Detector + * + * From: https://github.com/PokemonAutomation/ + * + * Detects which icon the cursor is hovering over in the X-menu. + * + * Two independent detection approaches are provided for testing: + * + * Approach 1 — dark pixel count (detect_dark_pixels): + * Each icon has BOXES_PER_ICON small boxes placed over a region that gains + * dark pixels when hovered. A box fires if >= MIN_DARK_PIXEL_COUNT pixels + * in it have R, G, B all <= BLACK_CHANNEL_MAX. An icon is selected when its + * fire count reaches REQUIRED_VOTES. + * Tune BLACK_CHANNEL_MAX upward if real dark pixels are being missed. + * Tune it downward (or raise MIN_DARK_PIXEL_COUNT) to reduce false positives. + * + * Approach 2 — template matching (detect_image_match): + * Each icon has one larger search box and a hovered-state reference PNG loaded + * at runtime via set_icon_reference(). The reference PNG is slid (at its native + * pixel size, without resizing) across the search box and the minimum RMSD over + * all window positions is computed. The icon with the lowest min-RMSD that is + * also <= MATCH_RMSD_THRESHOLD is considered selected. + * Tune MATCH_RMSD_THRESHOLD downward to require a closer match. + * + * detect() is the production method and currently delegates to approach 1. + * It will become a combined vote once both approaches are validated. + * + * Menu layouts: + * 5-icon (early game): POKEDEX POKEMON BAG CARD / OPTIONS + * 8-icon (late game): POKEDEX POKEMON BAG CARD / MAP CAPSULES OPTIONS MYSTERYGIFT + * + * Note: OPTIONS sits at a different screen position in each layout. + * Use separate MenuCursorDetector instances with different box coordinates + * if you need to detect OPTIONS reliably in both layouts. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_MenuCursorDetector_H +#define PokemonAutomation_PokemonBDSP_AutoStory_MenuCursorDetector_H + +#include +#include +#include + +#include "Common/Cpp/Color.h" + +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" + +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +enum class MenuCursorPosition { + UNKNOWN = -1, + POKEDEX = 0, + POKEMON = 1, + BAG = 2, + CARD = 3, + OPTIONS = 4, + MAP = 5, + CAPSULES = 6, + MYSTERYGIFT = 7, +}; + +static constexpr int MENU_CURSOR_NUM_ICONS = 8; + +std::string menu_cursor_to_string(MenuCursorPosition pos); + + +class MenuCursorDetector { +public: + // ── Approach 1: dark pixel detection ───────────────────────────────────── + static constexpr int BOXES_PER_ICON = 3; + static constexpr int REQUIRED_VOTES = 1; // out of BOXES_PER_ICON + static constexpr uint8_t BLACK_CHANNEL_MAX = 50; // R,G,B <= this → dark pixel + static constexpr size_t MIN_DARK_PIXEL_COUNT = 1; // dark pixels per box to fire + + // ── Approach 2: template match ─────────────────────────────────────────── + // Reference PNGs are the UN-hovered state of each icon. When hovered, the + // icon gains dark pixels and diverges from the reference, so RMSD increases. + // detect_image_match() selects the icon with the HIGHEST RMSD >= threshold. + // Tune upward to reduce false positives; downward if the correct icon is missed. + static constexpr double MATCH_RMSD_THRESHOLD = 53.0; + + MenuCursorDetector(Color color = COLOR_RED); + + void make_overlays(VideoOverlaySet& items) const; + + // Approach 1: votes across dark-pixel boxes, returns icon with highest + // vote count >= REQUIRED_VOTES, or UNKNOWN. + MenuCursorPosition detect_dark_pixels(const ImageViewRGB32& screen) const; + + // Approach 2: returns the icon with the highest RMSD against its reference, + // provided that RMSD >= MATCH_RMSD_THRESHOLD (high RMSD = hovered). + // Returns UNKNOWN if no references are loaded or nothing exceeds threshold. + MenuCursorPosition detect_image_match(const ImageViewRGB32& screen) const; + + // Production method — currently delegates to detect_dark_pixels(). + MenuCursorPosition detect(const ImageViewRGB32& screen) const; + + // Load a hovered-state reference image for one icon (enables approach 2 for + // that icon). The match box for that icon must already be set in the + // constructor (m_icon_match_box); this method just attaches the reference. + void set_icon_reference(MenuCursorPosition icon, ImageRGB32 reference); + + // Returns the raw RMSD of `icon` against its reference on the given frame. + // Returns -1.0 if no reference is loaded for that icon. + double icon_rmsd(const ImageViewRGB32& screen, MenuCursorPosition icon) const; + +private: + Color m_color; + + // ── Approach 1 boxes ───────────────────────────────────────────────────── + // 3 boxes per icon targeting regions that gain dark pixels when hovered. + // All initialized to (0,0,0,0) — replace with real normalized coordinates. + ImageFloatBox m_pokedex_box[BOXES_PER_ICON]; + ImageFloatBox m_pokemon_box[BOXES_PER_ICON]; + ImageFloatBox m_bag_box[BOXES_PER_ICON]; + ImageFloatBox m_card_box[BOXES_PER_ICON]; + ImageFloatBox m_options_box[BOXES_PER_ICON]; + ImageFloatBox m_map_box[BOXES_PER_ICON]; + ImageFloatBox m_capsules_box[BOXES_PER_ICON]; + ImageFloatBox m_mysterygift_box[BOXES_PER_ICON]; + + // ── Approach 2 search boxes + references ──────────────────────────────── + // One search box per icon (the area to slide the template across). + // References are populated via set_icon_reference() at runtime. + ImageFloatBox m_icon_match_box[MENU_CURSOR_NUM_ICONS]; + std::array, MENU_CURSOR_NUM_ICONS> m_icon_references; +}; + + +// Captures the current frame from `stream` and runs detect() on it. +MenuCursorPosition detect_menu_cursor(VideoStream& stream, ProControllerContext& context); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.cpp new file mode 100644 index 0000000000..3e4e0f41ca --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.cpp @@ -0,0 +1,135 @@ +/* BDSP AutoStory - Overworld Detector + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" + +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/Images/SolidColorTest.h" + +#include "PokemonBDSP_AutoStory_OverworldDetector.h" + +using namespace std::chrono_literals; + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +// --------------------------------------------------------------------------- +// OverworldDetector +// --------------------------------------------------------------------------- + +OverworldDetector::OverworldDetector(Color color) + : m_color(color) + , m_battle_menu(BattleType::STANDARD, color){ +} + +void OverworldDetector::make_overlays(VideoOverlaySet& items) const { + m_dialog.make_overlays(items); + m_battle_menu.make_overlays(items); + m_menu.make_overlays(items); +} + +bool OverworldDetector::detect(const ImageViewRGB32& screen){ + // We are in the overworld when none of the overlay screens are present. + if (m_dialog.detect(screen)){ + return false; + } + if (m_battle_menu.detect(screen)){ + return false; + } + if (m_menu.detect(screen)){ + return false; + } + return true; +} + + +// --------------------------------------------------------------------------- +// OverworldWatcher +// --------------------------------------------------------------------------- + +OverworldWatcher::OverworldWatcher(Color color) + : OverworldDetector(color) + , VisualInferenceCallback("OverworldWatcher"){ +} + +void OverworldWatcher::make_overlays(VideoOverlaySet& items) const { + OverworldDetector::make_overlays(items); +} + +bool OverworldWatcher::process_frame(const ImageViewRGB32& frame, WallClock /*timestamp*/){ + return detect(frame); +} + + +// --------------------------------------------------------------------------- +// PoketchButtonDetector +// --------------------------------------------------------------------------- + +PoketchButtonDetector::PoketchButtonDetector(Color color) + : m_color(color) + , m_button(0.968, 0.170, 0.022, 0.120){ +} + +void PoketchButtonDetector::make_overlays(VideoOverlaySet& items) const { + items.add(m_color, m_button); +} + +bool PoketchButtonDetector::detect(const ImageViewRGB32& screen){ + ImageStats stats = image_stats(extract_box_reference(screen, m_button)); + // The button is a glossy red with a highlight, so allow a wide stddev. + return is_solid(stats, {0.74, 0.10, 0.16}, 0.12, 80); +} + +PoketchButtonWatcher::PoketchButtonWatcher(Color color) + : PoketchButtonDetector(color) + , VisualInferenceCallback("PoketchButtonWatcher"){ +} + +void PoketchButtonWatcher::make_overlays(VideoOverlaySet& items) const { + PoketchButtonDetector::make_overlays(items); +} + +bool PoketchButtonWatcher::process_frame(const ImageViewRGB32& frame, WallClock){ + return detect(frame); +} + + +static bool look_for_poketch_button(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + PoketchButtonWatcher watcher; + return wait_until(stream, context, 2s, {{watcher}}) == 0; +} + +bool ensure_poketch_visible(VideoStream& stream, ProControllerContext& context){ + for (size_t attempt = 0; attempt < 3; attempt++){ + if (look_for_poketch_button(stream, context)){ + return true; + } + stream.log("[AutoStory] Poketch button not found. Attempt " + std::to_string(attempt + 1) + " of 3.", + COLOR_ORANGE); + + if (attempt == 0){ + // Most likely hidden: one tap shows it. + pbf_press_button(context, BUTTON_R, 205ms, 0ms); + }else{ + // Long press hides it, then a tap shows it at the normal size. + pbf_press_button(context, BUTTON_R, 1548ms, 0ms); + pbf_wait(context, 1418ms); + pbf_press_button(context, BUTTON_R, 143ms, 0ms); + } + } + + // Check the result of the last press sequence. + return look_for_poketch_button(stream, context); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h new file mode 100644 index 0000000000..4741325711 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h @@ -0,0 +1,98 @@ +/* BDSP AutoStory - Overworld Detector + * + * From: https://github.com/PokemonAutomation/ + * + * Detects that the player is in the free-roaming overworld state: + * no dialog box, no battle menu, and no in-game menu open. + * + * BDSP does not ship a standalone OverworldDetector in PokemonBDSP/Inference/, + * so this composite detector is defined here for AutoStory use. + * + * Reuses: + * - ShortDialogDetector (PokemonBDSP_DialogDetector.h) — absence required + * - BattleMenuDetector (PokemonBDSP_BattleMenuDetector.h) — absence required + * - MenuDetector (PokemonBDSP_MenuDetector.h) — absence required + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_OverworldDetector_H +#define PokemonAutomation_PokemonBDSP_AutoStory_OverworldDetector_H + +#include "Common/Cpp/Color.h" + +#include "CommonFramework/Tools/VideoStream.h" + +#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" +#include "CommonTools/VisualDetector.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +// Static (single-frame) version — use when you already have a frame. +class OverworldDetector : public StaticScreenDetector { +public: + OverworldDetector(Color color = COLOR_CYAN); + + virtual void make_overlays(VideoOverlaySet& items) const override; + + // Returns true when none of: short dialog, battle menu, or in-game menu + // are present on the given frame. + virtual bool detect(const ImageViewRGB32& screen) override; + +private: + Color m_color; + ShortDialogDetector m_dialog; + BattleMenuDetector m_battle_menu; + MenuDetector m_menu; +}; + + +// Watcher (callback) version — use with run_until<> or wait_until(). +class OverworldWatcher : public OverworldDetector, public VisualInferenceCallback { +public: + OverworldWatcher(Color color = COLOR_CYAN); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override; +}; + + +// Detects the big red button on the right edge of the Poketch overlay. +// Present only while the Poketch is shown at its normal size. +class PoketchButtonDetector : public StaticScreenDetector { +public: + PoketchButtonDetector(Color color = COLOR_RED); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + +private: + Color m_color; + ImageFloatBox m_button; +}; + +class PoketchButtonWatcher : public PoketchButtonDetector, public VisualInferenceCallback { +public: + PoketchButtonWatcher(Color color = COLOR_RED); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override; +}; + +// Tries up to 3 times to get the Poketch button on screen using R presses. +// Returns true if the button was seen. +bool ensure_poketch_visible(VideoStream& stream, ProControllerContext& context); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp new file mode 100644 index 0000000000..67fce06504 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp @@ -0,0 +1,771 @@ + + +#include +#include + +#include "Common/Cpp/Json/JsonObject.h" +#include "Common/Cpp/Json/JsonValue.h" + +#include "CommonFramework/Exceptions/FatalProgramException.h" +#include "CommonFramework/Globals.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 "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" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +using namespace std::chrono_literals; + + +// --------------------------------------------------------------------------- +// Segment list +// --------------------------------------------------------------------------- + +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; +} + +static const std::vector>& ALL_SEGMENTS(){ + static std::vector> list = make_segment_list(); + return list; +} +static std::vector> make_checkpoint_list(){ + std::vector> checkpoint_list; + + //Segment 00 + 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 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()); + + // 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()); + + // 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()); + + // 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()); + + // 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()); + + // 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());*/ + + return checkpoint_list; +} + +static const std::vector>& ALL_CHECKPOINTS(){ + static std::vector> list = make_checkpoint_list(); + return list; +} + +// --------------------------------------------------------------------------- +// Segment database +// --------------------------------------------------------------------------- + +// Preserve Tutorial and Segments 01-03 numeric selections. Revised Segments +// 04-09 use explicit slugs, and obsolete selections require reselection. +// Keep Tutorial and Main Story separate for the manual HOME transfer. +static constexpr size_t TUTORIAL_CHECKPOINT_COUNT = 7; +// Keep existing Main Story option IDs stable after splitting the tutorial. +static constexpr size_t LEGACY_MAIN_CHECKPOINT_START = 6; +static const StringSelectDatabase& section_database(bool checkpoints, bool tutorial){ + static const StringSelectDatabase tutorial_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++){ + db.add_entry(StringSelectEntry(std::to_string(i), ALL_SEGMENTS()[i]->name())); + } + return db; + }(); + static const StringSelectDatabase tutorial_checkpoints = [] { + StringSelectDatabase db; + for (size_t i = 0; i < TUTORIAL_CHECKPOINT_COUNT; i++){ + const std::string name = ALL_CHECKPOINTS()[i]->name(); + db.add_entry(StringSelectEntry(name.substr(0, 5), name)); + } + return db; + }(); + static const StringSelectDatabase main_checkpoints = [] { + StringSelectDatabase db; + size_t legacy_index = LEGACY_MAIN_CHECKPOINT_START; + for (size_t i = TUTORIAL_CHECKPOINT_COUNT; i < ALL_CHECKPOINTS().size(); i++){ + const std::string name = ALL_CHECKPOINTS()[i]->name(); + if (name.compare(0, 3, "04.") >= 0 && name.compare(0, 3, "09.") <= 0){ + db.add_entry(StringSelectEntry(name.substr(0, 5), name)); + }else{ + db.add_entry(StringSelectEntry(std::to_string(legacy_index++), name)); + } + } + db.add_entry(StringSelectEntry("checkpoint-reselect", "Select a revised checkpoint (old selection)")); + return db; + }(); + return checkpoints ? (tutorial ? tutorial_checkpoints : main_checkpoints) + : (tutorial ? tutorial_segments : main_segments); +} + +// --------------------------------------------------------------------------- +// Descriptor +// --------------------------------------------------------------------------- + +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 { + return std::make_unique(); +} + + +// --------------------------------------------------------------------------- +// Program Instance +// --------------------------------------------------------------------------- + +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); +} + + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +BDSPAutoStory::BDSPAutoStory() + : SingleSwitchProgramInstance() + // 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, + "00.00") + , END_CHECKPOINT_TUTORIAL("End Checkpoint:", + section_database(true, true), + LockMode::UNLOCK_WHILE_RUNNING, + "00.06") + + , START_DESCRIPTION_SEGMENT("Start:
", "") + , END_DESCRIPTION_SEGMENT("End:
", "") + + , START_DESCRIPTION_CHECKPOINT("Start:
", "") + , END_DESCRIPTION_CHECKPOINT("End:
", "") + + , START_CHECKPOINT_MAINSTORY("Start Checkpoint (Main):", + section_database(true, false), + LockMode::LOCK_WHILE_RUNNING, + "6") + + , END_CHECKPOINT_MAINSTORY("End Checkpoint (Main):", + section_database(true, false), + LockMode::UNLOCK_WHILE_RUNNING, + section_database(true, false)[ALL_CHECKPOINTS().size() - TUTORIAL_CHECKPOINT_COUNT - 1].slug) + + , 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) + + , NOTIFICATION_STATUS_UPDATE("Status Update", true, false, std::chrono::seconds(30)) + , NOTIFICATIONS({ + &NOTIFICATION_STATUS_UPDATE, + &NOTIFICATION_PROGRAM_FINISH, + &NOTIFICATION_ERROR_RECOVERABLE, + &NOTIFICATION_ERROR_FATAL, + }) + +{ + // ----------------------------------------------------------------------- + // Options + // ----------------------------------------------------------------------- + + 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(STARTERCHOICE); + PA_ADD_OPTION(STARTER_SHINY); + PA_ADD_OPTION(CATCH_LEGENDARY); + + PA_ADD_OPTION(HELP_MODE); + PA_ADD_OPTION(OUTSIDE_HELP_POKEMON); + + PA_ADD_OPTION(OUTSIDE_HELP_ATTACK_1); + PA_ADD_OPTION(OUTSIDE_HELP_ATTACK_2); + PA_ADD_OPTION(OUTSIDE_HELP_ATTACK_3); + PA_ADD_OPTION(OUTSIDE_HELP_ATTACK_4); + + PA_ADD_OPTION(NOTIFICATIONS); + + HELP_MODE.add_listener(*this); + OUTSIDE_HELP_POKEMON.add_listener(*this); + START_CHECKPOINT_TUTORIAL.add_listener(*this); + END_CHECKPOINT_TUTORIAL.add_listener(*this); + 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); +} +std::string BDSPAutoStory::check_validity() const { + const std::string error = SingleSwitchProgramInstance::check_validity(); + if (!error.empty()){ + return error; + } + if (ENABLE_ADVANCED_MODE && STORY_SECTION == StorySection::MAIN_STORY && + (START_CHECKPOINT_MAINSTORY.slug() == "checkpoint-reselect" || + END_CHECKPOINT_MAINSTORY.slug() == "checkpoint-reselect")){ + return "Checkpoints in Segments 04 through 09 have been consolidated. Select the revised start and end " + "checkpoints that match your in-game save before starting."; + } + return {}; +} + +void BDSPAutoStory::load_json(const JsonValue& json){ + SingleSwitchProgramInstance::load_json(json); + const JsonObject* object = json.to_object(); + if (!object){ + return; + } + const char* keys[] = {"START_CHECKPOINT_MAINSTORY", "END_CHECKPOINT_MAINSTORY"}; + StringSelectOption* selections[] = {&START_CHECKPOINT_MAINSTORY, &END_CHECKPOINT_MAINSTORY}; + for (size_t selection = 0; selection < 2; selection++){ + const std::string* slug = object->get_string(keys[selection]); + if (!slug){ + continue; + } + if (*slug == "07.17" || *slug == "06-reselect" || *slug == "07-reselect"){ + selections[selection]->set_by_slug("checkpoint-reselect"); + continue; + } + for (size_t old_index = 22; old_index <= 125; old_index++){ + if (*slug == std::to_string(old_index)){ + selections[selection]->set_by_slug("checkpoint-reselect"); + break; + } + } + } + const char* tutorial_keys[] = {"START_CHECKPOINT_TUTORIAL", "END_CHECKPOINT_TUTORIAL"}; + StringSelectOption* tutorial_selections[] = {&START_CHECKPOINT_TUTORIAL, &END_CHECKPOINT_TUTORIAL}; + for (size_t selection = 0; selection < 2; selection++){ + const std::string* slug = object->get_string(tutorial_keys[selection]); + if (!slug){ + continue; + } + for (size_t old_index = 0; old_index <= 5; old_index++){ + if (*slug == std::to_string(old_index)){ + size_t revised_index = old_index; + if (old_index == 5){ + revised_index = 6; + }else if (old_index == 4 && selection == 1){ + // Old 00.04 included both the catch and post-catch route. + revised_index = 5; + } + tutorial_selections[selection]->set_by_slug("00.0" + std::to_string(revised_index)); + break; + } + } + } + // Preserve the intent of a saved post-catch diagnostic configuration. + const std::string* old_tutorial_start = object->get_string("START_CHECKPOINT_TUTORIAL"); + if (object->get_boolean_default("DIAGNOSTIC_POST_CATCH_ONLY") && old_tutorial_start && *old_tutorial_start == "4"){ + START_CHECKPOINT_TUTORIAL.set_by_slug("00.05"); + END_CHECKPOINT_TUTORIAL.set_by_slug("00.06"); + } + 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_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_ATTACK_1.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); + OUTSIDE_HELP_ATTACK_2.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); + OUTSIDE_HELP_ATTACK_3.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); + 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 = tutorial ? START_CHECKPOINT_TUTORIAL.index() + : START_CHECKPOINT_MAINSTORY.index() + TUTORIAL_CHECKPOINT_COUNT; + size_t end = tutorial ? END_CHECKPOINT_TUTORIAL.index() + : END_CHECKPOINT_MAINSTORY.index() + TUTORIAL_CHECKPOINT_COUNT; + + if (start < checkpoints.size()){ + START_DESCRIPTION_CHECKPOINT.set_text("Start: " + checkpoints[start]->start_text()); + }else{ + START_DESCRIPTION_CHECKPOINT.set_text("Select a revised checkpoint matching your in-game save."); + } + if (end < checkpoints.size()){ + END_DESCRIPTION_CHECKPOINT.set_text("End: " + checkpoints[end]->end_text()); + }else{ + END_DESCRIPTION_CHECKPOINT.set_text("Select a revised ending checkpoint."); + } + + }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()); + + if (start < segments.size()){ + START_DESCRIPTION_SEGMENT.set_text("Start: " + segments[start]->start_text()); + } + if (end < segments.size()){ + END_DESCRIPTION_SEGMENT.set_text("End: " + segments[end]->end_text()); + } + } + + update_ui_state(); +} +// --------------------------------------------------------------------------- +// Program logic +// --------------------------------------------------------------------------- + +void BDSPAutoStory::program(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + const std::string error = check_validity(); + if (!error.empty()){ + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, error); + } + AutoStoryStats& stats = env.current_stats(); + + AutoStoryOptions options{Language::English, + STARTERCHOICE, + NOTIFICATION_STATUS_UPDATE, + (HelpMode)HELP_MODE, + 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}; + + stats.starter_choice = options.starter_choice; + switch (options.starter_choice){ + case StarterChoice::TURTWIG: + stats.move_pp_capacity = {15, 15, 10, 25}; + break; + case StarterChoice::CHIMCHAR: + stats.move_pp_capacity = {15, 15, 10, 30}; + break; + case StarterChoice::PIPLUP: + stats.move_pp_capacity = {20, 10, 15, 35}; + break; + } + // A direct main-story start requires full HP/PP, as described in the wiki. + // After startup, only confirmed healing or a successful save reload refills it. + stats.move_pp_remaining = stats.move_pp_capacity; + + const bool fake_saves = options.checkpoint_save_mode == CheckpointSaveMode::FAKE; + if (fake_saves){ + options.checkpoint_retry_scope = CheckpointRetryScope::DEFER_TO_SELECTED_RANGE; + } + bool tutorial_completed = false; + 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."); + } + + + const bool tutorial = STORY_SECTION == StorySection::TUTORIAL; + const auto& segments = ALL_SEGMENTS(); + + if (ENABLE_ADVANCED_MODE){ + size_t start = tutorial ? START_CHECKPOINT_TUTORIAL.index() + : START_CHECKPOINT_MAINSTORY.index() + TUTORIAL_CHECKPOINT_COUNT; + size_t end = tutorial ? END_CHECKPOINT_TUTORIAL.index() + : END_CHECKPOINT_MAINSTORY.index() + TUTORIAL_CHECKPOINT_COUNT; + + 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); + if (tutorial && i + 1 == TUTORIAL_CHECKPOINT_COUNT){ + tutorial_completed = true; + } + } + + }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()); + + for (size_t i = start; i <= end && i < segments.size(); i++){ + env.log("Running Segment: " + std::to_string(i)); + segments[i]->run_segment(env, context, options, stats); + if (tutorial && i == 0){ + tutorial_completed = true; + } + } + } + }; + 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); + } + + std::string completion_message; + if (tutorial_completed){ + context.wait_for_all_requests(); + completion_message = "Tutorial complete. Deposit your starter in the Jubilife Pokemon Center PC, " + "save and close BDSP, then transfer it through Pokemon HOME. Return your fully evolved " + "level-100 starter to the first party slot with the wiki moveset and full HP and PP " + "before starting Main Story."; + MAINSTORY_NOTE.set_text(completion_message); + MAINSTORY_NOTE.set_visibility(ConfigOptionState::ENABLED); + env.log_to_ui(completion_message, COLOR_GREEN); + env.console.log(completion_message, COLOR_GREEN); + } + send_program_finished_notification(env, NOTIFICATION_PROGRAM_FINISH, completion_message); +} + + +} +} +} \ 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 new file mode 100644 index 0000000000..ef9a50d0d2 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include + +#include "Common/Cpp/Options/BooleanCheckBoxOption.h" +#include "Common/Cpp/Options/EnumDropdownOption.h" +#include "Common/Cpp/Options/StaticTextOption.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 +}; + +class BDSPAutoStory_Descriptor : public SingleSwitchProgramDescriptor { +public: + BDSPAutoStory_Descriptor(); + + virtual std::unique_ptr make_stats() const override; +}; + + +class BDSPAutoStory : public SingleSwitchProgramInstance, public ConfigOption::Listener { +public: + using Descriptor = BDSPAutoStory_Descriptor; + ~BDSPAutoStory(); + BDSPAutoStory(); + + virtual std::string check_validity() const override; + virtual void load_json(const JsonValue& json) 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_FAKE_CHECKPOINT_SAVE; + EnumDropdownOption STORY_SECTION; + StaticTextOption MAINSTORY_NOTE; + BooleanCheckBoxOption ENABLE_ADVANCED_MODE; + + StringSelectOption START_CHECKPOINT_TUTORIAL; + StringSelectOption END_CHECKPOINT_TUTORIAL; + StaticTextOption START_DESCRIPTION_SEGMENT; + StaticTextOption END_DESCRIPTION_SEGMENT; + + 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; + EnumDropdownOption OUTSIDE_HELP_POKEMON; + 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 new file mode 100644 index 0000000000..a34f52973d --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp @@ -0,0 +1,2141 @@ + + + +/* BDSP AutoStory Tools + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include + +#include "Common/Cpp/Filesystem/Filesystem.h" + +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/Globals.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageDiff.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/Notifications/ProgramNotifications.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" + +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/Images/ImageFilter.h" +#include "CommonTools/Images/SolidColorTest.h" +#include "CommonTools/OCR/OCR_Routines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "CommonTools/VisualDetectors/ImageMatchDetector.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_Superscalar.h" + +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h" +#include "PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.h" +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "PokemonBDSP/Programs/PokemonBDSP_GameEntry.h" +#include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" + +#include "Detect/PokemonBDSP_AutoStory_OverworldDetector.h" + +#include "PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +const char* torterra_move_slug(TorterraMove move){ + switch (move){ + case TorterraMove::EARTHQUAKE: + return "earthquake"; + case TorterraMove::WOOD_HAMMER: + return "wood-hammer"; + case TorterraMove::RAZOR_LEAF: + return "razor-leaf"; + case TorterraMove::CRUNCH: + return "crunch"; + case TorterraMove::GIGA_DRAIN: + return "giga-drain"; + case TorterraMove::IRON_TAIL: + return "iron-tail"; + case TorterraMove::ROCK_TOMB: + return "rock-tomb"; + case TorterraMove::FACADE: + return "facade"; + case TorterraMove::STONE_EDGE: + return "stone-edge"; + case TorterraMove::ROCK_SLIDE: + return "rock-slide"; + case TorterraMove::BULLDOZE: + return "bulldoze"; + case TorterraMove::STRENGTH: + return "strength"; + case TorterraMove::ROCK_SMASH: + return "rock-smash"; + case TorterraMove::ROCK_CLIMB: + return "rock-climb"; + } + return "unknown"; +} + +const char* trainer_slug(Trainer t){ + switch (t){ + case Trainer::Veilstone_GalacticGrunts_2v2: + return "Veilstone_GalacticGrunts_2v2"; + } + return "unknown"; +} + +bool fake_save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + pbf_mash_button(context, BUTTON_L, 1000ms); + + context.wait_for_all_requests(); + pbf_press_button(context, BUTTON_X, 80ms, 1000ms); + pbf_press_button(context, BUTTON_R, 80ms, 1000ms); + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + + return true; +} + +bool save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + static constexpr size_t MAX_RETRIES = 3; + + for (size_t attempt = 0; attempt < MAX_RETRIES; attempt++){ + try { + env.console.log("Checkpoint save attempt: " + std::to_string(attempt + 1)); + + // Only mash B on retries - no need to on first attempt + // if already cleanly in the overworld. + if (attempt > 0){ + pbf_mash_button(context, BUTTON_B, 1000ms); + } + + // BlackScreenOverWatcher can finish while the destination is still + // fading in. Drain movement and wait before sending the menu input. + context.wait_for_all_requests(); + BlackScreenWatcher destination_visible(COLOR_RED, + {0.1, 0.1, 0.8, 0.8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 1000ms); + if (wait_until(env.console, context, 15s, {{destination_visible}}) != 0){ + throw OperationFailedExceptionWithScreenshot(ErrorReportMode::SEND_ERROR_REPORT, + "Checkpoint destination did not become visible before saving.", + env.console); + } + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + + OverworldWatcher ready_to_save; + if (wait_until(env.console, context, 15s, {{ready_to_save}}) != 0){ + throw OperationFailedExceptionWithScreenshot(ErrorReportMode::SEND_ERROR_REPORT, + "Checkpoint destination did not return to the overworld before saving.", + env.console); + } + + // Reuse existing feedback-based save: opens menu with MenuWatcher + // + respects GameSettings::OVERWORLD_TO_MENU_DELAY0 internally. + save_game(env.console, context); + + // Return to overworld cleanly. + pbf_mash_button(context, BUTTON_B, 1000ms); + + // Drain R/confirm/B before detection; an earlier overworld frame + // must not report success while saving commands are still queued. + context.wait_for_all_requests(); + + // Verify overworld. Generous timeout: save animation + fade can be slow. + OverworldWatcher overworld; + int ret = wait_until(env.console, context, std::chrono::seconds(15), {overworld}); + if (ret < 0){ + throw OperationFailedExceptionWithScreenshot(ErrorReportMode::SEND_ERROR_REPORT, + "Failed to detect overworld after save.", + env.console); + } + + env.console.log("Checkpoint save verified."); + return true; + + } catch (OperationFailedException& e){ + env.console.log("Checkpoint save failed: " + e.message(), COLOR_RED); + context.wait_for(1000ms); + } + } + + // Blind fallback: reuse the non-feedback save_game(context) from GameNavigation + // rather than duplicating the button sequence manually. + env.console.log("All save retries failed. Using blind fallback.", COLOR_ORANGE); + pbf_mash_button(context, BUTTON_B, 1000ms); + save_game(context); // PokemonBDSP_GameNavigation.h - non-feedback overload + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + + return false; +} + + +void checkpoint_save(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + EventNotificationOption& notif_status_update, + AutoStoryStats& stats, + CheckpointSaveMode save_mode){ + const bool fake = save_mode == CheckpointSaveMode::FAKE; + env.console.log( + fake ? "[Checkpoint save] DIAGNOSTIC FAKE mode: save is not verified." : "[Checkpoint save] REAL mode."); + bool success = fake ? fake_save_game(env, context) : save_game(env, context); + + stats.m_checkpoint++; + env.update_stats(); + + send_program_status_notification(env, + notif_status_update, + fake ? "Diagnostic fake checkpoint save (not verified)." + : success ? "Saved at checkpoint (verified)." + : "Saved at checkpoint (blind fallback)."); +} + + +void reload_backup_save(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + // TODO: implement backup save reload. + // This is called when a checkpoint with save=false fails. + // Load the game's automatic backup save rather than resetting from HOME. + (void)env; + (void)context; +} + + +void checkpoint_reattempt_loop(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + EventNotificationOption& notif_status_update, + AutoStoryStats& stats, + std::function&& action, + bool save, + CheckpointSaveMode save_mode, + CheckpointRetryScope retry_scope){ + const auto pp_at_checkpoint = stats.move_pp_remaining; + static constexpr size_t MAX_ATTEMPTS = 20; + + for (size_t i = 0;; i++){ + try { + if (i == 0 && retry_scope != CheckpointRetryScope::SELECTED_RANGE){ + if (save || stats.m_first_run){ + checkpoint_save(env, context, notif_status_update, stats, save_mode); + } + stats.m_first_run = false; + } + + action(i); + return; // success + + } 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); + stats.m_reset++; + env.update_stats(); + + if (i + 1 >= MAX_ATTEMPTS){ + throw; + } + + if (save || retry_scope == CheckpointRetryScope::SELECTED_RANGE){ + env.console.log("Resetting game from HOME before retry...", COLOR_ORANGE); + bool ok = reset_game_from_home(env, env.console, context, true, 1000ms); + if (!ok){ + throw OperationFailedExceptionWithScreenshot(ErrorReportMode::SEND_ERROR_REPORT, + "Failed to reset game from HOME during checkpoint retry.", + env.console); + } + // Only a real game reload restores the corresponding PP balance. + stats.move_pp_remaining = pp_at_checkpoint; + }else{ + env.console.log("Reloading from backup save before retry...", COLOR_ORANGE); + reload_backup_save(env, context); + } + } + } +} + + +void repeat(std::initializer_list> actions, size_t times){ + for (size_t i = 0; i < times; i++){ + for (const auto& action : actions){ + action(); + } + } +} + + +void wait_for_dialogue(VideoStream& stream, + ProControllerContext& context, + const std::string& label, + Milliseconds timeout){ + auto log_detected = [&] { + if (!label.empty()){ + stream.log(label + " dialogue detected."); + } + }; + + ShortDialogWatcher watcher_cyan(COLOR_CYAN); + int ret = run_until(stream, + context, + [timeout](ProControllerContext& context){ + context.wait_for(timeout); + }, + {{watcher_cyan}}); + if (ret == 0){ + log_detected(); + return; + } + + ShortDialogWatcher watcher_red(COLOR_RED); + ret = run_until(stream, + context, + [timeout](ProControllerContext& context){ + context.wait_for(timeout); + }, + {{watcher_red}}); + if (ret == 0){ + log_detected(); + return; + } + + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + (label.empty() ? "wait_for_dialogue" : label) + ": dialogue not detected within timeout.", + stream); +} + + +void mash_until_dialogue_ends(VideoStream& stream, ProControllerContext& context, Button button, Milliseconds timeout){ + OverworldWatcher overworld; + int ret = run_until(stream, + context, + [button, timeout](ProControllerContext& context){ + pbf_mash_button(context, button, timeout); + }, + {{overworld}}); + if (ret < 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "mash_until_dialogue_ends: overworld not reached within timeout.", + stream); + } +} + + +void repeat_dpad(ProControllerContext& context, + DpadState& state, + DpadPosition dir, + Milliseconds press, + Milliseconds hold, + size_t times, + bool include_last_dir){ + if (include_last_dir && state.last_dir != DPAD_NONE && state.last_dir != dir){ + times += 1; + } + state.last_dir = dir; + for (size_t i = 0; i < times; i++){ + pbf_press_dpad(context, dir, press, hold); + } +} + +static const char* shop_item_name(ShopItem item){ + switch (item){ + case ShopItem::FullRestore: + return "Full restore"; + case ShopItem::MaxPotion: + return "Max potion"; + case ShopItem::HyperPotion: + return "Hyper potion"; + case ShopItem::SuperPotion: + return "Super potion"; + case ShopItem::Potion: + return "Potion"; + case ShopItem::Antidote: + return "Antidote"; + case ShopItem::ParalyzHeal: + return "Paralyz heal"; + case ShopItem::Awakening: + return "Awakening"; + case ShopItem::Repel: + return "Repel"; + case ShopItem::SuperRepel: + return "Super repel"; + case ShopItem::MaxRepel: + return "Max repel"; + case ShopItem::EscapeRope: + return "Escape rope"; + default: + return "Unknown"; + } +} + +// Returns the OCR slug used as the dictionary key in ShopItemNameOCR.json. +static const char* shop_item_slug(ShopItem item){ + switch (item){ + case ShopItem::FullRestore: + return "full-restore"; + case ShopItem::MaxPotion: + return "max-potion"; + case ShopItem::HyperPotion: + return "hyper-potion"; + case ShopItem::SuperPotion: + return "super-potion"; + case ShopItem::Potion: + return "potion"; + case ShopItem::Antidote: + return "antidote"; + case ShopItem::ParalyzHeal: + return "paralyze-heal"; + case ShopItem::Awakening: + return "awakening"; + case ShopItem::Repel: + return "repel"; + case ShopItem::SuperRepel: + return "super-repel"; + case ShopItem::MaxRepel: + return "max-repel"; + case ShopItem::EscapeRope: + return "escape-rope"; + default: + return "unknown"; + } +} + +// {MaxRepel, 2, 4}, {FullRestore, 0, 3} → "4 Max repel, 3 Full restore" +static std::string make_buy_label(const std::vector& items){ + std::string label; + for (size_t i = 0; i < items.size(); i++){ + if (i > 0){ + label += ", "; + } + if (items[i].quantity == BUY_MAX){ + label += "max "; + }else{ + label += std::to_string(items[i].quantity) + " "; + } + label += shop_item_name(items[i].item); + } + return label; +} + +bool buy_items(VideoStream& stream, + ProControllerContext& context, + const std::vector& items, + Language language){ + if (items.empty()){ + return true; + } + + const std::string label = make_buy_label(items); + context.wait_for_all_requests(); + pbf_wait(context, 500ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + context.wait_for_all_requests(); + + + // ── Coordinate constants ───────────────────────────────────────────────── + + // Area where the selection arrow appears at the vendor greeting menu (Buy/Sell/Leave). + static const ImageFloatBox VENDOR_ARROW_BOX{0.630000, 0.550000, 0.070000, 0.120000}; + + // Area where the selection arrow appears at the YES/NO confirmation dialog. + static const ImageFloatBox CONFIRM_ARROW_BOX{0.700000, 0.620000, 0.070000, 0.120000}; + + // Full area containing all item rows in the shop list. + // The hover detector scans this region to dynamically locate the red outline. + static const ImageFloatBox ITEM_SCAN_AREA{0.417000, 0.100000, 0.540000, 0.625000}; + + static constexpr size_t MAX_OUTER = 50; + static constexpr size_t MAX_ITEM_SEARCH = 50; // max iterations per item before giving up + + // ── Outer state machine ────────────────────────────────────────────────── + for (size_t outer = 0; outer < MAX_OUTER; outer++){ + SelectionArrowFinder arrow_vendor(stream.overlay(), VENDOR_ARROW_BOX, COLOR_GREEN); + ShopBuyingWindowWatcher buying_window; + ShortDialogWatcher dialog(COLOR_RED); + + // case 0: no detectors fired — press A in a for-loop (5 presses) while watching. + stream.log("Starting to loop over items"); + int state = run_until(stream, + context, + [](ProControllerContext& ctx){ + for (int i = 0; i < 5; i++){ + pbf_press_button(ctx, BUTTON_A, 80ms, 1000ms); + } + }, + {{arrow_vendor}, {buying_window}, {dialog}}); + + context.wait_for_all_requests(); + pbf_wait(context, 500ms); + + switch (state){ + + case 0: // case 1: SelectionArrow at vendor menu → press A to choose "Buy" + stream.log("Found the selection arrow, pressing A to enter the buying window...", COLOR_GREEN); + pbf_press_button(context, BUTTON_A, 80ms, 500ms); + break; + + case 1: // case 2: buying window open → buy each requested item + stream.log("Found the buying window, searching for the right item...", COLOR_GREEN); + { + bool buying_failed = false; + + for (const ItemToBuy& item : items){ + stream.log("Buying " + std::to_string(item.quantity) + "x " + shop_item_slug(item.item)); + if (buying_failed){ + break; + } + + // Detector fires only when the red outline is found AND both + // name and price OCR match the expected item slug. + ShopItemHoverDetector hover(stream.logger(), + ITEM_SCAN_AREA, + shop_item_slug(item.item), + language, + COLOR_CYAN); + ShopQuantityWindowWatcher qty_window; + SelectionArrowFinder confirm_arrow(stream.overlay(), CONFIRM_ARROW_BOX, COLOR_GREEN); + + bool item_bought = false; + bool hover_confirmed = false; + for (size_t inner = 0; inner < MAX_ITEM_SEARCH && !item_bought; inner++){ + context.wait_for_all_requests(); + + int sub = run_until(stream, + context, + [](ProControllerContext& ctx){ + ctx.wait_for(1000ms); + }, + {{confirm_arrow}, {qty_window}, {hover}}); + + context.wait_for_all_requests(); + + stream.log("buy_items: sub=" + std::to_string(sub), COLOR_WHITE); + + switch (sub){ + + case 0: // YES/NO confirmation arrow → confirm purchase + stream.log("Found the confirmation to buy arrow, pressing A to buy", COLOR_GREEN); + { + context.wait_for_all_requests(); + ShopBuyingWindowWatcher back_in_shop; + run_until(stream, + context, + [](ProControllerContext& ctx){ + for (int i = 0; i < 10; i++){ + pbf_press_button(ctx, BUTTON_A, 80ms, 800ms); + } + }, + {{back_in_shop}}); + item_bought = true; + break; + } + + case 1: // quantity window + if (!hover_confirmed){ + // Quantity window appeared before we confirmed the right item — + // an earlier A press accidentally opened the wrong item's selector. + // Press B to go back to the list and continue searching. + stream.log("Unexpected quantity window (wrong item), pressing B...", COLOR_YELLOW); + pbf_press_button(context, BUTTON_B, 80ms, 800ms); + break; + } + stream.log("Found the quantity window", COLOR_GREEN); + if (item.quantity == BUY_MAX){ + stream.log("Buying max quantity"); + pbf_press_dpad(context, DPAD_DOWN, 80ms, 300ms); + }else{ + stream.log("Buying " + std::to_string(item.quantity) + " of the item"); + for (int q = 1; q < item.quantity; q++){ + pbf_press_dpad(context, DPAD_UP, 80ms, 300ms); + } + } + pbf_press_button(context, BUTTON_A, 80ms, 300ms); + pbf_wait(context, 1000ms); + break; + + case 2: // correct item highlighted → press A to open quantity selector + stream.log("Found the right item, pressing A to buy a certain quantity...", COLOR_GREEN); + hover_confirmed = true; + pbf_press_button(context, BUTTON_A, 80ms, 300ms); + pbf_wait(context, 1000ms); + break; + + default: // outline not found or wrong item → advance to next row + stream.log("Not on the right item, moving...", COLOR_GREEN); + pbf_press_dpad(context, DPAD_DOWN, 80ms, 300ms); + pbf_wait(context, 300ms); + break; + } + } + + if (!item_bought){ + stream.log(label + ": buy_items: could not locate " + shop_item_name(item.item) + + " in shop list.", + COLOR_RED); + buying_failed = true; + } + } + + if (!buying_failed){ + stream.log("All items bought, exiting shop...", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 5000ms); + return true; + } + break; + } + + case 2: // case 3: unexpected ShortDialog → press B until back in buying window + { + ShopBuyingWindowWatcher back_in_shop; + run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_mash_button(ctx, BUTTON_B, 5000ms); + }, + {{back_in_shop}}); + break; + } + + default: // outer default: unrecognised state → press B to recover + pbf_press_button(context, BUTTON_B, 80ms, 3000ms); + break; + } + } + + stream.log(label + ": buy_items: failed to complete after max iterations.", COLOR_RED); + return false; +} + + +bool heal_pokemon(VideoStream& stream, + ProControllerContext& context, + const std::string& label, + AutoStoryStats* stats){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, +1}, 1800ms, 100ms); // 7+ + context.wait_for_all_requests(); + + pbf_mash_button(context, BUTTON_A, 5000ms); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + wait_for_dialogue(stream, context, label + " - Pokemon healing"); + mash_until_dialogue_ends(stream, context, BUTTON_B); + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + // Exiting the Pokemon Center + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 15000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("heal_pokemon(" + label + "): black screen not detected", COLOR_RED); + return false; + } + + context.wait_for_all_requests(); + if (stats){ + stats->move_pp_remaining = stats->move_pp_capacity; + stream.log("[Move PP] " + label + ": restored the selected starter's PP budgets."); + } + return true; +} + +std::string fly_point_name(FlyPoint place){ + switch (place){ + case FlyPoint::CanalavCity: + return "canalave_city"; + case FlyPoint::CelesticTown: + return "celestic_town"; + case FlyPoint::EternaCity: + return "eterna_city"; + case FlyPoint::FloaromaTown: + return "floaroma_town"; + case FlyPoint::HearthomeCity: + return "hearthome_city"; + case FlyPoint::JubilifeCity: + return "jubilife_city"; + case FlyPoint::OreburghCity: + return "oreburgh_city"; + case FlyPoint::PastoriaCity: + return "pastoria_city"; + case FlyPoint::PokemonLeagueLower: + return "pokemon_league_lower"; + case FlyPoint::PokemonLeagueUpper: + return "pokemon_league_upper"; + case FlyPoint::Route221: + return "route_221"; + case FlyPoint::SandgemTown: + return "sandgem_town"; + case FlyPoint::SolaceonTown: + return "solaceon_town"; + case FlyPoint::SnowpointCity: + return "snowpoint_city"; + case FlyPoint::SunyshoreCity: + return "sunyshore_city"; + case FlyPoint::TwinleafTown: + return "twinleaf_town"; + case FlyPoint::VeilstoneCity: + return "veilstone_city"; + case FlyPoint::CurrentCity: + return "current_city"; + } + return "unknown"; +} + +bool fly_to(VideoStream& stream, ProControllerContext& context, FlyPoint place){ + // Each city branch below should update this to the city icon's position on the fly map. + // Detection: when the cursor brackets overlap the icon, its normally uniform + // color becomes non-uniform (stddev rises). Box stays as a rough fallback + // until each city is calibrated. + ImageFloatBox city_icon_box{0.700000, 0.550000, 0.100000, 0.120000}; + + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + open_menu(stream, context, MenuCursorPosition::MAP, 8); + + if (place == FlyPoint::CurrentCity){ + ImageFloatBox button_a_box{0.540000, 0.960000, 0.070000, 0.035000}; + struct FlyAButtonDetector : VisualInferenceCallback { + ImageFloatBox m_box; + FlyAButtonDetector(const ImageFloatBox& box) + : VisualInferenceCallback("FlyAButtonDetector") + , m_box(box){ + } + void make_overlays(VideoOverlaySet&) const override { + } + bool process_frame(const ImageViewRGB32& frame, WallClock) override { + ImageStats s = image_stats(extract_box_reference(frame, m_box)); + return s.stddev.sum() > 30.0; + } + }; + FlyAButtonDetector watcher(button_a_box); + int ret = wait_until(stream, context, 3000ms, {{watcher}}); + if (ret < 0){ + stream.log("CurrentCity: A button not found.", COLOR_RED); + return false; + } + BlackScreenOverWatcher flying_black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int fly_ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{flying_black_screen}}); + if (fly_ret < 0){ + stream.log("CurrentCity: black screen not detected!", COLOR_RED); + return false; + } + stream.log("CurrentCity: black screen detected, arrived.", COLOR_GREEN); + return true; + } + + switch (place){ + case FlyPoint::CelesticTown: + pbf_move_left_joystick(context, {-1, +1}, 4000ms, 500ms); + break; + case FlyPoint::OreburghCity: + pbf_move_left_joystick(context, {-1, -1}, 4000ms, 500ms); + break; + case FlyPoint::HearthomeCity: + case FlyPoint::PastoriaCity: + case FlyPoint::PokemonLeagueLower: + case FlyPoint::PokemonLeagueUpper: + case FlyPoint::SolaceonTown: + case FlyPoint::SunyshoreCity: + case FlyPoint::VeilstoneCity: + pbf_move_left_joystick(context, {+1, -1}, 4000ms, 500ms); + break; + case FlyPoint::SnowpointCity: + pbf_move_left_joystick(context, {-1, +1}, 4000ms, 500ms); + break; + case FlyPoint::TwinleafTown: + pbf_move_left_joystick(context, {-1, -1}, 4000ms, 500ms); + break; + default: + pbf_move_left_joystick(context, {-1, -1}, 4000ms, 500ms); + break; + } + context.wait_for_all_requests(); + + ImageFloatBox button_a_box{0.540000, 0.960000, 0.070000, 0.035000}; + + struct FlyAButtonDetector : VisualInferenceCallback { + ImageFloatBox m_box; + FlyAButtonDetector(const ImageFloatBox& box) + : VisualInferenceCallback("FlyAButtonDetector") + , m_box(box){ + } + void make_overlays(VideoOverlaySet&) const override { + } + bool process_frame(const ImageViewRGB32& frame, WallClock) override { + ImageStats s = image_stats(extract_box_reference(frame, m_box)); + // Blue stripe is uniform when no button shown; A button disrupts it + return s.stddev.sum() > 30.0; + } + }; + FlyAButtonDetector watcher_1(button_a_box); + + switch (place){ + case FlyPoint::CanalavCity: + break; + case FlyPoint::CelesticTown: + city_icon_box = {0.660000, 0.470000, 0.012000, 0.020000}; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 15, false); + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 13, false); + break; + case FlyPoint::EternaCity: + break; + case FlyPoint::FloaromaTown: + break; + case FlyPoint::HearthomeCity: + city_icon_box = {0.660000, 0.637000, 0.029000, 0.052000}; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 16, false); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 7, false); + break; + case FlyPoint::JubilifeCity: + city_icon_box = {0.453000, 0.702000, 0.016000, 0.054000}; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 4, false); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 5, false); + break; + case FlyPoint::OreburghCity: + city_icon_box = {0.566000, 0.702000, 0.012000, 0.054000}; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 10, false); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 5, false); + break; + case FlyPoint::PastoriaCity: + break; + case FlyPoint::PokemonLeagueLower: + break; + case FlyPoint::PokemonLeagueUpper: + break; + case FlyPoint::Route221: + break; + case FlyPoint::SandgemTown: + break; + case FlyPoint::SolaceonTown: + city_icon_box = {0.735000, 0.603000, 0.030000, 0.022000}; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 12, false); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 9, false); + break; + case FlyPoint::SnowpointCity: + city_icon_box = {0.602000, 0.070000, 0.013000, 0.054000}; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 12, false); + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1, false); + break; + case FlyPoint::SunyshoreCity: + break; + case FlyPoint::TwinleafTown: + city_icon_box = {0.415000, 0.870000, 0.013000, 0.022000}; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2, false); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 1, false); + break; + case FlyPoint::VeilstoneCity: + city_icon_box = {0.829000, 0.537000, 0.017000, 0.054000}; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 8, false); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 10, false); + break; + case FlyPoint::CurrentCity: + break; + } + + context.wait_for_all_requests(); + + struct CityIconDetector : VisualInferenceCallback { + ImageFloatBox m_box; + CityIconDetector(const ImageFloatBox& box) + : VisualInferenceCallback("CityIconDetector") + , m_box(box){ + } + void make_overlays(VideoOverlaySet&) const override { + } + bool process_frame(const ImageViewRGB32& frame, WallClock) override { + ImageStats s = image_stats(extract_box_reference(frame, m_box)); + // City icon is normally a uniform color; cursor brackets disrupt it + return s.stddev.sum() > 30.0; + } + }; + CityIconDetector watcher_2(city_icon_box); + + const std::string name = fly_point_name(place); + int ret_1 = wait_until(stream, context, 3000ms, {{watcher_1}}); + int ret_2 = wait_until(stream, context, 4000ms, {{watcher_2}}); + if (ret_1 < 0){ + stream.log("A button not found, retrying...", COLOR_RED); + }; + if (ret_2 < 0){ + stream.log(name + " not found, retrying...", COLOR_RED); + }; + if (ret_1 == 0 and ret_2 == 0){ + stream.log("Found " + name + ", flying towards it.", COLOR_GREEN); + WhiteScreenOverWatcher flying_white_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + BlackScreenOverWatcher flying_black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{flying_white_screen}, {flying_black_screen}}); + if (ret < 0){ + stream.log("Flying to " + name + ": white / black screen not detected!", COLOR_RED); + return false; + }else if (ret >= 0){ + stream.log("Flying to " + name + ": white / black screen detected!", COLOR_GREEN); + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Arrow - Based Potion Usage Helper + +// This version assumes : +// +// you are already inside the Bag +// Potion has already been selected +// the game is currently waiting for you to choose a Pokemon +// +// Flow : +// 1. Move LEFT once to Pokemon list +// 2. Press A on first Pokemon +// 3. Use SelectionArrow to detect arrow position +// 4. Move DOWN until arrow reaches slot 3 +// 5. Mash A for 3 seconds +// 6. Mash B for 10 seconds +// +// --------------------------------------------------------------------------- + +// Slot 1's colored HP bar in the Bag. Read fill position rather than OCR. +static double potion_hp_fraction(const ImageViewRGB32& frame){ + if (bag_pocket_position(frame) == 0){ + return -1; + } + const ImageViewRGB32 bar = extract_box_reference(frame, ImageFloatBox{0.137, 0.160, 0.202, 0.016}); + 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++){ + uint32_t p = bar.pixel(x, y); + 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() / 4; + } + return bar.width() ? (double)filled / bar.width() : -1; +} + +static double confirmed_potion_hp(VideoStream& stream, ProControllerContext& context){ + double previous = -1; + size_t stable = 0; + for (size_t check = 0; check < 20; check++){ + context.wait_for_all_requests(); + auto frame = stream.video().snapshot(); + double hp = frame ? potion_hp_fraction(frame) : -1; + if (hp >= 0 && previous >= 0 && std::abs(hp - previous) <= 0.015){ + if (++stable >= 3){ + return hp; + } + }else{ + stable = 0; + } + previous = hp; + pbf_wait(context, 150ms); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Potion: Slot 1 HP bar could not be confirmed.", + stream); +} + +void use_potion_first_pokemon(VideoStream& stream, ProControllerContext& context){ + 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))){ + 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){ + healed = true; + break; + } + if (before <= 0.005){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Potion: Slot 1 appears fainted; a Potion cannot revive it.", + stream); + } + pbf_press_button(context, BUTTON_A, 80ms, 500ms); + pbf_press_button(context, BUTTON_A, 80ms, 500ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + 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, + "Potion: Did not return to the overworld after use.", + stream); + } + // Reopen Bag to verify the result, including when the last Potion was used. + open_menu(stream, context, MenuCursorPosition::BAG, 5); + pbf_wait(context, 750ms); + context.wait_for_all_requests(); + const double after = confirmed_potion_hp(stream, context); + stream.log("[Potion] Slot 1 HP bar after use=" + std::to_string(after * 100) + "%."); + if (after <= before + 0.005){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Potion: Slot 1 HP did not increase; refusing to continue movement.", + stream); + } + if (after >= 0.98){ + healed = true; + break; + } + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + } + if (!healed){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Potion: Full HP not confirmed after three uses.", + stream); + } + 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, + "Potion: Final overworld return not confirmed.", + stream); + } + stream.log("[Potion] Slot 1 HP bar full and overworld confirmed."); +} + + +namespace { + +struct IconGrid { + int row, col; +}; + +IconGrid icon_to_grid(MenuCursorPosition pos, int num_icons){ + switch (pos){ + case MenuCursorPosition::POKEDEX: + return {0, 0}; + case MenuCursorPosition::POKEMON: + return {0, 1}; + case MenuCursorPosition::BAG: + return {0, 2}; + case MenuCursorPosition::CARD: + return {0, 3}; + case MenuCursorPosition::OPTIONS: + return {1, num_icons <= 5 ? 0 : 2}; + case MenuCursorPosition::MAP: + return {1, 0}; + case MenuCursorPosition::CAPSULES: + return {1, 1}; + case MenuCursorPosition::MYSTERYGIFT: + return {1, 3}; + default: + return {-1, -1}; + } +} + +} // anonymous namespace + + +void open_menu(VideoStream& stream, ProControllerContext& context, MenuCursorPosition target, int num_icons){ + using namespace std::chrono_literals; + + // Previous single opening attempt: + // context.wait_for_all_requests(); + // overworld_to_menu(stream, context); + // pbf_wait(context, 1000ms); + // context.wait_for_all_requests(); + context.wait_for_all_requests(); + + MenuCursorDetector detector; + { + static const char* ICON_NAMES[MENU_CURSOR_NUM_ICONS] = { + "POKEDEX", + "POKEMON", + "BAG", + "CARD", + "OPTIONS", + "MAP", + "CAPSULES", + "MYSTERYGIFT", + }; + // Previous optional template directory: PokemonBDSP/AutoStory. + std::string ref_dir = RESOURCE_PATH() + "PokemonBDSP/Autostory"; + for (int i = 0; i < MENU_CURSOR_NUM_ICONS; i++){ + std::string path = ref_dir + "/" + ICON_NAMES[i] + ".png"; + // These templates are optional. The dark-pixel cursor detector + // remains available when a template is not packaged. + if (!Filesystem::is_regular_file(path)){ + continue; + } + // Previously constructed the image before checking its availability. + ImageRGB32 img(path); + if (img){ + detector.set_icon_reference(static_cast(i), std::move(img)); + } + } + } + // Previous single detection attempt retained for comparison. + // MenuCursorPosition cur = MenuCursorPosition::UNKNOWN; + // for (int i = 0; i < 10 && cur == MenuCursorPosition::UNKNOWN; i++){ + // context.wait_for(50ms); + // VideoSnapshot snap = stream.video().snapshot(); + // if (snap) cur = detector.detect(snap); + // } + // if (cur == MenuCursorPosition::UNKNOWN){ + // OperationFailedExceptionWithScreenshot::fire( + // ErrorReportMode::SEND_ERROR_REPORT, + // "Menu cursor not detected after opening menu.", + // stream + // ); + // } + + MenuCursorPosition cur = MenuCursorPosition::UNKNOWN; + bool menu_confirmed_open = false; + for (size_t attempt = 1; attempt <= 3; attempt++){ + stream.log("[Open menu] Open and detect attempt " + std::to_string(attempt) + "/3."); + try { + // A late-opening menu may already be visible after a timeout. + // Check before pressing X again, since X would close that menu. + if (!menu_confirmed_open){ + MenuWatcher already_open; + if (wait_until(stream, context, 300ms, {{already_open}}) < 0){ + overworld_to_menu(stream, context); + }else{ + stream.log("[Open menu] Menu already visible; skipping X."); + } + menu_confirmed_open = true; + }else{ + // Cursor detection retries send no input that could close the + // confirmed menu. Keep observing it instead of toggling X. + stream.log("[Open menu] Retrying cursor detection in the open menu."); + } + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + for (int frame = 0; frame < 10 && cur == MenuCursorPosition::UNKNOWN; frame++){ + context.wait_for(50ms); + const VideoSnapshot snapshot = stream.video().snapshot(); + if (snapshot){ + cur = detector.detect(snapshot); + } + } + if (cur != MenuCursorPosition::UNKNOWN){ + stream.log("[Open menu] Menu and cursor detected on attempt " + std::to_string(attempt) + ".", + COLOR_GREEN); + break; + } + stream.log("[Open menu] Menu cursor not detected on attempt " + std::to_string(attempt) + ".", + COLOR_ORANGE); + } catch (OperationFailedException& error){ + stream.log("[Open menu] Attempt " + std::to_string(attempt) + " failed: " + error.message(), COLOR_ORANGE); + } + } + if (cur == MenuCursorPosition::UNKNOWN){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Menu or cursor detection failed after 3 opening attempts.", + stream); + } + + IconGrid cur_g = icon_to_grid(cur, num_icons); + IconGrid tgt_g = icon_to_grid(target, num_icons); + if (tgt_g.row == -1){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Unknown target menu icon.", + stream); + } + + if (cur_g.row != tgt_g.row){ + DpadPosition vert = (cur_g.row < tgt_g.row) ? DPAD_DOWN : DPAD_UP; + pbf_press_dpad(context, vert, 80ms, 300ms); + context.wait_for_all_requests(); + } + + int col_diff = tgt_g.col - cur_g.col; + if (col_diff != 0){ + DpadPosition horiz = (col_diff > 0) ? DPAD_RIGHT : DPAD_LEFT; + for (int i = 0; i < std::abs(col_diff); i++){ + pbf_press_dpad(context, horiz, 80ms, 300ms); + context.wait_for_all_requests(); + } + } + + pbf_press_button(context, BUTTON_A, 80ms, 200ms); +} + + +// Search the selected item's fixed description header rather than guessing +// which list row is selected. This remains in place while the list scrolls. +// A cursor parked at row 2 or 6 does not indicate a list end: only repeated +// unchanged item headers after movement are used to detect an end. +// Previous selected-item-only search, retained for reference. +// bool find_bag_item( +// VideoStream& stream, +// ProControllerContext& context, +// const BagItemTemplate& item, +// int num_icons, +// size_t max_scrolls_per_pocket, +// std::chrono::milliseconds search_timeout +// ){ +// auto reference = std::make_shared( +// RESOURCE_PATH() + item.reference_path +// ); +// ImageMatchDetector icon(reference, item.icon_box, item.max_icon_rmsd, true); +// ImageMatchDetector name(reference, item.name_box, item.max_name_rmsd, true); +// ImageMatchDetector bag(reference, ImageFloatBox(0.055, 0.012, 0.044, 0.045), 35.0, true); +// const ImageFloatBox selected_header(0.428, 0.754, 0.382, 0.046); +// const auto deadline = std::chrono::steady_clock::now() + search_timeout; +// +// stream.log("[Bag search] Looking for " + item.label + "."); +// open_menu(stream, context, MenuCursorPosition::BAG, num_icons); +// pbf_wait(context, 1000ms); +// context.wait_for_all_requests(); +// +// for (size_t pocket = 0; pocket < 8; pocket++){ +// ImageRGB32 initial_header; +// ImageRGB32 previous_header; +// size_t unchanged = 0; +// size_t matching_frames = 0; +// size_t scrolls = 0; +// bool scanning_up = false; +// +// while (std::chrono::steady_clock::now() < deadline){ +// context.wait_for_all_requests(); +// const VideoSnapshot frame = stream.video().snapshot(); +// if (!frame || !bag.detect(frame)){ +// stream.log("[Bag search] Bag screen not detected.", COLOR_ORANGE); +// return false; +// } +// const double icon_score = icon.rmsd(frame); +// const double name_score = item.check_name ? name.rmsd(frame) : 0.0; +// stream.log( +// "[Bag search] Pocket " + std::to_string(pocket + 1) + +// ", scroll " + std::to_string(scrolls) + +// ": icon=" + std::to_string(icon_score) + +// ", name=" + std::to_string(name_score) +// ); +// if (icon_score <= item.max_icon_rmsd && +// name_score <= item.max_name_rmsd){ +// if (++matching_frames >= 3){ +// stream.log("[Bag search] " + item.label + " confirmed.", COLOR_GREEN); +// return true; +// } +// pbf_wait(context, 150ms); +// continue; +// } +// matching_frames = 0; +// +// const ImageRGB32 header = extract_box_reference(frame, selected_header).scale_to(382, 46); +// if (!initial_header){ +// initial_header = header.copy(); +// } +// else if (scrolls > 0){ +// const bool same_as_previous = +// ImageMatch::pixel_RMSD(previous_header, header) <= 3.0; +// const bool returned_to_start = +// ImageMatch::pixel_RMSD(initial_header, header) <= 3.0; +// if (returned_to_start && !same_as_previous){ +// break; +// } +// // Repeated unchanged rows mean an empty pocket or list end. +// // Returning to the initial row means the list wrapped. +// unchanged = same_as_previous || returned_to_start ? unchanged + 1 : 0; +// if (unchanged >= 3){ +// if (scanning_up){ +// break; +// } +// // A non-wrapping list may have opened in the middle. +// // Search upward as well before moving to another pocket. +// scanning_up = true; +// unchanged = 0; +// initial_header = header.copy(); +// } +// } +// previous_header = header.copy(); +// if (scrolls >= max_scrolls_per_pocket){ +// stream.log("[Bag search] Scroll limit reached; search incomplete.", COLOR_ORANGE); +// return false; +// } +// scrolls++; +// pbf_press_dpad(context, scanning_up ? DPAD_UP : DPAD_DOWN, 80ms, 350ms); +// } +// if (std::chrono::steady_clock::now() >= deadline){ +// stream.log("[Bag search] Search timed out; search incomplete.", COLOR_ORANGE); +// return false; +// } +// if (pocket < 7){ +// pbf_press_dpad(context, DPAD_RIGHT, 80ms, 500ms); +// context.wait_for_all_requests(); +// } +// } +// stream.log("[Bag search] Item not found in any pocket.", COLOR_ORANGE); +// return false; +// } +// + +// One narrow strip on the left side of the red selection border. Divide its +// height into the seven row bands; the item's art and NEW badge are outside it. +int bag_cursor_position(const ImageViewRGB32& frame){ + if (!frame){ + return 0; + } + const ImageViewRGB32 strip = extract_box_reference(frame, ImageFloatBox(0.431, 0.229, 0.005, 0.470)); + double best = 0.0; + double second = 0.0; + int position = 0; + for (int row = 0; row < 7; row++){ + const size_t top = static_cast(strip.height() * (row * 0.075 / 0.470)); + const size_t bottom = static_cast(strip.height() * ((row * 0.075 + 0.020) / 0.470)); + size_t red = 0; + size_t total = 0; + for (size_t y = top; y < bottom && y < strip.height(); y++){ + for (size_t x = 0; x < strip.width(); x++){ + const uint32_t pixel = strip.pixel(x, y); + const unsigned r = (pixel >> 16) & 255; + const unsigned g = (pixel >> 8) & 255; + const unsigned b = pixel & 255; + red += r > 180 && g < 120 && b < 120; + total++; + } + } + const double score = total == 0 ? 0.0 : static_cast(red) / total; + if (score > best){ + second = best; + best = score; + position = row + 1; + }else if (score > second){ + second = score; + } + } + return best >= 0.15 && best - second >= 0.10 ? position : 0; +} + +static ImageFloatBox bag_list_icon_box(size_t row){ + return ImageFloatBox(0.449, 0.212 + (row - 1) * 0.075, 0.033, 0.050); +} + +static ImageFloatBox bag_list_name_box(size_t row){ + // Previous height included the faint lower selection border in rows 2/6. + // return ImageFloatBox(0.492, 0.213 + (row - 1) * 0.075, 0.335, 0.052); + return ImageFloatBox(0.492, 0.213 + (row - 1) * 0.075, 0.335, 0.050); +} + +static double bag_row_patch_score(const ImageRGB32& reference, const ImageViewRGB32& frame, const ImageFloatBox& box){ + const ImageRGB32 live = extract_box_reference(frame, box).scale_to(reference.width(), reference.height()); + return ImageMatch::pixel_RMSD(reference, live); +} + +// Previous name-area empty-list check retained for reference. +// static bool bag_list_is_empty(const ImageViewRGB32& frame){ +// const ImageViewRGB32 names = extract_box_reference( +// frame, ImageFloatBox(0.492, 0.213, 0.335, 0.500) +// ); +// size_t dark = 0; +// for (size_t y = 0; y < names.height(); y++){ +// for (size_t x = 0; x < names.width(); x++){ +// const uint32_t pixel = names.pixel(x, y); +// dark += ((pixel >> 16) & 255) < 100 && +// ((pixel >> 8) & 255) < 100 && (pixel & 255) < 100; +// } +// } +// return dark < names.width() * names.height() * 0.001; +// } + +size_t bag_clear_tab_count(const ImageViewRGB32& frame){ + if (!frame || bag_pocket_position(frame) == 0){ + return 0; + } + static const std::vector> references = [] { + std::vector> refs; + for (const char* name : {"Normal", + "NormalMedium", + "NormalSmall", + "Alternate", + "AlternateMedium", + "AlternateSmall", + "Selected", + "SelectedMedium", + "SelectedSmall", + "SelectedBlue", + "SelectedBlueMedium", + "SelectedBlueSmall"}){ + refs.emplace_back( + std::make_shared(RESOURCE_PATH() + "PokemonBDSP/Autostory/BagTabs/" + name + ".png")); + } + return refs; + }(); + size_t clear = 0; + for (size_t tab = 0; tab < 8; tab++){ + const double x = .570 + tab * .0421; + // A selected tab has a thin red outline. The NEW marker fills the + // interior of this upper-left patch, which the outline leaves white. + auto badge = extract_box_reference(frame, ImageFloatBox(x + .007, .074, .006, .014)); + size_t red = 0; + for (size_t y = 0; y < badge.height(); y++){ + for (size_t bx = 0; bx < badge.width(); bx++){ + const uint32_t pixel = badge.pixel(bx, y); + int rr = (pixel >> 16) & 255, gg = (pixel >> 8) & 255, bb = pixel & 255; + red += rr > 150 && rr > gg + 60 && rr > bb + 60; + } + } + if (badge.width() * badge.height() && (double)red / (badge.width() * badge.height()) > .45){ + continue; + } + const ImageFloatBox box{x, .06, .038, .072}; + for (const auto& reference : references){ + ImageMatchDetector detector(reference, box, 50.0); + if (detector.detect(frame)){ + clear++; + break; + } + } + } + return clear; +} + +bool clear_new_item_flags(VideoStream& stream, ProControllerContext& context, int num_icons){ + stream.log("[Bag NEW flags] Opening bag to verify all eight tabs."); + open_menu(stream, context, MenuCursorPosition::BAG, num_icons); + pbf_wait(context, 1000ms); + auto all_clear = [&](){ + context.wait_for_all_requests(); + size_t confirmed = 0; + for (size_t check = 0; check < 3; check++){ + const auto frame = stream.video().snapshot(); + const size_t count = frame ? bag_clear_tab_count(*frame.frame) : 0; + stream.log("[Bag NEW flags] Clear tabs: " + std::to_string(count) + "/8."); + if (count != 8){ + return false; + } + confirmed++; + pbf_wait(context, 150ms); + context.wait_for_all_requests(); + } + return confirmed == 3; + }; + bool cleared = all_clear(); + for (size_t pocket = 0; pocket < 32 && !cleared; pocket++){ + if (pocket){ + pbf_press_dpad(context, DPAD_RIGHT, 80ms, 300ms); + } + // Let the initial selection and each subsequent item clear its NEW + // flag. Short taps avoid skipping rows through held-button repeat. + pbf_wait(context, 1000ms); + for (size_t tap = 0; tap < 12; tap++){ + pbf_press_dpad(context, DPAD_DOWN, 80ms, 1000ms); + } + cleared = all_clear(); + } + pbf_press_dpad(context, DPAD_UP, 3000ms, 250ms); + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + stream.log(cleared ? "[Bag NEW flags] All eight tabs clear; bag closed." + : "[Bag NEW flags] Full pocket pass did not clear all eight tabs; bag closed.", + cleared ? COLOR_GREEN : COLOR_ORANGE); + return cleared; +} + +int bag_pocket_position(const ImageViewRGB32& frame){ + if (!frame){ + return 0; + } + int selected = 0; + for (int pocket = 1; pocket <= 8; pocket++){ + // The lower left edge of each tab is below the red NEW badge. + // This strip includes both the normal outline and enlarged selection. + const auto edge = + extract_box_reference(frame, ImageFloatBox(0.570 + (pocket - 1) * 0.0421, 0.098, 0.0045, 0.021)); + size_t colored = 0; + for (size_t y = 0; y < edge.height(); y++){ + for (size_t x = 0; x < edge.width(); x++){ + const uint32_t pixel = edge.pixel(x, y); + const int r = (pixel >> 16) & 255; + const int g = (pixel >> 8) & 255; + const int b = pixel & 255; + // Red when focused, blue when the item list has focus. + colored += (r > 160 && r > g + 60 && r > b + 60) || (b > 160 && b > r + 40 && b > g + 20); + } + } + const double pixels = (double)edge.width() * edge.height(); + if (pixels > 0 && colored / pixels >= 0.18){ + if (selected != 0){ + return 0; + } + selected = pocket; + } + } + return selected; +} + +bool bag_list_is_empty(const ImageViewRGB32& frame){ + if (!frame || bag_pocket_position(frame) == 0 || bag_cursor_position(frame) != 0){ + return false; + } + // Empty pockets have no item icons. The central Empty label is outside + // this column; counting dark pixels in the name area included that label. + const auto icons = extract_box_reference(frame, ImageFloatBox(0.449, 0.213, 0.032, 0.500)); + const auto stats = image_stats(icons); + return stats.average.r > 220 && stats.average.g > 220 && stats.average.b > 220 && stats.stddev.sum() < 20; +} + + +std::array +match_bag_item_rows(const ImageViewRGB32& frame, const ImageViewRGB32& reference, const BagItemTemplate& item){ + std::array result; + if (!frame || !reference || item.reference_row < 1 || item.reference_row > 7){ + return result; + } + const ImageRGB32 icon = extract_box_reference(reference, bag_list_icon_box(item.reference_row)).copy(); + const ImageRGB32 name = extract_box_reference(reference, bag_list_name_box(item.reference_row)).copy(); + for (size_t row = 1; row <= 7; row++){ + auto& match = result[row - 1]; + match.icon_rmsd = bag_row_patch_score(icon, frame, bag_list_icon_box(row)); + match.name_rmsd = item.check_name ? bag_row_patch_score(name, frame, bag_list_name_box(row)) : 0.0; + match.matched = match.icon_rmsd <= item.max_icon_rmsd && match.name_rmsd <= item.max_name_rmsd; + } + return result; +} + +BagMovePlan plan_bag_item_move(int cursor_row, int item_row){ + if (cursor_row < 1 || cursor_row > 7 || item_row < 0 || item_row > 7){ + return {}; + } + if (item_row != 0){ + if (item_row == cursor_row){ + return {BagMoveAction::CONFIRM, 0}; + } + return {item_row > cursor_row ? BagMoveAction::DOWN : BagMoveAction::UP, + static_cast(std::abs(item_row - cursor_row))}; + } + if (cursor_row == 7){ + return {BagMoveAction::REWIND, 1}; + } + return {BagMoveAction::PAGE_DOWN, cursor_row == 1 ? 13 : static_cast(13 - cursor_row)}; +} + + +static int bag_confirm_pocket(VideoStream& stream, ProControllerContext& context){ + int previous = 0; + size_t matches = 0; + for (size_t check = 0; check < 10; check++){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + const int pocket = frame ? bag_pocket_position(frame) : 0; + matches = pocket != 0 && pocket == previous ? matches + 1 : pocket != 0 ? 1 : 0; + previous = pocket; + if (matches >= 3){ + return pocket; + } + pbf_wait(context, 100ms); + } + return 0; +} + +// static bool bag_advance_pocket(VideoStream& stream, ProControllerContext& context, int current){ +// const int expected = current % 8 + 1; +// for (size_t attempt = 0; attempt < 3; attempt++){ +// // Recheck before retrying: a delayed change must not cause a second step. +// const int observed = bag_confirm_pocket(stream, context); +// if (observed == expected) return true; +// if (observed != current){ +// stream.log("[Bag search] Pocket selection uncertain; stopping.", COLOR_ORANGE); +// return false; +// } +// pbf_press_dpad(context, DPAD_RIGHT, 80ms, 500ms); +// const int result = bag_confirm_pocket(stream, context); +// stream.log("[Bag search] Pocket change: " + std::to_string(current) + " -> " + +// std::to_string(result) + ", expected " + std::to_string(expected) + "."); +// if (result == expected) return true; +// } +// stream.log("[Bag search] Could not confirm pocket change after 3 attempts.", COLOR_ORANGE); +// return false; +// } + +BagPocketMovePlan plan_bag_pocket_move(int current, int target){ + if (current < 1 || current > 8 || target < 1 || target > 8){ + return {}; + } + const int right = (target - current + 8) % 8; + const int left = (current - target + 8) % 8; + if (right == 0){ + return {BagPocketAction::CONFIRM, 0}; + } + return right <= left ? BagPocketMovePlan{BagPocketAction::RIGHT, static_cast(right)} + : BagPocketMovePlan{BagPocketAction::LEFT, static_cast(left)}; +} + +static bool bag_navigate_to_pocket(VideoStream& stream, ProControllerContext& context, int target){ + int current = bag_confirm_pocket(stream, context); + for (size_t attempt = 0; attempt < 3; attempt++){ + const auto plan = plan_bag_pocket_move(current, target); + if (plan.action == BagPocketAction::CONFIRM){ + return true; + } + if (plan.action == BagPocketAction::UNKNOWN){ + stream.log("[Bag search] Selected pocket not detected.", COLOR_ORANGE); + return false; + } + stream.log("[Bag search] Pocket " + std::to_string(current) + " to " + std::to_string(target) + ": " + + (plan.action == BagPocketAction::RIGHT ? "right " : "left ") + std::to_string(plan.presses) + + " presses, attempt " + std::to_string(attempt + 1) + "/3."); + for (size_t press = 0; press < plan.presses; press++){ + pbf_press_dpad(context, plan.action == BagPocketAction::RIGHT ? DPAD_RIGHT : DPAD_LEFT, 80ms, 150ms); + } + pbf_wait(context, 350ms); + current = bag_confirm_pocket(stream, context); + stream.log( + "[Bag search] Final pocket=" + std::to_string(current) + ", expected=" + std::to_string(target) + "."); + if (current == target){ + return true; + } + } + stream.log("[Bag search] Target pocket not reached after 3 movement batches.", COLOR_ORANGE); + return false; +} + + +bool find_bag_item(VideoStream& stream, + ProControllerContext& context, + const BagItemTemplate& item, + int target_pocket, + int num_icons, + size_t max_scrolls_per_pocket, + std::chrono::milliseconds search_timeout){ + if (target_pocket < 1 || target_pocket > 8 || item.reference_row < 1 || item.reference_row > 7){ + stream.log("[Bag search] Invalid template reference row.", COLOR_ORANGE); + return false; + } + auto reference = std::make_shared(RESOURCE_PATH() + item.reference_path); + // const ImageRGB32 reference_icon = extract_box_reference( + // *reference, bag_list_icon_box(item.reference_row) + // ).copy(); + // const ImageRGB32 reference_name = extract_box_reference( + // *reference, bag_list_name_box(item.reference_row) + // ).copy(); + ImageMatchDetector bag(reference, ImageFloatBox(0.055, 0.012, 0.044, 0.045), 35.0, true); + const auto deadline = std::chrono::steady_clock::now() + search_timeout; + + open_menu(stream, context, MenuCursorPosition::BAG, num_icons); + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + + if (!bag_navigate_to_pocket(stream, context, target_pocket)){ + return false; + } + // Previously searched all eight pockets. Only inspect the requested pocket now. + // for (size_t pocket = 0; pocket < 8; pocket++){ + { + const int selected_pocket = bag_confirm_pocket(stream, context); + if (selected_pocket == 0){ + stream.log("[Bag search] Selected pocket not detected.", COLOR_ORANGE); + return false; + } + size_t commands = 0; + bool empty = false; + auto move = [&](DpadPosition direction, size_t count, bool check_rows = false){ + for (size_t press = 0; press < count; press++){ + if (commands >= max_scrolls_per_pocket || std::chrono::steady_clock::now() >= deadline){ + stream.log("[Bag search] Limit reached; search incomplete.", COLOR_ORANGE); + return false; + } + commands++; + pbf_press_dpad(context, direction, 80ms, 150ms); + if (check_rows){ + context.wait_for_all_requests(); + const VideoSnapshot page_frame = stream.video().snapshot(); + if (!page_frame || !bag.detect(page_frame)){ + return false; + } + const auto matches = match_bag_item_rows(page_frame, *reference, item); + for (const auto& match : matches){ + if (match.matched){ + // Stop paging; the main loop verifies and selects the row. + return true; + } + } + } + } + pbf_wait(context, 250ms); + context.wait_for_all_requests(); + return true; + }; + for (size_t pass = 0; pass < 3 && !empty; pass++){ + size_t matching_frames = 0; + size_t missing_cursor = 0; + size_t unchanged_pages = 0; + ImageRGB32 previous_page; + stream.log("[Bag search] Looking for " + item.label + ": pocket " + std::to_string(selected_pocket) + + ", pass " + std::to_string(pass + 1) + "/3."); + while (std::chrono::steady_clock::now() < deadline){ + const VideoSnapshot frame = stream.video().snapshot(); + if (!frame || !bag.detect(frame)){ + stream.log("[Bag search] Bag screen not detected.", COLOR_ORANGE); + return false; + } + const int cursor = bag_cursor_position(frame); + int target = 0; + double best = 1000.0; + bool ambiguous = false; + // The seven list ROIs move neither with the cursor nor with + // scrolling. Each contains an icon patch and a name patch. + // Previous per-row loop retained for reference. + // for (size_t row = 1; row <= 7; row++){ + // const double icon = bag_row_patch_score(reference_icon, frame, bag_list_icon_box(row)); + // const double name = item.check_name + // ? bag_row_patch_score(reference_name, frame, bag_list_name_box(row)) : 0.0; + // if (icon <= item.max_icon_rmsd && name <= item.max_name_rmsd){ + // if (target != 0){ + // ambiguous = true; + // } + // if (icon + name < best){ + // target = static_cast(row); + // best = icon + name; + // } + // } + // } + const auto matches = match_bag_item_rows(frame, *reference, item); + for (size_t row = 1; row <= 7; row++){ + const auto& match = matches[row - 1]; + if (match.matched){ + if (target != 0){ + ambiguous = true; + } + if (match.icon_rmsd + match.name_rmsd < best){ + target = static_cast(row); + best = match.icon_rmsd + match.name_rmsd; + } + } + } + stream.log("[Bag search] " + item.label + (target == 0 ? " is not detected" : " is detected") + + " on screen; item row=" + std::to_string(target) + ", cursor row=" + std::to_string(cursor) + "."); + if (ambiguous){ + stream.log("[Bag search] Multiple rows match; selection is uncertain.", COLOR_ORANGE); + return false; + } + if (cursor == 0){ + matching_frames = 0; + if (bag_list_is_empty(frame)){ + empty = true; + break; + } + if (++missing_cursor >= 3){ + stream.log("[Bag search] Cursor location uncertain.", COLOR_ORANGE); + return false; + } + pbf_wait(context, 200ms); + context.wait_for_all_requests(); + continue; + } + missing_cursor = 0; + if (target != 0){ + previous_page = ImageRGB32(); + unchanged_pages = 0; + // A unique list sprite/name match with the cursor on that + // row is sufficient; do not depend on the detail panel. + if (target == cursor){ + if (++matching_frames >= 3){ + stream.log("[Bag search] Item and cursor confirmed in row " + std::to_string(cursor) + ".", + COLOR_GREEN); + return true; + } + pbf_wait(context, 150ms); + context.wait_for_all_requests(); + continue; + } + matching_frames = 0; + const size_t count = plan_bag_item_move(cursor, target).presses; + stream.log("[Bag search] Moving " + std::string(target > cursor ? "down " : "up ") + + std::to_string(count) + " time(s), then checking again."); + if (!move(target > cursor ? DPAD_DOWN : DPAD_UP, count)){ + return false; + } + continue; + } + matching_frames = 0; + const ImageRGB32 page = + extract_box_reference(frame, ImageFloatBox(0.449, 0.212, 0.378, 0.500)).scale_to(378, 500); + if (previous_page && ImageMatch::pixel_RMSD(previous_page, page) <= 3.0){ + unchanged_pages++; + }else{ + unchanged_pages = 0; + } + previous_page = page.copy(); + // Row 7 signals the bottom of a long list. Repeated pages + // also cover shorter lists whose final row is less than 7. + if (cursor == 7 || unchanged_pages >= 2){ + stream.log("[Bag search] List end; returning to row 1."); + while (true){ + const VideoSnapshot rewind_frame = stream.video().snapshot(); + if (!rewind_frame || !bag.detect(rewind_frame)){ + return false; + } + const int rewind_cursor = bag_cursor_position(rewind_frame); + if (rewind_cursor == 1){ + break; + } + if (rewind_cursor == 0 || !move(DPAD_UP, 1)){ + return false; + } + } + break; + } + const size_t count = plan_bag_item_move(cursor, 0).presses; + stream.log("[Bag search] Advancing " + std::to_string(count) + " presses to another page."); + if (!move(DPAD_DOWN, count, true)){ + return false; + } + } + if (std::chrono::steady_clock::now() >= deadline){ + stream.log("[Bag search] Timeout; search incomplete.", COLOR_ORANGE); + return false; + } + } + // if (pocket < 7){ + // // pbf_press_dpad(context, DPAD_RIGHT, 80ms, 500ms); + // // context.wait_for_all_requests(); + // if (!bag_advance_pocket(stream, context, selected_pocket)) return false; + // } + } + stream.log("[Bag search] Item not found after three passes per nonempty pocket.", 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; + } + + pbf_press_button(context, BUTTON_A, 80ms, 350ms); + context.wait_for_all_requests(); + SelectionArrowFinder use_menu(stream.overlay(), ImageFloatBox(0.660, 0.100, 0.310, 0.430), COLOR_GREEN); + if (wait_until(stream, context, 3s, {{use_menu}}) != 0){ + stream.log("[Escape Rope] Item action menu not detected.", COLOR_ORANGE); + return false; + } + + // Watch while selecting Use so the transition cannot happen before + // the watcher starts. Extra A presses handle a confirmation prompt. + BlackScreenWatcher transition; + // Escape Rope fades white in the supplied clips; support black transitions too. + WhiteScreenWatcher white_transition; + const int result = run_until(stream, + context, + [](ProControllerContext& ctx){ + for (size_t press = 0; press < 10; press++){ + pbf_press_button(ctx, BUTTON_A, 80ms, 500ms); + } + ctx.wait_for_all_requests(); + pbf_wait(ctx, 10s); + ctx.wait_for_all_requests(); + }, + {transition, white_transition}); + if (result < 0){ + stream.log("[Escape Rope] No exit transition detected.", COLOR_ORANGE); + return false; + } + BlackScreenWatcher finished(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 100, 10, BlackScreenWatcher::FinderType::GONE, 500ms); + WhiteScreenWatcher white_finished(COLOR_RED, + {0.1, 0.1, 0.8, 0.8}, + 500, + 10, + WhiteScreenWatcher::FinderType::GONE, + 500ms); + stream.log(std::string("[Escape Rope] ") + (result == 1 ? "White" : "Black") + " exit transition detected."); + // if (wait_until(stream, context, 15s, {{finished}}) != 0){ return false; } + const int finish_result = result == 1 ? wait_until(stream, context, 15s, {{white_finished}}) + : wait_until(stream, context, 15s, {{finished}}); + if (finish_result != 0){ + stream.log("[Escape Rope] Exit transition did not finish.", COLOR_ORANGE); + return false; + } + OverworldWatcher overworld; + if (wait_until(stream, context, 10s, {{overworld}}) != 0){ + stream.log("[Escape Rope] Overworld not detected after transition.", COLOR_ORANGE); + return false; + } + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + stream.log("[Escape Rope] Exit transition and overworld confirmed.", COLOR_GREEN); + return true; +} + + +bool activate_repel(VideoStream& stream, ProControllerContext& context, int num_icons, int repel_type){ + static const char* const slugs[] = {"repel", "super-repel", "max-repel"}; + if (repel_type < 0 || repel_type > 2){ + stream.log("activate_repel: type must be 0 (Repel), 1 (Super Repel), or 2 (Max Repel).", COLOR_RED); + return false; + } + const std::string slug = slugs[repel_type]; + stream.log("Activating " + slug); + open_menu(stream, context, MenuCursorPosition::BAG, num_icons); + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + auto repel_tab_ref = std::make_shared(RESOURCE_PATH() + "PokemonBDSP/Autostory/Bag_RepelTab.png"); + // A smaller reference covers capture-resize sampling differences without + // loosening the match threshold or changing shared image processing. + auto repel_tab_small_ref = + std::make_shared(RESOURCE_PATH() + "PokemonBDSP/Autostory/Bag_RepelTabSmall.png"); + // Obtaining Honey adds a NEW badge to the same selected Other Items tab. + // Keep the original references and threshold for the earlier route stages. + auto repel_tab_honey_ref = + std::make_shared(RESOURCE_PATH() + "PokemonBDSP/Autostory/Bag_RepelTabHoney.png"); + auto repel_tab_honey_small_ref = + std::make_shared(RESOURCE_PATH() + "PokemonBDSP/Autostory/Bag_RepelTabHoneySmall.png"); + const ImageFloatBox repel_tab_box{0.735000, 0.055000, 0.045000, 0.080000}; + const double repel_tab_rmsd = 50.0; + + // BDSP bag has 8 compartment tabs; check before each press so zero-press + // detection works when the bag already starts on the repel tab. + bool found = false; + for (int press = 0; press <= 8; press++){ + context.wait_for_all_requests(); + + ImageMatchWatcher watcher(repel_tab_ref, repel_tab_box, repel_tab_rmsd); + ImageMatchWatcher small_watcher(repel_tab_small_ref, repel_tab_box, repel_tab_rmsd); + ImageMatchWatcher honey_watcher(repel_tab_honey_ref, repel_tab_box, repel_tab_rmsd); + ImageMatchWatcher honey_small_watcher(repel_tab_honey_small_ref, repel_tab_box, repel_tab_rmsd); + int ret = + wait_until(stream, context, 400ms, {{watcher}, {small_watcher}, {honey_watcher}, {honey_small_watcher}}); + if (ret >= 0){ + stream.log("[AutoStory] activate_repel: repel tab found after " + std::to_string(press) + " press(es).", + COLOR_GREEN); + found = true; + break; + } + + if (press < 8){ + stream.log( + "[AutoStory] activate_repel: tab not found, pressing right (" + std::to_string(press + 1) + "/8)."); + pbf_press_dpad(context, DPAD_RIGHT, 80ms, 300ms); + } + } + + if (!found){ + stream.log("[AutoStory] activate_repel: failed to find repel compartment tab.", COLOR_RED); + return false; + } + + // Reuse the Bag cursor/name geometry and existing Tesseract backend. A later + // save may contain all three Repels and unrelated Other Items in any order. + bool item_found = false; + size_t confirmed = 0; + size_t unknown_cursor = 0; + for (size_t scan = 0; scan < 256; scan++){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (!frame || bag_list_is_empty(*frame.frame)){ + break; + } + const int cursor = bag_cursor_position(*frame.frame); + if (!cursor){ + if (++unknown_cursor >= 3){ + break; + } + pbf_wait(context, 200ms); + continue; + } + unknown_cursor = 0; + // Use the existing Tesseract entry point directly so item selection + // does not depend on the optional Paddle/ONNX runtime configuration. + 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)); + } + } + static const char* const names[] = {"repel", "superrepel", "maxrepel"}; + if (normalized == names[repel_type]){ + if (++confirmed >= 2){ + item_found = true; + break; + } + pbf_wait(context, 150ms); + }else{ + confirmed = 0; + // Search both directions so a non-wrapping list opened halfway + // down still reaches items above the initial cursor position. + pbf_press_dpad(context, scan < 128 ? DPAD_DOWN : DPAD_UP, 80ms, 300ms); + } + } + if (!item_found){ + stream.log("activate_repel: requested item not confirmed: " + slug, COLOR_RED); + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + return false; + } + stream.log("[AutoStory] activate_repel: selected item confirmed: " + slug, COLOR_GREEN); + + context.wait_for_all_requests(); + pbf_wait(context, 400ms); + pbf_press_button(context, BUTTON_A, 80ms, 300ms); + context.wait_for_all_requests(); + + // The Use-item popup follows the selected row, which may be anywhere + // in a later save's list. Exclude the Bag list cursor on the left. + const ImageFloatBox repel_item_box{0.700000, 0.100000, 0.280000, 0.650000}; + SelectionArrowFinder arrow_finder(stream.overlay(), repel_item_box, COLOR_GREEN); + int arrow_ret = wait_until(stream, context, 2000ms, {{arrow_finder}}); + if (arrow_ret != 0){ + stream.log("[AutoStory] activate_repel: selection arrow not found after pressing A.", COLOR_RED); + return false; + } + stream.log("[AutoStory] activate_repel: selection arrow confirmed.", COLOR_GREEN); + pbf_press_button(context, BUTTON_A, 80ms, 105ms); + pbf_mash_button(context, BUTTON_B, 2000ms); + return true; +} + + +void use_HM(VideoStream& stream, ProControllerContext& context, const std::string& label + +){ + stream.log("Using " + label); + const ImageFloatBox box{0.670000, 0.600000, 0.100000, 0.150000}; + SelectionArrowFinder arrow(stream.overlay(), box, COLOR_GREEN); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 30000ms); + }, + {{arrow}}); + if (ret != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Using " + label + ": HM confirmation arrow not found; stopping before further movement.", + stream); + } + + stream.log("Using " + label + ": selection arrow found, confirming.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 1000ms); + pbf_mash_button(context, BUTTON_B, 6000ms); +} + + +bool get_on_bicycle(VideoStream& stream, ProControllerContext& context, GearLevel /*gear*/){ + static const ImageFloatBox FRONT_BOX{0.440000, 0.470000, 0.035000, 0.054000}; + static const ImageFloatBox BACK_BOX{0.520000, 0.470000, 0.022000, 0.054000}; + // Spark color range: white (R=232,G=255,B=255) down to light green (R=163,G=217,B=181) + static const uint32_t SPARK_MIN = 0xFF000000u | (163u << 16) | (217u << 8) | 181u; + static const uint32_t SPARK_MAX = 0xFFFFFFFFu; + static const size_t SPARK_THRESHOLD = 13; + + stream.log("[AutoStory] get_on_bicycle: mounting bicycle.", COLOR_WHITE); + pbf_press_button(context, BUTTON_PLUS, 80ms, 800ms); + + // Press B, then poll up to 1 s for Fast-gear sparks. + // Logs the peak pixel counts in both boxes for threshold tuning. + // Returns true if sparks are detected (switched TO Fast gear). + auto toggle_and_check = [&]() -> bool { + context.wait_for_all_requests(); + pbf_press_button(context, BUTTON_B, 80ms, 800ms); + + size_t max_front = 0, max_back = 0; + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(1000); + while (std::chrono::steady_clock::now() < deadline){ + context.wait_for(50ms); + VideoSnapshot screen = stream.video().snapshot(); + if (!screen){ + continue; + } + + size_t front_pixels = 0, back_pixels = 0; + filter_rgb32_range(front_pixels, + extract_box_reference(screen, FRONT_BOX), + SPARK_MIN, + SPARK_MAX, + Color(0), + false); + filter_rgb32_range(back_pixels, + extract_box_reference(screen, BACK_BOX), + SPARK_MIN, + SPARK_MAX, + Color(0), + false); + + if (front_pixels > max_front){ + max_front = front_pixels; + } + if (back_pixels > max_back){ + max_back = back_pixels; + } + + if (front_pixels >= SPARK_THRESHOLD || back_pixels >= SPARK_THRESHOLD){ + stream.log("[AutoStory] get_on_bicycle: sparks detected " + "(front=" + + std::to_string(front_pixels) + " back=" + std::to_string(back_pixels) + + " threshold=" + std::to_string(SPARK_THRESHOLD) + ").", + COLOR_GREEN); + return true; + } + } + stream.log("[AutoStory] get_on_bicycle: no sparks detected " + "(peak front=" + + std::to_string(max_front) + " back=" + std::to_string(max_back) + + " threshold=" + std::to_string(SPARK_THRESHOLD) + ").", + COLOR_ORANGE); + return false; + }; + + if (toggle_and_check()){ + stream.log("[AutoStory] get_on_bicycle: Fast gear confirmed.", COLOR_GREEN); + return true; + } + + // First toggle produced no sparks -> we switched to Slow gear; toggle back to Fast. + stream.log("[AutoStory] get_on_bicycle: Slow gear detected, toggling back to Fast.", COLOR_ORANGE); + if (toggle_and_check()){ + stream.log("[AutoStory] get_on_bicycle: Fast gear confirmed after correction.", COLOR_GREEN); + return true; + } + + stream.log("[AutoStory] get_on_bicycle: Failed to confirm Fast gear after two toggles.", COLOR_RED); + return false; +} + + +} +} +} \ No newline at end of file diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h new file mode 100644 index 0000000000..b8f4f32279 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h @@ -0,0 +1,480 @@ +/* BDSP AutoStory Tools + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStoryTools_H +#define PokemonAutomation_PokemonBDSP_AutoStoryTools_H + +#include +#include +#include +#include +#include + +#include "Common/Cpp/Options/EnumDropdownOption.h" + +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/Language.h" +#include "CommonFramework/Notifications/EventNotificationsTable.h" +#include "CommonFramework/ProgramStats/StatsTracking.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" + +#include "CommonTools/VisualDetector.h" + +#include "NintendoSwitch/NintendoSwitch_SingleSwitchProgram.h" + +#include "PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h" + +#include "Detect/PokemonBDSP_AutoStory_MenuCursorDetector.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +enum class StarterChoice { + TURTWIG, + CHIMCHAR, + PIPLUP, +}; + +struct AutoStoryStats : public StatsTracker { + AutoStoryStats() + : m_checkpoint(m_stats["Checkpoint"]) + , m_segment(m_stats["Segment"]) + , m_reset(m_stats["Reset"]){ + m_display_order.emplace_back("Checkpoint"); + m_display_order.emplace_back("Segment"); + m_display_order.emplace_back("Reset"); + } + std::atomic& m_checkpoint; + std::atomic& m_segment; + std::atomic& m_reset; + bool m_first_run = true; + // Main-story slots: contact, non-contact, spread, high-PP backup. + StarterChoice starter_choice = StarterChoice::CHIMCHAR; + std::array move_pp_capacity{15, 15, 10, 30}; + std::array move_pp_remaining = move_pp_capacity; +}; + + +// --------------------------------------------------------------------------- +// Options passed to every segment/checkpoint +// --------------------------------------------------------------------------- + +enum class HelpMode { + NO_OUTSIDE_HELP, + OUTSIDE_HELP, +}; + +enum class OutsideHelpPokemon { + TORTERRA, +}; + +enum class TorterraMove { + EARTHQUAKE, + WOOD_HAMMER, + RAZOR_LEAF, + CRUNCH, + GIGA_DRAIN, + IRON_TAIL, + ROCK_TOMB, + FACADE, + STONE_EDGE, + ROCK_SLIDE, + BULLDOZE, + STRENGTH, + ROCK_SMASH, + ROCK_CLIMB, +}; + +// Returns the JSON slug for a configured helper move. +const char* torterra_move_slug(TorterraMove move); + +enum class Trainer { + Veilstone_GalacticGrunts_2v2, + // Extend with new trainer battle IDs as the AutoStory adds them. +}; + +// Returns the trainers.json battle-ID string for a Trainer value. +const char* trainer_slug(Trainer t); + +struct OutsideHelpConfig { + OutsideHelpPokemon pokemon; + TorterraMove attacks[4]; +}; + +enum class CheckpointSaveMode { + REAL, + FAKE +}; +// Real saves retry one checkpoint. Fake-save runs own one retry loop around +// the selected range; inner checkpoints propagate failures to that owner. +enum class CheckpointRetryScope { + CHECKPOINT, + SELECTED_RANGE, + DEFER_TO_SELECTED_RANGE +}; + +struct AutoStoryOptions { + Language language; + StarterChoice starter_choice; + EventNotificationOption& notif_status_update; + HelpMode help_mode; + OutsideHelpConfig outside_help; + CheckpointSaveMode checkpoint_save_mode = CheckpointSaveMode::REAL; + CheckpointRetryScope checkpoint_retry_scope = CheckpointRetryScope::CHECKPOINT; +}; + + +// --------------------------------------------------------------------------- +// Base classes +// --------------------------------------------------------------------------- + +class AutoStory_Segment { +public: + virtual ~AutoStory_Segment() = default; + virtual std::string name() const = 0; + virtual std::string start_text() const = 0; + virtual std::string end_text() const = 0; + virtual void run_segment(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const = 0; +}; + +class AutoStory_Checkpoint { +public: + virtual ~AutoStory_Checkpoint() = default; + virtual std::string name() const = 0; + virtual std::string start_text() const = 0; + virtual std::string end_text() const = 0; + virtual void run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const = 0; +}; + + +// --------------------------------------------------------------------------- +// Shared utility functions (implementations in .cpp) +// --------------------------------------------------------------------------- + +// 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); + +// Fake save game to be replaced with save_game when program runs smoothly +void checkpoint_save(SingleSwitchProgramEnvironment& env, ProControllerContext& context); + +// Save the game from the overworld, increment the checkpoint counter, +// update stats display, and send a status notification. +void checkpoint_save(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + EventNotificationOption& notif_status_update, + AutoStoryStats& stats, + CheckpointSaveMode save_mode = CheckpointSaveMode::REAL); + +// Reload the game from the most recent backup save (auto-save). +// Called on retry when a checkpoint has no hard save (save=false). +// TODO: implement this function. +void reload_backup_save(SingleSwitchProgramEnvironment& env, ProControllerContext& context); + +// If save=true (default): save the game before the first attempt, then run +// `action(attempt_number)`. On failure, reset from HOME and retry. +// If save=false: skip the hard save. On failure, reload from backup save and retry. +// SELECTED_RANGE skips the entry save and owns HOME resets for the range. +// DEFER_TO_SELECTED_RANGE lets an inner failure reach that outer retry loop. +// Throws a fatal error after too many consecutive failures. +void checkpoint_reattempt_loop(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + EventNotificationOption& notif_status_update, + AutoStoryStats& stats, + std::function&& action, + bool save = true, + CheckpointSaveMode save_mode = CheckpointSaveMode::REAL, + CheckpointRetryScope retry_scope = CheckpointRetryScope::CHECKPOINT); + +// Forward the explicit checkpoint save mode selected by AutoStory's options. +inline void checkpoint_reattempt_loop(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats, + std::function&& action, + bool save = true){ + checkpoint_reattempt_loop(env, + context, + options.notif_status_update, + stats, + std::move(action), + save, + options.checkpoint_save_mode, + options.checkpoint_retry_scope); +} + +// Execute every action in `actions` in order, repeated `times` times. +void repeat(std::initializer_list> actions, size_t times); + +// Tracks the last D-pad direction pressed. Pass one per navigation function +// so each function gets a clean slate on each attempt. +struct DpadState { + DpadPosition last_dir = DPAD_NONE; +}; + +// Press `dir` on the D-pad `times` times (plus one extra if the direction +// changed from the previous call, since the first press turns the character). +void repeat_dpad(ProControllerContext& context, + DpadState& state, + DpadPosition dir, + Milliseconds press, + Milliseconds hold, + size_t times, + bool include_last_dir = true); + +// Wait (without pressing any buttons) until a short dialogue box appears. +// Logs `label + " dialogue detected."` when found (omit label to skip logging). +// Throws OperationFailedException if no dialogue appears within `timeout`. +void wait_for_dialogue(VideoStream& stream, + ProControllerContext& context, + const std::string& label = "", + Milliseconds timeout = std::chrono::seconds(60)); + +// Mash `button` (default B) until the game returns to the free-roaming overworld +// (no dialogue, menu, or battle UI visible). +// Throws OperationFailedException if the overworld is not reached within `timeout`. +void mash_until_dialogue_ends(VideoStream& stream, + ProControllerContext& context, + Button button = BUTTON_B, + Milliseconds timeout = std::chrono::seconds(60)); + +// Scripted trainer battle: choose a 1-based move position each turn, then +// confirm the default target with A and clear narration with B. No target steering. +// A single position is reused for the whole battle. A list is consumed in +// order, then its final position is reused. Empty lists and slots outside 1-4 fail. +// post_battle_dialogue_cleanup must be chosen explicitly: false returns after +// the completed battle-exit transition; true also clears post-fight NPC dialogue. +// Examples: +// fight_trainer_using_moves(stream, context, 2, "Grunt", false); +// const std::vector moves{2, 1, 3}; +// fight_trainer_using_moves(stream, context, moves, "Gym leader", true); +void fight_trainer_using_moves(VideoStream& stream, + ProControllerContext& context, + size_t move, + const std::string& label, + bool 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); + +// Use the selected starter's slot-role priorities and retain PP across battles. +// Slots 1/2/3/4 are contact/non-contact/spread/backup. Exhausted slots are +// skipped only within this battle's allowed priorities. One confirmed selection +// reserves one PP; this budget does not replace actual-PP OCR or model Pressure. +void fight_trainer_using_moves(VideoStream& stream, + ProControllerContext& context, + AutoStoryStats& stats, + const std::string& label, + bool post_battle_dialogue_cleanup); + +// A full Bag screenshot with the desired item highlighted. Paths are relative +// to RESOURCE_PATH(); quantities and selection borders are excluded. +struct BagItemTemplate { + std::string reference_path; + std::string label; + ImageFloatBox icon_box{0.428, 0.754, 0.029, 0.044}; + ImageFloatBox name_box{0.460, 0.755, 0.350, 0.045}; + double max_icon_rmsd = 25.0; + double max_name_rmsd = 20.0; + bool check_name = true; + // Selected item row in the reference screenshot (1-7). + size_t reference_row = 1; +}; + +// Screenshot-only helpers shared by live search and developer tests. +// Cursor 0 means unknown. Array index 0 corresponds to visible row 1. +struct BagItemRowMatch { + double icon_rmsd = 1000.0; + double name_rmsd = 1000.0; + bool matched = false; +}; + +// Pocket positions 1-8: Medicine, Balls, Battle Items, Berries, Other Items, +// TMs, Treasures, Key Items. Zero means the selected tab is uncertain. +int bag_pocket_position(const ImageViewRGB32& frame); +// Count normal, unobscured pocket icons; NEW badges prevent a tab match. +size_t bag_clear_tab_count(const ImageViewRGB32& frame); +// Open bag, pause 1s on the initial selection in each pocket, then scan +// with 80ms Down taps / 1s rest until all 8 tabs match, +// then restore the top of the item list and close the bag. +// Returns false after four bounded pocket passes. +bool clear_new_item_flags(VideoStream& stream, ProControllerContext& context, int num_icons = 8); +bool bag_list_is_empty(const ImageViewRGB32& frame); +int bag_cursor_position(const ImageViewRGB32& frame); +std::array +match_bag_item_rows(const ImageViewRGB32& frame, const ImageViewRGB32& reference, const BagItemTemplate& item); + +enum class BagMoveAction { + UNKNOWN, + CONFIRM, + UP, + DOWN, + PAGE_DOWN, + REWIND +}; +struct BagMovePlan { + BagMoveAction action = BagMoveAction::UNKNOWN; + size_t presses = 0; +}; + +// Describes the next input without sending it. REWIND means repeat Up and +// recheck until row 1; a screenshot cannot tell the total distance to the top. +BagMovePlan plan_bag_item_move(int cursor_row, int item_row); + +enum class BagPocketAction { + UNKNOWN, + CONFIRM, + LEFT, + RIGHT +}; +struct BagPocketMovePlan { + BagPocketAction action = BagPocketAction::UNKNOWN; + size_t presses = 0; +}; +// Circular eight-pocket route; ties prefer Right. Invalid positions send no input. +BagPocketMovePlan plan_bag_pocket_move(int current, int target); + + +// Starts in the overworld, opens the Bag, and leaves the requested item +// highlighted. Navigates to the requested pocket, then searches its item rows. +// Checks all seven visible rows and the cursor, then confirms the selected +// item in the green header. Makes up to three passes per pocket. +// Limits report an incomplete search, not absence. +// Returns false on missing frames, timeout, or no match; does not use an item. +bool find_bag_item(VideoStream& stream, + ProControllerContext& context, + const BagItemTemplate& item, + int target_pocket, + int num_icons = 8, + size_t max_scrolls_per_pocket = 512, + std::chrono::milliseconds search_timeout = std::chrono::minutes(10)); + +// 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); + +// Reuse the tutorial's verified Jubilife Center alignment and entry. +void align_and_enter_jubilife_center(VideoStream& stream, ProControllerContext& context); + +// Open the bag, navigate to the repel compartment tab, and use a repel. +// Returns false if the repel compartment tab could not be found. +// num_icons: early/late menu layout (preserved for existing callers). +// repel_type: 0=Repel, 1=Super Repel, 2=Max Repel. English item names. +bool activate_repel(VideoStream& stream, ProControllerContext& context, int num_icons = 8, int repel_type = 0); + +// Press A until the any HM selection arrow appears, confirm with A, then mash B for 3 s. +void use_HM(VideoStream& stream, ProControllerContext& context, const std::string& label); + +// Walk to the nurse, heal the party, and exit the Pokemon Center. +// `label` names the center for log messages (e.g. "Veilstone"). +// Returns false if the exit black screen was not detected. +bool heal_pokemon(VideoStream& stream, + ProControllerContext& context, + const std::string& label, + AutoStoryStats* stats = nullptr); + +enum class FlyPoint { + CanalavCity, + CelesticTown, + EternaCity, + FloaromaTown, + HearthomeCity, + JubilifeCity, + OreburghCity, + PastoriaCity, + PokemonLeagueLower, + PokemonLeagueUpper, + Route221, + SandgemTown, + SolaceonTown, + SnowpointCity, + SunyshoreCity, + TwinleafTown, + VeilstoneCity, + CurrentCity, // cursor already starts on the city; skip all map navigation +}; + +std::string fly_point_name(FlyPoint place); + +// Open the map, move the cursor to either bottom left, bottom right or +// top left, then move the cursor a number of steps, then checks for +// correctness, and mashes A until a black screen is detected +bool fly_to(VideoStream& stream, ProControllerContext& context, FlyPoint place); + +// Opens the X-menu from the overworld, navigates the cursor to `target`, and presses A. +// 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); + +enum class GearLevel { + Fast, +}; + + +// --------------------------------------------------------------------------- +// Mart buying +// --------------------------------------------------------------------------- + +enum class ShopItem { + FullRestore, + MaxPotion, + HyperPotion, + SuperPotion, + Potion, + Antidote, + ParalyzHeal, + Awakening, + Repel, + SuperRepel, + MaxRepel, + EscapeRope, +}; + +// Pass as the `quantity` field to buy the maximum available amount. +static constexpr int BUY_MAX = -1; + +struct ItemToBuy { + ShopItem item; + int quantity; // positive count to buy, or BUY_MAX +}; + +// Walk up to a vendor (already talking to them), navigate through the Buy menu, +// and purchase each item in `items` in order. The function presses DPAD_DOWN +// until the matching item name is found via OCR, so items may appear in any +// list order. Returns true on success. +bool buy_items(VideoStream& stream, + ProControllerContext& context, + const std::vector& items, + Language language = Language::English); + + +// Mount the bicycle (press +) and confirm it is in the requested gear. +// Presses B to toggle gear and uses the Fast-gear spark visual effect to +// determine which gear was entered. If the wrong gear is entered, presses B +// again and re-checks. Player must be on foot when called. +// Returns false if gear state cannot be confirmed after two toggles. +bool get_on_bicycle(VideoStream& stream, ProControllerContext& context, GearLevel gear); + +} +} +} +#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 new file mode 100644 index 0000000000..42ef5e0d5f --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -0,0 +1,1392 @@ +/* BDSP AutoStory - Segment 00 + * From player's bedroom to pokedex acquisition. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#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 "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Programs/DateManip/NintendoSwitch_DateManip.h" + +#include "Pokemon/Pokemon_Notification.h" +#include "Pokemon/Pokemon_Strings.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 "PokemonBDSP_AutoStory_Segment_00.h" + +using namespace std::chrono_literals; + +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."); +} + +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_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"); +} + +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"); +} + +// 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); + } + + // 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 +} + + +// --------------------------------------------------------------------------- +// 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(); +} + +// 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, + 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"); +} + +// 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){ + 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."); +} + +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); +} + +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 +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Segment wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Segment_00::name() const { + return "00: Intro to Poketch"; +} +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: Poketch received; standing at the Jubilife Pokemon Center PC, ready to deposit the starter for the HOME transfer."; +} +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); + AutoStory_Checkpoint_0006().run_checkpoint(env, context, options, stats); + + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + +// --------------------------------------------------------------------------- +// Checkpoint wrappers +// --------------------------------------------------------------------------- + +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 { + checkpoint_0000(env, context, options, stats); +} + +void checkpoint_0000(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions /*options*/, + AutoStoryStats& /*stats*/){ + // 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_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 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); +} + +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); +} + +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); +} + +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); +} + +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); +} + +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 "On Route 202 after catching the second Pokemon, with the catch sequence finished."; +} +void AutoStory_Checkpoint_0004::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0004(env, context, options, stats); +} + +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); +} + +std::string AutoStory_Checkpoint_0005::name() const { + return "00.05 - Route 202 after catch to Jubilife"; +} +std::string AutoStory_Checkpoint_0005::start_text() const { + return AutoStory_Checkpoint_0004().end_text(); +} +std::string AutoStory_Checkpoint_0005::end_text() const { + return "In Jubilife after speaking to Dawn, ready to deliver the Town Map to Barry."; +} +void AutoStory_Checkpoint_0005::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0005(env, context, options, stats); +} + +void checkpoint_0005(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); +} + +std::string AutoStory_Checkpoint_0006::name() const { + return "00.06 - Jubilife to Poketch"; +} +std::string AutoStory_Checkpoint_0006::start_text() const { + return AutoStory_Checkpoint_0005().end_text(); +} +std::string AutoStory_Checkpoint_0006::end_text() const { + return "Standing at the Jubilife Pokemon Center PC with the Poketch received, ready to deposit the starter."; +} +void AutoStory_Checkpoint_0006::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0006(env, context, options, stats); +} + +void checkpoint_0006(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); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.h new file mode 100644 index 0000000000..12f6a84a18 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.h @@ -0,0 +1,137 @@ +/* BDSP AutoStory - Segment 00 + * From beginning cinematic until after choosing the starter Pokemon. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment00_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment00_H + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +class AutoStory_Segment_00 : 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_0000 : 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; +}; +void checkpoint_0000(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0001 : 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; +}; +void checkpoint_0001(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0002 : 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; +}; +void checkpoint_0002(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0003 : 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; +}; +void checkpoint_0003(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0004 : 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; +}; +void checkpoint_0004(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0005 : 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; +}; +void checkpoint_0005(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0006 : 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; +}; +void checkpoint_0006(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +} +} +} +#endif 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 new file mode 100644 index 0000000000..f18f8fc403 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp @@ -0,0 +1,1829 @@ +/* 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, AutoStoryStats& stats){ + // 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); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + 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); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); //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); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); //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, AutoStoryStats& stats){ + 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); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); //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); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); //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, AutoStoryStats& stats){ + + //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. + fight_trainer_using_moves(stream, context, stats, "Roark", false); + + 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, stats); + oreburgh_gate_nav(env, env.console, context, stats); + }, + 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, stats); + }, + + true); +} + + +} +} +} 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 new file mode 100644 index 0000000000..dee25efb45 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h @@ -0,0 +1,131 @@ +/* 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, AutoStoryStats& stats); + +void oreburgh_gate_nav(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats); + +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, AutoStoryStats& stats); + +// 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 diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_02.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_02.cpp new file mode 100644 index 0000000000..ea85cc755f --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_02.cpp @@ -0,0 +1,2850 @@ +/* BDSP AutoStory - Segment 02 + * From after acquiring the first badge until after acquiring the second badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include + +#include + +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/ImageTools/ImageStats.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 "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.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/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h" + +#include "PokemonBDSP_AutoStory_Segment_02.h" + +using namespace std::chrono_literals; + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { +static void align_to_oreburgh_mart(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + size_t confirmations = 0, missing = 0; + for (size_t attempt = 0; attempt < 40; attempt++){ + const VideoSnapshot frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Mart alignment: No video frame.", + stream); + } + const OverworldMatchResult result = match_oreburgh_mart(*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("[Mart alignment] score=" + std::to_string(landmark.score) + ", screen-relative offset=(" + + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); + } + if (!result.located){ + confirmations = 0; + if (++missing >= 8){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Mart alignment: Emblem match missing or ambiguous.", + stream); + } + // A passing shadow must not cause an immediate checkpoint reset. + // Stay still until a trusted match returns; never steer by a rejected candidate. + pbf_wait(context, 250ms); + context.wait_for_all_requests(); + continue; + } + missing = 0; + if (result.at_target){ + if (++confirmations >= 3){ + stream.log("[Mart alignment] Door position confirmed."); + return; + } + pbf_wait(context, 500ms); + }else{ + confirmations = 0; + const double dx = result.dx, dy = result.dy; + const bool horizontal = std::abs(dx) >= std::abs(dy) * 9.0 / 16; + const double error = horizontal ? dx : 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 ? (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, + "Mart alignment: Could not reach the doorway reference position.", + stream); +} + +static void enter_oreburgh_gate_from_city(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + stream.log("[Oreburgh Gate entry] Moving up, then searching left/down for the entrance."); + // Observe before the first move so entry during the upward + // alignment or either search direction cannot be missed. + BlackScreenWatcher black; + const int result = run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0.0, 1.0}, 800ms, 100ms); + for (size_t attempt = 0; attempt < 30; attempt++){ + pbf_move_left_joystick(ctx, {-1.0, 0.0}, 200ms, 80ms); + pbf_move_left_joystick(ctx, {0.0, -1.0}, 120ms, 80ms); + ctx.wait_for_all_requests(); + } + // Keep watching if the last pulse starts a delayed fade. + pbf_wait(ctx, 1500ms); + }, + {{black}}); + if (result != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Oreburgh Gate entry: No black transition during left/down search.", + stream); + } + stream.log("[Oreburgh Gate entry] Black transition detected; movement stopped."); + 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, + "Oreburgh Gate entry: Transition did not finish.", + stream); + } + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + stream.log("[Oreburgh Gate entry] Cave visible; continuing the route.", COLOR_GREEN); +} + +// Call at a safe walking point, away from trainer/NPC prompts. +void force_repel_expiry(VideoStream& stream, + ProControllerContext& context, + RepelRenewalChoice choice, + bool north_south, + bool require_expiry){ + context.wait_for_all_requests(); + stream.log(std::string("[Repel expiry] Wiggling ") + (north_south ? "north/south" : "west/east") + + " until expiry; renewal response=" + (choice == RepelRenewalChoice::YES ? "Yes." : "No.")); + const Button response = choice == RepelRenewalChoice::YES ? BUTTON_A : BUTTON_B; + ShortDialogWatcher expired(COLOR_CYAN); + // 108 fresh Max Repels, two calibration runs: mean 41.244682s, + // sample SD 3.668059s. Round mean + SD up to the next millisecond. + // Match the calibration's 500ms movement / 100ms rest pulses. + const auto movement_deadline = std::chrono::steady_clock::now() + 44913ms; + const int result = run_until(stream, + context, + [north_south, movement_deadline](ProControllerContext& ctx){ + while (std::chrono::steady_clock::now() + 1200ms <= movement_deadline){ + // Complete steps before reversing so the Repel counter advances. + if (north_south){ + pbf_move_left_joystick(ctx, {0.0, 1.0}, 500ms, 100ms); + pbf_move_left_joystick(ctx, {0.0, -1.0}, 500ms, 100ms); + }else{ + pbf_move_left_joystick(ctx, {-1.0, 0.0}, 500ms, 100ms); + pbf_move_left_joystick(ctx, {1.0, 0.0}, 500ms, 100ms); + } + ctx.wait_for_all_requests(); + } + // Finish the observation window without an unpaired move. + const auto remaining = + std::chrono::duration_cast(movement_deadline - std::chrono::steady_clock::now()); + if (remaining > 0ms){ + pbf_wait(ctx, remaining); + } + }, + {{expired}}); + if (result != 0){ + if (require_expiry){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Repel renewal: Required expiry prompt not observed within calibrated limit.", + stream); + } + stream.log( + "[Repel expiry] Calibrated 44.913s limit reached without an expiry prompt; checking clear overworld before continuing.", + COLOR_ORANGE); + } + // Renewal has a gap before the final used-Repel message. A single + // overworld frame is insufficient: require three continuous clear seconds. + context.wait_for_all_requests(); + ShortDialogDetector dialogue(COLOR_YELLOW); + BlackScreenDetector black; + OverworldDetector overworld; + VideoOverlaySet overlays(stream.overlay()); + dialogue.make_overlays(overlays); + black.make_overlays(overlays); + overworld.make_overlays(overlays); + const auto deadline = std::chrono::steady_clock::now() + 30s; + auto quiet_since = std::chrono::steady_clock::now(); + bool quiet = false; + while (std::chrono::steady_clock::now() < deadline){ + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Repel renewal: No video frame during cleanup.", + stream); + } + const auto now = std::chrono::steady_clock::now(); + if (dialogue.detect(*frame.frame)){ + quiet = false; + pbf_mash_button(context, response, 100ms); + }else if (black.detect(*frame.frame) || !overworld.detect(*frame.frame)){ + quiet = false; + pbf_wait(context, 100ms); + }else{ + if (!quiet){ + quiet = true; + quiet_since = now; + } + if (now - quiet_since >= 3s){ + context.wait_for_all_requests(); + if (result == 0){ + stream.log(std::string("[Repel expiry] Renewal ") + + (choice == RepelRenewalChoice::YES ? "accepted" : "declined") + + "; three continuous seconds of overworld confirmed."); + }else{ + stream.log( + "[Repel expiry] No expiry observed within the calibrated limit; three continuous seconds of clear overworld confirmed. Continuing the route."); + } + return; + } + // Do not send confirmation/cancel buttons while the overworld is clear. + pbf_wait(context, 100ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Repel renewal: Three clear seconds not confirmed after renewal.", + stream); +} + +static void wait_for_trainer_battle_menu(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + ShortDialogDetector dialogue(COLOR_YELLOW); + BattleMenuDetector menu(BattleType::TRAINER, COLOR_GREEN); + VideoOverlaySet overlays(stream.overlay()); + dialogue.make_overlays(overlays); + menu.make_overlays(overlays); + const auto deadline = std::chrono::steady_clock::now() + 120s; + size_t confirmed = 0; + bool previous_dialogue = false; + while (std::chrono::steady_clock::now() < deadline){ + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Trainer approach: No video frame.", + stream); + } + const bool dialog = dialogue.detect(*frame.frame); + if (dialog && !previous_dialogue){ + stream.log("[Trainer approach] Clearing dialogue with B."); + } + previous_dialogue = dialog; + if (menu.detect(*frame.frame)){ + if (++confirmed >= 3){ + stream.log("[Trainer approach] Battle menu confirmed; starting combat."); + return; + } + pbf_wait(context, 100ms); + }else{ + confirmed = 0; + pbf_mash_button(context, BUTTON_B, 100ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Trainer approach: Battle menu did not appear after dialogue cleanup.", + stream); +} + +static void exit_oreburgh_gate_to_route203(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + stream.log("[Oreburgh Gate exit] Moving west until the white transition starts."); + WhiteScreenWatcher white; + const int result = run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {-1.0, 0.0}, 6000ms, 0ms); + pbf_wait(ctx, 1000ms); + }, + {{white}}); + if (result != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Oreburgh Gate exit: White transition did not start.", + stream); + } + WhiteScreenWatcher visible(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 500, 10, WhiteScreenWatcher::FinderType::GONE, 500ms); + if (wait_until(stream, context, 15s, {{visible}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Oreburgh Gate exit: White transition did not finish.", + stream); + } + pbf_wait(context, 700ms); + context.wait_for_all_requests(); +} + +static void approach_galactic_dialogue(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + ShortDialogDetector dialogue(COLOR_YELLOW); + BattleMenuDetector battle(BattleType::TRAINER, COLOR_GREEN); + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic approach: No video frame.", + stream); + } + // Alignment may already have handed off after the story started. + if (dialogue.detect(*frame.frame) || battle.detect(*frame.frame)){ + return; + } + stream.log( + "[Galactic approach] Plaza position confirmed; sidestepping left around the sign, then moving up until story dialogue."); + ShortDialogWatcher started(COLOR_YELLOW); + BattleMenuWatcher menu(BattleType::TRAINER, COLOR_GREEN); + const int result = run_until(stream, + context, + [](ProControllerContext& ctx){ + // The reference position faces the sign, which blocks a + // straight upward approach and consumes A as a sign read. + pbf_move_left_joystick(ctx, {-1.0, 0.0}, 200ms, 150ms); + ctx.wait_for_all_requests(); + for (size_t pulse = 0; pulse < 20; pulse++){ + pbf_move_left_joystick(ctx, {0.0, 1.0}, 100ms, 100ms); + ctx.wait_for_all_requests(); + } + // If an NPC blocks the final approach, initiate interaction. + pbf_press_button(ctx, BUTTON_A, 80ms, 1000ms); + pbf_wait(ctx, 1500ms); + }, + {{started}, {menu}}); + if (result < 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic approach: Story dialogue/battle did not start during the bounded upward approach.", + stream); + } + stream.log("[Galactic approach] Story dialogue/battle detected; movement stopped."); +} + +// Dialogue indicates the trainer interaction started; combat still waits +// for a confirmed battle menu before invoking the battle handler. +static bool watch_route204_trainer_event(VideoStream& stream, + ProControllerContext& context, + const std::function& action, + const std::string& label){ + context.wait_for_all_requests(); + ShortDialogWatcher dialogue(COLOR_YELLOW); + BattleMenuWatcher menu(BattleType::TRAINER, COLOR_GREEN); + const int result = run_until(stream, + context, + [&action](ProControllerContext& ctx){ + action(ctx); + }, + {{dialogue}, {menu}}); + const bool detected = result >= 0; + stream.log("[" + label + "] Trainer dialogue/battle detected=" + std::to_string(detected)); + return detected; +} + +static bool approach_route204_female_trainer(VideoStream& stream, ProControllerContext& context){ + return watch_route204_trainer_event( + stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0.0, 1.0}, 8000ms, 0ms); + // Replaces the 2374ms settle plus the unbounded-looking + // generic dialogue wait with one explicit detection window. + pbf_wait(ctx, 2500ms); + }, + "Route 204 female trainer approach"); +} + +static void recover_route204_female_trainer(VideoStream& stream, ProControllerContext& context){ + stream.log("[Route 204 female trainer] Missed initial trigger; crossing her sight line down/up."); + const bool recovered = watch_route204_trainer_event( + stream, + context, + [](ProControllerContext& ctx){ + for (size_t pair = 0; pair < 8; pair++){ + // We have passed her: go back down first, then up. + pbf_move_left_joystick(ctx, {0.0, -1.0}, 500ms, 150ms); + pbf_move_left_joystick(ctx, {0.0, 1.0}, 500ms, 150ms); + ctx.wait_for_all_requests(); + } + pbf_wait(ctx, 2500ms); + }, + "Route 204 female trainer recovery"); + if (!recovered){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 female trainer: No dialogue/battle after bounded down/up recovery.", + stream); + } +} + +static void recover_route204_youngster(VideoStream& stream, ProControllerContext& context){ + // The recording ends above the youngster after passing left. + // Cross his row down/up instead of resetting after one miss. + const bool recovered = watch_route204_trainer_event( + stream, + context, + [](ProControllerContext& ctx){ + for (size_t pair = 0; pair < 6; pair++){ + pbf_move_left_joystick(ctx, {0, -1}, 500ms, 200ms); + // Body collision can leave us adjacent without a sightline trigger. + pbf_press_button(ctx, BUTTON_A, 100ms, 250ms); + pbf_move_left_joystick(ctx, {0, 1}, 500ms, 200ms); + pbf_press_button(ctx, BUTTON_A, 100ms, 250ms); + ctx.wait_for_all_requests(); + } + pbf_wait(ctx, 2500ms); + }, + "Route 204 youngster sightline recovery"); + if (!recovered){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 youngster: No trigger after bounded down/up recovery.", + stream); + } +} + +static void floaroma_house_transition(VideoStream& stream, ProControllerContext& context, double y){ + context.wait_for_all_requests(); + BlackScreenWatcher black; + const int result = run_until(stream, + context, + [y](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, y}, 2500ms, 0ms); + pbf_wait(ctx, 1000ms); + }, + {{black}}); + if (result != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Floaroma house: Door transition not detected.", + stream); + } + context.wait_for_all_requests(); + BlackScreenDetector black_frame; + const auto deadline = std::chrono::steady_clock::now() + 15s; + auto clear_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, + "Floaroma house: No video frame after transition.", + stream); + } + const auto now = std::chrono::steady_clock::now(); + if (black_frame.detect(*frame.frame)){ + clear_since = now; + }else if (now - clear_since >= 500ms){ + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + return; + } + pbf_wait(context, 100ms); + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Floaroma house: Door transition did not finish.", + stream); +} + +static void establish_floaroma_house_exit(VideoStream& stream, ProControllerContext& context){ + stream.log("[Floaroma checkpoint] Blind northward house entry, then immediate exit."); + floaroma_house_transition(stream, context, 1); + floaroma_house_transition(stream, context, -1); + stream.log("[Floaroma checkpoint] House entry/exit completed; ready for checkpoint save.", COLOR_GREEN); +} + +static void recover_teighler_trigger(VideoStream& stream, ProControllerContext& context){ + // The first pass ended east of the corner. Return left, then + // cross her sightline along the same bottom ledge for five seconds. + const bool recovered = watch_route204_trainer_event( + stream, + context, + [](ProControllerContext& ctx){ + // The old 500ms sweeps stopped beneath/west of Teighler. + // Cross the full bottom ledge in both directions, still + // limiting the total recovery motion to five seconds. + for (size_t pair = 0; pair < 3; pair++){ + const Milliseconds sweep = pair < 2 ? 1000ms : 500ms; + pbf_move_left_joystick(ctx, {-1, 0}, sweep, 0ms); + pbf_move_left_joystick(ctx, {1, 0}, sweep, 0ms); + ctx.wait_for_all_requests(); + } + pbf_wait(ctx, 2500ms); + }, + "Teighler five-second ledge recovery"); + if (!recovered){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Teighler: No battle after five-second left/right ledge recovery.", + stream); + } +} + +// True means the next trainer triggered before the position was reached. +// False means the sign position was confirmed. Both outcomes are explicit. +// Route 204 arrival accepts a small standing area, rather than one +// exact pose. Preserve strong observations through passing shadows +// only while stationary; every movement invalidates this history. +static bool align_to_route204_floaroma_turn(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + ShortDialogDetector dialogue(COLOR_YELLOW); + BattleMenuDetector battle_menu(BattleType::TRAINER, COLOR_GREEN); + size_t confirmed = 0, missing = 0, blocked_left = 0; + double previous_dx = 1; + bool previously_left = false; + stream.log("[Route 204 north turn] Locate left to the sign; correct height after horizontal alignment."); + for (size_t attempt = 0; attempt < 60; attempt++){ + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 north turn: No video frame.", + stream); + } + if (dialogue.detect(*frame.frame) || battle_menu.detect(*frame.frame)){ + return true; + } + const auto result = match_route204_floaroma_turn(*frame.frame); + if (result.at_target){ + if (++confirmed >= 3){ + stream.log("[Route 204 north turn] Sign position confirmed; ready to continue west.", COLOR_GREEN); + return false; + } + pbf_wait(context, 150ms); + context.wait_for_all_requests(); + continue; + } + confirmed = 0; + double x = -1, y = 0; + Milliseconds duration = 100ms; + if (result.located){ + missing = 0; + stream.log("[Route 204 north turn] Sign offset=(" + std::to_string(result.dx) + "," + + std::to_string(result.dy) + ")"); + const bool horizontal = std::abs(result.dx) > 1.0 / 50; + if (previously_left && std::abs(result.dx - previous_dx) < 1.0 / 384){ + blocked_left++; + }else{ + blocked_left = 0; + } + // Primarily locate left. If a fence/NPC blocks progress, + // correct height briefly before resuming measured travel. + const bool correct_height = !horizontal || (blocked_left >= 3 && std::abs(result.dy) > 1.0 / 54); + x = correct_height ? 0.0 : (result.dx < 0 ? -1.0 : 1.0); + y = correct_height ? (result.dy > 0 ? -1.0 : 1.0) : 0.0; + const double error = correct_height ? std::abs(result.dy) : std::abs(result.dx); + duration = error < .08 ? 40ms : 100ms; + previous_dx = result.dx; + previously_left = x < 0; + if (correct_height){ + blocked_left = 0; + } + }else{ + previously_left = false; + blocked_left = 0; + if (++missing > 12){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 north turn: Sign unavailable during bounded leftward acquisition.", + stream); + } + } + if (watch_route204_trainer_event( + stream, + context, + [x, y, duration](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); + pbf_wait(ctx, 250ms); + }, + "Route 204 north sign alignment")){ + return true; + } + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 north turn: Sign position did not converge.", + stream); +} + +static void align_to_windworks_sign(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + size_t confirmed = 0, missing = 0; + bool previously_located = false; + stream.log("[Windworks approach] Locate beside the sign before the blind wall/NPC approach."); + for (size_t attempt = 0; attempt < 80; attempt++){ + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Windworks approach: No video frame.", + stream); + } + const auto result = match_windworks_sign_approach(*frame.frame); + if (result.at_target){ + if (++confirmed >= 3){ + return; + } + pbf_wait(context, 150ms); + }else{ + confirmed = 0; + double x = 0, y = 1; + Milliseconds duration = 100ms; + if (result.located){ + missing = 0; + previously_located = true; + // Correct large height errors first, including coming + // south from the wall, then clear the grass horizontally. + const bool vertical = result.dy > .08 || std::abs(result.dx) <= 1.0 / 50; + const double error = vertical ? std::abs(result.dy) : std::abs(result.dx); + x = vertical ? 0 : (result.dx < 0 ? -1 : 1); + y = vertical ? (result.dy > 0 ? -1 : 1) : 0; + duration = error < .08 ? 40ms : 100ms; + stream.log("[Windworks approach] Sign offset=(" + std::to_string(result.dx) + "," + + std::to_string(result.dy) + ")"); + }else if (previously_located && ++missing <= 12){ + pbf_wait(context, 250ms); + context.wait_for_all_requests(); + continue; + }else if (previously_located || ++missing > 12){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Windworks approach: Sign unavailable during bounded northward acquisition.", + stream); + } + pbf_move_left_joystick(context, {x, y}, duration, 0ms); + pbf_wait(context, 250ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Windworks approach: Sign position did not converge.", + stream); +} + +struct Route204ArrivalTracker { + using Clock = std::chrono::steady_clock; + static constexpr double TOLERANCE_X = 1.0 / 32; + static constexpr double TOLERANCE_Y = 1.0 / 36; + struct Anchor { + bool seen = false; + double dx = 0, dy = 0; + Clock::time_point timestamp{}; + }; + Anchor anchors[2]; + bool holding = false; + Clock::time_point hold_started{}; + void reset(){ + anchors[0].seen = anchors[1].seen = false; + holding = false; + } + bool observe(const OverworldMatchResult& result, Clock::time_point now){ + if (result.landmarks.size() != 2){ + reset(); + return false; + } + for (const auto& landmark : result.landmarks){ + if (landmark.accepted && (std::abs(landmark.dx) > TOLERANCE_X || std::abs(landmark.dy) > TOLERANCE_Y)){ + reset(); + return false; + } + } + for (size_t i = 0; i < 2; i++){ + const auto& landmark = result.landmarks[i]; + if (!landmark.accepted){ + continue; + } + anchors[i] = {true, landmark.dx, landmark.dy, now}; + if (!holding){ + holding = true; + hold_started = now; + } + } + for (auto& anchor : anchors){ + if (anchor.seen && now - anchor.timestamp > 10s){ + anchor.seen = false; + } + } + return anchors[0].seen && anchors[1].seen && std::abs(anchors[0].dx - anchors[1].dx) <= 1.0 / 64 && + std::abs(anchors[0].dy - anchors[1].dy) <= 1.0 / 54; + } + bool wait_for_shadow(Clock::time_point now) const { + // Bound the stationary wait even if one anchor keeps matching. + return holding && now - hold_started < 10s; + } +}; + +static bool align_to_route204_sign(VideoStream& stream, ProControllerContext& context, bool northbound = false){ + context.wait_for_all_requests(); + ShortDialogDetector dialogue(COLOR_YELLOW); + BattleMenuDetector menu(BattleType::TRAINER, COLOR_GREEN); + BlackScreenDetector black; + WhiteScreenDetector white; + size_t confirmations = 0, missing = 0, blocked_east = 0, detours = 0; + double previous_dx = 1; + bool previous_east = false; + Route204ArrivalTracker arrival; + for (size_t attempt = 0; attempt < 80; attempt++){ + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 sign: No video frame.", + stream); + } + if (dialogue.detect(*frame.frame) || menu.detect(*frame.frame)){ + stream.log("[Route 204 sign] Next trainer triggered; stopping alignment."); + return true; + } + double x = 0, y = 0; + bool detour = false, measured = false; + double measured_dx = 0; + Milliseconds duration = 250ms; + VideoOverlaySet overlays(stream.overlay()); + dialogue.make_overlays(overlays); + menu.make_overlays(overlays); + if (black.detect(*frame.frame) || white.detect(*frame.frame)){ + confirmations = 0; + arrival.reset(); + }else{ + const auto result = + northbound ? match_route204_north_approach(*frame.frame) : match_route204_sign(*frame.frame); + measured = result.located; + measured_dx = result.dx; + for (const auto& landmark : result.landmarks){ + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted){ + overlays.add(COLOR_GREEN, landmark.found); + } + stream.log("[Route 204 sign] " + landmark.name + " score=" + std::to_string(landmark.score) + + ", margin=" + std::to_string(landmark.margin) + ", accepted=" + std::to_string(landmark.accepted) + + ", offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); + } + const auto now = Route204ArrivalTracker::Clock::now(); + // The broad pre-battle cache tolerance can accept the sign + // while still inside the fence's collision column. + bool clear_column = true; + if (northbound){ + for (const auto& landmark : result.landmarks){ + if (landmark.accepted && std::abs(landmark.dx) > 1.0 / 128){ + clear_column = false; + } + } + } + if (!clear_column){ + arrival.reset(); + } + const bool arrived = clear_column && arrival.observe(result, now); + if (arrived){ + if (++confirmations >= 3){ + stream.log(northbound + ? "[Route 204 sign] Left-of-sign northbound position confirmed." + : "[Route 204 sign] Position confirmed; ready for the next trainer approach."); + return false; + } + }else if (arrival.wait_for_shadow(now)){ + // One strong nearby anchor is enough to hold still, + // but both are still required to confirm arrival. + confirmations = 0; + missing = 0; + duration = 250ms; + stream.log("[Route 204 sign] Holding near target while waiting for shadowed landmark."); + }else if (result.located){ + confirmations = 0; + missing = 0; + stream.log(std::string("[Route 204 sign] ") + + (result.coarse ? "Sign-guided acquisition." : "Both landmarks located.")); + // At the wrong height, leftward movement can hit the + // grass boundary. Acquire the sign's height first. + const bool horizontal = (!result.coarse || std::abs(result.dy) <= 1.0 / 54) && + std::abs(result.dx) >= std::abs(result.dy) * 9.0 / 16; + const double error = horizontal ? result.dx : result.dy * 9.0 / 16; + x = horizontal ? (result.dx > 0 ? 1.0 : -1.0) : 0.0; + y = horizontal ? 0.0 : (result.dy < 0 ? 1.0 : -1.0); + duration = std::abs(error) > 1.0 / 24 ? 80ms : std::abs(error) > 1.0 / 64 ? 40ms : 16ms; + if (result.coarse && std::abs(result.dx) <= 1.0 / 128 && std::abs(result.dy) <= 1.0 / 72){ + // Resume fine acquisition after the bounded shadow wait. + // The bounded shadow wait has ended. Make a small + // correction instead of holding indefinitely. + duration = 40ms; + } + }else{ + confirmations = 0; + if (++missing > 24){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 sign: Landmarks missing during bounded acquisition.", + stream); + } + // The sign is north of the defeated female trainer. + // Search that height first, then return below it. + if (northbound){ + // Tyler leaves us west of the sign. Search east + // along the road instead of climbing into grass. + x = 1.0; + y = 0.0; + }else{ + y = missing <= 8 ? 1.0 : -1.0; + } + duration = 100ms; + } + } + if (northbound && measured && x > 0 && previous_east && std::abs(previous_dx - measured_dx) < 1.0 / 384){ + blocked_east++; + }else{ + blocked_east = 0; + } + previous_east = northbound && measured && x > 0; + // Reuse this snapshot; no movement has been sent since it. + if (previous_east){ + previous_dx = measured_dx; + } + if (blocked_east >= 3){ + if (++detours > 2){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 north approach: Tyler obstruction did not clear.", + stream); + } + stream.log("[Route 204 sign] Eastward movement blocked; passing below defeated Tyler."); + detour = true; + blocked_east = 0; + previous_east = false; + } + if (x != 0 || y != 0 || detour){ + arrival.reset(); + } + // Observe throughout each correction and its settle period, + // so an early next-trainer trigger cancels remaining movement. + if (watch_route204_trainer_event( + stream, + context, + [x, y, duration, detour](ProControllerContext& ctx){ + if (detour){ + pbf_move_left_joystick(ctx, {0, -1}, 180ms, 0ms); + pbf_move_left_joystick(ctx, {1, 0}, 400ms, 0ms); + pbf_move_left_joystick(ctx, {0, 1}, 180ms, 0ms); + pbf_wait(ctx, 500ms); + }else if (x != 0 || y != 0){ + pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); + pbf_wait(ctx, 500ms); + }else{ + pbf_wait(ctx, duration); + } + }, + "Route 204 sign alignment")){ + return true; + } + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 sign: Position could not be confirmed.", + stream); +} + +static void enter_ravaged_path_from_route204(SingleSwitchProgramEnvironment& env, + VideoStream& stream, + ProControllerContext& context, AutoStoryStats& stats){ + context.wait_for_all_requests(); + // The cave's brown corridor and black map void distinguish an + // indoor return from a battle fade or an outdoor scene. All ROIs + // are normalized and visible without labels in the overlays. + const ImageFloatBox floor_left{0.42, 0.35, 0.08, 0.10}; + const ImageFloatBox floor_right{0.52, 0.35, 0.08, 0.10}; + const ImageFloatBox map_void{0.15, 0.85, 0.10, 0.08}; + auto cave_visible = [&](const ImageRGB32& frame){ + for (const auto& box : {floor_left, floor_right}){ + const auto stats = image_stats(extract_box_reference(frame, box)); + if (stats.average.r < stats.average.g + 12 || stats.average.g < stats.average.b + 12){ + return false; + } + } + const auto empty = image_stats(extract_box_reference(frame, map_void)); + return empty.average.r < 20 && empty.average.g < 20 && empty.average.b < 20 && empty.stddev.sum() < 30; + }; + auto handle_trainer = [&] { + stream.log("[Ravaged Path approach] Lass Samantha triggered; completing battle before continuing."); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + clear_post_battle_dialogue(stream, context); + }; + Route204ArrivalTracker arrival; + size_t position_confirmations = 0; + auto missing_since = std::chrono::steady_clock::now(); + bool initial_approach = true; + size_t acquisition_pulses = 0; + size_t blocked_north = 0; + double previous_north_dy = 1; + bool previous_coarse_north = false; + for (size_t attempt = 0; attempt < 120; attempt++){ + ShortDialogWatcher dialogue(COLOR_YELLOW); + BattleMenuWatcher battle(BattleType::TRAINER, COLOR_GREEN); + BlackScreenWatcher entrance; + VideoOverlaySet overlays(stream.overlay()); + overlays.add(COLOR_CYAN, floor_left); + overlays.add(COLOR_CYAN, floor_right); + overlays.add(COLOR_CYAN, map_void); + const auto before = stream.video().snapshot(); + if (!before){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Ravaged Path entrance: No video frame.", + stream); + } + ShortDialogDetector current_dialogue; + BattleMenuDetector current_battle(BattleType::TRAINER); + if (current_dialogue.detect(*before.frame) || current_battle.detect(*before.frame)){ + handle_trainer(); + arrival.reset(); + position_confirmations = 0; + missing_since = std::chrono::steady_clock::now(); + continue; + } + double x = 0, y = 0; + Milliseconds duration = 250ms; + if (initial_approach){ + // Retain the northbound approach from Tyler. Subsequent + // movement is landmark-guided, without the old right sweep. + initial_approach = false; + y = 1; + duration = 2400ms; + }else{ + auto result = match_ravaged_path_south_entrance(*before.frame); + for (const auto& landmark : result.landmarks){ + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted){ + overlays.add(COLOR_GREEN, landmark.found); + } + stream.log("[Ravaged Path locator] " + landmark.name + " score=" + std::to_string(landmark.score) + + ", margin=" + std::to_string(landmark.margin) + ", accepted=" + std::to_string(landmark.accepted) + + ", offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); + } + const auto now = std::chrono::steady_clock::now(); + auto fixed = result; + fixed.landmarks.resize(2); + // Cave entry needs tighter horizontal alignment than the + // standing area beside the Route 204 sign. + bool horizontal_ready = true; + for (const auto& anchor : fixed.landmarks){ + if (anchor.accepted && std::abs(anchor.dx) > 1.0 / 64){ + horizontal_ready = false; + } + } + if (!horizontal_ready){ + arrival.reset(); + } + const bool arrived = horizontal_ready && arrival.observe(fixed, now); + if (arrived){ + if (++position_confirmations >= 3){ + stream.log("[Ravaged Path locator] Entrance position confirmed; moving north into cave.", + COLOR_GREEN); + y = 1; + duration = 1200ms; + } + }else if (arrival.wait_for_shadow(now)){ + position_confirmations = 0; + stream.log("[Ravaged Path locator] Holding still for shadowed landmark."); + }else if (result.located){ + missing_since = now; + position_confirmations = 0; + // Escape the upper-right alcove vertically before + // moving left. Near the doorway, correct horizontal first. + if (std::abs(result.dy) > 1.0 / 36){ + y = result.dy > 0 ? -1.0 : 1.0; + }else if (std::abs(result.dx) > 1.0 / 64){ + x = result.dx > 0 ? 1.0 : -1.0; + } + duration = 80ms; + if (result.coarse && y > 0 && previous_coarse_north && + std::abs(result.dy - previous_north_dy) < 1.0 / 384){ + blocked_north++; + }else{ + blocked_north = 0; + } + previous_north_dy = result.dy; + previous_coarse_north = result.coarse && y > 0; + if (blocked_north >= 4){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Ravaged Path locator: Northward coarse acquisition blocked; refusing repeated movement.", + stream); + } + if (result.coarse && x == 0 && y == 0){ + // After the stationary shadow wait, correct a + // small height error that may still change the + // sign's perspective enough to prevent matching. + if (std::abs(result.dy) <= 1.0 / 72){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Ravaged Path locator: Near entrance, but sign remained shadowed; refusing blind correction.", + stream); + } + y = result.dy < 0 ? 1.0 : -1.0; + duration = 40ms; + } + if (y < 0){ + // Fixed entrance geometry predicts the lass's + // feet when she is shadowed/off-screen. A visible + // lass may tighten this limit, never relax it. + double boundary = 930.0 / 1080 + result.dy; + if (result.landmarks.size() > 2 && result.landmarks[2].accepted){ + boundary = std::min(boundary, result.landmarks[2].found.y + result.landmarks[2].found.height); + } + // Leave >12% screen height above her before any + // short down pulse. No blind southward search. + if (boundary - 0.55 <= 0.12){ + y = 0; + stream.log("[Ravaged Path locator] Downward correction blocked by lass boundary.", COLOR_RED); + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Ravaged Path locator: Southern safety boundary reached.", + stream); + } + } + }else{ + position_confirmations = 0; + if (now - missing_since > 10s){ + // Failed recording: still beside the Route 204 + // sign, blocked below the northbound approach. + // Only this recognizable route authorizes search. + const auto route = match_route204_sign(*before.frame); + const bool known_route = !route.landmarks.empty() && route.landmarks[0].accepted && + route.landmarks[0].score >= .94 && route.landmarks[0].margin >= .30; + if (known_route && acquisition_pulses < 8){ + const bool sidestep = acquisition_pulses++ % 2 == 0; + x = sidestep ? -1.0 : 0.0; + y = sidestep ? 0.0 : 1.0; + duration = sidestep ? 150ms : 500ms; + stream.log("[Ravaged Path locator] Recognized Route 204 sign; bounded left/north acquisition."); + }else{ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Ravaged Path locator: Landmarks unavailable after bounded acquisition.", + stream); + } + } + } + } + if (x != 0 || y != 0){ + arrival.reset(); + position_confirmations = 0; + missing_since = std::chrono::steady_clock::now(); + } + const int ret = run_until(stream, + context, + [x, y, duration](ProControllerContext& ctx){ + if (x != 0 || y != 0){ + pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); + pbf_wait(ctx, 500ms); + }else{ + pbf_wait(ctx, duration); + } + }, + {{entrance}, {dialogue}, {battle}}); + context.wait_for_all_requests(); + if (ret == 1 || ret == 2){ + handle_trainer(); + arrival.reset(); + position_confirmations = 0; + missing_since = std::chrono::steady_clock::now(); + continue; + } + if (ret != 0){ + continue; + } + // A trainer transition can also go black. Require a stable + // cave scene, or route the detected battle through combat. + const auto deadline = std::chrono::steady_clock::now() + 15s; + size_t confirmed = 0; + bool trainer_seen = false; + while (std::chrono::steady_clock::now() < deadline){ + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Ravaged Path entrance: No frame after transition.", + stream); + } + if (current_dialogue.detect(*frame.frame) || current_battle.detect(*frame.frame)){ + trainer_seen = true; + break; + } + if (cave_visible(*frame.frame)){ + if (++confirmed >= 5){ + stream.log("[Ravaged Path entrance] Transition and indoor corridor confirmed.", COLOR_GREEN); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + return; + } + }else{ + confirmed = 0; + } + pbf_wait(context, 100ms); + context.wait_for_all_requests(); + } + if (trainer_seen){ + handle_trainer(); + } + arrival.reset(); + position_confirmations = 0; + missing_since = std::chrono::steady_clock::now(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Ravaged Path entrance: Could not confirm entry after bounded approach.", + stream); +} + +static void exit_ravaged_path_to_route204(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + stream.log("[Ravaged Path north exit] Right wall, lower wall, then left/down search."); + // Observe the entire approach: reaching the exit during either + // wall movement must cancel the remaining queued commands. + WhiteScreenWatcher white; + const int result = run_until(stream, + context, + [](ProControllerContext& ctx){ + // Deliberate overtravel establishes the walls despite + // variation in the position after Rock Smash. + pbf_move_left_joystick(ctx, {1, 0}, 3000ms, 100ms); + pbf_move_left_joystick(ctx, {0, -1}, 1800ms, 100ms); + for (size_t attempt = 0; attempt < 30; attempt++){ + pbf_move_left_joystick(ctx, {-1, 0}, 160ms, 80ms); + pbf_move_left_joystick(ctx, {0, -1}, 120ms, 80ms); + ctx.wait_for_all_requests(); + } + pbf_wait(ctx, 1500ms); + }, + {{white}}); + if (result != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Ravaged Path north exit: No white transition during left/down search.", + stream); + } + context.wait_for_all_requests(); + stream.log("[Ravaged Path north exit] White transition detected; movement stopped."); + WhiteScreenWatcher visible(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 500, 10, WhiteScreenWatcher::FinderType::GONE, 500ms); + if (wait_until(stream, context, 15s, {{visible}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Ravaged Path north exit: White transition did not finish.", + stream); + } + pbf_wait(context, 700ms); + context.wait_for_all_requests(); +} + +void roark_to_floaroma(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + pbf_move_left_joystick(context, {1.000000, -0.000000}, 550ms, 0ms); + pbf_wait(context, 1046ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 2000ms, 0ms); + pbf_wait(context, 288ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 250ms, 0ms); + pbf_wait(context, 1009ms); + align_to_oreburgh_mart(stream, context); + pbf_wait(context, 1009ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1128ms, 0ms); + pbf_wait(context, 2924ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 885ms, 0ms); + buy_items(stream, context, {{ShopItem::Repel, BUY_MAX}}); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1193ms, 0ms); + pbf_wait(context, 2226ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2544ms, 0ms); + pbf_wait(context, 753ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 464ms, 0ms); + pbf_wait(context, 519ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1467ms, 0ms); + pbf_wait(context, 2816ms); + // Barry conversation + wait_for_dialogue(stream, context, "Barry conversation"); + context.wait_for_all_requests(); + + ShortDialogDetector dialogue(COLOR_YELLOW); + const auto started = std::chrono::steady_clock::now(); + auto absent_since = started; + + while (true){ + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Barry conversation: No video frame.", + stream); + } + + const auto now = std::chrono::steady_clock::now(); + if (now - started >= 180s){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Barry conversation: Dialogue did not finish.", + stream); + } + + if (dialogue.detect(*frame.frame)){ + absent_since = now; + }else if (now - absent_since >= 2s){ + break; + } + + // Keep mashing through pauses between dialogue boxes. + pbf_mash_button(context, BUTTON_B, 100ms); + context.wait_for_all_requests(); + } + + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 282ms, 0ms); + pbf_wait(context, 483ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 218ms, 0ms); + pbf_wait(context, 482ms); + enter_oreburgh_gate_from_city(stream, context); + activate_repel(stream, context, 5); + exit_oreburgh_gate_to_route203(stream, context); + // Align north, then go only far enough west to jump the ledge. + // Avoid descending the upper stairs and climbing straight back up. + pbf_move_left_joystick(context, {0.0, 1.0}, 600ms, 0ms); + pbf_wait(context, 400ms); + pbf_move_left_joystick(context, {-1.0, 0.0}, 2100ms, 0ms); + pbf_wait(context, 300ms); + pbf_move_left_joystick(context, {0.0, -1.0}, 1700ms, 0ms); + pbf_wait(context, 500ms); + // Cross the lower stairs at the offset needed for the next north leg. + // Combine the old left/right correction and the two upward moves. + pbf_move_left_joystick(context, {-1.0, 0.0}, 850ms, 0ms); + pbf_wait(context, 150ms); + pbf_move_left_joystick(context, {0.0, 1.0}, 1800ms, 0ms); + pbf_wait(context, 150ms); + pbf_move_left_joystick(context, {-1.0, 0.0}, 1400ms, 0ms); + pbf_wait(context, 100ms); + pbf_move_left_joystick(context, {0.0, -1.0}, 1000ms, 0ms); + pbf_wait(context, 100ms); + pbf_move_left_joystick(context, {-1.0, 0.0}, 1600ms, 0ms); + pbf_wait(context, 600ms); + // Exhaust the active Repel and decline renewal before the safe + // Jubilife approach, story battle, and Pokemon Center sequence. + force_repel_expiry(stream, context, RepelRenewalChoice::NO); + // One clock setup covers this whole new locator strip: street, + // Galactic plaza approach, and the post-battle return. Existing + // locators elsewhere keep their current time-independent behavior. + stream.log("[Jubilife locator setup] Setting 13:00 once for the street/plaza locator strip; preserving the date."); + DateTime locator_time; + locator_time.hour = 13; + locator_time.minute = 0; + locator_time.second = 0; + set_time_fast(env, context, locator_time, true); + pbf_wait(context, 5000ms); + context.wait_for_all_requests(); + // Stop east of the TV-station overshoot seen in the recording. + // Acquire/correct the street lane before moving north; the locator + // now supplies vertical travel instead of the blind 2579ms up move. + pbf_move_left_joystick(context, {-1.0, 0.0}, 3700ms, 0ms); + pbf_wait(context, 850ms); + align_to_overworld_landmarks(stream, + context, + match_jubilife_street_approach, + "Jubilife street alignment", + false, + true); + // Stop the upward approach if the story dialogue starts early. + ShortDialogWatcher plaza_story(COLOR_YELLOW); + run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0.0, 1.0}, 2600ms, 0ms); + pbf_wait(ctx, 250ms); + }, + {{plaza_story}}); + align_to_overworld_landmarks(stream, context, match_jubilife_plaza_approach, "Jubilife plaza alignment", true); + approach_galactic_dialogue(stream, context); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + clear_post_battle_dialogue(stream, context); + pbf_wait(context, 2000ms); + clear_post_battle_dialogue(stream, context); + // Rowan and the Poketch president leave the player north of the + // reference. Bring the plaza landmarks back into view first. + stream.log("[Galactic return] Dialogue cleared; short downward return before reacquiring the plaza."); + pbf_move_left_joystick(context, {0.0, -1.0}, 600ms, 0ms); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + align_to_overworld_landmarks(stream, + context, + match_jubilife_plaza_approach, + "Jubilife post-battle plaza alignment"); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 5141ms, 0ms); + pbf_wait(context, 749ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 868ms, 0ms); + pbf_wait(context, 1073ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 431ms, 0ms); + pbf_wait(context, 3099ms); + // Stop outside: the old additional 1028ms up move sometimes + // entered the building before exterior alignment even started. + // Repel is inactive here; no expiry search is needed before the Center. + align_and_enter_jubilife_center(stream, context); + if (!heal_pokemon(stream, context, "Jubilife", &stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Jubilife Center: Healing/exit did not complete.", + stream); + } + + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1440ms, 0ms); + pbf_wait(context, 2259ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 765ms, 0ms); + pbf_wait(context, 439ms); + const bool missed_trainer = !approach_route204_female_trainer(stream, context); + stream.log("[Route 204 female trainer] missed_trainer=" + std::to_string(missed_trainer)); + if (missed_trainer){ + recover_route204_female_trainer(stream, context); + } + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + clear_post_battle_dialogue(stream, context); + // Resume encounter protection after the Center and the following battle. + if (!activate_repel(stream, context, 6)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Post-Center battle: Could not activate a new Repel from the Bag.", + stream); + } + context.wait_for_all_requests(); + const bool next_trainer_started = align_to_route204_sign(stream, context); + if (!next_trainer_started){ + const bool triggered = watch_route204_trainer_event( + stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {-1.0, 0.0}, 1200ms, 0ms); + pbf_wait(ctx, 2500ms); + }, + "Route 204 next trainer approach"); + if (!triggered){ + recover_route204_youngster(stream, context); + } + } + // An early locator interruption still has to complete combat; + // it must never fall through into overworld movement mid-battle. + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + clear_post_battle_dialogue(stream, context); + // Keep the recently activated Repel through Ravaged Path. + // Reacquire west of the sign after Tyler before moving north; + // do not spend its remaining steps on an early expiry search. + bool northbound_aligned = false; + for (size_t recovery = 0; recovery < 3; recovery++){ + if (!align_to_route204_sign(stream, context, true)){ + northbound_aligned = true; + break; + } + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + clear_post_battle_dialogue(stream, context); + } + if (!northbound_aligned){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 north approach: Trainer interruptions exhausted.", + stream); + } + enter_ravaged_path_from_route204(env, stream, context, stats); + //cave navigation + + // Ravaged Path -> Floaroma -> Valley Windworks -> Mars. + // First-pass route from ravagedpath.txt and the 2026-10-13 + // 19-48-08 recording. Timings need live route validation. + auto move = [&](double x, double y, Milliseconds duration, Milliseconds settle = 250ms){ + pbf_move_left_joystick(context, {x, y}, duration, 0ms); + pbf_wait(context, settle); + context.wait_for_all_requests(); + }; + auto battle = [&](const std::string& label){ + stream.log("[Segment 02 battle] " + label); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + clear_post_battle_dialogue(stream, context); + }; + // Break the blocking rock using the existing HM confirmation helper. + move(0, 1, 1100ms, 900ms); + move(1, 0, 600ms); + use_HM(stream, context, "Rock Smash in Ravaged Path"); + context.wait_for_all_requests(); + clear_post_battle_dialogue(stream, context); + // Failed frames stop against the remaining center rocks. Pass + // above their collision row before the eastward exit approach. + move(0, 1, 200ms); + exit_ravaged_path_to_route204(stream, context); + move(0, -1, 350ms, 500ms); + + // Resolve the current Repel before searching for Teighler so + // its expiry prompt cannot interrupt trainer acquisition. + // Renew here to protect the remaining route to Floaroma. + force_repel_expiry(stream, context, RepelRenewalChoice::YES); + + // Route 204 north: go around the cliff to Aroma Lady Teighler. + move(-1, 0, 1300ms); + move(0, 1, 1300ms, 650ms); + // Timed approach: get onto the alcove's left edge, descend + // to its bottom ledge, then cross right through her sightline. + // Watch the entire approach so an early trigger stops movement. + if (!watch_route204_trainer_event( + stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {1, 0}, 600ms, 100ms); + pbf_move_left_joystick(ctx, {0, -1}, 600ms, 100ms); + pbf_move_left_joystick(ctx, {1, 0}, 1000ms, 0ms); + pbf_wait(ctx, 3000ms); + }, + "Aroma Lady Teighler timed ledge approach")){ + recover_teighler_trigger(stream, context); + } + stream.log("[Segment 02 battle] Aroma Lady Teighler: move slot 2."); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Aroma Lady Teighler", false); + clear_post_battle_dialogue(stream, context); + + // Omit the Awakening. The grass north of Teighler can trigger + // the Bug Catcher: finish that battle before any Center locator. + // Small watched steps preserve the remaining route if he interrupts. + size_t northbound_battles = 0; + ShortDialogDetector northbound_dialogue(COLOR_YELLOW); + BattleMenuDetector northbound_menu(BattleType::TRAINER, COLOR_GREEN); + auto northbound_move = [&](double x, double y, Milliseconds remaining, bool stop_after_battle = false){ + while (remaining > 0ms){ + const Milliseconds step = remaining > 200ms ? 200ms : remaining; + // A dialogue can appear at the end of the previous + // short step. Check it directly before restarting the + // watcher's 250ms confirmation window or sending input. + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 northbound: No video frame.", + stream); + } + const bool interrupted = northbound_dialogue.detect(*frame.frame) || northbound_menu.detect(*frame.frame) || + watch_route204_trainer_event( + stream, + context, + [x, y, step](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {x, y}, step, 0ms); + }, + "Route 204 northbound movement"); + if (interrupted){ + if (++northbound_battles > 2){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 northbound: Too many trainer interruptions.", + stream); + } + battle("Route 204 north Bug Catcher"); + // The sign locator establishes the next turn after this + // battle; do not finish an obsolete northward timer. + if (stop_after_battle){ + return; + } + // Replay only this possibly interrupted short step, + // never the complete pre-battle travel duration. + }else{ + remaining -= step; + } + } + }; + northbound_move(1, 0, 2000ms); // Reach the right wall. + northbound_move(0, 1, 450ms); // Clear the corner above it. + northbound_move(1, 0, 1200ms); // Move into the northbound lane. + // Restore the north/west turns from the successful manual + // recording. The former 3200ms up stopped at the Bug Catcher, + // well short of town, and omitted the flower-bed detour. + northbound_move(0, 1, 2200ms, true); + // Locate left to the sign after the Bug Catcher, or proceed + // directly if skipped. Do not seek out a missed trainer. + for (size_t recovery = 0; align_to_route204_floaroma_turn(stream, context); recovery++){ + if (recovery >= 2){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 204 north turn: Too many trainer interruptions.", + stream); + } + battle("Route 204 north Bug Catcher"); + } + northbound_move(-1, 0, 1100ms); + northbound_move(0, 1, 150ms); + northbound_move(-1, 0, 1200ms); + northbound_move(-1, 0, 800ms); + // Continue through Floaroma's southern fence opening before + // turning east. The shorter approach stopped against the fence. + stream.log("[Floaroma approach] Moving farther north before Center approach."); + northbound_move(0, 1, 2700ms); + // Expire the Repel in the town approach lane before moving + // toward the Center. Alignment corrects any partial final step. + force_repel_expiry(stream, context, RepelRenewalChoice::NO, true); + establish_floaroma_house_exit(stream, context); + context.wait_for_all_requests(); + stream.log("[Segment 02] Floaroma house exit established; ready for town checkpoint.", COLOR_GREEN); +} + +static void floaroma_to_mars(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + auto move = [&](double x, double y, Milliseconds duration, Milliseconds settle = 250ms){ + pbf_move_left_joystick(context, {x, y}, duration, 0ms); + pbf_wait(context, settle); + context.wait_for_all_requests(); + }; + auto transition = [&](double x, double y, Milliseconds duration, const std::string& label){ + context.wait_for_all_requests(); + stream.log("[Route transition] " + label); + BlackScreenWatcher black; + WhiteScreenWatcher white; + const int ret = run_until(stream, + context, + [x, y, duration](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); + pbf_wait(ctx, 1500ms); + // Bounded doorway correction if the recorded move stops short. + for (size_t pulse = 0; pulse < 10; pulse++){ + pbf_move_left_joystick(ctx, {x, y}, 100ms, 100ms); + } + }, + {{black}, {white}}); + if (ret < 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Entrance/exit transition not detected.", + stream); + } + context.wait_for_all_requests(); + BlackScreenDetector black_frame; + WhiteScreenDetector white_frame; + // Accept either fade type, then require a stable return before moving. + const auto deadline = std::chrono::steady_clock::now() + 15s; + auto clear_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, + label + ": No video frame during transition.", + stream); + } + const auto now = std::chrono::steady_clock::now(); + if (black_frame.detect(*frame.frame) || white_frame.detect(*frame.frame)){ + clear_since = now; + }else if (now - clear_since >= 500ms){ + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + return; + } + pbf_wait(context, 100ms); + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Transition did not finish.", + stream); + }; + auto battle = [&](const std::string& label){ + stream.log("[Segment 02 battle] " + label); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + clear_post_battle_dialogue(stream, context); + }; + auto talk = [&](const std::string& label, Button response = BUTTON_B){ + stream.log("[Segment 02 dialogue] " + label); + pbf_press_button(context, BUTTON_A, 150ms, 500ms); + context.wait_for_all_requests(); + wait_for_dialogue(stream, context, label, 5000ms); + clear_post_battle_dialogue(stream, context, response); + }; + + // The house exit fixes the start pose. Use the median of the + // twenty successful recorded approaches: 24 south taps, then + // nine east taps. Retain the recorded tap cadence; replacing + // it with one continuous hold changes acceleration/step timing. + stream.log("[Floaroma Center] Blind south/east approach from the house exit."); + for (size_t step = 0; step < 24; step++){ + move(0, -1, 80ms, 250ms); + } + for (size_t step = 0; step < 9; step++){ + move(1, 0, 80ms, 500ms); + } + transition(0, 1, 950ms, "Floaroma Pokemon Center entrance"); + // heal_pokemon includes a watched exit; do not exit a second time. + if (!heal_pokemon(stream, context, "Floaroma", &stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Floaroma Center: Healing/exit did not complete.", + stream); + } + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + // Clear the Center doorway to the west before turning north, + // so we cannot walk back through the door. Repel is already off. + move(-1, 0, 1500ms); + + // Meet the girl on Route 205, then return for the Works Key. + move(0, 1, 1000ms); + // Include the westward clearance and the former 650 ms east leg. + move(1, 0, 6550ms, 900ms); + talk("Route 205 girl requesting help for her father"); + move(-1, 0, 4500ms, 500ms); + // Repel is already inactive; omit the recorded renewal A presses. + move(-1, 0, 3000ms); + move(0, 1, 2300ms); + // The westward overtravel establishes the tree wall. The + // recording then stops west of the Meadow gap: move right + // into that opening instead of pushing farther left. + move(1, 0, 300ms); + transition(0, 1, 1000ms, "Floaroma Meadow entrance"); + move(0, 1, 1100ms); + pbf_press_button(context, BUTTON_A, 150ms, 500ms); + stream.log("[Segment 02 battle] Floaroma Meadow first Galactic grunt."); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + // The second grunt starts another battle automatically. Do not + // wait for quiet overworld or send movement between the fights. + battle("Floaroma Meadow second Galactic grunt"); + talk("Floaroma Meadow honey seller and Works Key"); + if (!clear_new_item_flags(stream, context, 6)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Floaroma Meadow: Bag NEW flags did not clear.", + stream); + } + transition(0, -1, 1000ms, "Floaroma Meadow exit"); + move(0, -1, 2600ms); + + // No Repel is needed on the town/road route to Valley Windworks. + move(1, 0, 4200ms); + move(0, -1, 200ms); + move(1, 0, 5500ms); + move(0, -1, 500ms); + move(1, 0, 1200ms); + align_to_windworks_sign(stream, context); + // From the sign-side pose, the building wall fixes north/south + // position and the grunt blocks the subsequent eastward travel. + move(0, 1, 2000ms); + move(1, 0, 1500ms); + pbf_press_button(context, BUTTON_A, 150ms, 500ms); + battle("Valley Windworks door Galactic grunt"); + + // Probe the door after the grunt retreats. Cancel all remaining + // movement as soon as dialogue appears, preserving doorway alignment. + bool door_dialogue = false; + for (size_t attempt = 0; attempt < 20 && !door_dialogue; attempt++){ + ShortDialogWatcher dialogue(COLOR_YELLOW); + door_dialogue = run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, 1}, 100ms, 0ms); + pbf_press_button(ctx, BUTTON_A, 150ms, 400ms); + pbf_move_left_joystick(ctx, {1, 0}, 40ms, 0ms); + pbf_wait(ctx, 250ms); + }, + {{dialogue}}) >= 0; + context.wait_for_all_requests(); + } + if (!door_dialogue){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Valley Windworks: Door dialogue not detected after bounded up/A/right probes.", + stream); + } + stream.log("[Windworks door] Dialogue detected; confirm Works Key for two seconds."); + pbf_mash_button(context, BUTTON_A, 2000ms); + context.wait_for_all_requests(); + // Interaction reaches the door from west of its entrance lane. + // One additional recorded nudge centers the subsequent northward entry. + move(1, 0, 40ms); + transition(0, 1, 1000ms, "Valley Windworks entrance"); + clear_post_battle_dialogue(stream, context); // Entrance warning from the grunt. + move(-1, 0, 2000ms); // Combined 1210ms + 817ms, separated by only 5ms. + bool fought_grunt = watch_route204_trainer_event( + stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, 1}, 1250ms, 0ms); + pbf_wait(ctx, 2500ms); + }, + "Valley Windworks interior grunt approach"); + if (fought_grunt){ + battle("Valley Windworks interior Galactic grunt"); + } + auto grunt_position = [&](){ + OverworldMatchResult position; + for (size_t attempt = 0; attempt < 12; attempt++){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (frame){ + position = match_windworks_interior_grunt(*frame.frame); + } + if (position.located){ + return position; + } + pbf_wait(context, 250ms); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Windworks interior: Could not determine player position relative to the grunt.", + stream); + return position; + }; + auto position = grunt_position(); + if (!fought_grunt && position.dy <= .025){ + stream.log("[Windworks interior] Below/level with grunt; recover north and interact."); + for (size_t attempt = 0; attempt < 20 && !fought_grunt; attempt++){ + fought_grunt = watch_route204_trainer_event( + stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, 1}, 100ms, 0ms); + pbf_press_button(ctx, BUTTON_A, 150ms, 500ms); + }, + "Windworks interior below-grunt recovery"); + } + if (!fought_grunt){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Windworks interior: North/A recovery did not trigger the grunt battle.", + stream); + } + battle("Valley Windworks interior Galactic grunt (below recovery)"); + } + auto align_above_grunt = [&](){ + // Re-establish the west wall after a below-grunt interaction, + // then climb past his row before crossing the partition gap. + move(-1, 0, 1000ms); + bool above = false; + for (size_t attempt = 0; attempt < 20; attempt++){ + position = grunt_position(); + if (position.dy > .025){ + above = true; + break; + } + move(0, 1, 80ms); + } + if (!above){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Windworks interior: Could not get above the defeated grunt.", + stream); + } + }; + if (fought_grunt){ + align_above_grunt(); + }else{ + stream.log("[Windworks interior] Above grunt; try the rightward crossing for his battle."); + } + const bool crossing_event = watch_route204_trainer_event( + stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {1, 0}, 750ms, 0ms); + pbf_wait(ctx, 2500ms); + }, + "Windworks interior upper crossing"); + if (crossing_event){ + if (!fought_grunt){ + battle("Valley Windworks interior Galactic grunt (upper crossing)"); + }else{ + clear_post_battle_dialogue(stream, context); + } + // The event cancelled travel at an unknown point. Reacquire + // the west wall and upper row, then cross from that known pose. + align_above_grunt(); + move(1, 0, 750ms, 450ms); + }else if (!fought_grunt){ + stream.log( + "[Windworks interior] Rightward crossing completed without a battle; grunt missed, continuing to Mars."); + } + + // The upper crossing above replaces the old blind north/right + // pair. Retain the remaining turns between the machines. + move(0, -1, 300ms, 0ms); + move(1, 0, 900ms); + move(0, 1, 250ms); + move(1, 0, 550ms, 400ms); + move(0, 1, 200ms); + move(1, 0, 750ms, 500ms); + pbf_press_button(context, BUTTON_A, 150ms, 500ms); + battle("Commander Mars"); + clear_post_battle_dialogue(stream, context); // Father/daughter reunion after Mars leaves. + stream.log("[Segment 02] Mars defeated; post-battle dialogue cleared.", COLOR_GREEN); +} +static void align_to_route205_marker(VideoStream& stream, ProControllerContext& context, bool forest){ + context.wait_for_all_requests(); + const std::string label = forest ? "Route 205 forest entrance" : "Route 205 bridge"; + size_t confirmed = 0, missing = 0; + bool acquired = false; + 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); + } + const auto result = forest ? match_route205_forest_entrance(*frame.frame) : match_route205_bridge(*frame.frame); + if (result.at_target){ + acquired = true; + missing = 0; + if (++confirmed >= 3){ + stream.log("[" + label + "] Sign position confirmed.", COLOR_GREEN); + return; + } + pbf_wait(context, 150ms); + }else{ + confirmed = 0; + double x = 0, y = 1; + Milliseconds duration = 80ms; + if (result.located){ + acquired = true; + missing = 0; + stream.log("[" + label + "] Sign offset=(" + std::to_string(result.dx) + "," + + std::to_string(result.dy) + ")"); + const bool horizontal = std::abs(result.dx) > 1.0 / 50; + const double error = horizontal ? std::abs(result.dx) : std::abs(result.dy); + x = horizontal ? (result.dx < 0 ? -1 : 1) : 0; + y = horizontal ? 0 : (result.dy > 0 ? -1 : 1); + duration = error < .08 ? 40ms : 80ms; + }else if (++missing > 12){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Sign unavailable during bounded acquisition.", + stream); + }else if (acquired){ + // Never walk past a sign after losing a trusted match. + pbf_wait(context, 250ms); + context.wait_for_all_requests(); + continue; + } + pbf_move_left_joystick(context, {x, y}, duration, 0ms); + pbf_wait(context, 250ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Sign position did not converge.", + stream); +} + +static void align_to_route205_bank_tree(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + size_t confirmed = 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, + "Route 205 bank tree: No video frame.", + stream); + } + const auto result = match_route205_bank_tree(*frame.frame); + if (!result.located){ + confirmed = 0; + if (++missing >= 12){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 205 bank tree: Tree and blue-hat child unavailable; holding position.", + stream); + } + pbf_wait(context, 250ms); + }else if (result.at_target){ + missing = 0; + if (++confirmed >= 3){ + stream.log("[Route 205 bank tree] Position confirmed.", COLOR_GREEN); + return; + } + pbf_wait(context, 150ms); + }else{ + confirmed = 0; + missing = 0; + stream.log( + "[Route 205 bank tree] Offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")"); + // Clear the north bridge lip before attempting to step west. + // Once at bank height, keep horizontal priority beside the tree. + const bool clear_bank = result.dy < -1.0 / 54; + const bool horizontal = !clear_bank && std::abs(result.dx) > 1.0 / 96; + const double error = horizontal ? std::abs(result.dx) : std::abs(result.dy); + const double x = horizontal ? (result.dx < 0 ? -1 : 1) : 0; + const double y = horizontal ? 0 : (result.dy > 0 ? -1 : 1); + pbf_move_left_joystick(context, {x, y}, error < .08 ? 40ms : 80ms, 0ms); + pbf_wait(context, 250ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 205 bank tree: Position did not converge.", + stream); +} + +static void align_route205_patrol_position(VideoStream& stream, + ProControllerContext& context, + const std::function& match, + const std::string& label){ + size_t confirmed = 0, missing = 0; + for (size_t attempt = 0; attempt < 100; attempt++){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No video frame during patrol positioning.", + stream); + } + const auto result = match(*frame.frame); + if (!result.located){ + confirmed = 0; + if (++missing >= 16){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Patrol landmarks unavailable; holding position.", + stream); + } + pbf_wait(context, 250ms); + }else if (result.at_target){ + missing = 0; + if (++confirmed >= 3){ + stream.log("[" + label + "] Patrol position confirmed.", COLOR_GREEN); + return; + } + pbf_wait(context, 150ms); + }else{ + confirmed = 0; + missing = 0; + stream.log( + "[" + label + "] Patrol offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")"); + // Correct height first so the subsequent horizontal step + // stays on the stair landing rather than hitting its cliff. + const bool vertical = std::abs(result.dy) > 1.0 / 54; + const double error = vertical ? std::abs(result.dy) : std::abs(result.dx); + pbf_move_left_joystick(context, + {vertical ? 0 : (result.dx < 0 ? -1.0 : 1.0), vertical ? (result.dy > 0 ? -1.0 : 1.0) : 0}, + error < .08 ? 40ms : 80ms, + 0ms); + pbf_wait(context, 250ms); + } + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Patrol position did not converge.", + stream); +} + +void mars_to_eterna_forest(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + auto move = [&](double x, double y, Milliseconds duration, Milliseconds settle = 250ms){ + pbf_move_left_joystick(context, {x, y}, duration, 0ms); + pbf_wait(context, settle); + context.wait_for_all_requests(); + }; + auto battle = [&](const std::string& label){ + stream.log("[Route 205 battle] " + label); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Route trainer", false); + clear_post_battle_dialogue(stream, context); + }; + auto patrol_battle = [&](const std::string& label, + const std::function& match, + const std::function& approach){ + // One watcher covers every blind step, locator correction and + // stationary wait. An early encounter cancels queued movement. + if (!watch_route204_trainer_event( + stream, + context, + [&](ProControllerContext& ctx){ + approach(ctx); + ctx.wait_for_all_requests(); + align_route205_patrol_position(stream, ctx, match, label); + pbf_wait(ctx, 45000ms); + }, + label)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Trainer did not approach the confirmed patrol position.", + stream); + } + battle(label); + // Either trainer can interrupt the approach. After the fight, + // continue the route without returning to a patrol waypoint + // or replaying the canceled approach movement. + }; + + // Recorded path around the Windworks machines from the exact + // reunion position at the end of 02.02. Keep directional turns. + move(-1, 0, 1335ms, 847ms); + move(0, 1, 880ms, 209ms); + move(-1, 0, 906ms, 471ms); + move(0, -1, 427ms, 728ms); + move(-1, 0, 1213ms, 936ms); + move(-1, 0, 991ms, 175ms); + move(0, -1, 1714ms, 456ms); + move(1, -1, 6809ms, 1862ms); + move(0, -1, 922ms, 554ms); + move(-1, 0, 813ms, 1370ms); + move(0, -1, 1862ms, 145ms); + move(-1, 0, 3478ms, 500ms); + // Replace the manual left/right/up sign adjustments with the + // supplied repeatable position south of the bridge. + align_to_route205_marker(stream, context, false); + // Stop on the north bank, before Jacob's sight line. The longer + // hold reached his patrol and interrupted the westward crossing. + move(0, 1, 800ms, 500ms); + if (!activate_repel(stream, context, 6)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 205: Could not activate Repel before the grass.", + stream); + } + + // Cross beside the golden tree first, then travel through the + // grass west of Jacob. Clamp north against the plateau cliff + // before completing the westward approach to its south stairs. + // Keep the recorded total westward travel, split around the turn. + move(-1, 0, 600ms, 268ms); + align_to_route205_bank_tree(stream, context); + move(0, 1, 2500ms, 250ms); + move(-1, 0, 2212ms, 268ms); + // Route 204's missed-fight pattern: one continuous approach, + // then stand still and decide before sending the next movement. + ShortDialogDetector hiker_dialogue(COLOR_YELLOW); + BattleDialogDetector hiker_intro(COLOR_YELLOW); + BattleMenuDetector hiker_menu(BattleType::TRAINER, COLOR_GREEN); + BlackScreenDetector hiker_transition; + const auto hiker_started = [&](){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Daniel north pass: No frame while checking for a dodged battle.", + stream); + } + return hiker_dialogue.detect(*frame.frame) || hiker_intro.detect(*frame.frame) || + hiker_menu.detect(*frame.frame) || hiker_transition.detect(*frame.frame); + }; + ShortDialogWatcher hiker_dialogue_watch(COLOR_YELLOW); + DetectorToFinder hiker_intro_watch("Daniel battle intro", 250ms, COLOR_YELLOW); + BattleMenuWatcher hiker_menu_watch(BattleType::TRAINER, COLOR_GREEN); + BlackScreenWatcher hiker_transition_watch; + const bool hiker_encounter = hiker_started() || + run_until(stream, + context, + [&](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, 1}, 1200ms, 0ms); + ctx.wait_for_all_requests(); + stream.log("[Daniel dodge check] North movement stopped; observing without input."); + // Battle intro uses a long dialogue box before the menu. + // Keep one uninterrupted watcher through the approach and + // this stationary window; no wiggle and no A press. + pbf_wait(ctx, 10000ms); + }, + {{hiker_dialogue_watch}, {hiker_intro_watch}, {hiker_menu_watch}, {hiker_transition_watch}}) >= 0 || + hiker_started(); + stream.log("[Daniel dodge check] missed_trainer=" + std::to_string(!hiker_encounter)); + if (hiker_encounter){ + battle("Hiker Daniel"); + stream.log("[Route 205] Daniel defeated; moving north from his battle landing to Zackary."); + }else{ + stream.log("[Route 205] Daniel dodged; continuing north up the stairs before turning right to Zackary.", + COLOR_GREEN); + } + // Daniel stops us at his row when he engages. A skipped pass + // has already carried us farther north. Resume from each pose + // with its own remaining climb, then confirm the visual waypoint. + // Stop at the bottom of the upper landing: the previous extra + // 300 ms north put us in Zackary's walking lane before the turn. + const Milliseconds north_to_zackary = hiker_encounter ? 1000ms : 400ms; + patrol_battle("Camper Zackary", match_route205_zackary, [north_to_zackary](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, 1}, north_to_zackary, 250ms); + // Clamp against the right cliff at the landing's bottom + // corner before visual confirmation and stationary wait. + pbf_move_left_joystick(ctx, {1, 0}, 900ms, 250ms); + }); + // Restore the west side for the recorded bridge/stair turns. + move(-1, 0, 1800ms, 250ms); + patrol_battle("Battle Girl Kelsey", match_route205_kelsey, [](ProControllerContext& ctx){ + const auto step = [&](double x, double y, Milliseconds duration, Milliseconds settle){ + pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); + pbf_wait(ctx, settle); + }; + step(0, 1, 638ms, 673ms); + step(1, 0, 476ms, 508ms); + step(0, 1, 717ms, 254ms); + step(-1, 0, 415ms, 430ms); + step(0, 1, 2517ms, 57ms); + step(1, 0, 410ms, 299ms); + step(0, 1, 1343ms, 500ms); + step(-1, 0, 294ms, 250ms); + }); + stream.log("[Route 205] Kelsey defeated; taking the left/north/right/north forest approach."); + // Original recorded turns around the ledge and northern outcrop. + // This exit applies even if Kelsey interrupted an earlier step. + move(-1, 0, 415ms, 430ms); + // Renew in the cleared lane so expiry cannot interrupt the climb. + // Keep both recorded northward movement durations unchanged. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, true); + move(0, 1, 2517ms, 57ms); + move(1, 0, 410ms, 299ms); + move(0, 1, 1343ms, 500ms); + align_to_route205_marker(stream, context, true); + move(0, 1, 20ms); + stream.log("[Route 205] Forest entrance sign confirmed; checkpoint complete.", COLOR_GREEN); +} +// Cheryl controls her own Pokemon. Select move slot 2 visually, +// then A advances target selection and battle text, as in the recording. +void eterna_forest(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + (void)env; + // Second recorded run, beginning at the northern Route 205 sign. + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1466ms, 0ms); + pbf_wait(context, 1588ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 531ms, 0ms); + pbf_wait(context, 3398ms); + wait_for_dialogue(stream, context, "Cheryl introduction"); + // Reuse the Galactic plaza cutscene cleanup, including a second + // pass after the animation gap before route movement resumes. + clear_post_battle_dialogue(stream, context); + pbf_wait(context, 2000ms); + clear_post_battle_dialogue(stream, context); + auto encounter = [&](const std::string& label, const std::function& approach){ + if (!watch_route204_trainer_event(stream, context, approach, label)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No trainer encounter on the recorded approach.", + stream); + } + stream.log("[Eterna Forest battle] " + label); + wait_for_trainer_battle_menu(stream, context); + // Ordinary trainers return after the confirmed battle-exit transition. + fight_trainer_using_moves(stream, context, stats, label, false); + // Cheryl restores the party after each completed partner battle. + stats.move_pp_remaining = stats.move_pp_capacity; + }; + encounter("Lass Briana and Bug Catcher Jack", [&](ProControllerContext& context){ + pbf_move_left_joystick(context, {1.000000, -0.000000}, 579ms, 0ms); + pbf_wait(context, 404ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 494ms, 0ms); + pbf_wait(context, 358ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1011ms, 0ms); + pbf_wait(context, 205ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1980ms, 0ms); + pbf_wait(context, 125ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1522ms, 0ms); + pbf_wait(context, 38ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1295ms, 0ms); + pbf_wait(context, 1811ms); + pbf_wait(context, 10000ms); + }); + encounter("Psychics Lindsey and Elijah", [&](ProControllerContext& context){ + pbf_move_left_joystick(context, {0.000000, 1.000000}, 707ms, 0ms); + pbf_wait(context, 437ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1211ms, 0ms); + pbf_wait(context, 39ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1440ms, 0ms); + pbf_move_left_joystick(context, {1.000000, 1.000000}, 11ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1426ms, 0ms); + pbf_wait(context, 54ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1788ms, 0ms); + pbf_wait(context, 3269ms); + pbf_wait(context, 10000ms); + }); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 2844ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -1.000000}, 12ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1358ms, 0ms); + pbf_wait(context, 223ms); + // The recording renews here before walking into the last pair. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, true); + encounter("Bug Catchers Donald and Phillip", [&](ProControllerContext& context){ + pbf_move_left_joystick(context, {1.000000, -0.000000}, 2335ms, 0ms); + pbf_wait(context, 1960ms); + pbf_wait(context, 10000ms); + }); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 3878ms, 0ms); + pbf_wait(context, 33ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 6451ms, 0ms); + pbf_wait(context, 117ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1727ms, 0ms); + pbf_wait(context, 207ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1693ms, 0ms); + pbf_wait(context, 109ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1613ms, 0ms); + pbf_wait(context, 538ms); + // Clear the recorded expiry prompt before Cheryl's farewell. + clear_post_battle_dialogue(stream, context, BUTTON_A); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 928ms, 0ms); + pbf_wait(context, 1487ms); + wait_for_dialogue(stream, context, "Cheryl farewell"); + // Reuse the Galactic plaza cutscene cleanup, including a second + // pass after the animation gap before route movement resumes. + clear_post_battle_dialogue(stream, context); + pbf_wait(context, 2000ms); + clear_post_battle_dialogue(stream, context); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1238ms, 0ms); + pbf_wait(context, 2157ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 510ms, 0ms); + pbf_wait(context, 544ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 864ms, 0ms); + pbf_wait(context, 289ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1694ms, 0ms); + pbf_wait(context, 383ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 643ms, 0ms); + pbf_wait(context, 346ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 844ms, 0ms); + pbf_wait(context, 122ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 678ms, 0ms); + pbf_wait(context, 151ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 4143ms, 0ms); + context.wait_for_all_requests(); + stream.log("[Eterna Forest] East exit and Route 205 bridges complete; at Eterna City approach.", COLOR_GREEN); +} +void eterna_forest_exit_to_gym(SingleSwitchProgramEnvironment& env, + VideoStream& stream, + ProControllerContext& context, AutoStoryStats& stats){ + (void)env; + // Clear Repel on the open street before locator corrections. + // Reuse the helper's horizontal wiggles and decline renewal. + force_repel_expiry(stream, context, RepelRenewalChoice::NO, false); + // Reuse landmark alignment to clear the blue post east of the + // door before entering. Healing starts only after map transition. + align_to_overworld_landmarks(stream, context, match_eterna_center_entrance, "Eterna Center doorway", false, true); + BlackScreenOverWatcher center_entry(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int entry = run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, 1}, 4000ms, 0ms); + pbf_wait(ctx, 3000ms); + }, + {{center_entry}}); + if (entry != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna Center doorway: Entry transition not confirmed.", + stream); + } + context.wait_for_all_requests(); + if (!heal_pokemon(stream, context, "Eterna", &stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna Center exit was not confirmed.", + stream); + } + pbf_wait(context, 500ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 500ms, 0ms); + pbf_wait(context, 433ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 580ms, 0ms); + pbf_wait(context, 537ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 144ms, 0ms); + pbf_wait(context, 508ms); + bool entered_house = false; + align_to_overworld_landmarks(stream, + context, + match_eterna_underground_house, + "Underground Man house doorway", + false, + true, + false, + match_eterna_underground_man, + &entered_house); + BlackScreenOverWatcher house_entry(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + if (!entered_house && + run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, 1}, 4000ms, 0ms); + pbf_wait(ctx, 3000ms); + }, + {{house_entry}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Underground Man house: Entry not confirmed.", + stream); + } + pbf_wait(context, 500ms); + // Retain the recorded northward approach from the fixed entrance. + pbf_move_left_joystick(context, {0, 1}, 290ms, 0ms); + align_to_overworld_landmarks(stream, + context, + match_eterna_underground_man, + "Underground Man interaction", + false, + true); + pbf_move_left_joystick(context, {0, 1}, 80ms, 200ms); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + wait_for_dialogue(stream, context, "Underground Man introduction"); + // Recorded forced walk: 4.7s between house and street dialogue. + // Keep watching for that gap plus a 500ms hardware buffer. + clear_post_battle_dialogue(stream, + context, + BUTTON_A, + 5200ms, + "PokemonBDSP/Autostory/EternaUndergroundMan/ExplorerKitCard.png"); + // The first-use tutorial opens the Bag, uses the Kit, and takes + // us underground automatically. The dialogue helper also clears + // Roark's introduction. Do not reopen the Bag to enter again. + const auto underground_frame = stream.video().snapshot(); + const char* underground_reference = underground_frame && underground_frame.frame->width() >= 1600 + ? "PokemonBDSP/Autostory/EternaUndergroundMan/UndergroundArrival.png" + : underground_frame && underground_frame.frame->width() >= 1100 + ? "PokemonBDSP/Autostory/EternaUndergroundMan/UndergroundArrivalMedium.png" + : "PokemonBDSP/Autostory/EternaUndergroundMan/UndergroundArrivalSmall.png"; + ImageMatchDetector underground_map(std::make_shared(RESOURCE_PATH() + underground_reference), + ImageFloatBox{.005, .110, .085, .130}, + 30.0); + if (!underground_frame || !underground_map.detect(*underground_frame.frame)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Grand Underground tutorial: Underground arrival not confirmed.", + stream); + } + stream.log("[Grand Underground] Tutorial dialogue complete; returning with Y."); + // User-requested full settling interval immediately before Y. + pbf_wait(context, 2000ms); + pbf_press_button(context, BUTTON_Y, 129ms, 0ms); + pbf_wait(context, 2076ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 88ms, 0ms); + pbf_wait(context, 819ms); + pbf_press_button(context, BUTTON_A, 127ms, 0ms); + pbf_wait(context, 1972ms); + pbf_press_button(context, BUTTON_A, 127ms, 0ms); + pbf_wait(context, 5330ms); + // Keep the Y / Go up / Yes inputs above, then watch the + // Underground Man's reward dialogue through the surface return. + context.wait_for_all_requests(); + wait_for_dialogue(stream, context, "Underground Man return"); + clear_post_battle_dialogue(stream, context, BUTTON_A, 3500ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 584ms, 0ms); + pbf_wait(context, 85ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1448ms, 0ms); + pbf_wait(context, 122ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1455ms, 0ms); + pbf_wait(context, 145ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1257ms, 0ms); + pbf_wait(context, 956ms); + // Reuse the plaza/Cheryl cutscene cleanup rather than a + // recorded number of A presses during Cynthia's camera changes. + context.wait_for_all_requests(); + wait_for_dialogue(stream, context, "Cynthia introduction"); + clear_post_battle_dialogue(stream, context, BUTTON_A, 3500ms); + // Let Cynthia's departure/camera finish before any route input, + // then watch once more for delayed dialogue on slower hardware. + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + clear_post_battle_dialogue(stream, context, BUTTON_A, 1500ms); + pbf_wait(context, 500ms); + stream.log("[Cynthia exit] Departure settled; moving west and south to reacquire the Center."); + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); + pbf_move_left_joystick(context, {0, -1}, 1800ms, 300ms); + pbf_move_left_joystick(context, {1, 0}, 1200ms, 100ms); + pbf_move_left_joystick(context, {0, -1}, 250ms, 300ms); + // Clear rewards before the approach can cross the gym doorway. + context.wait_for_all_requests(); + if (!clear_new_item_flags(stream, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "New reward flags could not be cleared before continuing.", + stream); + } + // Keep a loose horizontal street lane, but normalize the row: + // the following fixed south leg requires a repeatable start. + align_to_overworld_landmarks(stream, + context, + match_eterna_center_entrance, + "Eterna Center gym departure", + false, + true, + true, + nullptr, + nullptr, + .03, + .02); + // User's eternapctogym.txt with a shorter south leg; omit its final left127ms correction. + // Start acquiring north of the old fence collision, then let + // gym landmarks correct the final row and rightward approach. + pbf_move_left_joystick(context, {0, -1}, 4300ms, 0ms); + pbf_wait(context, 642ms); + pbf_move_left_joystick(context, {1, 0}, 1125ms, 0ms); + pbf_wait(context, 939ms); + pbf_move_left_joystick(context, {0, 1}, 56ms, 0ms); + pbf_wait(context, 467ms); + pbf_move_left_joystick(context, {0, 1}, 39ms, 0ms); + pbf_wait(context, 1255ms); + // Configurable only for a watched gym entry attempt. The normal + // three-landmark exterior arrival gates remain unchanged. + constexpr double GYM_MODERATE_ENTRY_CONFIDENCE = .88; + constexpr size_t GYM_ENTRY_ATTEMPTS = 3; + bool gym_confirmed = false, transition_seen = false; + for (size_t attempt = 1; attempt <= GYM_ENTRY_ATTEMPTS && !gym_confirmed; attempt++){ + stream.log("[Eterna Gym entry] Attempt " + std::to_string(attempt) + "/" + std::to_string(GYM_ENTRY_ATTEMPTS)); + if (!transition_seen){ + bool already_inside = false; + // Watch throughout alignment, not just the final north + // hold: a correction can cross the doorway first. + BlackScreenWatcher entering(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + const int outcome = run_until(stream, + context, + [&](ProControllerContext& ctx){ + align_to_overworld_landmarks(stream, + ctx, + match_eterna_gym_entrance, + "Eterna Gym doorway", + false, + true, + false, + match_eterna_gym_statues, + &already_inside, + 0, + 0, + GYM_MODERATE_ENTRY_CONFIDENCE); + if (!already_inside){ + pbf_move_left_joystick(ctx, {0, 1}, 3000ms, 0ms); + pbf_wait(ctx, 5000ms); + } + }, + {{entering}}); + transition_seen = outcome == 0 || already_inside; + stream.log("[Eterna Gym entry] Black transition detected=" + std::to_string(outcome == 0) + + ", interior seen during alignment=" + std::to_string(already_inside)); + } + if (transition_seen){ + 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){ + stream.log("[Eterna Gym entry] Transition has not finished; retrying within limit."); + continue; + } + stream.log("[Eterna Gym entry] Transition finished; verifying both statues."); + } + // Black alone never proves entry. Stop navigation and retry + // verification if a transition occurred into an unknown room. + size_t stable = 0; + for (size_t check = 0; check < 8; check++){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna Gym: No interior verification frame.", + stream); + } + const auto interior = match_eterna_gym_statues(*frame.frame); + for (const auto& statue : interior.landmarks){ + stream.log("[Eterna Gym statues] " + statue.name + " score=" + std::to_string(statue.score) + + ", margin=" + std::to_string(statue.margin) + ", offset=(" + std::to_string(statue.dx) + "," + + std::to_string(statue.dy) + ")"); + } + stable = interior.located ? stable + 1 : 0; + stream.log("[Eterna Gym statues] Both in expected positions=" + std::to_string(interior.located) + + ", stable frames=" + std::to_string(stable) + "/3"); + if (stable >= 3){ + gym_confirmed = true; + break; + } + pbf_wait(context, 250ms); + } + if (!gym_confirmed){ + stream.log("[Eterna Gym entry] Statues not verified; bounded retry."); + } + } + if (!gym_confirmed){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna Gym: Both entrance statues were not verified after three attempts.", + stream); + } + stream.log("[Eterna City] Watched Explorer Kit/Cynthia events and verified gym entry complete.", COLOR_GREEN); +} +static void gym_Puzzle_02(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + + //Upon entering the Gym. + + struct Move { + DpadPosition dir; + int count; + }; + + const Move sequence1[] = { + {DPAD_UP, 5}, + {DPAD_LEFT, 2}, + {DPAD_UP, 9}, + {DPAD_RIGHT, 2}, + {DPAD_UP, 7}, + {DPAD_LEFT, 3}, + {DPAD_UP, 2}, + {DPAD_LEFT, 5}, + {DPAD_DOWN, 4}, + }; + + const Move sequence2[] = { + {DPAD_UP, 6}, + {DPAD_LEFT, 6}, + {DPAD_DOWN, 9}, + {DPAD_RIGHT, 2}, + {DPAD_DOWN, 5}, + {DPAD_LEFT, 2}, + {DPAD_DOWN, 4}, + {DPAD_LEFT, 2}, + {DPAD_DOWN, 6}, + {DPAD_RIGHT, 5}, + }; + + const Move sequence3[] = { + {DPAD_LEFT, 5}, + {DPAD_UP, 6}, + {DPAD_RIGHT, 2}, + {DPAD_UP, 5}, + {DPAD_RIGHT, 2}, + {DPAD_UP, 5}, + {DPAD_LEFT, 2}, + {DPAD_UP, 8}, + {DPAD_RIGHT, 7}, + {DPAD_DOWN, 2}, + {DPAD_RIGHT, 5}, + {DPAD_DOWN, 6}, + {DPAD_RIGHT, 2}, + {DPAD_DOWN, 5}, + {DPAD_LEFT, 2}, + {DPAD_DOWN, 4}, + {DPAD_RIGHT, 6}, + {DPAD_UP, 3}, + {DPAD_RIGHT, 2}, + }; + + const Move sequence4[] = { + {DPAD_LEFT, 2}, + {DPAD_DOWN, 4}, + {DPAD_LEFT, 6}, + {DPAD_DOWN, 6}, + {DPAD_RIGHT, 11}, + {DPAD_UP, 3}, + {DPAD_RIGHT, 2}, + {DPAD_UP, 16}, + {DPAD_LEFT, 6}, + }; + + const Move sequence5[] = { + {DPAD_RIGHT, 6}, + {DPAD_DOWN, 17}, + {DPAD_LEFT, 5}, + {DPAD_DOWN, 2}, + {DPAD_LEFT, 8}, + {DPAD_UP, 11}, + {DPAD_RIGHT, 2}, + {DPAD_UP, 7}, + {DPAD_LEFT, 2}, + {DPAD_UP, 7}, + }; + + const Move sequence6[] = { + {DPAD_DOWN, 10}, + {DPAD_RIGHT, 2}, + {DPAD_DOWN, 4}, + {DPAD_LEFT, 2}, + {DPAD_DOWN, 13}, + {DPAD_RIGHT, 2}, + {DPAD_DOWN, 2}, + }; + + for (int i = 0; i < 7; i++){ + pbf_press_dpad(context, DPAD_UP, 80ms, 300ms); + } + + // Reuse the Roark/Cynthia dialogue watcher so Gardenia's + // introduction finishes before the doorway movement starts. + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + context.wait_for_all_requests(); + wait_for_dialogue(stream, context, "Gardenia introduction"); + clear_post_battle_dialogue(stream, context, BUTTON_A, 3500ms); + stream.log("[Eterna Gym puzzle] Gardenia introduction dialogue cleared."); + // Door transition and gym camera introduction take about 7.5s + // in 2026-10-18 20-01-01; include 500ms for hardware variation. + stream.log( + "[Eterna Gym puzzle] Entering Gardenia's doorway; waiting 8000ms for transition/cutscene (includes 500ms buffer)."); + pbf_press_dpad(context, DPAD_UP, 80ms, 8000ms); + + for (const auto& move : sequence1){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + } + } + + //Button presses to initiate Trainer battle 01 (Lass Caroline). + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Lass Caroline", false); + + pbf_mash_button(context, BUTTON_B, 2500ms); //Battle exit dialogue. + + for (const auto& move : sequence2){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + } + } + + //Button presses to initiate Trainer battle 02 (Aroma Lady Jenna). + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Aroma Lady Jenna", false); + + pbf_mash_button(context, BUTTON_B, 2500ms); //Battle exit dialogue. + + for (const auto& move : sequence3){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + } + } + + //Button presses to initiate Trainer battle 03 (Aroma Lady Angela). + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Aroma Lady Angela", false); + + pbf_mash_button(context, BUTTON_B, 2500ms); //Battle exit dialogue + + for (const auto& move : sequence4){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + } + } + + //Button presses to initiate Trainer battle 04 (Beauty Lindsay). + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Beauty Lindsay", false); + + pbf_mash_button(context, BUTTON_B, 2500ms); //Battle exit dialogue + + for (const auto& move : sequence5){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + } + } + + //Button presses to initiate Gym battle. + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + wait_for_trainer_battle_menu(stream, context); + fight_trainer_using_moves(stream, context, stats, "Gardenia", true); + + // The shared battle handler already advances reward dialogue + // while confirming the overworld. Do not require the badge + // animation again after it may have finished or be underway. + // Continue the existing B watcher through remaining rewards; + // its clear confirmation spans the observed ~5s badge gap. + stream.log("[Eterna Gym puzzle] Shared battle cleanup returned; clearing remaining Gardenia rewards with B."); + clear_post_battle_dialogue(stream, context, BUTTON_B, 6000ms); + stream.log("[Eterna Gym puzzle] Gardenia post-battle dialogue cleared; starting exit path."); + + for (const auto& move : sequence6){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + } + } + + pbf_wait(context, 2000ms); + + for (int i = 0; i < 9; i++){ + pbf_press_dpad(context, DPAD_DOWN, 80ms, 300ms); + } + + //Exits Buiilding + pbf_wait(context, 1000ms); +} + +std::string AutoStory_Segment_02::name() const { + return "02: First Badge to Second Badge"; +} +std::string AutoStory_Segment_02::start_text() const { + return "Start: First Gym Badge acquired (Oreburgh City)."; +} +std::string AutoStory_Segment_02::end_text() const { + return "End: Second Gym Badge acquired (Eterna City)."; +} + +void AutoStory_Segment_02::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_0201().run_checkpoint(env, context, options, stats); + AutoStory_checkpoint_0202().run_checkpoint(env, context, options, stats); + AutoStory_checkpoint_0203().run_checkpoint(env, context, options, stats); + AutoStory_checkpoint_0204().run_checkpoint(env, context, options, stats); + AutoStory_checkpoint_0205().run_checkpoint(env, context, options, stats); + AutoStory_checkpoint_0206().run_checkpoint(env, context, options, stats); + + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); +} +std::string AutoStory_checkpoint_0201::name() const { + return "02.01 - Outside Gym 1"; +} +std::string AutoStory_checkpoint_0201::start_text() const { + return "First Gym Badge acquired (Oreburgh City)."; +} +std::string AutoStory_checkpoint_0201::end_text() const { + return "Outside the central house in Floaroma Town immediately after exiting, facing south, with Repel inactive."; +} + +void AutoStory_checkpoint_0201::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0201(env, context, options, stats); +} + + +void checkpoint_0201(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + roark_to_floaroma(env, env.console, context, stats); + }, + + true); +} +std::string AutoStory_checkpoint_0202::name() const { + return "02.02 - Floaroma Town"; +} +std::string AutoStory_checkpoint_0202::start_text() const { + return "Outside the central house in Floaroma Town immediately after exiting, facing south, with Repel inactive."; +} +std::string AutoStory_checkpoint_0202::end_text() const { + return "Standing in Valley Windworks after defeating Commander Mars."; +} +void AutoStory_checkpoint_0202::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + floaroma_to_mars(env, env.console, context, stats); + }, + true); +} + +std::string AutoStory_checkpoint_0203::name() const { + return "02.03 - Route 205 to Eterna Forest"; +} +std::string AutoStory_checkpoint_0203::start_text() const { + return "Standing in Valley Windworks after defeating Commander Mars."; +} +std::string AutoStory_checkpoint_0203::end_text() const { + return "On Route 205 outside Eterna Forest, west of the northern 205 sign, facing north with Repel active."; +} + +void AutoStory_checkpoint_0203::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0203(env, context, options, stats); +} + + +void checkpoint_0203(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + mars_to_eterna_forest(env, env.console, context, stats); + }, + + true); +} + +std::string AutoStory_checkpoint_0204::name() const { + return "02.04 - Eterna Forest"; +} +std::string AutoStory_checkpoint_0204::start_text() const { + return "On Route 205 outside Eterna Forest, west of the northern 205 sign, facing north with Repel active."; +} +std::string AutoStory_checkpoint_0204::end_text() const { + return "At the Eterna City west approach after leaving Eterna Forest and crossing the Route 205 bridges, facing east."; +} + +void AutoStory_checkpoint_0204::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0204(env, context, options, stats); +} + + +void checkpoint_0204(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + // TODO: implement Segment 02 gameplay logic + eterna_forest(env, env.console, context, stats); + }, + + true); +} + +std::string AutoStory_checkpoint_0205::name() const { + return "02.05 - Eterna City to Gym"; +} +std::string AutoStory_checkpoint_0205::start_text() const { + return "At the Eterna City west approach after leaving Eterna Forest and crossing the Route 205 bridges, facing east."; +} +std::string AutoStory_checkpoint_0205::end_text() const { + return "Inside Eterna Gym at the entrance, facing north."; +} + +void AutoStory_checkpoint_0205::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0205(env, context, options, stats); +} + + +void checkpoint_0205(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + // TODO: implement Segment 02 gameplay logic + eterna_forest_exit_to_gym(env, env.console, context, stats); + }, + true); +} + +std::string AutoStory_checkpoint_0206::name() const { + return "02.06 - Inside Gym"; +} +std::string AutoStory_checkpoint_0206::start_text() const { + return "Inside Eterna Gym at the entrance, facing north."; +} +std::string AutoStory_checkpoint_0206::end_text() const { + return "Outside Eterna Gym after defeating Gardenia and acquiring the Forest Badge."; +} + +void AutoStory_checkpoint_0206::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0206(env, context, options, stats); +} + + +void checkpoint_0206(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + //#. Eterna Gym navigation and battle + gym_Puzzle_02(env, env.console, context, stats); + }, + true); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_02.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_02.h new file mode 100644 index 0000000000..0d9b7e8d61 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_02.h @@ -0,0 +1,145 @@ +/* BDSP AutoStory - Segment 02 + * From after acquiring the first badge until after acquiring the second badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment02_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment02_H + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +// Reused at safe walking points to synchronize Repel expiration and renewal. +enum class RepelRenewalChoice { + YES, + NO +}; +void force_repel_expiry(VideoStream& stream, + ProControllerContext& context, + RepelRenewalChoice choice, + bool north_south = false, + bool require_expiry = false); + +class AutoStory_Segment_02 : 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_0201 : 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: first badge acquired +// end: Floaroma central house exit, facing south, Repel inactive +void checkpoint_0201(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +// Save/retry boundary at the central Floaroma house exit, matching 02.01 end. +class AutoStory_checkpoint_0202 : 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_0203 : 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: first badge acquired +// end: second Gym Badge acquired (Eterna City) +void checkpoint_0203(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); +class AutoStory_checkpoint_0204 : 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: first badge acquired +// end: second Gym Badge acquired (Eterna City) +void checkpoint_0204(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_checkpoint_0205 : 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: first badge acquired +// end: second Gym Badge acquired (Eterna City) +void checkpoint_0205(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_checkpoint_0206 : 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: first badge acquired +// end: second Gym Badge acquired (Eterna City) +void checkpoint_0206(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_03.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_03.cpp new file mode 100644 index 0000000000..42315d92fb --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_03.cpp @@ -0,0 +1,3048 @@ + +/* BDSP AutoStory - Segment 03 + * From after acquiring the second badge until after acquiring the third badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include + +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/Globals.h" +#include "CommonFramework/ImageTypes/ImageRGB32.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/Battles/PokemonBDSP_StartBattleDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.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/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h" + +#include "PokemonBDSP_AutoStory_Segment_02.h" + +#include "PokemonBDSP_AutoStory_Segment_03.h" + +using namespace std::chrono_literals; + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { +static void segment03_finish_transition(VideoStream& stream, ProControllerContext& context, const std::string& label){ + 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 + ": Transition did not finish.", + stream); + } + pbf_wait(context, 500ms); + context.wait_for_all_requests(); +} + +// Stationary destination confirmation: do not steer during an NPC reward +// scene or declare arrival from a fade that has only just started. +static void segment03_confirm_location(VideoStream& stream, + ProControllerContext& context, + OverworldMatchResult (*matcher)(const ImageRGB32&), + const std::string& label, + size_t observations = 20, + bool advance_dialogue = false, + bool require_target = false, + OverworldMatchResult (*instruction_card)(const ImageRGB32&) = nullptr){ + context.wait_for_all_requests(); + size_t confirmations = 0; + for (size_t observation = 0; observation < observations; observation++){ + const auto frame = stream.video().snapshot(); + // Instruction cards require B; A cannot dismiss the Bike Riding card. + // Require both the blue Bike illustration and its B Close prompt. + if (frame && instruction_card && instruction_card(*frame.frame).located){ + confirmations = 0; + stream.log("[" + label + "] Blue Bike and B Close prompt detected; dismissing instruction card."); + pbf_press_button(context, BUTTON_B, 80ms, 250ms); + context.wait_for_all_requests(); + continue; + } + const auto match = frame ? matcher(*frame.frame) : OverworldMatchResult{}; + if (require_target ? match.at_target : match.located){ + if (++confirmations >= 3){ + stream.log("[" + label + "] Scene confirmed.", COLOR_GREEN); + return; + } + pbf_wait(context, 250ms); + }else{ + confirmations = 0; + if (advance_dialogue){ + pbf_press_button(context, BUTTON_A, 150ms, 500ms); + }else{ + pbf_wait(context, 500ms); + } + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Expected scene not confirmed.", + stream); +} + +// Watch the approach itself, cancelling queued movement as soon as the +// trainer speaks or a battle starts. The existing battle engine owns combat. +static void segment03_expected_encounter(VideoStream& stream, + ProControllerContext& context, AutoStoryStats& stats, + const std::string& label, + const std::function& approach, + Milliseconds encounter_wait = 45000ms, + bool require_encounter = true){ + context.wait_for_all_requests(); + ShortDialogWatcher dialogue; + StartBattleDetector starting(stream.overlay()); + BattleMenuWatcher menu(BattleType::TRAINER); + const int encountered = run_until(stream, + context, + [&](ProControllerContext& ctx){ + approach(ctx); + pbf_wait(ctx, encounter_wait); + }, + {{dialogue}, {starting}, {menu}}); + if (encountered < 0){ + if (!require_encounter){ + stream.log("[" + label + "] No encounter within " + std::to_string(encounter_wait.count()) + + "ms after approach; trainer dodged, continuing.", + COLOR_GREEN); + return; + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Expected trainer encounter did not start.", + stream); + } + stream.log("[" + label + "] Encounter detected; route movement cancelled."); + fight_trainer_using_moves(stream, context, stats, label, false); +} + +static void segment03_wait_image(VideoStream& stream, + ProControllerContext& context, + const std::string& resource, + const ImageFloatBox& box, + const std::string& label){ + auto reference = std::make_shared(RESOURCE_PATH() + resource); + ImageMatchWatcher ready(reference, box, 25.0, false, 250ms); + if (wait_until(stream, context, 5s, {{ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Visual confirmation missing.", + stream); + } +} + +static void segment03_register_bike(VideoStream& stream, ProControllerContext& context){ + // Receiving Bike adds new flags after the earlier pre-shop Bag pass. + if (!clear_new_item_flags(stream, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna Bike: Could not clear reward flags.", + stream); + } + BagItemTemplate bike; + bike.reference_path = "PokemonBDSP/Autostory/EternaBikeShop/BikeBag.png"; + bike.label = "Bike"; + bike.reference_row = 4; + if (!find_bag_item(stream, context, bike, 8)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna Bike: Bike selection not confirmed.", + stream); + } + // Locate the Key Items tab and Bike first, then use the fixed Register + // sequence without depending on popup, wheel, or confirmation images. + pbf_press_button(context, BUTTON_A, 150ms, 500ms); + pbf_press_dpad(context, DPAD_DOWN, 80ms, 250ms); + pbf_press_button(context, BUTTON_A, 150ms, 500ms); + pbf_press_dpad(context, DPAD_RIGHT, 80ms, 250ms); + pbf_press_button(context, BUTTON_A, 150ms, 500ms); + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + stream.log("[Eterna Bike] Registration inputs sent; preserving the reward's mounted state.", COLOR_GREEN); +} + +// Stop at the completed doorway transition, rather than spending the +// remaining movement budget inside the next map. +// Recorded Cycling Road movement may cross a gate before its budget ends. +// Cancel at either fade color and resume only after that fade has cleared. +// Recover the successful descent endpoint before spending eastward travel. +// Correct height first, while still west of the honey-tree collision column. +static void segment03_align_lower_stair_lane(VideoStream& stream, ProControllerContext& context){ + size_t confirmations = 0, missing = 0; + for (size_t observation = 0; observation < 40; observation++){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 208 lower stair lane: No video frame.", + stream); + } + const auto match = match_route208_lower_stair_lane(*frame.frame); + stream.log("[Route 208 lower stair lane] located=" + std::to_string(match.located) + + ", target=" + std::to_string(match.at_target) + ", offset=(" + std::to_string(match.dx) + "," + + std::to_string(match.dy) + ")"); + if (!match.located){ + confirmations = 0; + if (++missing > 6){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 208 lower stair lane: Cliff/tree anchors missing; no blind search.", + stream); + } + pbf_wait(context, 500ms); + continue; + } + missing = 0; + if (match.at_target && !match.coarse){ + if (++confirmations >= 3){ + return; + } + pbf_wait(context, 250ms); + continue; + } + confirmations = 0; + const bool vertical = match.coarse || std::abs(match.dy) > 1.0 / 54; + const double error = vertical ? match.dy * 9.0 / 16 : match.dx; + const auto duration = std::abs(error) > 1.0 / 24 ? 80ms : std::abs(error) > 1.0 / 64 ? 40ms : 16ms; + pbf_move_left_joystick(context, + {vertical ? 0.0 : (error > 0 ? 1.0 : -1.0), vertical ? (error < 0 ? 1.0 : -1.0) : 0.0}, + duration, + 0ms); + pbf_wait(context, 500ms); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 208 lower stair lane: Could not confirm the safe descent endpoint.", + stream); +} + +static bool segment03_cycling_move(VideoStream& stream, + ProControllerContext& context, AutoStoryStats& stats, + double x, + double y, + Milliseconds duration, + Milliseconds rest, + const std::string& label, + bool require_transition = false){ + context.wait_for_all_requests(); + BlackScreenWatcher black; + WhiteScreenWatcher white; + BattleMenuWatcher battle(BattleType::TRAINER); + const int result = run_until(stream, + context, + [&](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); + pbf_wait(ctx, rest); + }, + {{black}, {white}, {battle}}); + if (result == 2){ + fight_trainer_using_moves(stream, context, stats, label + " encounter", false); + return false; + } + if (result < 0){ + if (require_transition){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Transition did not start.", + stream); + } + return false; + } + BlackScreenWatcher black_gone(COLOR_RED, + {0.1, 0.1, 0.8, 0.8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + WhiteScreenWatcher white_gone(COLOR_RED, + {0.1, 0.1, 0.8, 0.8}, + 500, + 10, + WhiteScreenWatcher::FinderType::GONE, + 500ms); + const int finished = result == 1 ? wait_until(stream, context, 30s, {{white_gone}}) + : wait_until(stream, context, 30s, {{black_gone}}); + if (finished != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Transition did not finish.", + stream); + } + // A cleared fade can precede restored movement control. + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +// Returns true if an encounter began during alignment. The battle strip +// then owns it and counts completion without replaying entry movement. +static bool segment03_align_cycle_start(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + size_t confirmed = 0, missing = 0; + bool have_pose = false; + double previous_dx = 0, previous_dy = 0; + size_t observations_without_strong_alignment = 0; + for (size_t observation = 0; observations_without_strong_alignment < 240; observation++){ + const auto frame = stream.video().snapshot(); + const auto match = frame ? match_cycling_road_bridge_approach(*frame.frame) : OverworldMatchResult{}; + const bool stable = match.located && have_pose && std::abs(match.dx - previous_dx) <= 1.0 / 192 && + std::abs(match.dy - previous_dy) <= 1.0 / 108; + // Reliable rail/deck geometry and a settled pose renew the budget. + // Keep correcting while the visual alignment remains strong. + if (stable){ + observations_without_strong_alignment = 0; + }else{ + ++observations_without_strong_alignment; + } + std::string diagnostic = "[Cycling Road bridge] observation=" + std::to_string(observation + 1) + + " located=" + std::to_string(match.located) + " target=" + std::to_string(match.at_target) + + " stable=" + std::to_string(stable) + + " observations_without_strong_alignment=" + std::to_string(observations_without_strong_alignment) + + " offset=" + std::to_string(match.dx) + "," + std::to_string(match.dy); + for (const auto& anchor : match.landmarks){ + diagnostic += " [" + anchor.name + " coverage=" + std::to_string(anchor.score) + + " accepted=" + std::to_string(anchor.accepted) + "]"; + } + stream.log(diagnostic); + double x = 0, y = 0; + Milliseconds hold = 0ms, rest = 250ms; + if (!match.located){ + confirmed = 0; + have_pose = false; + if (++missing >= 6){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Cycling Road: Bridge deck and right rail not acquired.", + stream); + } + rest = 500ms; + }else{ + missing = 0; + if (std::abs(match.dx) > .32 || std::abs(match.dy) > .60){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Cycling Road: Bridge alignment offset exceeds bounds.", + stream); + } + if (!stable){ + // The camera keeps easing after the bicycle stops. Require + // two agreeing observations before every new correction. + confirmed = 0; + }else if (match.at_target){ + if (++confirmed >= 3){ + stream.log( + "[Cycling Road] Upper white platform/right rail pose confirmed; starting watched downhill entry.", + COLOR_GREEN); + return false; + } + }else{ + confirmed = 0; + // Align against the rail first. The demonstrated target is + // on the stationary upper white platform, before gravity. + if (std::abs(match.dx) > .012){ + x = match.dx > 0 ? 1 : -1; + hold = std::abs(match.dx) > .04 ? 320ms : 240ms; + }else if (std::abs(match.dy) > 1.0 / 54){ + y = match.dy > 0 ? -1 : 1; + hold = std::abs(match.dy) > .06 ? 320ms : 200ms; + } + } + previous_dx = match.dx; + previous_dy = match.dy; + // A movement invalidates the old pose. Reacquire two fresh + // settled measurements before another movement is allowed. + have_pose = hold == 0ms; + } + BlackScreenWatcher black; + WhiteScreenWatcher white; + ShortDialogWatcher dialogue; + StartBattleDetector starting(stream.overlay()); + BattleMenuWatcher menu(BattleType::TRAINER); + const int interrupted = run_until(stream, + context, + [&](ProControllerContext& ctx){ + if (hold > 0ms){ + pbf_move_left_joystick(ctx, {x, y}, hold, rest); + }else{ + pbf_wait(ctx, rest); + } + ctx.wait_for_all_requests(); + }, + {{black}, {white}, {dialogue}, {starting}, {menu}}); + if (interrupted >= 2){ + stream.log("[Cycling Road] Encounter during bridge approach; alignment movement cancelled."); + return true; + } + if (interrupted >= 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Cycling Road: Unexpected transition during bridge alignment.", + stream); + } + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Cycling Road: Bridge lane alignment did not converge.", + stream); +} + + +static void segment03_cycle_road_battles(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, bool encounter_pending){ + size_t completed = 0; + auto watch = [&](bool initial){ + context.wait_for_all_requests(); + ShortDialogWatcher dialogue; + StartBattleDetector starting(stream.overlay()); + BattleMenuWatcher menu(BattleType::TRAINER); + return run_until(stream, + context, + [&](ProControllerContext& ctx){ + if (initial){ + pbf_move_left_joystick(ctx, {0, -1}, 3300ms, 0ms); + } + // From the first encounter onward the controller stays + // neutral. The bicycle rolls naturally into the next trainer. + pbf_wait(ctx, completed >= 3 ? 7000ms : 45000ms); + ctx.wait_for_all_requests(); + }, + {{dialogue}, {starting}, {menu}}); + }; + while (completed < 4){ + const int result = encounter_pending ? 0 : watch(completed == 0); + encounter_pending = false; + if (result < 0){ + if (completed >= 3){ + stream.log("[Cycling Road] Three battles complete; 7s without another encounter. Continuing south."); + return; + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Cycling Road: Expected at least three battles before the south gate.", + stream); + } + fight_trainer_using_moves(stream, context, stats, "Cycling Road encounter " + std::to_string(completed + 1), false); + ++completed; + stream.log("[Cycling Road] Completed battles: " + std::to_string(completed) + "/4."); + } + stream.log("[Cycling Road] Four battles complete; skipping the quiet wait."); +} + +static void segment03_doorway(VideoStream& stream, + ProControllerContext& context, + bool north, + std::chrono::milliseconds duration, + const std::string& label, + double horizontal = 0){ + context.wait_for_all_requests(); + // Horizontal HQ stairs must cancel at fade-out, before loading can + // swallow or carry movement onto the next floor. Vertical door callers + // retain their existing transition behavior. + if (horizontal != 0){ + BlackScreenWatcher entering; + if (run_until(stream, + context, + [&](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {horizontal, 0}, duration, 0ms); + pbf_wait(ctx, 3000ms); + }, + {{entering}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Stair transition did not start.", + stream); + } + 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 + ": Stair transition did not finish.", + stream); + } + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + return; + } + BlackScreenOverWatcher transition(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int result = run_until(stream, + context, + [&](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, north ? 1.0 : -1.0}, duration, 0ms); + pbf_wait(ctx, 3000ms); + }, + {{transition}}); + if (result != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Doorway transition not confirmed.", + stream); + } + segment03_finish_transition(stream, context, label); +} + +void eterna_gym_to_jupiter(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + auto fight = [&](const std::string& label, bool post_battle_dialogue_cleanup = false){ + fight_trainer_using_moves(stream, context, stats, label, post_battle_dialogue_cleanup); + }; + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1060ms, 0ms); + pbf_wait(context, 225ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 4232ms, 0ms); + pbf_wait(context, 269ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 287ms, 0ms); + pbf_wait(context, 1777ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 367ms, 0ms); + pbf_wait(context, 237ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1140ms, 0ms); + pbf_wait(context, 2621ms); + if (!heal_pokemon(stream, context, "Eterna before Galactic HQ", &stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna Center exit was not confirmed.", + stream); + } + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1895ms, 0ms); + pbf_wait(context, 130ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 710ms, 0ms); + pbf_wait(context, 921ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 129ms, 0ms); + pbf_wait(context, 354ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 813ms, 0ms); + pbf_wait(context, 565ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 618ms, 0ms); + pbf_wait(context, 483ms); + context.wait_for_all_requests(); + // Align with the sign and Center emblem before trying Cut. Never search blindly or + // keep pushing north against the hedge when the tree is east of us. + size_t hq_cut_confirmations = 0, hq_cut_missing = 0; + for (size_t correction = 0; correction < 20; correction++){ + const auto frame = stream.video().snapshot(); + const auto position = frame ? match_galactic_hq_cut(*frame.frame) : OverworldMatchResult{}; + for (const auto& anchor : position.landmarks){ + stream.log("[Galactic HQ Cut] " + anchor.name + " score=" + std::to_string(anchor.score) + + ", margin=" + std::to_string(anchor.margin) + ", offset=(" + std::to_string(anchor.dx) + "," + + std::to_string(anchor.dy) + ")"); + } + if (!position.located){ + hq_cut_confirmations = 0; + if (++hq_cut_missing > 6){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic HQ: Front bush sign/Center alignment not confirmed.", + stream); + } + // Let a passing shadow clear; never search by moving blindly. + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + continue; + } + hq_cut_missing = 0; + if (std::abs(position.dx) > .20){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic HQ: Front bush alignment offset exceeds bounds.", + stream); + } + if (position.at_target){ + if (++hq_cut_confirmations >= 3){ + break; + } + }else{ + hq_cut_confirmations = 0; + pbf_move_left_joystick(context, + {position.dx > 0 ? 1.0 : -1.0, 0}, + std::abs(position.dx) > 1.0 / 24 ? 80ms : 40ms, + 0ms); + } + pbf_wait(context, 250ms); + context.wait_for_all_requests(); + } + if (hq_cut_confirmations < 3){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic HQ: Front bush column did not converge.", + stream); + } + // Some attempts reach the bush before the interaction accepts input. + // Confirm its dialogue while stationary, then finish the Cut animation. + ShortDialogWatcher hq_cut_dialogue; + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + for (size_t press = 0; press < 30; press++){ + pbf_move_left_joystick(ctx, {0, 1}, 80ms, 100ms); + pbf_press_button(ctx, BUTTON_A, 150ms, 500ms); + } + }, + {{hq_cut_dialogue}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic HQ: Front bush interaction did not produce dialogue.", + stream); + } + clear_post_battle_dialogue(stream, context, BUTTON_A, 6000ms); + context.wait_for_all_requests(); + // Cut succeeds, but the Center exit can leave us one tile west of + // the HQ doorway. Do not replay the interior route against its wall. + BlackScreenWatcher hq_entering; + const int hq_entry = run_until(stream, + context, + [&](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, 1}, 1507ms, 193ms); + ctx.wait_for_all_requests(); + for (size_t correction = 0; correction < 4; correction++){ + stream.log("[Galactic HQ entry] No transition; correcting east toward doorway, attempt " + + std::to_string(correction + 1) + "/4."); + pbf_move_left_joystick(ctx, {1, 0}, 80ms, 100ms); + pbf_move_left_joystick(ctx, {0, 1}, 350ms, 250ms); + ctx.wait_for_all_requests(); + } + pbf_wait(ctx, 1500ms); + }, + {{hq_entering}}); + if (hq_entry != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic HQ: Doorway transition not detected after bounded east correction.", + stream); + } + stream.log("[Galactic HQ entry] Doorway transition detected; waiting for interior."); + BlackScreenWatcher hq_visible(COLOR_RED, + {0.1, 0.1, 0.8, 0.8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + if (wait_until(stream, context, 30s, {{hq_visible}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic HQ: Entry transition did not finish.", + stream); + } + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + // galacticHQ.txt starts at the ground-floor entrance mat. + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1247ms, 0ms); + pbf_wait(context, 237ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1246ms, 0ms); + pbf_wait(context, 2989ms); + pbf_press_button(context, BUTTON_A, 150ms, 250ms); + fight("Galactic HQ entrance grunts", false); + // With one usable Pokemon these grunts battle consecutively. The next + // intro can outlast the ordinary dialogue quiet period. Keep movement + // stopped and reuse the battle handler only if its command menu appears. + // A party that fought both together needs no second battle handler. + BattleMenuWatcher hq_next_grunt(BattleType::TRAINER); + const int hq_next_battle = run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_mash_button(ctx, BUTTON_B, 30000ms); + }, + {{hq_next_grunt}}); + if (hq_next_battle == 0){ + stream.log("[Galactic HQ] Second entrance grunt detected before stair movement."); + fight("Galactic HQ second entrance grunt", false); + } + pbf_move_left_joystick(context, {0.000000, 1.000000}, 580ms, 0ms); + pbf_wait(context, 220ms); + segment03_doorway(stream, context, true, 1077ms, "Galactic HQ stairs 1F to 2F", -1.0); + pbf_wait(context, 1145ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 758ms, 0ms); + pbf_wait(context, 487ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1633ms, 0ms); + pbf_wait(context, 254ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 925ms, 0ms); + pbf_wait(context, 188ms); + segment03_doorway(stream, context, true, 1145ms, "Galactic HQ stairs 2F to 3F", -1.0); + pbf_wait(context, 1455ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 410ms, 0ms); + pbf_wait(context, 679ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 744ms, 0ms); + pbf_wait(context, 2084ms); + pbf_press_button(context, BUTTON_A, 150ms, 250ms); + fight("Galactic HQ upper-floor grunt", false); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 2119ms, 0ms); + pbf_wait(context, 903ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 263ms, 0ms); + pbf_wait(context, 984ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 342ms, 0ms); + pbf_wait(context, 1806ms); + pbf_press_button(context, BUTTON_A, 150ms, 250ms); + fight("Galactic HQ Scientist", false); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 425ms, 0ms); + pbf_wait(context, 161ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 723ms, 0ms); + pbf_wait(context, 1639ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 494ms, 0ms); + pbf_wait(context, 678ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 409ms, 0ms); + pbf_wait(context, 858ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 389ms, 0ms); + pbf_wait(context, 1189ms); + // Align directly above Jupiter, then turn south to interact without + // trying to enter the narrow gap between her and the rescued NPC. + align_to_overworld_landmarks(stream, context, match_galactic_hq_jupiter, "Galactic HQ Jupiter", false, true); + // Use an explicit Down press: the 16ms stick pulse can leave the + // previous right-facing direction active and interact with Clefairy. + pbf_press_dpad(context, DPAD_DOWN, 80ms, 250ms); + context.wait_for_all_requests(); + // Advance the introduction before entering the battle handler's + // B-based waiting loop. A single press may only open the first text. + BattleMenuWatcher jupiter_menu(BattleType::TRAINER); + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + for (size_t press = 0; press < 30; press++){ + pbf_press_button(ctx, BUTTON_A, 150ms, 500ms); + } + pbf_wait(ctx, 15000ms); + }, + {{jupiter_menu}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic HQ: Jupiter interaction did not reach the battle menu.", + stream); + } + fight("Commander Jupiter", true); + // Jupiter's departure and the rescued family have chained dialogue. + clear_post_battle_dialogue(stream, context, BUTTON_A, 6000ms); + stream.log("[Galactic HQ] Rescue dialogue cleared; following recorded exit.", COLOR_GREEN); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 691ms, 0ms); + pbf_wait(context, 575ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 330ms, 0ms); + pbf_wait(context, 2575ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 523ms, 0ms); + pbf_wait(context, 239ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 717ms, 0ms); + pbf_wait(context, 1721ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 306ms, 0ms); + pbf_wait(context, 294ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1041ms, 0ms); + pbf_wait(context, 307ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 431ms, 0ms); + pbf_move_left_joystick(context, {1.000000, 1.000000}, 11ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 999ms, 0ms); + pbf_wait(context, 573ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 343ms, 0ms); + pbf_wait(context, 175ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 999ms, 0ms); + pbf_wait(context, 456ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 277ms, 0ms); + pbf_wait(context, 122ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 654ms, 0ms); + pbf_wait(context, 1092ms); + context.wait_for_all_requests(); + BlackScreenOverWatcher hq_leaving(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, -1}, 2350ms, 0ms); + pbf_move_left_joystick(ctx, {-1, -1}, 2744ms, 0ms); + pbf_wait(ctx, 3000ms); + }, + {{hq_leaving}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Galactic HQ: Exit transition not confirmed.", + stream); + } + segment03_finish_transition(stream, context, "Galactic HQ exit"); + segment03_confirm_location(stream, context, match_galactic_hq_exterior, "Galactic HQ exterior"); + // Clamp against the uncut front bush; checkpoint 03.02 starts here. + pbf_move_left_joystick(context, {0, -1}, 350ms, 500ms); + context.wait_for_all_requests(); + stream.log("[Galactic HQ] Outside, facing the front Cut bush.", COLOR_GREEN); +} +static void eterna_mart_escape_ropes(VideoStream& stream, ProControllerContext& context){ + stream.log("[Eterna Mart] Buying exactly 3 Escape Ropes after the Jupiter heal."); + // Recorded Center-exit to Mart-door path (eternamart1.txt). + pbf_move_left_joystick(context, {0, -1}, 2713ms, 0ms); + pbf_wait(context, 444ms); + pbf_move_left_joystick(context, {1, 0}, 587ms, 0ms); + pbf_wait(context, 705ms); + segment03_doorway(stream, context, true, 896ms, "Eterna Mart entrance"); + // Standard Mart layout: walk north from the entrance to the clerk. + pbf_move_left_joystick(context, {0, 1}, 885ms, 300ms); + if (!buy_items(stream, context, {{ShopItem::EscapeRope, 3}})){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna Mart: Could not buy 3 Escape Ropes.", + stream); + } + segment03_doorway(stream, context, false, 5000ms, "Eterna Mart exit"); + + // Retrace to the Center column, then use its doorway to normalize the + // position. Do not reverse the time spent loading the Mart interior. + pbf_move_left_joystick(context, {0, -1}, 896ms, 100ms); + pbf_move_left_joystick(context, {-1, 0}, 587ms, 100ms); + segment03_doorway(stream, context, true, 5000ms, "Eterna Center return after Mart"); + segment03_doorway(stream, context, false, 5000ms, "Eterna Center exit after Mart"); + align_to_overworld_landmarks(stream, + context, + match_eterna_center_departure, + "Eterna Center departure after Mart", + false, + true); +} + +static void segment03_enter_cycle_shop(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + auto frame = stream.video().snapshot(); + if (frame && match_eterna_cycle_shop_interior(*frame.frame).located){ + segment03_confirm_location(stream, context, match_eterna_cycle_shop_interior, "Eterna bicycle shop interior"); + return; + } + bool entered = false; + // Watch the entire approach AND the locator. A fade cancels all queued + // input, even if the doorway is crossed before alignment has finished. + BlackScreenWatcher entering; + const int result = run_until(stream, + context, + [&](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, -1}, 1514ms, 0ms); + pbf_wait(ctx, 177ms); + pbf_move_left_joystick(ctx, {1, 0}, 587ms, 0ms); + pbf_wait(ctx, 522ms); + pbf_move_left_joystick(ctx, {1, 0}, 144ms, 0ms); + pbf_wait(ctx, 439ms); + align_to_overworld_landmarks(stream, + ctx, + match_eterna_cycle_shop_entrance, + "Eterna bicycle shop entrance", + false, + true, + false, + match_eterna_cycle_shop_interior, + &entered); + if (!entered){ + pbf_move_left_joystick(ctx, {0, 1}, 4000ms, 0ms); + pbf_wait(ctx, 3000ms); + } + }, + {{entering}}); + if (result != 0 && !entered){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna bicycle shop: Entry transition not detected.", + stream); + } + segment03_finish_transition(stream, context, "Eterna bicycle shop entrance"); + segment03_confirm_location(stream, context, match_eterna_cycle_shop_interior, "Eterna bicycle shop interior"); + stream.log("[Eterna bicycle shop] Interior confirmed; outdoor alignment stopped.", COLOR_GREEN); +} + +void jupiter_to_coronet(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + // Continue from 03.01 outside HQ, directly in front of the Cut bush. + use_HM(stream, context, "Galactic HQ exit Cut"); + clear_post_battle_dialogue(stream, context, BUTTON_A, 6000ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1413ms, 0ms); + pbf_wait(context, 182ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 712ms, 0ms); + pbf_wait(context, 91ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1126ms, 0ms); + pbf_wait(context, 27ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 745ms, 0ms); + pbf_wait(context, 868ms); + align_to_overworld_landmarks(stream, + context, + match_eterna_center_entrance, + "Eterna Center after Jupiter", + false, + true); + segment03_doorway(stream, context, true, 4000ms, "Eterna Center entrance after Jupiter"); + if (!heal_pokemon(stream, context, "Eterna after Jupiter", &stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna Center exit not confirmed.", + stream); + } + segment03_finish_transition(stream, context, "Eterna Center heal exit"); + align_to_overworld_landmarks(stream, + context, + match_eterna_center_departure, + "Eterna Center departure to Mart", + false, + true); + eterna_mart_escape_ropes(stream, context); + segment03_enter_cycle_shop(stream, context); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 505ms, 0ms); + pbf_wait(context, 405ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 568ms, 0ms); + pbf_wait(context, 403ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 642ms, 0ms); + pbf_wait(context, 14ms); + // The recorded approach reaches the keeper even when the exact + // counter reference pose differs. Start talking before any reward wait. + // Cancel Up immediately on dialogue; all subsequent inputs are A only. + context.wait_for_all_requests(); + ShortDialogWatcher keeper_dialogue; + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + for (size_t press = 0; press < 20; press++){ + pbf_move_left_joystick(ctx, {0, 1}, 80ms, 100ms); + pbf_press_button(ctx, BUTTON_A, 150ms, 500ms); + } + }, + {{keeper_dialogue}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Eterna bicycle shop: Keeper interaction did not produce dialogue.", + stream); + } + // The reward scene returns us outside already mounted. Wait for that + // positive destination instead of replaying a fixed dialogue burst. + segment03_confirm_location(stream, + context, + match_eterna_cycle_shop_reward, + "Eterna bicycle reward complete", + 180, + true, + true, + match_eterna_bike_riding_card); + segment03_register_bike(stream, context); + segment03_cycling_move(stream, context, stats, -1, 0, 733ms, 316ms, "Cycling Road approach"); + segment03_cycling_move(stream, context, stats, 0, -1, 3280ms, 3000ms, "Cycling Road north gate entrance", true); + segment03_cycling_move(stream, context, stats, 0, -1, 1789ms, 3000ms, "Cycling Road north gate exit", true); + // Fade completion can precede restored player control. Settle and + // clear the steps before acquiring the bridge, not the cliff wall. + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + OverworldWatcher north_gate_overworld; + if (wait_until(stream, context, 10s, {{north_gate_overworld}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Cycling Road: Overworld not visible after north gate exit settle.", + stream); + } + BlackScreenWatcher north_gate_black; + WhiteScreenWatcher north_gate_white; + ShortDialogWatcher north_gate_dialogue; + StartBattleDetector north_gate_starting(stream.overlay()); + BattleMenuWatcher north_gate_menu(BattleType::TRAINER); + const int clearance = run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, -1}, 750ms, 250ms); + ctx.wait_for_all_requests(); + }, + {{north_gate_black}, {north_gate_white}, {north_gate_dialogue}, {north_gate_starting}, {north_gate_menu}}); + if (clearance == 0 || clearance == 1){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Cycling Road: Unexpected transition while clearing north gate steps.", + stream); + } + const bool cycling_encounter = clearance >= 2 || segment03_align_cycle_start(stream, context); + segment03_cycle_road_battles(stream, context, stats, cycling_encounter); + // Any approach movement may already enter the south gate. Preserve + // that transition instead of requesting two more doorway crossings. + bool south_gate_entered = + segment03_cycling_move(stream, context, stats, -1, 0, 129ms, 1499ms, "Cycling Road south gate approach"); + if (!south_gate_entered){ + south_gate_entered = + segment03_cycling_move(stream, context, stats, -1, 0, 361ms, 3345ms, "Cycling Road south gate approach"); + } + if (!south_gate_entered){ + south_gate_entered = + segment03_cycling_move(stream, context, stats, 0, -1, 472ms, 2574ms, "Cycling Road south gate approach"); + } + if (!south_gate_entered){ + segment03_cycling_move(stream, context, stats, 0, -1, 1977ms, 1544ms, "Cycling Road south gate entrance", true); + } + segment03_confirm_location(stream, + context, + match_cycling_road_south_gate_interior, + "Cycling Road south gate interior"); + segment03_cycling_move(stream, context, stats, 0, -1, 1977ms, 1544ms, "Cycling Road south gate exit", true); + OverworldWatcher south_gate_overworld; + if (wait_until(stream, context, 10s, {{south_gate_overworld}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Cycling Road: Overworld not visible after south gate exit.", + stream); + } + // Stay southbound until the scripted Dawn/Lucas dialogue actually + // appears. Cancel movement at the dialogue instead of spending inputs + // against the cutscene, and never begin the eastward trainer route early. + bool dawn_dialogue_seen = false; + for (size_t attempt = 0; attempt < 4 && !dawn_dialogue_seen; attempt++){ + ShortDialogWatcher dawn_dialogue; + // A dialogue already open after the transition needs no movement. + if (wait_until(stream, context, 500ms, {{dawn_dialogue}}) == 0){ + dawn_dialogue_seen = true; + break; + } + BlackScreenWatcher dawn_black; + WhiteScreenWatcher dawn_white; + StartBattleDetector dawn_starting(stream.overlay()); + BattleMenuWatcher dawn_menu(BattleType::TRAINER); + const Milliseconds south = attempt == 0 ? 1546ms : 3000ms; + stream.log("[Route 207 Dawn] South approach attempt=" + std::to_string(attempt + 1)); + const int result = run_until(stream, + context, + [south](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, -1}, south, 500ms); + ctx.wait_for_all_requests(); + }, + {{dawn_dialogue}, {dawn_black}, {dawn_white}, {dawn_starting}, {dawn_menu}}); + if (result == 0){ + dawn_dialogue_seen = true; + }else if (result >= 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 207 Dawn: Unexpected transition or battle before dialogue.", + stream); + } + } + if (!dawn_dialogue_seen){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 207 Dawn: Dialogue not encountered after southward retries.", + stream); + } + stream.log("[Route 207 Dawn] Dialogue detected; south movement cancelled.", COLOR_GREEN); + bool dawn_dialogue_cleared = false; + for (size_t pass = 0; pass < 6 && !dawn_dialogue_cleared; pass++){ + // A advances the conversation and accepts its default choice. + // Reuse the existing dialogue helper, then guard against a brief + // gap between text boxes being mistaken for the end of the scene. + mash_until_dialogue_ends(stream, context, BUTTON_A, 60s); + context.wait_for_all_requests(); + ShortDialogWatcher resumed_dialogue; + if (wait_until(stream, context, 2000ms, {{resumed_dialogue}}) == 0){ + continue; + } + const auto frame = stream.video().snapshot(); + OverworldDetector overworld; + dawn_dialogue_cleared = frame && overworld.detect(*frame.frame); + } + if (!dawn_dialogue_cleared){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 207 Dawn: Dialogue did not remain cleared.", + stream); + } + pbf_wait(context, 6000ms); + stream.log("[Route 207 Dawn] Dialogue cleared; continuing to Camper Anthony.", COLOR_GREEN); + // restof206.txt begins after Dawn. Recorded battle/menu inputs are + // replaced by the encounter watchers and existing move-2 battle engine. + segment03_expected_encounter(stream, context, stats, "Route 207 Camper Anthony", [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, -1}, 1013ms, 1667ms); + pbf_move_left_joystick(ctx, {1, 0}, 3309ms, 1011ms); + }); + // The recorded approach triggers Lauren next without further steering. + segment03_expected_encounter(stream, context, stats, "Route 207 Picnicker Lauren", [](ProControllerContext&){ + }); + // Justin can be dodged. After the recorded approach, wait three seconds + // for an encounter; otherwise keep the remaining Coronet route. + segment03_expected_encounter( + stream, + context, stats, + "Route 207 Hiker Justin", + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {1, 0}, 994ms, 508ms); + pbf_move_left_joystick(ctx, {0, 1}, 534ms, 457ms); + pbf_move_left_joystick(ctx, {1, 0}, 2076ms, 149ms); + }, + 3000ms, + false); + // Keep the recorded route after Justin; replace its final blind doorway + // excerpt with the same watched down/right search used at Oreburgh Gate. + bool coronet_entered = segment03_cycling_move(stream, context, stats, 1, 0, 942ms, 443ms, "Mount Coronet approach"); + if (!coronet_entered){ + coronet_entered = segment03_cycling_move(stream, context, stats, 0, -1, 776ms, 119ms, "Mount Coronet approach"); + } + if (!coronet_entered){ + coronet_entered = segment03_cycling_move(stream, context, stats, 1, 0, 627ms, 147ms, "Mount Coronet approach"); + } + if (!coronet_entered){ + coronet_entered = segment03_cycling_move(stream, context, stats, 0, 1, 725ms, 1008ms, "Mount Coronet approach"); + } + if (!coronet_entered){ + enter_cave_with_down_right_search(stream, context, "Mount Coronet west entrance"); + segment03_finish_transition(stream, context, "Mount Coronet west entrance"); + // finish_transition includes 500ms; allow two seconds in total. + pbf_wait(context, 1500ms); + context.wait_for_all_requests(); + } + OverworldWatcher coronet_overworld; + if (wait_until(stream, context, 10s, {{coronet_overworld}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Mount Coronet: Interior not ready after west entrance.", + stream); + } + context.wait_for_all_requests(); +} +void coronet1(VideoStream& stream, ProControllerContext& context){ + activate_repel(stream, context, 5); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 2190ms, 0ms); + pbf_wait(context, 104ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 852ms, 0ms); + pbf_move_left_joystick(context, {1.000000, 1.000000}, 37ms, 0ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 102ms, 0ms); + pbf_move_left_joystick(context, {1.000000, 1.000000}, 78ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 983ms, 0ms); + pbf_wait(context, 1754ms); + pbf_press_button(context, BUTTON_A, 173ms, 0ms); + pbf_wait(context, 1072ms); + pbf_press_button(context, BUTTON_A, 168ms, 0ms); + pbf_wait(context, 155ms); + pbf_press_button(context, BUTTON_A, 142ms, 0ms); + pbf_wait(context, 136ms); + pbf_press_button(context, BUTTON_A, 159ms, 0ms); + pbf_wait(context, 1598ms); + pbf_press_button(context, BUTTON_A, 244ms, 0ms); + pbf_wait(context, 148ms); + pbf_press_button(context, BUTTON_A, 170ms, 0ms); + pbf_wait(context, 587ms); + pbf_press_button(context, BUTTON_A, 211ms, 0ms); + pbf_wait(context, 135ms); + pbf_press_button(context, BUTTON_A, 160ms, 0ms); + pbf_wait(context, 321ms); + pbf_press_button(context, BUTTON_A, 211ms, 0ms); + pbf_wait(context, 205ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + pbf_wait(context, 672ms); + pbf_press_button(context, BUTTON_A, 182ms, 0ms); + pbf_wait(context, 122ms); + pbf_press_button(context, BUTTON_A, 172ms, 0ms); + pbf_wait(context, 362ms); + pbf_press_button(context, BUTTON_A, 172ms, 0ms); + pbf_wait(context, 373ms); + pbf_press_button(context, BUTTON_A, 199ms, 0ms); + pbf_wait(context, 150ms); + pbf_press_button(context, BUTTON_A, 183ms, 0ms); + pbf_wait(context, 190ms); + pbf_press_button(context, BUTTON_A, 188ms, 0ms); + pbf_wait(context, 213ms); + pbf_press_button(context, BUTTON_A, 143ms, 0ms); + pbf_wait(context, 7001ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 999ms, 0ms); + pbf_wait(context, 268ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 375ms, 0ms); + pbf_wait(context, 227ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 741ms, 0ms); + pbf_wait(context, 46ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 743ms, 0ms); + pbf_wait(context, 68ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 265ms, 0ms); + pbf_wait(context, 255ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 890ms, 0ms); + pbf_wait(context, 88ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 462ms, 0ms); + pbf_wait(context, 271ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 647ms, 0ms); + pbf_wait(context, 20ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1047ms, 0ms); + pbf_wait(context, 56ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 776ms, 0ms); + pbf_wait(context, 2038ms); + context.wait_for_all_requests(); +} +void coronet_to_heartholme(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + // The expiry routine starts west. Step east first to leave room for + // its west/east pairs, then clear the old Repel before route movement. + context.wait_for_all_requests(); + pbf_wait(context, 250ms); + pbf_move_left_joystick(context, {1, 0}, 1000ms, 0ms); + pbf_wait(context, 250ms); + context.wait_for_all_requests(); + // Leave Repel off during the potentially long Hiker sight-line sweeps. + force_repel_expiry(stream, context, RepelRenewalChoice::NO, false, true); + + // Either Hiker may interrupt the crossing. Resume toward the east + // wall after each fight. The wiggles are one encounter-triggering + // attempt: once started, do not restart or finish them after an + // interruption. Keep the resumed east approach watched for a later Hiker. + bool bridge_approach_clear = false, bridge_wiggle_started = false; + size_t bridge_battles = 0, approach_dialogues = 0; + for (size_t pass = 0; pass < 6 && !bridge_approach_clear; pass++){ + ShortDialogWatcher dialogue; + StartBattleDetector starting(stream.overlay()); + BattleMenuWatcher menu(BattleType::TRAINER); + const int engaged = run_until(stream, + context, + [&](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {1, 0}, 4828ms, 104ms); + ctx.wait_for_all_requests(); + pbf_wait(ctx, 3000ms); + ctx.wait_for_all_requests(); + if (!bridge_wiggle_started){ + // Mark before the first input: an encounter during + // this set fulfills its purpose and consumes the set. + bridge_wiggle_started = true; + for (size_t pair = 0; pair < 8; pair++){ + pbf_move_left_joystick(ctx, {0, 1}, 500ms, 150ms); + pbf_move_left_joystick(ctx, {0, -1}, 500ms, 150ms); + ctx.wait_for_all_requests(); + } + }else{ + stream.log( + "[Route 208 bridge] Wiggle set already used; continuing watched east approach without repeating it."); + } + pbf_wait(ctx, 2500ms); + }, + {{dialogue}, {starting}, {menu}}); + if (engaged < 0){ + bridge_approach_clear = true; + break; + } + if (engaged == 0){ + // A short box can be a Repel prompt. Advance it with A (the + // default renewal choice) while watching for actual combat. + // Never send a nonbattle choice arrow to the battle engine. + StartBattleDetector dialogue_starting(stream.overlay()); + BattleMenuWatcher dialogue_menu(BattleType::TRAINER); + bool dialogue_cleared = false; + const int battle_started = run_until(stream, + context, + [&](ProControllerContext& ctx){ + mash_until_dialogue_ends(stream, ctx, BUTTON_A, 20s); + ctx.wait_for_all_requests(); + pbf_wait(ctx, 3000ms); + ctx.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + OverworldDetector overworld; + ShortDialogDetector short_dialogue; + dialogue_cleared = frame && overworld.detect(*frame.frame) && !short_dialogue.detect(*frame.frame); + }, + {{dialogue_starting}, {dialogue_menu}}); + if (battle_started < 0){ + if (!dialogue_cleared || ++approach_dialogues > 2){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 208 bridge: Approach dialogue did not clear within bounded recovery.", + stream); + } + stream.log("[Route 208 bridge] Nonbattle dialogue cleared; resuming watched crossing."); + continue; + } + } + fight_trainer_using_moves(stream, context, stats, "Route 208 Hiker", false); + if (++bridge_battles > 2){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 208 bridge: Unexpected additional Hiker battle.", + stream); + } + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + } + if (!bridge_approach_clear){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 208 bridge: Watched east approach did not clear.", + stream); + } + stream.log("[Route 208 bridge] Full east approach clear after " + std::to_string(bridge_battles) + + " battle(s); continuing toward the stairs.", + COLOR_GREEN); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 421ms, 0ms); + pbf_wait(context, 178ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1436ms, 0ms); + pbf_wait(context, 81ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1197ms, 0ms); + pbf_wait(context, 298ms); + // Fixed eastward travel can stop beyond the stair column. Center above + // the stairs using the native left-rock pose before moving south. + bool rock_acquisition_timed_out = false; + align_to_overworld_landmarks(stream, + context, + match_route208_stair_top, + "Route 208 stair-top rocks", + false, + true, + false, + nullptr, + nullptr, + 0, + 0, + 0, + &rock_acquisition_timed_out); + if (!activate_repel(stream, context, 5)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 208 stair top: Repel activation failed.", + stream); + } + context.wait_for_all_requests(); + if (rock_acquisition_timed_out){ + stream.log("[Route 208 stair fallback] Using east 2000ms, southwest 1500ms, then south trial 1650ms."); + // Keep the recorded inputs explicit for review and calibration. + // Watch this whole recovery phase; cancel its remaining inputs if + // a transition or battle starts before the gate approach. + BlackScreenWatcher fallback_black; + WhiteScreenWatcher fallback_white; + BattleMenuWatcher fallback_battle(BattleType::TRAINER); + const int fallback_interrupted = run_until(stream, + context, + [&](ProControllerContext& ctx){ + // East: clamp against the cliff edge. + pbf_move_left_joystick(ctx, {1, 0}, 2000ms, 0ms); + ctx.wait_for_all_requests(); + // Southwest: drag toward the stair column. + pbf_move_left_joystick(ctx, {-1, -1}, 1500ms, 0ms); + ctx.wait_for_all_requests(); + // South: user-requested 1650ms trial; recovery may start farther down the stairs. + pbf_move_left_joystick(ctx, {0, -1}, 1650ms, 0ms); + pbf_wait(ctx, 643ms); + ctx.wait_for_all_requests(); + segment03_align_lower_stair_lane(stream, ctx); + }, + {{fallback_black}, {fallback_white}, {fallback_battle}}); + if (fallback_interrupted >= 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 208 stair fallback: Unexpected transition or battle before west gate approach.", + stream); + } + }else{ + // Pass just above the honey tree, between the southern trainer + // and Hannah to the north. Calibrate this 1825ms trial from video. + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1825ms, 0ms); + pbf_wait(context, 643ms); + } + // Entry may occur during an earlier approach leg. Once a fade is + // caught, skip the remaining outdoor approach rather than replaying it + // inside the gatehouse. The existing watcher cancels at black/white, + // waits for GONE500ms, then settles for2s before the next input. + bool hearthome_gate_entered = + segment03_cycling_move(stream, context, stats, 1, 0, 3500ms, 443ms, "Hearthome west gate approach"); + if (!hearthome_gate_entered){ + hearthome_gate_entered = + segment03_cycling_move(stream, context, stats, 0, 1, 250ms, 405ms, "Hearthome west gate doorway alignment"); + } + if (!hearthome_gate_entered){ + hearthome_gate_entered = + segment03_cycling_move(stream, context, stats, 1, 0, 600ms, 1617ms, "Hearthome west gate entrance", true); + } + if (!hearthome_gate_entered){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome west gate: Interior transition not confirmed.", + stream); + } + if (!segment03_cycling_move(stream, context, stats, 1, 0, 1800ms, 1588ms, "Hearthome west gate exit", true)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome west gate: Exterior transition not confirmed.", + stream); + } + OverworldWatcher hearthome_outside; + if (wait_until(stream, context, 10s, {{hearthome_outside}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome west gate: Overworld not ready after exit.", + stream); + } + if (segment03_cycling_move(stream, context, stats, 1, 0, 2200ms, 6189ms, "Hearthome Buneary approach")){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Buneary approach: Unexpected extra transition.", + stream); + } + // Begin the scene deadline after the approach requests finish, not + // while the recorded movement and neutral waits remain queued. + context.wait_for_all_requests(); + ShortDialogWatcher buneary_dialogue; + if (wait_until(stream, context, 15s, {{buneary_dialogue}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Buneary: Dialogue did not start.", + stream); + } + bool buneary_cleared = false; + for (size_t pass = 0; pass < 6 && !buneary_cleared; pass++){ + mash_until_dialogue_ends(stream, context, BUTTON_A, 60s); + context.wait_for_all_requests(); + ShortDialogWatcher resumed_dialogue; + if (wait_until(stream, context, 2000ms, {{resumed_dialogue}}) == 0){ + continue; + } + // Let the NPC finish walking away before any expiration movement. + pbf_wait(context, 6000ms); + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + OverworldDetector overworld; + ShortDialogDetector dialogue; + buneary_cleared = frame && overworld.detect(*frame.frame) && !dialogue.detect(*frame.frame); + } + if (!buneary_cleared){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Buneary: Scene did not clear.", + stream); + } + stream.log("[Hearthome Buneary] Scene cleared; expiring Repel without renewal before Center travel.", COLOR_GREEN); + pbf_move_left_joystick(context, {1, 0}, 500ms, 0ms); + context.wait_for_all_requests(); + force_repel_expiry(stream, context, RepelRenewalChoice::NO, false, true); + // Use the same whole-approach watcher as the Eterna bicycle shop. + // A doorway reached during recorded travel or alignment cancels all + // remaining outdoor inputs; the room must then be confirmed visually. + bool center_entered = false; + BlackScreenWatcher center_transition; + const int center_entry = run_until(stream, + context, + [&](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {1.000000, -0.000000}, 3350ms, 0ms); + pbf_wait(ctx, 437ms); + // Clear the northbound lamp column before crossing the plaza. + pbf_move_left_joystick(ctx, {-1.000000, -0.000000}, 250ms, 0ms); + pbf_move_left_joystick(ctx, {0.000000, 1.000000}, 3000ms, 0ms); + pbf_wait(ctx, 1582ms); + pbf_move_left_joystick(ctx, {-1.000000, -0.000000}, 1150ms, 0ms); + pbf_wait(ctx, 334ms); + // Repel expired before this travel; no blind response inputs remain. + pbf_move_left_joystick(ctx, {-1.000000, -0.000000}, 2250ms, 0ms); + pbf_wait(ctx, 304ms); + pbf_move_left_joystick(ctx, {0.000000, -1.000000}, 300ms, 0ms); + pbf_wait(ctx, 174ms); + pbf_move_left_joystick(ctx, {-1.000000, -0.000000}, 500ms, 0ms); + pbf_move_left_joystick(ctx, {-1.000000, 1.000000}, 100ms, 0ms); + pbf_move_left_joystick(ctx, {0.000000, 1.000000}, 1800ms, 0ms); + pbf_wait(ctx, 21ms); + pbf_wait(ctx, 221ms); + pbf_move_left_joystick(ctx, {-1.000000, -0.000000}, 500ms, 0ms); + pbf_move_left_joystick(ctx, {0.000000, 1.000000}, 250ms, 0ms); + pbf_wait(ctx, 59ms); + align_to_overworld_landmarks(stream, + ctx, + match_hearthome_center_entrance, + "Hearthome Center exterior alignment", + false, + true, + false, + match_hearthome_center_interior, + ¢er_entered); + if (!center_entered){ + pbf_move_left_joystick(ctx, {0, 1}, 4000ms, 0ms); + pbf_wait(ctx, 3000ms); + } + }, + {{center_transition}}); + if (center_entry != 0 && !center_entered){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Center: Entry transition not detected.", + stream); + } + segment03_finish_transition(stream, context, "Hearthome Center entrance"); + pbf_wait(context, 1500ms); + context.wait_for_all_requests(); + segment03_confirm_location(stream, context, match_hearthome_center_interior, "Hearthome Center interior"); + if (!heal_pokemon(stream, context, "Hearthome", &stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Center exit not confirmed.", + stream); + } + // Let the exit scene and camera settle before spending the Hall travel + // budget. The old east hold began while the exit was still fading in. + segment03_finish_transition(stream, context, "Hearthome Center heal exit"); + pbf_wait(context, 1500ms); + context.wait_for_all_requests(); + OverworldWatcher center_exit_ready; + if (wait_until(stream, context, 10s, {{center_exit_ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Center: Exterior not ready after healing.", + stream); + } + pbf_move_left_joystick(context, {1, 0}, 2092ms, 185ms); + // Bring the Hall into view without the old 1004ms north overshoot. + pbf_move_left_joystick(context, {0, 1}, 600ms, 500ms); + context.wait_for_all_requests(); + align_to_overworld_landmarks(stream, + context, + match_hearthome_contest_fantina, + "Hearthome Contest Hall Fantina", + false, + true); + // Repeat the existing short Up/A interaction within the 15s window. + // Dialogue cancels the remaining inputs before any text cleanup. + ShortDialogWatcher fantina_dialogue; + BlackScreenWatcher fantina_black; + WhiteScreenWatcher fantina_white; + const int fantina_started = run_until(stream, + context, + [](ProControllerContext& ctx){ + constexpr auto pulse = 16ms + 100ms + 194ms + 277ms; + Milliseconds elapsed = 0ms; + for (; elapsed + pulse <= 15s; elapsed += pulse){ + pbf_move_left_joystick(ctx, {0, 1}, 16ms, 100ms); + pbf_press_button(ctx, BUTTON_A, 194ms, 277ms); + ctx.wait_for_all_requests(); + } + pbf_wait(ctx, 15s - elapsed); + }, + {{fantina_dialogue}, {fantina_black}, {fantina_white}}); + if (fantina_started > 0){ + // Fantina's opening camera cut fades to black before the text box. + // The watcher has cancelled Up/A; wait without further movement. + stream.log("[Hearthome Fantina] Opening fade detected; waiting for dialogue."); + segment03_finish_transition(stream, context, "Hearthome Fantina opening"); + ShortDialogWatcher opening_dialogue; + if (wait_until(stream, context, 15s, {{opening_dialogue}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Fantina: Opening fade was not followed by dialogue.", + stream); + } + }else if (fantina_started != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Contest Hall: Fantina dialogue did not start.", + stream); + } + clear_post_battle_dialogue(stream, context, BUTTON_B); + pbf_wait(context, 2000ms); + clear_post_battle_dialogue(stream, context, BUTTON_B); + // Keep Hall entry separate from Fantina's outdoor conversation. + segment03_cycling_move(stream, context, stats, 0, 1, 1179ms, 4268ms, "Hearthome Contest Hall entrance", true); + pbf_press_button(context, BUTTON_A, 181ms, 219ms); + context.wait_for_all_requests(); + ShortDialogWatcher contest_dialogue; + if (wait_until(stream, context, 15s, {{contest_dialogue}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Contest Hall: Interior dialogue did not start.", + stream); + } + // Use Buneary's resumed-text and scene-clear checks, with B to clear. + bool contest_cleared = false; + for (size_t pass = 0; pass < 6 && !contest_cleared; pass++){ + mash_until_dialogue_ends(stream, context, BUTTON_B, 60s); + context.wait_for_all_requests(); + ShortDialogWatcher resumed_dialogue; + if (wait_until(stream, context, 2000ms, {{resumed_dialogue}}) == 0){ + continue; + } + pbf_wait(context, 6000ms); + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + OverworldDetector overworld; + ShortDialogDetector dialogue; + contest_cleared = frame && overworld.detect(*frame.frame) && !dialogue.detect(*frame.frame); + } + if (!contest_cleared){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Contest Hall: Interior scene did not clear.", + stream); + } + // Own the exit before handing off the checkpoint. The transition watcher + // cancels southward movement at the fade, leaving the fixed outdoor spawn. + if (!segment03_cycling_move(stream, context, stats, 0, -1, 903ms, 1455ms, "Hearthome Contest Hall exit", true)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Contest Hall: Exit transition not confirmed.", + stream); + } + OverworldWatcher hall_outside; + if (wait_until(stream, context, 10s, {{hall_outside}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Contest Hall: Overworld not ready after exit.", + stream); + } + // The approach landmark view is not the post-exit doorway view. + // The watched south exit plus restored overworld completes this boundary. + stream.log("[Hearthome Contest Hall] Checkpoint complete: outside doorway, facing south."); + // End 03.04 / start 03.05: just outside the Hall, south-facing, Repel off. +} + +void heartholme_to_lost_tower(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + OverworldWatcher hall_start; + if (wait_until(stream, context, 10s, {{hall_start}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Contest Hall checkpoint start: Overworld not ready.", + stream); + } + // butters' recorded Contest Hall-to-rival route. + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1294ms, 0ms); + pbf_wait(context, 132ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 3255ms, 0ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 2666ms, 0ms); + pbf_wait(context, 62ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2068ms, 0ms); + pbf_wait(context, 456ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1065ms, 0ms); + fight_trainer_using_moves(stream, context, stats, "Barry in Hearthome", true); + // butters' recorded rival-to-Lost Tower route. + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1088ms, 0ms); + pbf_wait(context, 95ms); + context.wait_for_all_requests(); + // Cancel at each gate fade, then allow a defined settling wait. + BlackScreenWatcher east_gate_entry; + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {1, 0}, 3446ms, 0ms); + pbf_wait(ctx, 983ms); + }, + {{east_gate_entry}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome east gate: Entrance transition did not start.", + stream); + } + BlackScreenWatcher east_gate_entry_finished(COLOR_RED, + {.1, .1, .8, .8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + if (wait_until(stream, context, 30s, {{east_gate_entry_finished}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome east gate: Entrance transition did not finish.", + stream); + } + pbf_wait(context, 983ms); + context.wait_for_all_requests(); + + BlackScreenWatcher east_gate_exit; + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {1, 0}, 2095ms, 0ms); + pbf_wait(ctx, 1660ms); + }, + {{east_gate_exit}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome east gate: Exit transition did not start.", + stream); + } + BlackScreenWatcher east_gate_exit_finished(COLOR_RED, + {.1, .1, .8, .8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + if (wait_until(stream, context, 30s, {{east_gate_exit_finished}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome east gate: Exit transition did not finish.", + stream); + } + pbf_wait(context, 1660ms); + context.wait_for_all_requests(); + stream.log("[Hearthome east gate] Entry and exit transitions finished.", COLOR_GREEN); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1128ms, 0ms); + pbf_wait(context, 7266ms); + // Use the existing Bag helper instead of replaying recorded menu inputs. + if (!activate_repel(stream, context, 8, 0)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 209: Repel activation failed.", + stream); + } + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1412ms, 0ms); + pbf_wait(context, 77ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1878ms, 0ms); + pbf_wait(context, 100ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1246ms, 0ms); + pbf_wait(context, 522ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 3110ms, 0ms); + pbf_wait(context, 322ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1245ms, 0ms); + pbf_wait(context, 89ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 3634ms, 0ms); + pbf_wait(context, 966ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 2378ms, 0ms); + pbf_wait(context, 310ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 612ms, 0ms); + pbf_wait(context, 1001ms); + context.wait_for_all_requests(); + // butters' marked road pose: sign frame and rock pair. + align_to_overworld_landmarks(stream, + context, + match_route209_repel_point, + "Route 209 Repel point before expiry", + false, + true); + force_repel_expiry(stream, context, RepelRenewalChoice::YES, false, true); + align_to_overworld_landmarks(stream, + context, + match_route209_repel_point, + "Route 209 Repel point after renewal", + false, + true); + + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1345ms, 0ms); + pbf_wait(context, 309ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 789ms, 0ms); + pbf_wait(context, 456ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1100ms, 0ms); + pbf_wait(context, 122ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1040ms, 0ms); + pbf_wait(context, 93ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1157ms, 0ms); + pbf_wait(context, 488ms); + // Repel was renewed at the marked road pose; no delayed prompt inputs. + pbf_move_left_joystick(context, {0.000000, 1.000000}, 835ms, 0ms); + pbf_wait(context, 549ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1451ms, 0ms); + pbf_wait(context, 901ms); + context.wait_for_all_requests(); + // Normalize the checkpoint boundary with a complete enter/exit cycle. + // Cancel movement at each fade so it cannot continue onto the next map. + BlackScreenWatcher tower_entry; + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, 1}, 1311ms, 0ms); + pbf_wait(ctx, 3000ms); + }, + {{tower_entry}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower handoff: Entry transition did not start.", + stream); + } + BlackScreenWatcher tower_entry_finished(COLOR_RED, + {.1, .1, .8, .8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + if (wait_until(stream, context, 30s, {{tower_entry_finished}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower handoff: Entry transition did not finish.", + stream); + } + OverworldWatcher tower_inside; + if (wait_until(stream, context, 10s, {{tower_inside}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower handoff: Indoor overworld not ready.", + stream); + } + + BlackScreenWatcher tower_exit; + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, -1}, 1311ms, 0ms); + pbf_wait(ctx, 3000ms); + }, + {{tower_exit}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower handoff: Exit transition did not start.", + stream); + } + BlackScreenWatcher tower_exit_finished(COLOR_RED, + {.1, .1, .8, .8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + if (wait_until(stream, context, 30s, {{tower_exit_finished}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower handoff: Exit transition did not finish.", + stream); + } + OverworldWatcher tower_outside; + if (wait_until(stream, context, 10s, {{tower_outside}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower handoff: Outdoor overworld not ready.", + stream); + } + context.wait_for_all_requests(); + stream.log("[Lost Tower handoff] Outside doorway, facing south; checkpoint complete.", COLOR_GREEN); +} +void lost_tower(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + // Checkpoint 03.06 starts outside the doorway, facing south, with Repel active. + // Turn north, then enter using the same single-step timing as Segment 02. + context.wait_for_all_requests(); + BlackScreenWatcher entering_tower; + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + for (int i = 0; i < 2; i++){ + pbf_press_dpad(ctx, DPAD_UP, 80ms, 300ms); + } + pbf_wait(ctx, 2389ms); + }, + {{entering_tower}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower entry: Transition did not start.", + stream); + } + BlackScreenWatcher tower_entry_finished(COLOR_RED, + {.1, .1, .8, .8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + if (wait_until(stream, context, 30s, {{tower_entry_finished}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower entry: Transition did not finish.", + stream); + } + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + OverworldWatcher tower_entry_ready; + if (wait_until(stream, context, 10s, {{tower_entry_ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower entry: Overworld not ready.", + stream); + } + + // butters' route, using the same counted D-pad steps as the Eterna Gym. + // Counts include one facing press whenever the commanded direction changes. + // Keep the same direction across a dialogue stop without another facing press. + struct Move { + DpadPosition dir; + int count; + }; + const Move to_floor2[] = { + {DPAD_UP, 4}, + {DPAD_LEFT, 4}, + {DPAD_UP, 6}, + {DPAD_LEFT, 2}, + {DPAD_UP, 3}, + {DPAD_RIGHT, 2}, + }; + BlackScreenWatcher stairs_to_floor2; + if (run_until(stream, + context, + [&](ProControllerContext& ctx){ + for (const auto& move : to_floor2){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(ctx, move.dir, 80ms, 300ms); + } + } + // Keep watching if the last step starts a delayed stair fade. + pbf_wait(ctx, 3000ms); + }, + {{stairs_to_floor2}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower floor 2: Stair transition did not start.", + stream); + } + segment03_finish_transition(stream, context, "Lost Tower floor 2"); + OverworldWatcher floor2_ready; + if (wait_until(stream, context, 10s, {{floor2_ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower floor 2: Overworld not ready after stairs.", + stream); + } + + const Move to_floor3[] = { + {DPAD_DOWN, 12}, + {DPAD_LEFT, 6}, + {DPAD_UP, 2}, + // Includes the extra left step to clear the gravestone column. + {DPAD_LEFT, 8}, + {DPAD_UP, 8}, + {DPAD_RIGHT, 2}, + {DPAD_UP, 4}, + {DPAD_RIGHT, 4}, + }; + BlackScreenWatcher stairs_to_floor3; + if (run_until(stream, + context, + [&](ProControllerContext& ctx){ + for (const auto& move : to_floor3){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(ctx, move.dir, 80ms, 300ms); + } + } + // Keep watching if the last step starts a delayed stair fade. + pbf_wait(ctx, 3000ms); + }, + {{stairs_to_floor3}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower floor 3: Stair transition did not start.", + stream); + } + segment03_finish_transition(stream, context, "Lost Tower floor 3"); + OverworldWatcher floor3_ready; + if (wait_until(stream, context, 10s, {{floor3_ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower floor 3: Overworld not ready after stairs.", + stream); + } + + const Move to_repel[] = { + {DPAD_DOWN, 6}, + }; + bool repel_renewed = false; + ShortDialogDetector pending_repel_dialogue(COLOR_YELLOW); + for (const auto& move : to_repel){ + for (int i = 0; i < move.count; i++){ + // Never queue another step while an expiry prompt is already visible. + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower Repel: No frame before the next down step.", + stream); + } + if (pending_repel_dialogue.detect(*frame.frame)){ + stream.log("[Lost Tower Repel] Pending renewal before down press " + std::to_string(i + 1) + + "; clearing before movement."); + clear_post_battle_dialogue(stream, context, BUTTON_A); + repel_renewed = true; + } + + // Watch only one press at a time. Its 80ms hold finishes before the + // fresh dialogue watcher's 250ms confirmation can cancel the rest. + // The triggering step counts; all subsequent steps remain unqueued. + ShortDialogWatcher step_repel_dialogue(COLOR_YELLOW); + const int expiry = run_until(stream, + context, + [&](ProControllerContext& ctx){ + pbf_press_dpad(ctx, move.dir, 80ms, 300ms); + ctx.wait_for_all_requests(); + pbf_wait(ctx, 500ms); + }, + {{step_repel_dialogue}}); + if (expiry == 0){ + stream.log("[Lost Tower Repel] Renewal after down press " + std::to_string(i + 1) + "/" + + std::to_string(move.count) + "; clearing before the remaining steps."); + clear_post_battle_dialogue(stream, context, BUTTON_A); + repel_renewed = true; + } + context.wait_for_all_requests(); + } + } + // Preserve the bounded final observation if expiry arrives after the last step. + if (!repel_renewed){ + ShortDialogWatcher late_repel_dialogue(COLOR_YELLOW); + if (wait_until(stream, context, 3s, {{late_repel_dialogue}}) == 0){ + stream.log("[Lost Tower Repel] Late renewal dialogue detected; accepting renewal."); + clear_post_battle_dialogue(stream, context, BUTTON_A); + }else{ + stream.log("[Lost Tower Repel] No renewal dialogue at the recorded stop; continuing."); + } + } + + const Move to_biker[] = { + {DPAD_DOWN, 1}, + {DPAD_RIGHT, 2}, + {DPAD_DOWN, 3}, + {DPAD_LEFT, 2}, + {DPAD_DOWN, 6}, + // Clear the gravestone column before beginning the watched up step. + {DPAD_LEFT, 5}, + {DPAD_UP, 2}, + }; + for (const auto& move : to_biker){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + } + } + context.wait_for_all_requests(); + // The final up movement is one facing press and one movement press. + // Stop at a trainer prompt before any later path segment is queued. + constexpr int BIKER_UP_PRESSES = 2; + int biker_up_completed = 0; + // A trainer's walk-up can lock movement before the dialogue box appears. + ShortDialogWatcher corner_biker_dialogue(COLOR_YELLOW); + bool biker_challenged = wait_until(stream, context, 5s, {{corner_biker_dialogue}}) == 0; + ShortDialogDetector pending_biker_dialogue(COLOR_YELLOW); + while (!biker_challenged && biker_up_completed < BIKER_UP_PRESSES){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower biker: No frame before the next up press.", + stream); + } + if (pending_biker_dialogue.detect(*frame.frame)){ + biker_challenged = true; + break; + } + + // A fresh 250ms dialogue watch cannot cancel the 80ms hold itself. + // Count that press, then preserve any subsequent unqueued presses. + ShortDialogWatcher approaching_biker(COLOR_YELLOW); + const int challenge = run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_press_dpad(ctx, DPAD_UP, 80ms, 300ms); + ctx.wait_for_all_requests(); + // Observe the walk-up before another up press can be queued. + pbf_wait(ctx, 5000ms); + }, + {{approaching_biker}}); + biker_up_completed++; + if (challenge == 0){ + biker_challenged = true; + break; + } + context.wait_for_all_requests(); + } + if (biker_challenged){ + stream.log("[Lost Tower biker] Dialogue detected after " + std::to_string(biker_up_completed) + + " up presses; handling the trainer battle."); + fight_trainer_using_moves(stream, context, stats, "Lost Tower biker", false); + clear_post_battle_dialogue(stream, context, BUTTON_B); + + // The trainer conversation turns the player toward the biker. + // Restore north if its facing press was already used before the battle. + if (biker_up_completed > 0){ + pbf_press_dpad(context, DPAD_UP, 80ms, 300ms); + context.wait_for_all_requests(); + } + for (; biker_up_completed < BIKER_UP_PRESSES; biker_up_completed++){ + pbf_press_dpad(context, DPAD_UP, 80ms, 300ms); + } + context.wait_for_all_requests(); + stream.log("[Lost Tower biker] North-facing approach complete; resuming the recorded path."); + }else{ + stream.log("[Lost Tower biker] No dialogue; continuing the recorded path."); + } + + const Move to_floor4[] = { + {DPAD_UP, 1}, + {DPAD_LEFT, 2}, + {DPAD_UP, 4}, + {DPAD_LEFT, 6}, + {DPAD_UP, 4}, + {DPAD_RIGHT, 3}, + {DPAD_UP, 4}, + {DPAD_RIGHT, 3}, + }; + BlackScreenWatcher stairs_to_floor4; + if (run_until(stream, + context, + [&](ProControllerContext& ctx){ + for (const auto& move : to_floor4){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(ctx, move.dir, 80ms, 300ms); + } + } + // Keep watching if the last step starts a delayed stair fade. + pbf_wait(ctx, 3000ms); + }, + {{stairs_to_floor4}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower floor 4: Stair transition did not start.", + stream); + } + segment03_finish_transition(stream, context, "Lost Tower floor 4"); + OverworldWatcher floor4_ready; + if (wait_until(stream, context, 10s, {{floor4_ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower floor 4: Overworld not ready after stairs.", + stream); + } + + const Move to_floor5[] = { + {DPAD_DOWN, 6}, + {DPAD_LEFT, 8}, + {DPAD_UP, 3}, + {DPAD_LEFT, 2}, + {DPAD_UP, 4}, + {DPAD_RIGHT, 3}, + }; + BlackScreenWatcher stairs_to_floor5; + if (run_until(stream, + context, + [&](ProControllerContext& ctx){ + for (const auto& move : to_floor5){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(ctx, move.dir, 80ms, 300ms); + } + } + // Keep watching if the last step starts a delayed stair fade. + pbf_wait(ctx, 3000ms); + }, + {{stairs_to_floor5}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower floor 5: Stair transition did not start.", + stream); + } + segment03_finish_transition(stream, context, "Lost Tower floor 5"); + OverworldWatcher floor5_ready; + if (wait_until(stream, context, 10s, {{floor5_ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower floor 5: Overworld not ready after stairs.", + stream); + } + + const Move to_rewards[] = { + {DPAD_DOWN, 4}, + {DPAD_LEFT, 5}, + {DPAD_DOWN, 3}, + }; + for (const auto& move : to_rewards){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + } + } + context.wait_for_all_requests(); + // Use the Segment 01 conversation pattern: start once, then clear with B. + context.wait_for_all_requests(); + pbf_press_button(context, BUTTON_A, 80ms, 100ms); + context.wait_for_all_requests(); + wait_for_dialogue(stream, context, "Lost Tower reward: First box", 30s); + clear_post_battle_dialogue(stream, context, BUTTON_B, 4000ms); + stream.log("[Lost Tower reward] Dialogue absent for four seconds; opening the Bag."); + if (!use_escape_rope(stream, context, 8)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower: Escape Rope exit was not confirmed.", + stream); + } + // The exterior is visible before the Rope arrival camera finishes settling. + // Keep the following south/left movement out of that arrival animation. + pbf_wait(context, 5000ms); + context.wait_for_all_requests(); + OverworldWatcher rope_exit_ready; + if (wait_until(stream, context, 10s, {{rope_exit_ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Lost Tower: Overworld not ready after Escape Rope settling.", + stream); + } + context.wait_for_all_requests(); + stream.log("[Lost Tower] Reward dialogue cleared; Escape Rope exit settled.", COLOR_GREEN); +} + +void lost_tower_to_veilstone_gym(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + // Recorded Tower-to-Gym path; shared helpers handle prompts and battles. + pbf_move_left_joystick(context, {0.000000, -1.000000}, 336ms, 0ms); + pbf_wait(context, 590ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 327ms, 0ms); + pbf_wait(context, 590ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 745ms, 0ms); + pbf_wait(context, 379ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 77ms, 0ms); + pbf_wait(context, 17ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1167ms, 0ms); + pbf_wait(context, 1882ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 512ms, 0ms); + pbf_wait(context, 171ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 500ms, 0ms); + pbf_wait(context, 1767ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1701ms, 0ms); + pbf_press_button(context, BUTTON_PLUS, 181ms, 219ms); + // Stay north/south on the open road beside Lost Tower. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, true, true); + pbf_wait(context, 1767ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 4384ms, 0ms); + pbf_wait(context, 1767ms); + pbf_press_button(context, BUTTON_PLUS, 181ms, 219ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1701ms, 0ms); + pbf_wait(context, 133ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1379ms, 0ms); + pbf_wait(context, 365ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1701ms, 0ms); + pbf_wait(context, 133ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 3179ms, 0ms); + pbf_wait(context, 76ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 3172ms, 0ms); + pbf_wait(context, 28ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 4210ms, 0ms); + pbf_wait(context, 421ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 2327ms, 0ms); + pbf_wait(context, 71ms); + // Expiry can interrupt this eastward leg or the following north turn. + // Renew on the clear strip, then repeat the recorded eastward alignment + // against the trees before taking the uninterrupted north leg. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, false, true); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 2327ms, 0ms); + pbf_wait(context, 71ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1028ms, 0ms); + pbf_wait(context, 120ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 2480ms, 0ms); + pbf_wait(context, 773ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 182ms, 0ms); + pbf_wait(context, 567ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 2514ms, 0ms); + pbf_wait(context, 86ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 180ms, 0ms); + pbf_wait(context, 308ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 137ms, 0ms); + pbf_wait(context, 510ms); + pbf_wait(context, 1767ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 500ms, 0ms); + pbf_wait(context, 510ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 383ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 161ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 756ms, 0ms); + pbf_wait(context, 335ms); + // The clear cliff platform permits west/east renewal. Restore its east + // edge afterward so a partial expiry pulse cannot offset the bridge approach. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, false, true); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 756ms, 0ms); + pbf_wait(context, 335ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 2176ms, 0ms); + pbf_wait(context, 24ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 826ms, 0ms); + pbf_wait(context, 561ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 957ms, 0ms); + pbf_wait(context, 621ms); + context.wait_for_all_requests(); + ShortDialogWatcher calvin_dialogue; + StartBattleDetector calvin_start(stream.overlay()); + BattleMenuWatcher calvin_menu(BattleType::TRAINER); + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {1.000000, 0.000000}, 794ms, 0ms); + pbf_wait(ctx, 15000ms); + }, + {{calvin_dialogue}, {calvin_start}, {calvin_menu}}) < 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 Calvin: Trainer encounter did not start.", + stream); + } + fight_trainer_using_moves(stream, context, stats, "Route 215 Ruin Maniac Calvin", false); + // Renew in the clear area after Calvin, before the next walking strip. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, false, true); + // Cover at most one unfinished 500ms westward expiry pulse. + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1582ms, 0ms); + pbf_wait(context, 406ms); + // The item occupies lane 2. Search only lanes 4 and 3 (numbered from the top). + // Every D-pad press is observed separately: facing changes must not become + // an unverified extra step north. Fixed scenery measures eastward progress. + ShortDialogDetector jogger_dialogue; + BattleMenuDetector jogger_battle(BattleType::TRAINER, COLOR_GREEN); + BlackScreenDetector jogger_transition; + constexpr double lane_pitch = 70.0 / 1080; + constexpr double lane_tolerance = 1.0 / 54; + size_t missing_pose = 0, stale_frames = 0, stalled = 0, lane_changes = 0, lane_presses = 0, arrival_frames = 0, + jogger_battles = 0; + int changing_to = 0; + bool east_pending = false, last_coarse = false, corner_ready = false; + double last_x = 0; + Milliseconds bypass_movement = 0ms; + WallClock fresh_after = current_time(), last_frame_time = WallClock::min(); + auto bypass_started = std::chrono::steady_clock::now(); + for (size_t observation = 0; observation < 120; observation++){ + context.throw_if_cancelled(); + context.wait_for_all_requests(); + if (std::chrono::steady_clock::now() - bypass_started > 90s){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: Lane search timed out.", + stream); + } + const auto frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: No video frame.", + stream); + } + if (frame.timestamp <= fresh_after || frame.timestamp <= last_frame_time){ + if (++stale_frames >= 8){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: Video is not advancing after movement.", + stream); + } + pbf_wait(context, 500ms); + continue; + } + last_frame_time = frame.timestamp; + stale_frames = 0; + if (jogger_dialogue.detect(*frame.frame) || jogger_battle.detect(*frame.frame)){ + if (++jogger_battles > 1){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: Repeated unexpected encounter.", + stream); + } + // The recorded failure is the jogger's trainer introduction. + // Complete it with the shared move-list handler, then measure + // the current lane again; the trainer may have approached us. + fight_trainer_using_moves(stream, context, stats, "Route 215 jogger", false); + context.wait_for_all_requests(); + fresh_after = current_time(); + last_frame_time = WallClock::min(); + missing_pose = 0; + stale_frames = 0; + stalled = 0; + arrival_frames = 0; + changing_to = 0; + lane_presses = 0; + east_pending = false; + bypass_started = std::chrono::steady_clock::now(); + continue; + } + if (jogger_transition.detect(*frame.frame)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: Unexpected transition.", + stream); + } + const auto pose = match_route215_jogger_corner(*frame.frame); + for (const auto& anchor : pose.landmarks){ + stream.log("[Route 215 jogger] " + anchor.name + " score=" + std::to_string(anchor.score) + + ", margin=" + std::to_string(anchor.margin) + ", accepted=" + std::to_string(anchor.accepted) + + ", offset=(" + std::to_string(anchor.dx) + "," + std::to_string(anchor.dy) + ")"); + } + const int lane = pose.located && std::abs(pose.dy) <= lane_tolerance ? 4 + : pose.located && std::abs(pose.dy - lane_pitch) <= lane_tolerance ? 3 + : 0; + stream.log("[Route 215 jogger] lane=" + std::to_string(lane) + ", coarse=" + std::to_string(pose.coarse) + + ", offset=(" + std::to_string(pose.dx) + "," + std::to_string(pose.dy) + ")"); + if (!lane){ + // Wait out rain/clouds or a settling step; never search vertically + // from a missing or ambiguous pose, including after a blocked step. + arrival_frames = 0; + if (++missing_pose >= 8){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: Cannot verify lane 3 or 4.", + stream); + } + pbf_wait(context, 500ms); + continue; + } + missing_pose = 0; + if (east_pending){ + stalled = pose.coarse == last_coarse && last_x - pose.dx < 1.0 / 256 ? stalled + 1 : 0; + east_pending = false; + } + if (changing_to && lane == changing_to){ + changing_to = 0; + lane_presses = 0; + stalled = 0; + } + const bool near_corner = !pose.coarse && std::abs(pose.dx) <= 1.0 / 128; + if (near_corner && lane == 4 && !changing_to){ + if (++arrival_frames < 3){ + pbf_wait(context, 250ms); + continue; + } + force_repel_expiry(stream, context, RepelRenewalChoice::YES, false, true); + // Expiry preserves lane 4. Cover its possible unfinished 500ms + // west pulse, then settle east against the fence before going south. + pbf_move_left_joystick(context, {1.0, 0.0}, 1000ms, 0ms); + context.wait_for_all_requests(); + corner_ready = true; + break; + } + arrival_frames = 0; + if (!changing_to && ((near_corner && lane == 3) || stalled >= 2)){ + if (++lane_changes > 6){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: Both safe lanes remain blocked.", + stream); + } + changing_to = lane == 4 ? 3 : 4; + lane_presses = 0; + stalled = 0; + pbf_wait(context, 750ms); + continue; + } + const auto movement_limit = 8000ms; + if (changing_to){ + if (lane_presses >= 2){ + // A blocked lane change leaves us in the observed lane. + // Wait for the jogger, then try east again without assuming a step. + changing_to = 0; + lane_presses = 0; + pbf_wait(context, 750ms); + continue; + } + if (bypass_movement + 80ms > movement_limit){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: Movement budget exhausted.", + stream); + } + pbf_press_dpad(context, changing_to == 3 ? DPAD_UP : DPAD_DOWN, 80ms, 300ms); + context.wait_for_all_requests(); + fresh_after = current_time(); + bypass_movement += 80ms; + ++lane_presses; + continue; + } + if (!pose.coarse && pose.dx < -1.0 / 32){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: Passed the expected east corner.", + stream); + } + const auto duration = !pose.coarse && std::abs(pose.dx) <= 1.0 / 24 ? 40ms : 160ms; + if (bypass_movement + duration > movement_limit){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: Movement budget exhausted.", + stream); + } + last_x = pose.dx; + last_coarse = pose.coarse; + // Only a small westward correction is allowed at the verified corner. + pbf_move_left_joystick(context, {!pose.coarse && pose.dx < 0 ? -1.0 : 1.0, 0.0}, duration, 0ms); + pbf_wait(context, 400ms); + context.wait_for_all_requests(); + fresh_after = current_time(); + bypass_movement += duration; + east_pending = true; + } + if (!corner_ready){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 jogger: East corner not confirmed.", + stream); + } + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1682ms, 0ms); + pbf_wait(context, 501ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1843ms, 0ms); + pbf_wait(context, 55ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 2431ms, 0ms); + pbf_wait(context, 723ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2075ms, 0ms); + pbf_wait(context, 243ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 500ms, 0ms); + pbf_wait(context, 75ms); + // The corner-to-bridge leg stays below the regular Repel movement budget. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, false, true); + pbf_wait(context, 1767ms); + // Expiry stays on the clear upper strip. The recorded east crossing + // takes 2392ms; 3000ms also covers an unfinished 500ms west expiry pulse. + // The eastern wall fixes the starting column for Derek's south approach. + pbf_move_left_joystick(context, {1.0, 0.0}, 3000ms, 0ms); + pbf_wait(context, 43ms); + context.wait_for_all_requests(); + ShortDialogWatcher derek_dialogue; + StartBattleDetector derek_start(stream.overlay()); + BattleMenuWatcher derek_menu(BattleType::TRAINER); + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + // The uninterrupted 1000ms approach ends one step into grass. + // Stop one recorded step earlier on Derek's sightline while + // the existing watchers wait for him to turn toward the player. + pbf_move_left_joystick(ctx, {0.000000, -1.000000}, 850ms, 0ms); + pbf_wait(ctx, 15000ms); + }, + {{derek_dialogue}, {derek_start}, {derek_menu}}) < 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 Derek: Trainer encounter did not start.", + stream); + } + fight_trainer_using_moves(stream, context, stats, "Route 215 Black Belt Derek", false); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1929ms, 0ms); + pbf_wait(context, 5ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 2195ms, 0ms); + pbf_wait(context, 88ms); + // Renew on the clear landing below the stairs, then restore its column. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, false, true); + align_to_overworld_landmarks(stream, + context, + match_route215_derek_stairs_renewal, + "Route 215 below Derek stairs after renewal", + false, + true); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 501ms, 0ms); + pbf_wait(context, 390ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 954ms, 0ms); + pbf_wait(context, 310ms); + pbf_move_left_joystick(context, {0.000000, 1.000000}, 1148ms, 0ms); + pbf_wait(context, 453ms); + // Stay west/east on the clear road row; south would enter tall grass. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, false, true); + align_to_overworld_landmarks(stream, + context, + match_route215_derek_stair_top_renewal, + "Route 215 above Derek stairs after renewal", + false, + true); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 1855ms, 0ms); + pbf_wait(context, 171ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1385ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 2478ms, 0ms); + pbf_wait(context, 355ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 580ms, 0ms); + pbf_wait(context, 392ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 978ms, 0ms); + pbf_wait(context, 201ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1159ms, 0ms); + pbf_wait(context, 122ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 535ms, 0ms); + pbf_wait(context, 234ms); + // West/east follows the clear strip south of the grass before the Ace Trainers. + force_repel_expiry(stream, context, RepelRenewalChoice::YES, false, true); + pbf_wait(context, 1767ms); + // Resume from the road/fence pose shown at renewal in the recording. + // A fixed leftover west input compounds the variable expiry offset. + align_to_overworld_landmarks(stream, + context, + match_route215_ace_renewal, + "Route 215 before Ace Trainers after renewal", + false, + true); + context.wait_for_all_requests(); + ShortDialogWatcher dennis_dialogue; + StartBattleDetector dennis_start(stream.overlay()); + BattleMenuWatcher dennis_menu(BattleType::TRAINER); + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0.000000, -1.000000}, 1323ms, 0ms); + pbf_wait(ctx, 106ms); + pbf_move_left_joystick(ctx, {1.000000, 0.000000}, 834ms, 0ms); + pbf_wait(ctx, 206ms); + pbf_move_left_joystick(ctx, {0.000000, 1.000000}, 221ms, 0ms); + pbf_wait(ctx, 516ms); + pbf_move_left_joystick(ctx, {1.000000, 0.000000}, 667ms, 0ms); + pbf_wait(ctx, 15000ms); + }, + {{dennis_dialogue}, {dennis_start}, {dennis_menu}}) < 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Route 215 Dennis: Trainer encounter did not start.", + stream); + } + // Dennis and Maya are separate consecutive battles. + fight_trainer_using_moves(stream, context, stats, "Route 215 Ace Trainer Dennis", false); + fight_trainer_using_moves(stream, context, stats, "Route 215 Ace Trainer Maya", false); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 10884ms, 0ms); + pbf_wait(context, 48ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 2314ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -1.000000}, 26ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -0.000000}, 2251ms, 0ms); + pbf_move_left_joystick(context, {1.000000, -1.000000}, 27ms, 0ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 1382ms, 0ms); + pbf_wait(context, 29ms); + pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1035ms, 0ms); + pbf_move_left_joystick(context, {-1.000000, -1.000000}, 75ms, 0ms); + pbf_move_left_joystick(context, {0.000000, -1.000000}, 2190ms, 0ms); + // Veilstone needs no Repel. Clear the remaining expiry without renewal + // on the paved north/south strip before normalizing the city approach. + force_repel_expiry(stream, context, RepelRenewalChoice::NO, true); + align_to_overworld_landmarks(stream, + context, + match_veilstone_city_passage, + "Veilstone passage after expiry", + false, + true, + true); + // Replay the successful street approach, then correct onto the western + // stair lane before climbing. Do not use the NPC pair as a wall stop. + pbf_move_left_joystick(context, {0.0, -1.0}, 1342ms, 0ms); + pbf_move_left_joystick(context, {-1.0, 0.0}, 1404ms, 0ms); + pbf_move_left_joystick(context, {0.0, -1.0}, 368ms, 0ms); + pbf_move_left_joystick(context, {-1.0, -1.0}, 90ms, 0ms); + pbf_move_left_joystick(context, {-1.0, 0.0}, 1458ms, 0ms); + // Expose the western stair base with the first 200ms of the recorded + // north leg; correct its lane before continuing the remaining ascent. + pbf_move_left_joystick(context, {0.0, 1.0}, 200ms, 0ms); + context.wait_for_all_requests(); + align_to_overworld_landmarks(stream, + context, + match_veilstone_dawn_stairs, + "Veilstone western stair approach", + false, + true); + stream.log("[Veilstone approach] Following the western stairs toward Dawn."); + ShortDialogWatcher dawn_dialogue; + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0.0, 1.0}, 3023ms, 0ms); + pbf_press_button(ctx, BUTTON_A, 80ms, 100ms); + // Dawn's turn, walk and camera cut precede the first box. + // Keep watching through the cutscene; cancel as soon as it opens. + pbf_wait(ctx, 15000ms); + }, + {{dawn_dialogue}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Veilstone Dawn: Dialogue did not start from the west stair approach.", + stream); + } + clear_post_battle_dialogue(stream, context, BUTTON_B, 4000ms); + + // The new recording already enters the Gym. Cancel at its first fade. + context.wait_for_all_requests(); + BlackScreenWatcher gym_entry; + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0.000000, 1.000000}, 1954ms, 0ms); + pbf_wait(ctx, 223ms); + pbf_move_left_joystick(ctx, {1.000000, -0.000000}, 110ms, 0ms); + pbf_wait(ctx, 190ms); + pbf_move_left_joystick(ctx, {0.000000, 1.000000}, 910ms, 0ms); + pbf_wait(ctx, 3000ms); + }, + {{gym_entry}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Veilstone Gym: Entrance transition did not start.", + stream); + } + segment03_finish_transition(stream, context, "Veilstone Gym entrance"); + // The first visit pans across the Gym before returning to the entrance. + pbf_wait(context, 5000ms); + context.wait_for_all_requests(); + OverworldWatcher gym_ready; + if (wait_until(stream, context, 10s, {{gym_ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Veilstone Gym: Overworld not ready after the entrance scene.", + stream); + } + context.wait_for_all_requests(); +} + +static void gym_Puzzle_03(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + + //Upon entering the Gym. + + struct Move { + DpadPosition dir; + int count; + std::chrono::milliseconds delay; + }; + + const Move sequence1[] = { + {DPAD_RIGHT, 8, 0ms}, + {DPAD_UP, 6, 0ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_UP, 2, 0ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_RIGHT, 1, 1000ms}, + {DPAD_UP, 2, 0ms}, + }; + + const Move sequence2[] = { + {DPAD_UP, 5, 0ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_RIGHT, 1, 1000ms}, + {DPAD_DOWN, 8, 0ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_DOWN, 3, 0ms}, + {DPAD_LEFT, 5, 0ms}, + {DPAD_UP, 7, 0ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_RIGHT, 1, 1000ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_UP, 3, 0ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_UP, 2, 0ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_RIGHT, 1, 2000ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_DOWN, 10, 0ms}, + {DPAD_RIGHT, 6, 0ms}, + {DPAD_UP, 7, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 1000ms}, + {DPAD_UP, 6, 0ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_UP, 2, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 1000ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_DOWN, 13, 0ms}, + {DPAD_LEFT, 3, 0ms}, + {DPAD_UP, 4, 0ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_UP, 8, 0ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_UP, 4, 0ms}, + {DPAD_LEFT, 4, 0ms}, + {DPAD_DOWN, 2, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 1000ms}, + {DPAD_UP, 2, 0ms}, + {DPAD_RIGHT, 4, 0ms}, + {DPAD_DOWN, 6, 0ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_DOWN, 7, 0ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_DOWN, 3, 0ms}, + {DPAD_LEFT, 5, 0ms}, + {DPAD_UP, 7, 0ms}, + {DPAD_LEFT, 4, 0ms}, + {DPAD_LEFT, 1, 2000ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_UP, 5, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 2000ms}, + {DPAD_DOWN, 5, 0ms}, + {DPAD_RIGHT, 4, 0ms}, + {DPAD_DOWN, 3, 0ms}, + }; + + const Move sequence3[] = { + {DPAD_RIGHT, 2, 0ms}, + {DPAD_DOWN, 3, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 3000ms}, + {DPAD_DOWN, 3, 0ms}, + {DPAD_LEFT, 4, 0ms}, + {DPAD_UP, 5, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 2000ms}, + {DPAD_DOWN, 5, 0ms}, + {DPAD_LEFT, 8, 0ms}, + {DPAD_UP, 3, 0ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_RIGHT, 1, 3000ms}, + {DPAD_UP, 11, 0ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_RIGHT, 1, 1000ms}, + {DPAD_DOWN, 13, 0ms}, + {DPAD_RIGHT, 4, 0ms}, + {DPAD_UP, 3, 0ms}, + }; + + const Move sequence4[] = { + {DPAD_RIGHT, 2, 0ms}, + {DPAD_UP, 9, 0ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_UP, 4, 0ms}, + {DPAD_RIGHT, 4, 0ms}, + {DPAD_RIGHT, 1, 1000ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_DOWN, 8, 0ms}, + {DPAD_RIGHT, 4, 0ms}, + {DPAD_UP, 3, 0ms}, + }; + + const Move sequence5[] = { + {DPAD_RIGHT, 2, 0ms}, + {DPAD_UP, 3, 0ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_UP, 8, 0ms}, + }; + + for (const auto& move : sequence1){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + if (move.delay.count() > 0){ + pbf_wait(context, move.delay); + } + } + } + + //Button presses to initiate Trainer battle 01 (Black Belt Rafael). + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + fight_trainer_using_moves(stream, context, stats, "Black Belt Rafael", false); + + pbf_mash_button(context, BUTTON_B, 2500ms); //Battle exit dialogue + + for (const auto& move : sequence2){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + if (move.delay.count() > 0){ + pbf_wait(context, move.delay); + } + } + } + + //Button presses to initiate Trainer battle 02 (Black Belt Jeffrey). + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + fight_trainer_using_moves(stream, context, stats, "Black Belt Jeffrey", false); + + pbf_mash_button(context, BUTTON_B, 2500ms); //Battle exit dialogue + + for (const auto& move : sequence3){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + if (move.delay.count() > 0){ + pbf_wait(context, move.delay); + } + } + } + + //Button presses to initiate Trainer battle 03 (Black Belt Darren). + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + fight_trainer_using_moves(stream, context, stats, "Black Belt Darren", false); + + pbf_mash_button(context, BUTTON_B, 2500ms); //Battle exit dialogue + + for (const auto& move : sequence4){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + if (move.delay.count() > 0){ + pbf_wait(context, move.delay); + } + } + } + + //Button presses to initiate Trainer battle 04 (Black Belt Colby). + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + fight_trainer_using_moves(stream, context, stats, "Black Belt Colby", false); + + pbf_mash_button(context, BUTTON_B, 2500ms); //Battle exit dialogue + + for (const auto& move : sequence5){ + for (int i = 0; i < move.count; i++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + if (move.delay.count() > 0){ + pbf_wait(context, move.delay); + } + } + } + + //Button presses to initiate the Gym battle. + + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + fight_trainer_using_moves(stream, context, stats, "Maylene", true); + + // Clear badge and reward dialogue before handing off inside the Gym. + // Match Gardenia's quiet period so gaps between rewards do not end cleanup. + stream.log("[Veilstone Gym puzzle] Shared battle cleanup returned; clearing remaining Maylene rewards with B."); + clear_post_battle_dialogue(stream, context, BUTTON_B, 6000ms); + stream.log("[Veilstone Gym puzzle] Maylene post-battle dialogue cleared; remaining here for Segment 04."); +} + +std::string AutoStory_Segment_03::name() const { + return "03: Second Badge to Third Badge"; +} +std::string AutoStory_Segment_03::start_text() const { + return "Start: Second Gym Badge acquired (Eterna City)."; +} +std::string AutoStory_Segment_03::end_text() const { + return "End: Inside Veilstone Gym in front of Maylene, facing north, after acquiring the third badge and clearing her reward dialogue."; +} + +void AutoStory_Segment_03::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_0301().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0302().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0303().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0304().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0305().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0306().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0307().run_checkpoint(env, context, options, stats); + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + + +std::string AutoStory_Checkpoint_0301::name() const { + return "03.01 - Eterna Gym to Jupiter"; +} +std::string AutoStory_Checkpoint_0301::start_text() const { + return "Outside Eterna Gym after defeating Gardenia and acquiring the Forest Badge."; +} +std::string AutoStory_Checkpoint_0301::end_text() const { + return "Outside Eterna Galactic HQ after defeating Jupiter and completing the rescue dialogue, facing south against the uncut front bush."; +} + +void AutoStory_Checkpoint_0301::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + Checkpoint_0301(env, context, options, stats); +} + + +void Checkpoint_0301(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + // Recorded route with feedback battle handling. + eterna_gym_to_jupiter(env.console, context, stats); + }, + + true); +} +std::string AutoStory_Checkpoint_0302::name() const { + return "03.02 - Jupiter to Mount Coronet"; +} +std::string AutoStory_Checkpoint_0302::start_text() const { + return "Outside Eterna Galactic HQ after defeating Jupiter and completing the rescue dialogue, facing south against the uncut front bush."; +} +std::string AutoStory_Checkpoint_0302::end_text() const { + return "Inside Mount Coronet at the west entrance after defeating Hiker Justin, facing east."; +} + +void AutoStory_Checkpoint_0302::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + Checkpoint_0302(env, context, options, stats); +} + + +void Checkpoint_0302(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + // Set daylight once for this outdoor locator strip, including the + // Center, Mart return, bicycle shop, and north Route 206 gate. + context.wait_for_all_requests(); + env.console.log("[Eterna locator setup] Setting 14:00; preserving the current date."); + DateTime locator_time; + locator_time.hour = 14; + locator_time.minute = 0; + locator_time.second = 0; + set_time_fast(env, context, locator_time, true); + pbf_wait(context, 5000ms); + context.wait_for_all_requests(); + // Recorded route with feedback battle handling. + jupiter_to_coronet(env.console, context, stats); + }, + + true); +} +std::string AutoStory_Checkpoint_0303::name() const { + return "03.03 - Mount Coronet"; +} +std::string AutoStory_Checkpoint_0303::start_text() const { + return "Inside Mount Coronet at the west entrance after defeating Hiker Justin, facing east."; +} +std::string AutoStory_Checkpoint_0303::end_text() const { + return "Inside Mount Coronet near the east exit, after the first Cyrus meeting."; +} + +void AutoStory_Checkpoint_0303::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + Checkpoint_0303(env, context, options, stats); +} + + +void Checkpoint_0303(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + // Recorded route with feedback battle handling. + coronet1(env.console, context); + }, + + true); +} +std::string AutoStory_Checkpoint_0304::name() const { + return "03.04 - Coronet to Hearthome"; +} +std::string AutoStory_Checkpoint_0304::start_text() const { + return "Inside Mount Coronet near the east exit, after the first Cyrus meeting."; +} +std::string AutoStory_Checkpoint_0304::end_text() const { + return "Just outside the Hearthome Contest Hall doorway, facing south with Repel inactive, after the Keira/Johanna introduction."; +} + +void AutoStory_Checkpoint_0304::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + Checkpoint_0304(env, context, options, stats); +} + + +void Checkpoint_0304(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + // Recorded route with feedback battle handling. + coronet_to_heartholme(env.console, context, stats); + }, + + true); +} +std::string AutoStory_Checkpoint_0305::name() const { + return "03.05 - Hearthome to Lost Tower"; +} +std::string AutoStory_Checkpoint_0305::start_text() const { + return "Just outside the Hearthome Contest Hall doorway, facing south with Repel inactive, after the Keira/Johanna introduction."; +} +std::string AutoStory_Checkpoint_0305::end_text() const { + return "Just outside the Lost Tower doorway on Route 209 after entering and exiting, facing south with Repel active."; +} + +void AutoStory_Checkpoint_0305::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + Checkpoint_0305(env, context, options, stats); +} + + +void Checkpoint_0305(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + // Restore night lighting on every attempt, including after a reset. + context.wait_for_all_requests(); + env.console.log("[Route 209 locator setup] Setting 22:00; preserving the current date."); + DateTime locator_time; + locator_time.hour = 22; + locator_time.minute = 0; + locator_time.second = 0; + set_time_fast(env, context, locator_time, true); + pbf_wait(context, 5000ms); + context.wait_for_all_requests(); + // Recorded route with feedback battle handling. + heartholme_to_lost_tower(env.console, context, stats); + // Stop here. Checkpoint 03.06 saves outside and owns the Tower visit. + }, + + true); +} + +std::string AutoStory_Checkpoint_0306::name() const { + return "03.06 - Lost Tower to Veilstone"; +} +std::string AutoStory_Checkpoint_0306::start_text() const { + return "Just outside the Lost Tower doorway on Route 209 after entering and exiting, facing south with Repel active."; +} +std::string AutoStory_Checkpoint_0306::end_text() const { + return "Inside Veilstone Gym at the entrance, facing north."; +} + +void AutoStory_Checkpoint_0306::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + Checkpoint_0306(env, context, options, stats); +} + + +void Checkpoint_0306(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + // Restore night before the route on every attempt, including resets. + context.wait_for_all_requests(); + env.console.log("[Route 215 locator setup] Setting 22:00; preserving the current date."); + DateTime locator_time; + locator_time.hour = 22; + locator_time.minute = 0; + locator_time.second = 0; + set_time_fast(env, context, locator_time, true); + pbf_wait(context, 5000ms); + context.wait_for_all_requests(); + // Save/retry begins outside the Tower, at the end of 03.05. + lost_tower(env.console, context, stats); + lost_tower_to_veilstone_gym(env.console, context, stats); + }, + true); +} + +std::string AutoStory_Checkpoint_0307::name() const { + return "03.07 - Veilstone Gym (start inside at entrance)"; +} +std::string AutoStory_Checkpoint_0307::start_text() const { + return "Start inside Veilstone Gym at the entrance, facing north."; +} +std::string AutoStory_Checkpoint_0307::end_text() const { + return "Inside Veilstone Gym in front of Maylene, facing north, after acquiring the third badge and clearing her reward dialogue."; +} + +void AutoStory_Checkpoint_0307::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + Checkpoint_0307(env, context, options, stats); +} + + +void Checkpoint_0307(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + //#. Veilstone Gym navigation and battle + gym_Puzzle_03(env.console, context, stats); + }, + true); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_03.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_03.h new file mode 100644 index 0000000000..c2a1686089 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_03.h @@ -0,0 +1,157 @@ +/* BDSP AutoStory - Segment 03 + * From after acquiring the second badge until after acquiring the third badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment03_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment03_H + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +class AutoStory_Segment_03 : 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_0301 : 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: second badge acquired +// end: third Gym Badge acquired (Hearthome City) +void Checkpoint_0301(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); +class AutoStory_Checkpoint_0302 : 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: second badge acquired +// end: third Gym Badge acquired (Hearthome City) +void Checkpoint_0302(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0303 : 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: second badge acquired +// end: third Gym Badge acquired (Hearthome City) +void Checkpoint_0303(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0304 : 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: second badge acquired +// end: third Gym Badge acquired (Hearthome City) +void Checkpoint_0304(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0305 : 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: second badge acquired +// end: third Gym Badge acquired (Hearthome City) +void Checkpoint_0305(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0306 : 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: second badge acquired +// end: third Gym Badge acquired (Hearthome City) +void Checkpoint_0306(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0307 : 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: second badge acquired +// end: third Gym Badge acquired (Hearthome City) +void Checkpoint_0307(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_04.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_04.cpp new file mode 100644 index 0000000000..32a903f7f1 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_04.cpp @@ -0,0 +1,1219 @@ +/* BDSP AutoStory - Segment 04 + * From after acquiring the third badge until after acquiring the fourth badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include + +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/Globals.h" + +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "CommonTools/VisualDetectors/ImageMatchDetector.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_Superscalar.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MarkFinder.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" + +#include "PokemonBDSP_AutoStory_Segment_04.h" + +using namespace std::chrono_literals; + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static void +move_back_after_unexpected_battle(VideoStream& stream, ProControllerContext& context, const std::string& trainerid){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + if (trainerid == "route_213_tuber_chelsea"){ + + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 5 + }else if (trainerid == "route_213_tuber_jared"){ + // Just need to wait, nothing else + }else if (trainerid == "route_213_beauty_cindy"){ + pbf_move_left_joystick(context, {0, +1}, 1400ms, 100ms); // 7+ + } +} + +static bool handle_battle(VideoStream& stream, + ProControllerContext& context, + const std::string& trainerid, + AutoStoryStats& stats){ + fight_trainer_using_moves(stream, context, stats, trainerid, true); + if (trainerid == "route_213_tuber_chelsea" || trainerid == "route_213_tuber_jared" || + trainerid == "route_213_beauty_cindy"){ + move_back_after_unexpected_battle(stream, context, trainerid); + } + return true; +} + +// --------------------------------------------------------------------------- +// Named route navigation blocks +// --------------------------------------------------------------------------- + +static bool leave_gym(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("leave_gym: walking out of gym"); + + pbf_move_left_joystick(context, {0, -1}, 1200ms, 100ms); // 2 + pbf_move_left_joystick(context, {+1, 0}, 300ms, 100ms); // 2 + + pbf_press_dpad(context, DPAD_RIGHT, 280ms, 200ms); + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 2 + pbf_press_dpad(context, DPAD_LEFT, 280ms, 200ms); + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 4 + pbf_press_dpad(context, DPAD_RIGHT, 280ms, 200ms); + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 2 + pbf_press_dpad(context, DPAD_LEFT, 280ms, 200ms); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 0ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("leave_gym: black screen not detected!", COLOR_RED); + return false; + } + stream.log("leave_gym: transition confirmed."); + return true; +} + +static bool go_to_veilstone_pokemon_center(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + stream.log("Walking towards the pokemon center"); + pbf_wait(context, 4000ms); + wait_for_dialogue(stream, context, "Dawn"); + mash_until_dialogue_ends(stream, context, BUTTON_B); + context.wait_for_all_requests(); + pbf_wait(context, 4000ms); + + pbf_move_left_joystick(context, {0, -1}, 3000ms, 100ms); // 21 + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 14 + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 4 + pbf_move_left_joystick(context, {+1, 0}, 3400ms, 100ms); // 25 + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 11 + pbf_move_left_joystick(context, {-1, 0}, 1600ms, 100ms); // 12 + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 5 + pbf_move_left_joystick(context, {+1, 0}, 1400ms, 100ms); // 8+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 2, true); + context.wait_for_all_requests(); + + /* Entering the Pokemon Center */ + BlackScreenOverWatcher black_screen1(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen1}}); + if (ret < 0){ + stream.log("Didn't enter pokemon center!", COLOR_RED); + return false; + } + + stream.log("Entered pokemon center successfully!"); + + stream.log("go_to_veilstone_pokemon_center: transition confirmed."); + return true; +} + +static bool heal_at_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + return heal_pokemon(stream, context, "Veilstone", &stats); +} + +static bool go_to_galactic_warehouse(VideoStream& stream, ProControllerContext& context){ + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 6 + pbf_move_left_joystick(context, {0, -1}, 1400ms, 100ms); // 6+ + pbf_move_left_joystick(context, {-1, 0}, 1000ms, 100ms); // 7 + pbf_move_left_joystick(context, {0, +1}, 2400ms, 100ms); // 9+ + pbf_move_left_joystick(context, {-1, 0}, 2800ms, 100ms); // 12+ + pbf_move_left_joystick(context, {0, +1}, 4000ms, 100ms); // 15+ + pbf_move_left_joystick(context, {+1, 0}, 1400ms, 100ms); // 5+ + pbf_press_dpad(context, DPAD_DOWN, 400ms, 200ms); + + context.wait_for_all_requests(); + BlackScreenOverWatcher battle_black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 12000ms); + }, + {{battle_black_screen}}); + if (ret < 0){ + stream.log("go_to_galactic_warehouse: battle black screen not detected (2)", COLOR_RED); + return false; + } + stream.log("go_to_galactic_warehouse: battle transition confirmed."); + return true; +} + +static bool enter_galactic_warehouse(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + pbf_wait(context, 3000ms); + context.wait_for_all_requests(); + + pbf_mash_button(context, BUTTON_B, 16000ms); + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {+1, 0}, 1200ms, 100ms); // 6+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1, true); + + context.wait_for_all_requests(); + + BlackScreenOverWatcher black_screen_1(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen_1}}); + if (ret < 0){ + stream.log("Entering Galactic warehouse black screen not detected!", COLOR_RED); + return false; + } + stream.log("enter_galactic_warehouse: transition confirmed."); + return true; +} + +static bool exit_galactic_warehouse(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {+1, 0}, 1600ms, 100ms); // 7+ + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 2+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 2, true); + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 2+ + context.wait_for_all_requests(); + // Get fly + + ShortDialogWatcher getting_fly_TM(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 2000ms); + }, + {{getting_fly_TM}}); + if (ret < 0){ + stream.log("enter_galactic_warehouse_getting_fly_TM: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("enter_galactic_warehouse_getting_fly_TM: getting TM Fly not detected!"); + pbf_mash_button(context, BUTTON_A, 9000ms); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + pbf_move_left_joystick(context, {0, -1}, 1600ms, 100ms); // 5+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 5, true); + + // Black screen detector here when exiting the warehouse + BlackScreenOverWatcher black_screen_2(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen_2}}); + if (ret < 0){ + stream.log("enter_galactic_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("exit_galactic_warehouse: transition confirmed."); + return true; +} + +static bool go_to_route_214(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + stream.log("Moving towards Route 214"); + pbf_wait(context, 3000ms); + pbf_move_left_joystick(context, {0, -1}, 1600ms, 100ms); // 5+ + pbf_move_left_joystick(context, {-1, 0}, 1300ms, 100ms); // 8 + pbf_move_left_joystick(context, {0, -1}, 3000ms, 100ms); // 13+ + pbf_move_left_joystick(context, {+1, 0}, 3000ms, 100ms); // 15+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 4, true); + + pbf_move_left_joystick(context, {0, -1}, 2600ms, 100ms); // 14 + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {0, -1}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {+1, 0}, 1400ms, 100ms); // 7+ + + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 3, true); + + pbf_move_left_joystick(context, {+1, 0}, 2200ms, 100ms); // 15 + context.wait_for_all_requests(); + + BlackScreenOverWatcher black_screen_1(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen_1}}); + if (ret < 0){ + stream.log("go_to_route_214_enter_house: black screen not detected!", COLOR_RED); + return false; + } + stream.log("Entered house"); + pbf_wait(context, 1500ms); + + context.wait_for_all_requests(); + // Add a black screen detector here when entering the connecting house + BlackScreenOverWatcher black_screen_2(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen_2}}); + if (ret < 0){ + stream.log("go_to_route_214_exit_house: black screen not detected!", COLOR_RED); + return false; + } + stream.log("Exited house"); + pbf_wait(context, 1500ms); + + pbf_move_left_joystick(context, {0, -1}, 6000ms, 100ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_route_214: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_route_214: transition confirmed."); + return true; +} + +static bool +walk_through_route_214(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + context.wait_for_all_requests(); + stream.log("walk_through_route_214: Continuing on the first part of route 214"); + pbf_wait(context, 3000ms); + DpadState dpad; + + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 4 + pbf_move_left_joystick(context, {+1, 0}, 3000ms, 100ms); // 13+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3, true); + + if (!activate_repel(stream, context)){ + return false; + } + + { + MarkDetector battle_dialogue_1(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 30000ms, 100ms); // 13+ + }, + {{battle_dialogue_1}}); + if (ret == 0){ + context.wait_for_all_requests(); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("walk_through_route_214_collector_brady: black screen not detected!", COLOR_RED); + return false; + }; + handle_battle(stream, context, "route_214_collector_brady", stats); + } + } + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1, true); + pbf_move_left_joystick(context, {0, -1}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {-1, 0}, 400ms, 100ms); // 2 + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 5+ + + pbf_move_left_joystick(context, {+1, 0}, 1600ms, 100ms); // 8+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1, true); + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 6, true); + + { + MarkDetector battle_dialogue_2(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_wait(context, 30000ms); + }, + {{battle_dialogue_2}}); + if (ret == 0){ + context.wait_for_all_requests(); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("walk_through_route_214_psychic_mitchell: black screen not detected!", COLOR_RED); + return false; + }; + handle_battle(stream, context, "route_214_psychic_mitchell", stats); + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 6+ + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1, true); + pbf_move_left_joystick(context, {0, -1}, 2200ms, 100ms); // 11+ + + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 3+ + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2, true); + + pbf_move_left_joystick(context, {0, -1}, 6200ms, 100ms); // 31+ + + stream.log("walk_through_route_214: transition confirmed."); + return true; +} + +static bool pass_by_valor_lakefront(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + stream.log("Finishing going through route 214"); + pbf_wait(context, 1000ms); + DpadState dpad; + + { + ShortDialogWatcher repel_ran_out(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + DpadState dpad; + dpad.last_dir = DPAD_DOWN; + for (int c = 0; c < 20; c++){ + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 1, true); + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1, true); + } + }, + {{repel_ran_out}}); + if (ret == 0){ + pbf_mash_button(context, BUTTON_A, 2000ms); + }; + } + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 20+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 6, true); + + pbf_move_left_joystick(context, {0, -1}, 8000ms, 100ms); // 41+ + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 4 + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 5 + pbf_move_left_joystick(context, {-1, 0}, 2200ms, 100ms); // 10+ + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 4+ + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 4, true); + context.wait_for_all_requests(); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 30000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("walk_through_route_214: Didn't enter building!", COLOR_RED); + return false; + } + + return true; +} + +static bool go_through_building_route_213(VideoStream& stream, ProControllerContext& context){ + stream.log("Going through building between routes 214 and 213"); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + ShortDialogWatcher repel_ran_out(COLOR_CYAN); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 30000ms, 100ms); + }, + {{repel_ran_out}}); + if (ret == 0){ + stream.log("go_through_building_route_214: Not reapplying repel.", COLOR_RED); + pbf_mash_button(context, BUTTON_B, 2000ms); + } + + context.wait_for_all_requests(); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 30000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_building_route_214: Didn't exit building!", COLOR_RED); + return false; + } + stream.log("go_through_building_route_214: transition confirmed."); + + return true; +} + +static bool +go_through_route_213(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + context.wait_for_all_requests(); + stream.log("Going through route 213"); + pbf_wait(context, 2000ms); + DpadState dpad; + + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 4, true); + + MarkDetector tuber_chelsea_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 1000ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 5 + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + }, + {{tuber_chelsea_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_213_tuber_chelsea: Didn't detect battle starting!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_through_route_213_tuber_chelsea: Starting battle."); + handle_battle(stream, context, "route_213_tuber_chelsea", stats); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + } + } + context.wait_for_all_requests(); + + MarkDetector tuber_jared_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 4+ + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 3 + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + }, + {{tuber_jared_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_213_tuber_jared: Didn't detect battle starting!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_through_route_213_tuber_jared: Starting battle."); + handle_battle(stream, context, "route_213_tuber_jared", stats); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {-1, 0}, 3600ms, 100ms); // 20+ + pbf_move_left_joystick(context, {0, -1}, 400ms, 100ms); // 3 + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 13+ + + MarkDetector beauty_cindy_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 5000ms, 100ms); // 15+ + }, + {{beauty_cindy_fight}}); + if (ret == 0){ + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_213_beauty_cindy: Didn't detect battle starting!", COLOR_RED); + return false; + }else if (ret == 0){ + context.wait_for_all_requests(); + stream.log("go_through_route_213_beauty_cindy: Starting battle."); + handle_battle(stream, context, "route_213_beauty_cindy", stats); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {+1, 0}, 800ms, 100ms); // 5 + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 8 + + if (!activate_repel(stream, context)){ + return false; + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 100000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_213: Didn't enter building!", COLOR_RED); + return false; + } + stream.log("go_to_route_213: transition confirmed."); + + return true; +} + +static bool go_through_house_east_pastoria(VideoStream& stream, ProControllerContext& context){ + stream.log("Going through the building at the entrance of Pastoria city"); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 100000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_house_east_pastoria: Didn't exit building!", COLOR_RED); + return false; + } + stream.log("go_through_house_east_pastoria: transition confirmed."); + + return true; +} + +static bool go_through_pastoria(VideoStream& stream, ProControllerContext& context){ + stream.log("Going through Pastoria city and to the Pokemon center"); + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 2+ + pbf_move_left_joystick(context, {-1, 0}, 4200ms, 100ms); // 23+ + pbf_move_left_joystick(context, {0, -1}, 500ms, 100ms); // 3 + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 4 + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 4 + + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 3400ms, 100ms); // 19 + pbf_move_left_joystick(context, {+1, 0}, 3400ms, 100ms); // 19 + pbf_move_left_joystick(context, {-1, 0}, 3400ms, 100ms); // 19 + pbf_move_left_joystick(context, {+1, 0}, 3400ms, 100ms); // 19 + }, + {{repel_dialog}}); + if (ret == 0){ + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("go_through_pastoria: Didn't detect repel dialog box!", COLOR_RED); + return false; + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {-1, 0}, 3400ms, 100ms); // 19 + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 8, true); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 100000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_pastoria: Didn't enter Pokemon center!", COLOR_RED); + return false; + } + stream.log("go_through_pastoria: transition confirmed."); + + return true; +} + +static bool go_to_pastoria_gym(VideoStream& stream, ProControllerContext& context){ + stream.log("Going to Pastoria gym"); + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 4+ + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 5 + pbf_move_left_joystick(context, {0, -1}, 1200ms, 100ms); // 8 + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3, true); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 100000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_pastoria_gym: Didn't enter building!", COLOR_RED); + return false; + } + stream.log("go_to_pastoria_gym: transition confirmed."); + return true; +} + +// Start inside Pastoria Gym at its entrance; finish outside after Wake rewards. +static void pastoria_gym_puzzle_and_wake(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryStats& stats){ + VideoStream& stream = env.console; + struct Move { + DpadPosition dir; + int count; + Milliseconds delay; + }; + + // Trial: use the supplied counts without an extra facing press. + const std::vector sequences[] = { + { + // Trainer 1 + {DPAD_UP, 16, 0ms}, + {DPAD_LEFT, 11, 0ms}, + {DPAD_DOWN, 8, 0ms}, + {DPAD_DOWN, 1, 2000ms}, + {DPAD_RIGHT, 5, 0ms}, + {DPAD_UP, 7, 0ms}, + {DPAD_RIGHT, 10, 0ms}, + {DPAD_UP, 7, 0ms}, + {DPAD_LEFT, 4, 0ms}, + {DPAD_UP, 14, 0ms}, + {DPAD_RIGHT, 4, 0ms}, + {DPAD_RIGHT, 1, 2000ms}, + {DPAD_LEFT, 4, 0ms}, + {DPAD_DOWN, 6, 0ms}, + {DPAD_RIGHT, 3, 0ms}, + {DPAD_DOWN, 7, 0ms}, + {DPAD_LEFT, 12, 0ms}, + {DPAD_DOWN, 5, 0ms}, + {DPAD_RIGHT, 3, 0ms}, + }, + { + // Trainer 2 + {DPAD_RIGHT, 2, 0ms}, + {DPAD_RIGHT, 1, 2000ms}, + {DPAD_LEFT, 4, 0ms}, + {DPAD_DOWN, 11, 0ms}, + {DPAD_RIGHT, 6, 0ms}, + }, + { + // Trainer 3 + {DPAD_RIGHT, 10, 0ms}, + {DPAD_UP, 2, 0ms}, + }, + { + // Trainer 4 + {DPAD_UP, 21, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 2000ms}, + {DPAD_DOWN, 2, 0ms}, + {DPAD_RIGHT, 5, 0ms}, + {DPAD_DOWN, 17, 0ms}, + {DPAD_DOWN, 1, 2000ms}, + {DPAD_UP, 25, 0ms}, + {DPAD_LEFT, 18, 0ms}, + {DPAD_DOWN, 11, 0ms}, + {DPAD_LEFT, 4, 0ms}, + {DPAD_UP, 10, 0ms}, + }, + { + // Crasher Wake approach + {DPAD_UP, 2, 0ms}, + {DPAD_UP, 1, 4000ms}, + {DPAD_DOWN, 4, 0ms}, + {DPAD_RIGHT, 11, 0ms}, + {DPAD_UP, 5, 0ms}, + }, + { + // Return from Crasher Wake + {DPAD_UP, 3, 0ms}, + {DPAD_LEFT, 3, 0ms}, + {DPAD_UP, 3, 0ms}, + {DPAD_LEFT, 11, 0ms}, + {DPAD_DOWN, 35, 0ms}, + {DPAD_RIGHT, 10, 0ms}, + {DPAD_DOWN, 5, 0ms}, + {DPAD_RIGHT, 4, 0ms}, + {DPAD_DOWN, 2, 0ms}, + }, + }; + + for (size_t stage = 0; stage < 6; stage++){ + stream.log("[Pastoria Gym puzzle] Movement stage " + std::to_string(stage + 1) + "."); + if (stage == 5){ + // Observe the supplied return route and stop movement at the Gym exit fade. + BlackScreenWatcher exit_black(COLOR_RED); + if (run_until(stream, + context, + [&](ProControllerContext& ctx){ + for (const auto& move : sequences[stage]){ + // repeat_dpad(ctx, dpad, move.dir, 80ms, 300ms, move.count, true); + for (int press = 0; press < move.count; press++){ + pbf_press_dpad(ctx, move.dir, 80ms, 300ms); + if (move.delay > 0ms){ + pbf_wait(ctx, move.delay); + } + } + } + pbf_wait(ctx, 5000ms); + }, + {{exit_black}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Pastoria Gym return: Exit transition did not start.", + stream); + } + BlackScreenWatcher outside(COLOR_RED, + {0.1, 0.1, 0.8, 0.8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + if (wait_until(stream, context, 30s, {{outside}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Pastoria Gym return: Exit transition did not finish.", + stream); + } + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + // Let any automatic exit dialogue finish before 05.01 saves or opens the map. + clear_post_battle_dialogue(stream, context, BUTTON_B, 4000ms); + break; + } + for (const auto& move : sequences[stage]){ + // repeat_dpad(context, dpad, move.dir, 80ms, 300ms, move.count, true); + for (int press = 0; press < move.count; press++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + if (move.delay > 0ms){ + pbf_wait(context, move.delay); + } + } + } + context.wait_for_all_requests(); + + const bool gym_leader = stage == 4; + const std::string label = + gym_leader ? "Crasher Wake" : "Pastoria Gym trainer " + std::to_string(stage + 1); + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + fight_trainer_using_moves(stream, context, stats, label, gym_leader); + clear_post_battle_dialogue(stream, context, BUTTON_B, gym_leader ? 6000ms : 3000ms); + } + stream.log("[Pastoria Gym puzzle] Reward dialogue cleared; outside the Gym doorway, ready for 05.01.", + COLOR_GREEN); +} + +// --------------------------------------------------------------------------- +// Segment wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Segment_04::name() const { + return "04: Veilstone to Pastoria"; +} + +std::string AutoStory_Segment_04::start_text() const { + return "Start: Inside Veilstone Gym in front of Maylene, facing north, with third-badge rewards and dialogue finished."; +} + +std::string AutoStory_Segment_04::end_text() const { + return "Outside Pastoria Gym at the doorway, facing south, with fourth-badge rewards and dialogue finished."; +} + +void AutoStory_Segment_04::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_0401().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0402().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0403().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0404().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0405().run_checkpoint(env, context, options, stats); + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + +// --------------------------------------------------------------------------- +// Checkpoint wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Checkpoint_0401::name() const { + return "04.01 - Veilstone recovery"; +} + +std::string AutoStory_Checkpoint_0401::start_text() const { + return "Inside Veilstone Gym in front of Maylene, facing north, with third-badge rewards and dialogue finished."; +} + +std::string AutoStory_Checkpoint_0401::end_text() const { + return "Healed and outside Veilstone Pokemon Center, with Dawn's post-Gym dialogue finished."; +} + +void AutoStory_Checkpoint_0401::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0401(env, context, options, stats); +} + +void checkpoint_0401(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!leave_gym(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "leave_gym: transition not detected.", + env.console); + } + } + { + if (!go_to_veilstone_pokemon_center(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_veilstone_pokemon_center: transition not detected.", + env.console); + } + } + { + if (!heal_at_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_at_pokemon_center: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0402::name() const { + return "04.02 - Galactic grunts and Fly"; +} + +std::string AutoStory_Checkpoint_0402::start_text() const { + return "Healed and outside Veilstone Pokemon Center, with Dawn's post-Gym dialogue finished."; +} + +std::string AutoStory_Checkpoint_0402::end_text() const { + return "Outside Galactic Warehouse with Fly obtained and dialogue cleared."; +} + +void AutoStory_Checkpoint_0402::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0402(env, context, options, stats); +} + +void checkpoint_0402(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_galactic_warehouse(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_galactic_warehouse: transition not detected.", + env.console); + } + } + { + if (!handle_battle(env.console, context, "veilstone_galactic_grunts_1", stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "handle_battle_veilstone_galactic_grunts_1: transition not detected.", + env.console); + } + } + { + if (!enter_galactic_warehouse(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "enter_galactic_warehouse: transition not detected.", + env.console); + } + } + { + if (!exit_galactic_warehouse(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "exit_galactic_warehouse: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0403::name() const { + return "04.03 - Route 214 and Valor Lakefront"; +} + +std::string AutoStory_Checkpoint_0403::start_text() const { + return "Outside Galactic Warehouse with Fly obtained and dialogue cleared."; +} + +std::string AutoStory_Checkpoint_0403::end_text() const { + return "Outside the Valor Lakefront south gate on Route 213."; +} + +void AutoStory_Checkpoint_0403::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0403(env, context, options, stats); +} + +void checkpoint_0403(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_route_214(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_route_214: transition not detected.", + env.console); + } + } + { + if (!handle_battle(env.console, context, "route_214_psychic_abigail", stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "handle_battle_route_214_psychic_abigail: transition not detected.", + env.console); + } + } + { + if (!walk_through_route_214(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "walk_through_route_214: transition not detected.", + env.console); + } + } + { + if (!pass_by_valor_lakefront(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "pass_by_valor_lakefront: transition not detected.", + env.console); + } + } + { + if (!go_through_building_route_213(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_building_route_213: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0404::name() const { + return "04.04 - Route 213 to Pastoria"; +} + +std::string AutoStory_Checkpoint_0404::start_text() const { + return "Outside the Valor Lakefront south gate on Route 213."; +} + +std::string AutoStory_Checkpoint_0404::end_text() const { + return "Healed and outside Pastoria Pokemon Center at the doorway."; +} + +void AutoStory_Checkpoint_0404::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0404(env, context, options, stats); +} + +void checkpoint_0404(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_through_route_213(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_route_213: transition not detected.", + env.console); + } + } + { + if (!go_through_house_east_pastoria(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_house_east_pastoria: transition not detected.", + env.console); + } + } + { + if (!go_through_pastoria(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_pastoria: transition not detected.", + env.console); + } + } + { + if (!heal_at_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_at_pokemon_center: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0405::name() const { + return "04.05 - Pastoria Gym and Wake"; +} + +std::string AutoStory_Checkpoint_0405::start_text() const { + return "Healed and outside Pastoria Pokemon Center at the doorway."; +} + +std::string AutoStory_Checkpoint_0405::end_text() const { + return "Outside Pastoria Gym at the doorway, facing south, with fourth-badge rewards and dialogue finished."; +} + +void AutoStory_Checkpoint_0405::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0405(env, context, options, stats); +} + +void checkpoint_0405(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_pastoria_gym(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_pastoria_gym: transition not detected.", + env.console); + } + } + pastoria_gym_puzzle_and_wake(env, context, stats); + context.wait_for_all_requests(); + }, + true); +} + +} +} +} \ No newline at end of file diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_04.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_04.h new file mode 100644 index 0000000000..6ddc235338 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_04.h @@ -0,0 +1,107 @@ +/* BDSP AutoStory - Segment 04 + * From after acquiring the third badge until after acquiring the fourth badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment04_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment04_H + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +class AutoStory_Segment_04 : 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_0401 : 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; +}; +void checkpoint_0401(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0402 : 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; +}; +void checkpoint_0402(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0403 : 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; +}; +void checkpoint_0403(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0404 : 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; +}; +void checkpoint_0404(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0405 : 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; +}; +void checkpoint_0405(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_05.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_05.cpp new file mode 100644 index 0000000000..07b5b841dd --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_05.cpp @@ -0,0 +1,1691 @@ +/* BDSP AutoStory - Segment 05 + * From after acquiring the fourth badge until after acquiring the fifth badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/Globals.h" + +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "CommonTools/VisualDetectors/ImageMatchDetector.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_Superscalar.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MarkFinder.h" +#include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" + +#include "PokemonBDSP_AutoStory_Segment_02.h" + +#include "PokemonBDSP_AutoStory_Segment_05.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static void +move_back_after_unexpected_battle(VideoStream& stream, ProControllerContext& context, const std::string& trainerid){ + stream.log("Starting battle with " + trainerid, COLOR_GREEN); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + if (trainerid == "route_213_tuber_chelsea"){ + DpadState dpad; + + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 4+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 6); + }else if (trainerid == "route_213_tuber_jared"){ + // Just need to wait, nothing else + }else if (trainerid == "route_213_beauty_cindy"){ + pbf_move_left_joystick(context, {0, -1}, 400ms, 100ms); // 1+ + }else if (trainerid == "route_210_ace_ernest"){ + pbf_move_left_joystick(context, {-1, 0}, 3800ms, 100ms); // 19+ + } +} + +// --------------------------------------------------------------------------- +// Named route navigation blocks +// --------------------------------------------------------------------------- + +static bool leave_gym(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("leave_gym: walking out of gym"); + + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + pbf_move_left_joystick(context, {+1, 0}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {0, -1}, 6000ms, 100ms); // 34+ + pbf_move_left_joystick(context, {-1, 0}, 1800ms, 100ms); // 9+ + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 5+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3, true); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 100000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("leave_gym: Didn't enter building!", COLOR_RED); + return false; + } + stream.log("leave_gym: transition confirmed."); + return true; +} + +// Starts at 04.18's completed Gym exit. Fly within Pastoria to use the +// Pokemon Center's fixed arrival position instead of an unanchored city walk. +static bool go_to_pastoria_pokemon_center(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + stream.log("[Pastoria Center] Flying from outside the Gym to the city's Pokemon Center."); + if (!fly_to(stream, context, FlyPoint::CurrentCity)){ + stream.log("[Pastoria Center] Flight to the current city's Pokemon Center failed.", COLOR_RED); + return false; + } + + BlackScreenWatcher landed(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 100, 10, BlackScreenWatcher::FinderType::GONE, 500ms); + if (wait_until(stream, context, 30s, {{landed}}) != 0){ + stream.log("[Pastoria Center] Flight transition did not finish.", COLOR_RED); + return false; + } + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + + // The Fly arrival is directly south of the Center entrance. + BlackScreenWatcher entry_black(COLOR_RED); + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_move_left_joystick(ctx, {0, +1}, 5000ms, 0ms); + pbf_wait(ctx, 1000ms); + }, + {{entry_black}}) != 0){ + stream.log("[Pastoria Center] Entrance transition did not start.", COLOR_RED); + return false; + } + BlackScreenWatcher inside(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 100, 10, BlackScreenWatcher::FinderType::GONE, 500ms); + if (wait_until(stream, context, 30s, {{inside}}) != 0){ + stream.log("[Pastoria Center] Entrance transition did not finish.", COLOR_RED); + return false; + } + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + stream.log("[Pastoria Center] Entered after the completed Gym return.", COLOR_GREEN); + return true; +} + +static bool exit_pastoria_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + return heal_pokemon(stream, context, "Pastoria", &stats); +} + +static bool +pastoria_city_drama(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("pastoria_city_drama: starting the drama"); + + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2, true); + pbf_move_left_joystick(context, {+1, 0}, 1600ms, 100ms); // 10 + pbf_move_left_joystick(context, {0, +1}, 500ms, 100ms); // 3 + pbf_move_left_joystick(context, {+1, 0}, 1000ms, 100ms); // 7 + pbf_move_left_joystick(context, {0, +1}, 1400ms, 100ms); // 7+ + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 2+ + + context.wait_for_all_requests(); + + { + ShortDialogWatcher talk_to_grunt_1(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 2000ms); + }, + {{talk_to_grunt_1}}); + if (ret < 0){ + stream.log("pastoria_city_drama_talk_to_grunt_1: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("pastoria_city_drama_talk_to_grunt_1: talking to grunt!"); + clear_post_battle_dialogue(stream, context, BUTTON_B, 4000ms); + }; + } + + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {+1, 0}, 4800ms, 100ms); // 24+ + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 2, true); + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1, true); + + { + ShortDialogWatcher talk_to_grunt_2(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 2000ms); + }, + {{talk_to_grunt_2}}); + if (ret < 0){ + stream.log("pastoria_city_drama_talk_to_grunt_2: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("pastoria_city_drama_talk_to_grunt_2: talking to grunt!"); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret1 = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_B, 200000ms); + }, + {{black_screen}}); + if (ret1 < 0){ + stream.log("pastoria_city_drama_talk_to_clint: Didn't detect start of battle with Clint!", COLOR_RED); + return false; + } + stream.log("pastoria_city_drama_talk_to_clint: battling Clint."); + fight_trainer_using_moves(stream, context, stats, "pastoria_clint", true); + pbf_mash_button(context, BUTTON_B, 3000ms); + }; + } + + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1, true); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("pastoria_city_drama: transition not detected!", COLOR_RED); + return false; + } + stream.log("pastoria_city_drama: transition detected."); + } + + return true; +} + +static bool go_through_house_east_pastoria(VideoStream& stream, ProControllerContext& context){ + stream.log("Going through the building at the entrance of Pastoria city"); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 100000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_house_east_pastoria: Didn't exit building!", COLOR_RED); + return false; + } + stream.log("go_through_house_east_pastoria: transition confirmed."); + + return true; +} + +static bool +go_through_route_213_chasing(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_route_213_chasing: continuing the drama"); + + pbf_move_left_joystick(context, {+1, 0}, 1600ms, 100ms); // 12 + if (!activate_repel(stream, context)){ + return false; + } + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + pbf_move_left_joystick(context, {+1, 0}, 3600ms, 100ms); // 18+ + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 5, true); + + MarkDetector beauty_cindy_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 5000ms, 100ms); // 15+ + }, + {{beauty_cindy_fight}}); + if (ret == 0){ + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_213_beauty_cindy: Didn't detect battle starting!", COLOR_RED); + return false; + }else if (ret == 0){ + context.wait_for_all_requests(); + stream.log("go_through_route_213_beauty_cindy: Starting battle."); + fight_trainer_using_moves(stream, context, stats, "route_213_beauty_cindy", true); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + // Cindy approaches from the west and blocks the next westward leg. + // Step south into the gap above the cliff before walking around it. + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1, true); + context.wait_for_all_requests(); + } + } + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 4 + pbf_move_left_joystick(context, {0, -1}, 1200ms, 100ms); // 9 + pbf_move_left_joystick(context, {+1, 0}, 1600ms, 100ms); // 8+ + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 2+ + pbf_move_left_joystick(context, {+1, 0}, 600ms, 100ms); // 4 + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 3+ + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 3+ + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2, true); + + context.wait_for_all_requests(); + + // Rock Smash uses the same short dialogue box as an NPC conversation. + ShortDialogPromptDetector unexpected_choice(stream.overlay(), {0.70, 0.60, 0.20, 0.20}, COLOR_RED); + ShortDialogWatcher talk_to_grunt(COLOR_BLUE); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + // Finish approaching and facing east before interacting once. + pbf_move_left_joystick(context, {+1, 0}, 5000ms, 100ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + }, + {{unexpected_choice}, {talk_to_grunt}}); + if (ret < 0){ + stream.log("go_through_route_213_chasing_talk_to_grunt: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("[Route 213 grunt] Unexpected choice prompt; stopping the approach.", COLOR_RED); + clear_post_battle_dialogue(stream, context, BUTTON_B, 4000ms); + return false; + } + + // The blank left edge of the NPC nameplate is independent of language. + // A Rock Smash prompt has no nameplate, even if its arrow appears late. + BlackScreenWatcher npc_nameplate(COLOR_BLUE, {0.21, 0.765, 0.01, 0.02}); + if (wait_until(stream, context, 1000ms, {{npc_nameplate}}) != 0){ + stream.log("[Route 213 grunt] NPC nameplate not detected; refusing this dialogue.", COLOR_RED); + clear_post_battle_dialogue(stream, context, BUTTON_B, 4000ms); + return false; + } + stream.log("[Route 213 grunt] NPC dialogue detected at the grunt approach."); + clear_post_battle_dialogue(stream, context, BUTTON_B, 4000ms); + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 2+ + context.wait_for_all_requests(); + + force_repel_expiry(stream, context, RepelRenewalChoice::NO, false, true); + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); + + MarkDetector tuber_jared_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 3200ms, 100ms); // 17 + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 3 + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + }, + {{tuber_jared_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_213_tuber_jared: Didn't detect battle starting!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_through_route_213_tuber_jared: Starting battle."); + fight_trainer_using_moves(stream, context, stats, "route_213_tuber_jared", true); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + } + } + + context.wait_for_all_requests(); + + MarkDetector tuber_chelsea_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + DpadState dpad; + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 3+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 6, true); + }, + {{tuber_chelsea_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_213_tuber_chelsea: Didn't detect battle starting!"); + }else if (ret == 0){ + stream.log("go_through_route_213_tuber_chelsea: Starting battle."); + fight_trainer_using_moves(stream, context, stats, "route_213_tuber_chelsea", true); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + } + } + + BlackScreenOverWatcher route_213_east_building(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 20000ms, 100ms); + }, + {{route_213_east_building}}); + if (ret < 0){ + stream.log("go_through_route_213_east_building: Didn't detect entering the building!", COLOR_RED); + return false; + }; + + return true; +} + +static bool go_through_building_route_213(VideoStream& stream, ProControllerContext& context){ + stream.log("Going through building between routes 214 and 213"); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 30000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_building_route_214: Didn't exit building!", COLOR_RED); + return false; + } + stream.log("go_through_building_route_213: transition confirmed."); + + return true; +} + +static bool +go_through_route_214(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_route_214: continuing the drama"); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1, true); + pbf_move_left_joystick(context, {0, +1}, 4000ms, 100ms); // 21+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2, true); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 1, true); + + context.wait_for_all_requests(); + + ShortDialogWatcher talk_to_grunt(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {+1, 0}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_grunt}}); + if (ret < 0){ + stream.log("go_through_route_214_talk_to_grunt: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("go_through_route_214_talk_to_grunt: talking to grunt!"); + clear_post_battle_dialogue(stream, context, BUTTON_B, 4000ms); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, +1}, 2200ms, 100ms); // 11+ + pbf_move_left_joystick(context, {+1, 0}, 1200ms, 100ms); // 6+ + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 2, true); + + ShortDialogWatcher talk_to_grunt_2(COLOR_BLUE); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_grunt_2}}); + if (ret < 0){ + stream.log("go_through_route_214_talk_to_grunt_2: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("go_through_route_214_talk_to_grunt_2: talking to grunt!"); + context.wait_for_all_requests(); + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_214_galactic_grunt: Didn't detect battle starting!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_through_route_214_galactic_grunt: Starting battle."); + fight_trainer_using_moves(stream, context, stats, "route_214_galactic_grunt", true); + context.wait_for_all_requests(); + pbf_wait(context, 5000ms); + } + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1, true); + + ShortDialogWatcher talk_to_cynthia(COLOR_BLUE); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 10000ms); + ssf_mash1_button(context, BUTTON_A, 10000ms); + }, + {{talk_to_cynthia}}); + if (ret < 0){ + stream.log("go_through_route_214_talk_to_cynthia: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("go_through_route_214_talk_to_cynthia: talking to Cynthia!"); + clear_post_battle_dialogue(stream, context, BUTTON_A, 6000ms); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 4000ms); + + if (!fly_to(stream, context, FlyPoint::SolaceonTown)){ + return false; + } + + return true; +} + +static bool go_inside_solaceon_pokemon_center(VideoStream& stream, ProControllerContext& context){ + stream.log("go_inside_solaceon_pokemon_center: going inside"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 200000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_inside_solaceon_pokemon_center: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_inside_solaceon_pokemon_center: black screen detected!", COLOR_GREEN); + return true; + } + return true; +} + +static bool heal_at_solaceon_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + stream.log("heal_at_solaceon_pokemon_center: healing pokemon"); + context.wait_for_all_requests(); + return heal_pokemon(stream, context, "Solaceon", &stats); +} + +static bool +go_to_the_psyduck_route_210(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_the_psyduck_route_210: going towards the Psyducks!"); + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 4); + pbf_move_left_joystick(context, {0, +1}, 5500ms, 100ms); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + if (!activate_repel(stream, context)){ + return false; + } + + context.wait_for_all_requests(); + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {0, +1}, 200ms, 100ms); // 1+ + + { + MarkDetector battle_dialogue(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 20000ms, 100ms); + }, + {{battle_dialogue}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + stream.log("go_to_the_psyduck_route_210_rancher_marco: marker detected!", COLOR_GREEN); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_the_psyduck_route_210_rancher_marco: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + fight_trainer_using_moves(stream, context, stats, "route_210_rancher_marco", true); + } + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 2400ms, 100ms); // 8+ tall grass + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 6+ tall grass + + + { + MarkDetector battle_dialogue(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + ShortDialogWatcher battle_dialogue_2(COLOR_BLUE); + BattleMenuWatcher amber_menu(BattleType::TRAINER, COLOR_GREEN); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + DpadState dpad; + pbf_move_left_joystick(context, {0, +1}, 6600ms, + 100ms); // 22+ tall grass + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 4); + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{battle_dialogue}, {battle_dialogue_2}, {amber_menu}}); + if (ret >= 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + stream.log("go_to_the_psyduck_route_210_breeder_amber: marker detected!", COLOR_GREEN); + + if (ret != 2){ + // Confirm the command menu, not a black fade that could be battle exit. + BattleMenuWatcher ready(BattleType::TRAINER, COLOR_GREEN); + if (run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_mash_button(ctx, BUTTON_A, 30000ms); + }, + {{ready}}) != 0){ + stream.log("Route 210 Amber: Battle command menu not detected.", COLOR_RED); + return false; + } + } + fight_trainer_using_moves(stream, context, stats, "route_210_breeder_amber", true); + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 4+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + + ShortDialogWatcher talk_to_psyducks(COLOR_BLUE); + int ret_psyducks = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_psyducks}}); + if (ret_psyducks < 0){ + stream.log("go_to_the_psyduck_route_210_talk_to_psyducks: dialog box not detected!", COLOR_RED); + return false; + } + if (ret_psyducks == 0){ + stream.log("go_to_the_psyduck_route_210_talk_to_psyducks: talking to Psyducks!"); + // Finish the medicine prompts, then wait through the departure animation. + // Its dialogue-free gap does not mean Cynthia's follow-up is finished. + mash_until_dialogue_ends(stream, context, BUTTON_A, 60s); + stream.log("[Route 210] Waiting for Cynthia after the Psyducks leave."); + wait_for_dialogue(stream, context, "Route 210 - Cynthia after Psyducks", 30s); + clear_post_battle_dialogue(stream, context, BUTTON_A, 6000ms); + stream.log("[Route 210] Cynthia follow-up cleared; resuming movement and Repel setup."); + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 9); + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3); + context.wait_for_all_requests(); + + if (!activate_repel(stream, context)){ + return false; + } + + pbf_move_left_joystick(context, {0, +1}, 8000ms, 100ms); // 29+ tall grass + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + + force_repel_expiry(stream, context, RepelRenewalChoice::YES, true, true); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 5+ + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {0, +1}, 2400ms, 100ms); // 12+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + + context.wait_for_all_requests(); + + { + MarkDetector battle_dialogue(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, +1}, 10000ms, 100ms); + }, + {{battle_dialogue}}); + if (ret < 0){ + stream.log("go_to_the_psyduck_route_210_ace_alyssa: dialog box not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_the_psyduck_route_210_ace_alyssa: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + fight_trainer_using_moves(stream, context, stats, "route_210_ace_alyssa", true); + } + } + } + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 4); + + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 5+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 4); + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 6+ + + { + MarkDetector battle_dialogue(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 3800ms, 100ms); // 19+ + }, + {{battle_dialogue}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_the_psyduck_route_210_ace_ernest: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + fight_trainer_using_moves(stream, context, stats, "route_210_ace_ernest", true); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_210_ace_ernest"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + { + MarkDetector battle_dialogue(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 20000ms, 100ms); + }, + {{battle_dialogue}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_the_psyduck_route_210_blackbelt_adam: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + fight_trainer_using_moves(stream, context, stats, "route_210_blackbelt_adam", true); + } + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {0, -1}, 200ms, 100ms); + pbf_move_left_joystick(context, {+1, 0}, 200ms, 100ms); + force_repel_expiry(stream, context, RepelRenewalChoice::NO, false, true); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {+1, 0}, 1000ms, 100ms); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 5); + + pbf_move_left_joystick(context, {0, +1}, 1400ms, 100ms); // 7+ + + { + MarkDetector battle_dialogue(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 20000ms, 100ms); + }, + {{battle_dialogue}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_the_psyduck_route_210_birdkeeper_brianna: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + fight_trainer_using_moves(stream, context, stats, "route_210_birdkeeper_brianna", true); + } + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {-1, 0}, 200ms, 100ms); // 1+ + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 4); + + { + MarkDetector battle_dialogue(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 6000ms, 100ms); + }, + {{battle_dialogue}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_the_psyduck_route_210_veteran_brian: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + fight_trainer_using_moves(stream, context, stats, "route_210_veteran_brian", true); + } + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {-1, 0}, 4000ms, 100ms); // 20+ + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {0, -1}, 2200ms, 100ms); // 11+ + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 20000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_the_psyduck_route_210_enter_celestic_town_pokemon_center: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + return true; + } + } + return true; +} + +static bool heal_at_celestic_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + stream.log("heal_at_celestic_pokemon_center: healing pokemon"); + context.wait_for_all_requests(); + return heal_pokemon(stream, context, "Celestic", &stats); +} + +static bool +celestic_town_drama(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("celestic_town_drama: continuing the drama."); + + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + + { + ShortDialogWatcher talk_to_cynthias_grandma(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_cynthias_grandma}}); + if (ret < 0){ + stream.log("celestic_town_talk_to_cynthias_grandma: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("celestic_town_talk_to_cynthias_grandma: talking to Cynthia's grandma!"); + clear_post_battle_dialogue(stream, context, BUTTON_B, 6000ms); + }; + } + + context.wait_for_all_requests(); + pbf_wait(context, 1500ms); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {0, +1}, 3600ms, 100ms); // 18+ + pbf_move_left_joystick(context, {-1, 0}, 200ms, 100ms); // 1 + + { + ShortDialogWatcher talk_to_the_grunt(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {-1, 0}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_the_grunt}}); + if (ret < 0){ + stream.log("celestic_town_talk_to_the_grunt: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("celestic_town_talk_to_the_grunt: talking to the grunt!"); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret1 = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret1 < 0){ + stream.log("celestic_town_talk_to_the_grunt: Didn't detect start of battle with Clint!", COLOR_RED); + return false; + } + stream.log("celestic_town_talk_to_the_grunt: starting the battle."); + fight_trainer_using_moves(stream, context, stats, "celestic_town_galactic_grunt", true); + clear_post_battle_dialogue(stream, context, BUTTON_B, 4000ms); + }; + } + + context.wait_for_all_requests(); + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 20000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("celestic_town_enter_cave: black screen not detected!", COLOR_RED); + return false; + } + } + + return true; +} + +static bool discover_the_paintings(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("discover_the_paintings: going to the paintings."); + + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 8+ + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {0, +1}, 1400ms, 100ms); // 7+ + pbf_move_left_joystick(context, {+1, 0}, 400ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 2+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3); + + { + ShortDialogWatcher talk_to_cynthias_grandma(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_cynthias_grandma}}); + if (ret < 0){ + stream.log("discover_the_paintings_talk_to_cynthias_grandma: dialog box not detected!", COLOR_RED); + return false; + } + if (ret == 0){ + stream.log("discover_the_paintings_talk_to_cynthias_grandma: talking to the grandma!"); + clear_post_battle_dialogue(stream, context, BUTTON_B, 6000ms); + }; + } + context.wait_for_all_requests(); + pbf_wait(context, 5000ms); + + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 3+ + pbf_move_left_joystick(context, {0, -1}, 400ms, 100ms); // 2+ + pbf_move_left_joystick(context, {-1, 0}, 400ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, -1}, 1400ms, 100ms); // 7+ + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 6); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 20000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("discover_the_paintings: black screen for exiting the building not detected!", COLOR_RED); + return false; + } + } + + return true; +} + +static bool talk_to_cyrus_and_fly_hearthome(VideoStream& stream, ProControllerContext& context){ + // The cave exit can start dialogue before the checkpoint can save. + pbf_press_button(context, BUTTON_L, 80ms, 300ms); + pbf_press_button(context, BUTTON_L, 80ms, 300ms); + context.wait_for_all_requests(); + wait_for_dialogue(stream, context, "Cyrus", 10000ms); + clear_post_battle_dialogue(stream, context, BUTTON_B, 6000ms); + + stream.log("[After Celestic cave] Flying to Hearthome after Cyrus's conversation."); + if (!fly_to(stream, context, FlyPoint::HearthomeCity)){ + return false; + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 20000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("talk_to_cyrus_and_fly_hearthome: black screen for entering pokemon center not detected!", + COLOR_RED); + return false; + } + } + return true; +} + +static bool heal_at_hearthome_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + stream.log("heal_at_hearthome_pokemon_center: healing pokemon"); + context.wait_for_all_requests(); + return heal_pokemon(stream, context, "Hearthome", &stats); +} + +static bool get_into_hearthome_gym(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("get_into_hearthome_gym: getting into the gym."); + + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {+1, 0}, 7000ms, 100ms); // 38+ + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 4); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 20000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("get_into_hearthome_gym: black screen not detected!", COLOR_RED); + return false; + } + } + + return true; +} + +// Start inside Hearthome Gym at its entrance; finish outside with Cynthia dialogue cleared. +static void hearthome_gym_puzzle_and_cynthia(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryStats& stats){ + VideoStream& stream = env.console; + struct Move { + DpadPosition dir; + int count; + Milliseconds delay; + }; + + // Trial: use the supplied counts without an extra facing press. + const Move sequence1[] = { + {DPAD_UP, 7, 0ms}, + {DPAD_UP, 1, 5000ms}, + {DPAD_RIGHT, 6, 0ms}, + {DPAD_UP, 8, 0ms}, + {DPAD_UP, 1, 3500ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_UP, 9, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 5000ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_UP, 9, 0ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_UP, 1, 0ms}, + {DPAD_UP, 1, 3500ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_UP, 9, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 5000ms}, + {DPAD_LEFT, 6, 0ms}, + {DPAD_UP, 8, 0ms}, + {DPAD_UP, 1, 3500ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_UP, 9, 0ms}, + {DPAD_LEFT, 1, 0ms}, + {DPAD_LEFT, 1, 5000ms}, + {DPAD_RIGHT, 6, 0ms}, + {DPAD_UP, 8, 0ms}, + {DPAD_UP, 1, 3500ms}, + {DPAD_UP, 6, 0ms}, + }; + + stream.log("[Hearthome Gym puzzle] Approaching Fantina."); + for (const auto& move : sequence1){ + // repeat_dpad(context, dpad, move.dir, 80ms, 300ms, move.count, true); + for (int press = 0; press < move.count; press++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + if (move.delay > 0ms){ + pbf_wait(context, move.delay); + context.wait_for_all_requests(); + 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, + "Hearthome Gym: Room/lift transition did not finish.", + stream); + } + OverworldWatcher ready; + if (wait_until(stream, context, 15s, {{ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Gym: Overworld not ready after the recorded room/lift wait.", + stream); + } + } + } + } + context.wait_for_all_requests(); + pbf_press_button(context, BUTTON_A, 150ms, 300ms); + fight_trainer_using_moves(stream, context, stats, "Fantina", true); + clear_post_battle_dialogue(stream, context, BUTTON_B, 6000ms); + + const Move sequence2[] = { + {DPAD_DOWN, 6, 0ms}, + {DPAD_DOWN, 1, 3500ms}, + {DPAD_DOWN, 8, 0ms}, + {DPAD_LEFT, 6, 0ms}, + {DPAD_DOWN, 1, 0ms}, + {DPAD_DOWN, 1, 5000ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_DOWN, 8, 0ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_DOWN, 1, 0ms}, + {DPAD_DOWN, 1, 3500ms}, + {DPAD_DOWN, 8, 0ms}, + {DPAD_RIGHT, 6, 0ms}, + {DPAD_DOWN, 1, 0ms}, + {DPAD_DOWN, 1, 5000ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_DOWN, 8, 0ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_DOWN, 1, 0ms}, + {DPAD_DOWN, 1, 3500ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_DOWN, 10, 0ms}, + {DPAD_RIGHT, 1, 0ms}, + {DPAD_RIGHT, 1, 5000ms}, + {DPAD_LEFT, 2, 0ms}, + {DPAD_DOWN, 8, 0ms}, + {DPAD_RIGHT, 2, 0ms}, + {DPAD_DOWN, 1, 0ms}, + {DPAD_DOWN, 1, 3500ms}, + {DPAD_DOWN, 8, 0ms}, + {DPAD_LEFT, 6, 0ms}, + {DPAD_DOWN, 1, 0ms}, + {DPAD_DOWN, 1, 5000ms}, + {DPAD_DOWN, 8, 0ms}, + }; + + stream.log("[Hearthome Gym puzzle] Returning from Fantina."); + for (size_t move_index = 0; move_index + 1 < sizeof(sequence2) / sizeof(sequence2[0]); move_index++){ + const auto& move = sequence2[move_index]; + for (int press = 0; press < move.count; press++){ + pbf_press_dpad(context, move.dir, 80ms, 300ms); + if (move.delay > 0ms){ + pbf_wait(context, move.delay); + context.wait_for_all_requests(); + 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, + "Hearthome Gym: Room/lift transition did not finish.", + stream); + } + OverworldWatcher ready; + if (wait_until(stream, context, 15s, {{ready}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Gym return: Overworld not ready after the recorded room/lift wait.", + stream); + } + } + } + } + context.wait_for_all_requests(); + BlackScreenWatcher exit_black(COLOR_RED); + if (run_until(stream, + context, + [&](ProControllerContext& ctx){ + const auto& move = sequence2[sizeof(sequence2) / sizeof(sequence2[0]) - 1]; + for (int press = 0; press < move.count; press++){ + pbf_press_dpad(ctx, move.dir, 80ms, 300ms); + } + pbf_wait(ctx, 5000ms); + }, + {{exit_black}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Gym return: Exit transition did not start.", + stream); + } + BlackScreenWatcher outside(COLOR_RED, + {0.1, 0.1, 0.8, 0.8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + if (wait_until(stream, context, 30s, {{outside}}) != 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Hearthome Gym return: Exit transition did not finish.", + stream); + } + context.wait_for_all_requests(); + wait_for_dialogue(stream, context, "Cynthia after Hearthome Gym", 30000ms); + clear_post_battle_dialogue(stream, context, BUTTON_B, 6000ms); + context.wait_for_all_requests(); + stream.log( + "[Hearthome Gym puzzle] Fantina defeated; outside the Gym with Cynthia's dialogue finished.", + COLOR_GREEN); +} + +// --------------------------------------------------------------------------- +// Segment wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Segment_05::name() const { + return "05: Pastoria to Hearthome"; +} + +std::string AutoStory_Segment_05::start_text() const { + return "Start: Outside Pastoria Gym at the doorway, facing south, with fourth-badge rewards and dialogue finished."; +} + +std::string AutoStory_Segment_05::end_text() const { + return "Outside Hearthome Gym at the post-Cynthia position, with her dialogue finished and the fifth badge acquired."; +} + +void AutoStory_Segment_05::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_0501().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0502().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0503().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0504().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0505().run_checkpoint(env, context, options, stats); + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + +// --------------------------------------------------------------------------- +// Checkpoint wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Checkpoint_0501::name() const { + return "05.01 - Pastoria recovery"; +} + +std::string AutoStory_Checkpoint_0501::start_text() const { + return "Outside Pastoria Gym at the doorway, facing south, with fourth-badge rewards and dialogue finished."; +} + +std::string AutoStory_Checkpoint_0501::end_text() const { + return "Healed and outside Pastoria Pokemon Center at the doorway."; +} + +void AutoStory_Checkpoint_0501::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0501(env, context, options, stats); +} + +void checkpoint_0501(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_pastoria_pokemon_center(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_pastoria_pokemon_center: transition not detected.", + env.console); + } + } + { + if (!exit_pastoria_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "exit_pastoria_pokemon_center: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0502::name() const { + return "05.02 - Galactic chase and Solaceon"; +} + +std::string AutoStory_Checkpoint_0502::start_text() const { + return "Healed and outside Pastoria Pokemon Center at the doorway."; +} + +std::string AutoStory_Checkpoint_0502::end_text() const { + return "Healed and outside Solaceon Pokemon Center after the Galactic chase and Cynthia dialogue."; +} + +void AutoStory_Checkpoint_0502::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0502(env, context, options, stats); +} + +void checkpoint_0502(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!pastoria_city_drama(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "pastoria_city_drama: transition not detected.", + env.console); + } + } + { + if (!go_through_house_east_pastoria(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_house_east_pastoria: transition not detected.", + env.console); + } + } + { + if (!go_through_route_213_chasing(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_route_213_chasing: transition not detected.", + env.console); + } + } + { + if (!go_through_building_route_213(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_building_route_213: transition not detected.", + env.console); + } + } + { + if (!go_through_route_214(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_route_214: transition not detected.", + env.console); + } + } + { + if (!go_inside_solaceon_pokemon_center(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_inside_solaceon_pokemon_center: transition not detected.", + env.console); + } + } + { + if (!heal_at_solaceon_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_at_solaceon_pokemon_center: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0503::name() const { + return "05.03 - Route 210 to Celestic"; +} + +std::string AutoStory_Checkpoint_0503::start_text() const { + return "Healed and outside Solaceon Pokemon Center after the Galactic chase and Cynthia dialogue."; +} + +std::string AutoStory_Checkpoint_0503::end_text() const { + return "Healed and outside Celestic Pokemon Center after the Psyducks and Cynthia follow-up."; +} + +void AutoStory_Checkpoint_0503::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0503(env, context, options, stats); +} + +void checkpoint_0503(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_the_psyduck_route_210(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_the_psyduck_route_210: transition not detected.", + env.console); + } + } + { + if (!heal_at_celestic_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_at_celestic_pokemon_center: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0504::name() const { + return "05.04 - Celestic story and Hearthome"; +} + +std::string AutoStory_Checkpoint_0504::start_text() const { + return "Healed and outside Celestic Pokemon Center after the Psyducks and Cynthia follow-up."; +} + +std::string AutoStory_Checkpoint_0504::end_text() const { + return "Healed and outside Hearthome Pokemon Center after the paintings and Cyrus dialogue."; +} + +void AutoStory_Checkpoint_0504::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0504(env, context, options, stats); +} + +void checkpoint_0504(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!celestic_town_drama(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "celestic_town_drama: transition not detected.", + env.console); + } + } + { + if (!discover_the_paintings(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "discover_the_paintings: transition not detected.", + env.console); + } + } + { + if (!talk_to_cyrus_and_fly_hearthome(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "talk_to_cyrus_and_fly_hearthome: transition not detected.", + env.console); + } + } + { + if (!heal_at_hearthome_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_at_hearthome_pokemon_center: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0505::name() const { + return "05.05 - Hearthome Gym and Cynthia"; +} + +std::string AutoStory_Checkpoint_0505::start_text() const { + return "Healed and outside Hearthome Pokemon Center after the paintings and Cyrus dialogue."; +} + +std::string AutoStory_Checkpoint_0505::end_text() const { + return "Outside Hearthome Gym at the post-Cynthia position, with her dialogue finished and the fifth badge acquired."; +} + +void AutoStory_Checkpoint_0505::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0505(env, context, options, stats); +} + +void checkpoint_0505(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!get_into_hearthome_gym(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "get_into_hearthome_gym: transition not detected.", + env.console); + } + } + hearthome_gym_puzzle_and_cynthia(env, context, stats); + context.wait_for_all_requests(); + }, + true); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_05.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_05.h new file mode 100644 index 0000000000..b12991e75f --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_05.h @@ -0,0 +1,107 @@ +/* BDSP AutoStory - Segment 05 + * From after acquiring the fourth badge until after acquiring the fifth badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment05_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment05_H + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +class AutoStory_Segment_05 : 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_0501 : 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; +}; +void checkpoint_0501(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0502 : 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; +}; +void checkpoint_0502(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0503 : 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; +}; +void checkpoint_0503(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0504 : 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; +}; +void checkpoint_0504(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0505 : 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; +}; +void checkpoint_0505(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_06.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_06.cpp new file mode 100644 index 0000000000..b42238de15 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_06.cpp @@ -0,0 +1,647 @@ +/* BDSP AutoStory - Segment 06 + * From after acquiring the fifth badge until after acquiring the sixth badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/Exceptions/FatalProgramException.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/Globals.h" + +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "CommonTools/VisualDetectors/ImageMatchDetector.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_Superscalar.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MarkFinder.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" + +#include "PokemonBDSP_AutoStory_Segment_06.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static void +move_back_after_unexpected_battle(VideoStream& stream, ProControllerContext& context, const std::string& trainerid){ + stream.log("Finishing battle with " + trainerid, COLOR_GREEN); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + if (trainerid == "route_218_sailor_skyler"){ + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + pbf_move_left_joystick(context, {-1, 0}, 400ms, 100ms); // 1+ + pbf_move_left_joystick(context, {0, -1}, 2800ms, 100ms); // 14+ + } +} + +static bool handle_battle(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, const std::string& trainerid){ + fight_trainer_using_moves(stream, context, stats, trainerid, true); + return true; +} + +static bool test(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("test: Testing..."); + activate_repel(stream, context); + return true; +} + +// --------------------------------------------------------------------------- +// Named route navigation blocks +// --------------------------------------------------------------------------- + +static bool enter_jubilife_pokemon_center(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("enter_jubilife_pokemon_center: entering..."); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("enter_jubilife_pokemon_center: black screen not detected!", COLOR_RED); + return false; + } + }; + return true; +} + +static bool heal_and_exit_jubilife_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("heal_and_exit_jubilife_pokemon_center: healing and exiting..."); + if (!heal_pokemon(stream, context, "Jubilife city", &stats)){ + return false; + } + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +static bool move_to_jubilife_west_exit(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("move_to_jubilife_west_exit: moving towards building..."); + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 6 + pbf_move_left_joystick(context, {0, +1}, 2600ms, 100ms); // 18 + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 100000ms, 100ms); // 46 + }, + {{black_screen}}); + if (ret < 0){ + stream.log("move_to_jubilife_west_exit: transition not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("move_to_jubilife_west_exit: transition detected.", COLOR_GREEN); + } + } + + return true; +} + +static bool go_through_jubilife_west_exit(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_jubilife_west_exit: going through the building..."); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 100000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_jubilife_west_exit: transition not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_through_jubilife_west_exit: transition detected.", COLOR_GREEN); + } + } + + return true; +} + +static bool go_through_route_218(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_route_218: going through the road..."); + + pbf_move_left_joystick(context, {-1, 0}, 1800ms, 100ms); // 7+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {-1, 0}, 1800ms, 100ms); // 9+ + pbf_move_left_joystick(context, {0, +1}, 1800ms, 100ms); // 9+ + context.wait_for_all_requests(); + activate_repel(stream, context); + pbf_mash_button(context, BUTTON_A, 2000ms); + context.wait_for_all_requests(); + pbf_wait(context, 6000ms); + context.wait_for_all_requests(); + + { + MarkDetector sailor_skyler_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 2+ + pbf_move_left_joystick(context, {-1, 0}, 6000ms, + 100ms); // 28++ (getting out of the water) + pbf_move_left_joystick(context, {0, -1}, 2800ms, 100ms); // 14+ + }, + {{sailor_skyler_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_218_sailor_skyler: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_218_sailor_skyler"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_218_sailor_skyler"); + } + } + } + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 2); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 100000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_218: transition not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_through_route_218: transition detected.", COLOR_GREEN); + } + } + return true; +} + +static bool go_through_building_route_218(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_building_route_218: going through the building..."); + + { + ShortDialogWatcher talk_to_scientist(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 100000ms, 100ms); + }, + {{talk_to_scientist}}); + if (ret < 0){ + stream.log("go_through_building_route_218_scientist: dialog box not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_through_building_route_218_scientist: dialog box detected.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 12000ms); + context.wait_for_all_requests(); + } + } + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 100000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_building_route_218: transition not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_through_building_route_218: transition detected.", COLOR_GREEN); + } + } + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +static bool go_to_clint_and_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_clint_and_pokemon_center: going to the Pokemon Center..."); + + pbf_move_left_joystick(context, {-1, 0}, 1400ms, 100ms); // 7+ + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 5+ + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, +1}, 4200ms, 100ms); // 21+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 4); + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 10+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_to_clint_and_pokemon_center: Not activating repel again.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("go_to_clint_and_pokemon_center: Didn't detect repel dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 300ms); // 10+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 8); + + { + ShortDialogWatcher talk_to_clint(COLOR_BLUE); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 100000ms, 100ms); + }, + {{talk_to_clint}}); + if (ret < 0){ + stream.log("go_to_clint_and_pokemon_center_clint: dialog box not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_to_clint_and_pokemon_center_clint: dialog box detected, starting battle.", COLOR_GREEN); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_clint_and_pokemon_center_clint: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "canalave_clint"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + } + } + } + + context.wait_for_all_requests(); + pbf_mash_button(context, BUTTON_B, 3000ms); + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {+1, 0}, 3000ms, 300ms); // 15+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_clint_and_pokemon_center: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_clint_and_pokemon_center: entered pokemon center", COLOR_GREEN); + }; + return true; +} + +static bool heal_and_exit_canalave_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("heal_and_exit_canalave_pokemon_center: healing and exiting..."); + if (!heal_pokemon(stream, context, "Canalave city", &stats)){ + return false; + } + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +static bool go_to_canalave_gym(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_canalave_gym: going for the gym..."); + + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2, false); + + pbf_move_left_joystick(context, {-1, 0}, 5000ms, 100ms); // 26+ + pbf_move_left_joystick(context, {0, -1}, 1400ms, 100ms); // 9 + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 7); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_canalave_gym: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_canalave_gym: entered gym", COLOR_GREEN); + }; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + // The old 06.11 repeated Gym entry; it did not solve the puzzle. + // Stop here until the actual puzzle and Byron route are available. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "Canalave Gym entrance reached. The puzzle and Byron battle are not implemented; " + "Segment 06 is incomplete and Segment 07 must not start.", + stream); +} + +// --------------------------------------------------------------------------- +// Segment wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Segment_06::name() const { + return "06: Hearthome to Canalave"; +} + +std::string AutoStory_Segment_06::start_text() const { + return "Start: Outside Hearthome Gym at the post-Cynthia position, with her dialogue finished and the fifth badge acquired."; +} + +std::string AutoStory_Segment_06::end_text() const { + return "Target: Sixth Gym Badge acquired (Canalave City). The Canalave Gym puzzle and Byron battle are not implemented yet."; +} + +void AutoStory_Segment_06::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_0601().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0602().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0603().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0604().run_checkpoint(env, context, options, stats); + + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + +// --------------------------------------------------------------------------- +// Checkpoint wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Checkpoint_0601::name() const { + return "06.01 - Hearthome to Jubilife"; +} + +std::string AutoStory_Checkpoint_0601::start_text() const { + return "Outside Hearthome Gym at the post-Cynthia position, with her dialogue finished and the fifth badge acquired."; +} + +std::string AutoStory_Checkpoint_0601::end_text() const { + return "Healed and outside Jubilife City Pokemon Center at the doorway, facing south."; +} + +void AutoStory_Checkpoint_0601::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0601(env, context, options, stats); +} + +void checkpoint_0601(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!fly_to(env.console, context, FlyPoint::JubilifeCity)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.01: Flight to Jubilife did not complete.", + env.console); + } + if (!enter_jubilife_pokemon_center(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.01: Jubilife Center entry did not complete.", + env.console); + } + if (!heal_and_exit_jubilife_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.01: Jubilife healing and exit did not complete.", + env.console); + } + }, + true); +} + +std::string AutoStory_Checkpoint_0602::name() const { + return "06.02 - Jubilife to Canalave"; +} + +std::string AutoStory_Checkpoint_0602::start_text() const { + return "Outside Jubilife City Pokemon Center at the doorway, facing south."; +} + +std::string AutoStory_Checkpoint_0602::end_text() const { + return "Outside Canalave City east gate, facing west, after the scientist conversation."; +} + +void AutoStory_Checkpoint_0602::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0602(env, context, options, stats); +} + +void checkpoint_0602(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!move_to_jubilife_west_exit(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.02: Jubilife west gate entry did not complete.", + env.console); + } + if (!go_through_jubilife_west_exit(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.02: Jubilife west gate exit did not complete.", + env.console); + } + if (!go_through_route_218(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.02: Route 218 crossing did not complete.", + env.console); + } + if (!go_through_building_route_218(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.02: Canalave gate crossing and scientist conversation did not complete.", + env.console); + } + }, + true); +} + +std::string AutoStory_Checkpoint_0603::name() const { + return "06.03 - Canalave rival battle and healing"; +} + +std::string AutoStory_Checkpoint_0603::start_text() const { + return "Outside Canalave City east gate, facing west, after the scientist conversation."; +} + +std::string AutoStory_Checkpoint_0603::end_text() const { + return "Rival defeated; healed and outside Canalave City Pokemon Center at the doorway, facing south."; +} + +void AutoStory_Checkpoint_0603::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0603(env, context, options, stats); +} + +void checkpoint_0603(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!go_to_clint_and_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.03: Canalave rival battle and Center entry did not complete.", + env.console); + } + if (!heal_and_exit_canalave_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.03: Canalave healing and exit did not complete.", + env.console); + } + }, + true); +} + +std::string AutoStory_Checkpoint_0604::name() const { + return "06.04 - Canalave Gym (puzzle pending)"; +} + +std::string AutoStory_Checkpoint_0604::start_text() const { + return "Outside Canalave City Pokemon Center at the doorway, facing south, with full HP and PP."; +} + +std::string AutoStory_Checkpoint_0604::end_text() const { + return "Current implementation stops inside Canalave Gym at the entrance. Puzzle, Byron battle, and reward dialogue are pending."; +} + +void AutoStory_Checkpoint_0604::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0604(env, context, options, stats); +} + +void checkpoint_0604(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!go_to_canalave_gym(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "06.04: Canalave Gym entry did not complete.", + env.console); + } + }, + true); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_06.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_06.h new file mode 100644 index 0000000000..ad4838c3d4 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_06.h @@ -0,0 +1,92 @@ +/* BDSP AutoStory - Segment 06 + * From after acquiring the fifth badge until after acquiring the sixth badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment06_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment06_H + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +class AutoStory_Segment_06 : 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_0601 : 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; +}; +void checkpoint_0601(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0602 : 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; +}; +void checkpoint_0602(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0603 : 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; +}; +void checkpoint_0603(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0604 : 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; +}; +void checkpoint_0604(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_07.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_07.cpp new file mode 100644 index 0000000000..2c227c79ee --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_07.cpp @@ -0,0 +1,1845 @@ +/* BDSP AutoStory - Segment 07 + * From after acquiring the sixth badge until after acquiring the seventh badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/Exceptions/FatalProgramException.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/Globals.h" + +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "CommonTools/VisualDetectors/ImageMatchDetector.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_Superscalar.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MarkFinder.h" +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" + +#include "PokemonBDSP_AutoStory_Segment_07.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static void +move_back_after_unexpected_battle(VideoStream& stream, ProControllerContext& context, const std::string& trainerid){ + stream.log("Finishing battle with " + trainerid, COLOR_GREEN); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + if (trainerid == "route_216_ace_maria"){ + pbf_move_left_joystick(context, {-1, 0}, 1600ms, 100ms); // 8 + }else if (trainerid == "route_215_skier_edward"){ + pbf_move_left_joystick(context, {-1, 0}, 8100ms, 100ms); // 27++ (snow) + }else if (trainerid == "route_217_skier_lexie"){ + pbf_move_left_joystick(context, {0, +1}, 2100ms, 100ms); // 7++ (snow) + }else if (trainerid == "route_217_blackbelt_luke"){ + pbf_move_left_joystick(context, {0, +1}, 5100ms, 100ms); // 17++ (snow) + } +} + +static bool handle_battle(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, const std::string& trainerid){ + fight_trainer_using_moves(stream, context, stats, trainerid, true); + return true; +} + +// --------------------------------------------------------------------------- +// Named route navigation blocks +// --------------------------------------------------------------------------- + +static bool leave_gym(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("leave_gym: leaving the gym..."); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("leave_gym: black screen not detected!", COLOR_RED); + return false; + } + stream.log("leave_gym: exited gym", COLOR_GREEN); + }; + return true; +} + +static bool go_to_the_library(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_the_library: talking to Clint and going to the Library..."); + + { + ShortDialogWatcher talk_to_clint(COLOR_BLUE); + int ret = wait_until(stream, context, std::chrono::seconds(30), {{talk_to_clint}}); + if (ret < 0){ + stream.log("go_to_the_library_talk_to_clint: dialog box not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_to_the_library_talk_to_clint: dialog box detected.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 7000ms); + context.wait_for_all_requests(); + } + } + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {-1, 0}, 1600ms, 100ms); // 8+ + pbf_move_left_joystick(context, {0, +1}, 3800ms, 100ms); // 19+ + pbf_move_left_joystick(context, {+1, 0}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 4+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_the_library: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_the_library: entered the library", COLOR_GREEN); + }; + return true; +} + +static bool drama_at_the_library(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("drama_at_the_library: going to professor Rowan..."); + + pbf_move_left_joystick(context, {0, +1}, 1400ms, 100ms); // 7+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("drama_at_the_library: black screen not detected!", COLOR_RED); + return false; + } + stream.log("drama_at_the_library: moved to the first floor", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 3+ + pbf_move_left_joystick(context, {+1, 0}, 1400ms, 100ms); // 7 + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 3+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("drama_at_the_library: black screen not detected!", COLOR_RED); + return false; + } + stream.log("drama_at_the_library: moved to the second floor", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + ShortDialogWatcher talk_to_clint(COLOR_BLUE); + int ret = wait_until(stream, context, std::chrono::seconds(30), {{talk_to_clint}}); + if (ret < 0){ + stream.log("drama_at_the_library_talk_to_clint: dialog box not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("drama_at_the_library_talk_to_clint: dialog box detected.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 45000ms); + context.wait_for_all_requests(); + } + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {-1, 0}, 700ms, 100ms); // 5 + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 2+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("drama_at_the_library: black screen not detected!", COLOR_RED); + return false; + } + stream.log("drama_at_the_library: moved back to the first floor", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {-1, 0}, 1400ms, 100ms); // 7 + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 3+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("drama_at_the_library: black screen not detected!", COLOR_RED); + return false; + } + stream.log("drama_at_the_library: moved back to the ground floor", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("drama_at_the_library: black screen not detected!", COLOR_RED); + return false; + } + stream.log("drama_at_the_library: moved out of the library", COLOR_GREEN); + }; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("drama_at_the_library: finishing the Rowan follow-up outside the library..."); + + { + ShortDialogWatcher talk_to_rowan(COLOR_BLUE); + int ret = wait_until(stream, context, std::chrono::seconds(30), {{talk_to_rowan}}); + if (ret < 0){ + stream.log("drama_at_the_library_talk_to_rowan: dialog box not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("drama_at_the_library_talk_to_rowan: dialog box detected.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 21000ms); + context.wait_for_all_requests(); + } + } + + clear_post_battle_dialogue(stream, context, BUTTON_B, 6000ms); + context.wait_for_all_requests(); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +static bool fly_and_enter_mount_coronet(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + if (!fly_to(stream, context, FlyPoint::CelesticTown)){ + return false; + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + stream.log("fly_and_enter_mount_coronet: going towards Mt. Coronet..."); + + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {-1, 0}, 1800ms, 100ms); // 9+ + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 2+ + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 11); + pbf_move_left_joystick(context, {-1, 0}, 3200ms, 100ms); // 16+ + context.wait_for_all_requests(); + activate_repel(stream, context); + context.wait_for_all_requests(); + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {-1, 0}, 2400ms, 100ms); // 12+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 4); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("fly_and_enter_mount_coronet: black screen not detected!", COLOR_RED); + return false; + } + stream.log("fly_and_enter_mount_coronet: entered Mt. Coronet", COLOR_GREEN); + }; + return true; +} + +static bool go_through_mount_coronet(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_mount_coronet: going through Mt. Coronet..."); + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 4); + pbf_move_left_joystick(context, {+1, 0}, 400ms, 100ms); // 1+ + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + + use_HM(stream, context, "Strength"); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 10000ms, 500ms); // 10++ (strength) + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {0, +1}, 2400ms, 100ms); // 12+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_mount_coronet: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_mount_coronet: finished first part of Mt. Coronet", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + stream.log("go_through_mount_coronet: continuing through Mt. Coronet..."); + + pbf_move_left_joystick(context, {-1, 0}, 1000ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 2+ + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 11000ms, 100ms); // 55+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_through_mount_coronet: Reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 3000ms); + }else if (ret < 0){ + stream.log("go_through_mount_coronet: Didn't detect repel dialog box!", COLOR_RED); + return false; + } + } + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 5600ms, 100ms); // 28+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_mount_coronet: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_mount_coronet: finished second part of Mt. Coronet", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {+1, 0}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 8+ + + { + WhiteScreenOverWatcher white_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{white_screen}}); + if (ret < 0){ + stream.log("go_through_mount_coronet: white screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_mount_coronet: exited Mt. Coronet", COLOR_GREEN); + }; + + return true; +} + +static bool go_through_route_216(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_route_216: going through the snow..."); + + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 5+ + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 2+ + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 9600ms, 100ms); // 48+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_through_route_216: Reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 3000ms); + }else if (ret < 0){ + stream.log("go_through_route_216: Didn't detect repel dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + { + MarkDetector ace_maria_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 3200ms, 100ms); // 16 + }, + {{ace_maria_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_216_ace_maria: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_216_ace_maria"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_216_ace_maria"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + { + MarkDetector skier_edward_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 7500ms, 100ms); // 25++ (snow) + }, + {{skier_edward_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_216_skier_edward: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_216_skier_edward"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_216_skier_edward"); + } + } + } + + context.wait_for_all_requests(); + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 800ms, 11); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_route_216: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_route_216: entered chalet", COLOR_GREEN); + }; + + return true; +} + +static bool heal_small_house_route_216(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("heal_small_house_route_216: taking a quick nap..."); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 2); + + { + ShortDialogWatcher sleep_in_a_bed(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {+1, 0}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{sleep_in_a_bed}}); + if (ret == 0){ + stream.log("heal_small_house_route_216_sleep_in_a_bed: taking a nap...", COLOR_GREEN); + + const ImageFloatBox box{0.670000, 0.600000, 0.100000, 0.150000}; + SelectionArrowFinder arrow(stream.overlay(), box, COLOR_GREEN); + + int arrow_ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 10000ms); + }, + {{arrow}}); + if (arrow_ret != 0){ + stream.log("heal_small_house_route_216_sleep_in_a_bed: selection arrow not found.", COLOR_RED); + return false; + } + + pbf_press_button(context, BUTTON_A, 80ms, 300ms); + pbf_mash_button(context, BUTTON_B, 6000ms); + + }else if (ret < 0){ + stream.log("heal_small_house_route_216_sleep_in_a_bed: Didn't find the bed!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("heal_small_house_route_216: black screen not detected!", COLOR_RED); + return false; + } + stream.log("heal_small_house_route_216: exited chalet", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + stats.move_pp_remaining = stats.move_pp_capacity; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +static bool continue_through_route_216(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("continue_through_route_216: continuing through the snow..."); + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 800ms, 5); + pbf_move_left_joystick(context, {0, +1}, 2200ms, 100ms); // 11+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 800ms, 6); // Snow + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 11000ms, 100ms); // 55+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("continue_through_route_216: Not reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("continue_through_route_216: Didn't detect repel dialog box!", COLOR_RED); + return false; + } + } + + pbf_move_left_joystick(context, {0, +1}, 2000ms, 100ms); // 10++ (Snow) + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); // Snow + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 4++ (Snow) + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 800ms, 1); // Snow + + { + MarkDetector ace_dalton_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 7500ms, 100ms); // 25++ (snow) + }, + {{ace_dalton_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("continue_through_route_216_ace_dalton: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_217_ace_dalton"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + pbf_move_left_joystick(context, {0, +1}, 17000ms, 100ms); // 67++ (snow) + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 800ms, 8); // Snow + + { + ShortDialogWatcher pick_up_TM100(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, -1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{pick_up_TM100}}); + if (ret == 0){ + stream.log("continue_through_route_216: Picking up TM100.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 10000ms); + }else if (ret < 0){ + stream.log("continue_through_route_216: Didn't detect TM100 dialog box!", COLOR_RED); + return false; + } + } + + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 7++ (snow) + + { + MarkDetector skier_lexie_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 6000ms, 100ms); // 20++ (snow) + }, + {{skier_lexie_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("continue_through_route_216_skier_lexie: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_217_skier_lexie"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_217_skier_lexie"); + } + } + } + + context.wait_for_all_requests(); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 800ms, 2); // Snow + repeat_dpad(context, dpad, DPAD_UP, 80ms, 800ms, 3); // Snow + pbf_move_left_joystick(context, {-1, 0}, 1000ms, 100ms); // 2++ (snow) + pbf_move_left_joystick(context, {0, +1}, 2700ms, 100ms); // 9++ (snow) + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 800ms, 2); // Snow + repeat_dpad(context, dpad, DPAD_UP, 80ms, 800ms, 1); // Snow + + { + MarkDetector blackbelt_luke_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 5400ms, 100ms); // 21++ (snow) + }, + {{blackbelt_luke_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("continue_through_route_216_blackbelt_luke: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_217_blackbelt_luke"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_217_blackbelt_luke"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + activate_repel(stream, context); + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + { + MarkDetector ace_olivia_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{ace_olivia_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("continue_through_route_216_ace_olivia: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_217_ace_olivia"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, +1}, 1800ms, 100ms); // 9+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 4); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 11); + pbf_move_left_joystick(context, {+1, 0}, 7800ms, 100ms); // 26++ (snow) + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 800ms, 2); // Snow + pbf_move_left_joystick(context, {+1, 0}, 2400ms, 100ms); // 8++ (snow) + pbf_move_left_joystick(context, {0, +1}, 2400ms, 100ms); // 8++ (snow) + pbf_move_left_joystick(context, {+1, 0}, 2700ms, 100ms); // 9++ (snow) + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + if (!fly_to(stream, context, FlyPoint::VeilstoneCity)){ + return false; + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("continue_through_route_216: black screen not detected!", COLOR_RED); + return false; + } + stream.log("continue_through_route_216: entered Veilstone Pokemon Center", COLOR_GREEN); + } + + return true; +} + +static bool heal_and_exit_veilstone_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("heal_and_exit_veilstone_pokemon_center: healing and exiting..."); + if (!heal_pokemon(stream, context, "Veilstone city", &stats)){ + return false; + } + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +static bool go_to_house_south_veilstone(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_house_south_veilstone: heading South..."); + + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 4000ms, 100ms); // 20+ + pbf_move_left_joystick(context, {+1, 0}, 4000ms, 100ms); // 20+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_to_house_south_veilstone: Not reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("go_to_house_south_veilstone: Didn't detect repel dialog box!", COLOR_RED); + return false; + } + } + + pbf_move_left_joystick(context, {-1, 0}, 4000ms, 100ms); // 20+ + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 5+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 9); + + pbf_move_left_joystick(context, {0, -1}, 1400ms, 100ms); // 8 + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {0, -1}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {+1, 0}, 1800ms, 100ms); // 7+ + + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 3, true); + + pbf_move_left_joystick(context, {+1, 0}, 2200ms, 100ms); // 15 + context.wait_for_all_requests(); + + { + BlackScreenOverWatcher black_screen_1(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen_1}}); + if (ret < 0){ + stream.log("go_to_route_214_enter_house: black screen not detected!", COLOR_RED); + return false; + } + } + + return true; +} + +static bool go_through_house_south_veilstone(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_house_south_veilstone: moving towards exit..."); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_house_south_veilstone: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_house_south_veilstone: exited the house", COLOR_GREEN); + } + + return true; +} + +static bool walk_through_route_214(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("walk_through_route_214: going through route 214..."); + + pbf_move_left_joystick(context, {+1, 0}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, -1}, 3800ms, 100ms); // 19+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 8); + + activate_repel(stream, context); + + pbf_move_left_joystick(context, {0, -1}, 2600ms, 100ms); // 13+ + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1, true); + pbf_move_left_joystick(context, {0, -1}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {-1, 0}, 400ms, 100ms); // 2 + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 5+ + + pbf_move_left_joystick(context, {+1, 0}, 1600ms, 100ms); // 8+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1, true); + pbf_move_left_joystick(context, {0, -1}, 1600ms, 100ms); // 8+ + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1, true); + pbf_move_left_joystick(context, {0, -1}, 2200ms, 100ms); // 11+ + + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 3+ + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2, true); + + pbf_move_left_joystick(context, {0, -1}, 6200ms, 100ms); // 31+ + context.wait_for_all_requests(); + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 11000ms, 100ms); // 55+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("walk_through_route_214: Reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 3000ms); + }else if (ret < 0){ + stream.log("walk_through_route_214: Didn't detect repel dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {0, -1}, 2800ms, 100ms); // 14+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2); + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 3); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("walk_through_route_214: black screen not detected!", COLOR_RED); + return false; + } + stream.log("walk_through_route_214: entered Valor Lakefront", COLOR_GREEN); + } + + return true; +} + +static bool go_through_lake_valor(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_lake_valor: going through the remnants of the lake..."); + + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 6+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 2+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 3); + + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 2400ms, 100ms); // 12+ + pbf_move_left_joystick(context, {+1, 0}, 2400ms, 100ms); // 12+ + pbf_move_left_joystick(context, {-1, 0}, 2400ms, 100ms); // 12+ + pbf_move_left_joystick(context, {+1, 0}, 2400ms, 100ms); // 12+ + pbf_move_left_joystick(context, {-1, 0}, 2400ms, 100ms); // 12+ + pbf_move_left_joystick(context, {+1, 0}, 2400ms, 100ms); // 12+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_through_lake_valor: Not reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("go_through_lake_valor: Didn't detect repel dialog box!", COLOR_RED); + return false; + } + } + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + pbf_move_left_joystick(context, {-1, 0}, 2400ms, 100ms); // 12+ + + // TODO(07.04): Record the cave approach, Galactic battles, Saturn battle, + // reward/dialogue cleanup, and return to the outdoor cave exit. + // return_to_twinleaf_and_heal() preserves the continuation after this gap. + context.wait_for_all_requests(); + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "07.04 incomplete: Lake Valor cave approach, Saturn battle, and cave exit need recorded pathing. " + "Stopped before the missing route; later checkpoints have not run.", + stream); +} + +static bool return_to_twinleaf_and_heal(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + if (!fly_to(stream, context, FlyPoint::TwinleafTown)){ + return false; + } + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_lake_valor: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_lake_valor: back into own's house", COLOR_GREEN); + } + + + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("heal_at_home: sleeping at mom's..."); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + + { + ShortDialogWatcher mom_heal_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{mom_heal_dialog}}); + if (ret == 0){ + stream.log("heal_at_home: Sleeping...", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 4000ms); + }else if (ret < 0){ + stream.log("heal_at_home: Didn't detect mom dialog box!", COLOR_RED); + return false; + } + } + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {0, -1}, 400ms, 100ms); // 1+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("heal_at_home: black screen not detected!", COLOR_RED); + return false; + } + stream.log("heal_at_home: leaving own's house", COLOR_GREEN); + } + + context.wait_for_all_requests(); + stats.move_pp_remaining = stats.move_pp_capacity; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +static bool go_to_lake_verity(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_lake_verity: heading towards Lake Verity..."); + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 5); + + pbf_move_left_joystick(context, {0, +1}, 6800ms, 100ms); // 34+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {-1, 0}, 6200ms, 100ms); // 31+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_lake_verity: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_lake_verity: entered Verity lakefront", COLOR_GREEN); + } + return true; +} + +static bool commander_mars_at_verity(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + context.wait_for_all_requests(); + stream.log("commander_mars_at_verity: Completing the Lake Verity battles and Mars dialogue..."); + + /*{ + ShortDialogWatcher rowan_talking(COLOR_BLUE); + int ret = wait_until(stream, context, std::chrono::seconds(30), {{talk_to_clint}}); + if (ret < 0){ + stream.log("commander_mars_at_verity_rowan_talking: dialog box not detected!", COLOR_RED); + return false; + } else if (ret == 0) { + stream.log("commander_mars_at_verity_rowan_talking: dialog box detected.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 4000ms); + context.wait_for_all_requests(); + } + }*/ + + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 2+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2); + + { + MarkDetector grunt_1_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 3200ms, 100ms); // 16 + }, + {{grunt_1_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("commander_mars_at_verity_grunt_1: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "verity_lakefront_grunt_1"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("commander_mars_at_verity_grunt_4: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "verity_lakefront_grunt_2"); + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 2); + + pbf_move_left_joystick(context, {+1, 0}, 1400ms, 100ms); // 7+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 2); + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + + { + MarkDetector grunt_3_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 3200ms, 100ms); // 16 + }, + {{grunt_3_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("commander_mars_at_verity_grunt_3: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "verity_lakefront_grunt_3"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("commander_mars_at_verity_grunt_4: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "verity_lakefront_grunt_4"); + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {+1, 0}, 800ms, 100ms); // 3+ + + { + ShortDialogWatcher commander_mars_fight(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{commander_mars_fight}}); + if (ret < 0){ + stream.log("commander_mars_at_verity: Didn't detect Commander Mars dialog box!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("commander_mars_at_verity: Battling Commander Mars...", COLOR_GREEN); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("commander_mars_at_verity_commander_mars: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "verity_lakefront_commander_mars"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_mash_button(context, BUTTON_B, 10000ms); + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + clear_post_battle_dialogue(stream, context, BUTTON_B, 6000ms); + context.wait_for_all_requests(); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +static bool fly_to_snowpoint_and_heal(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + if (!fly_to(stream, context, FlyPoint::SnowpointCity)){ + return false; + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("fly_to_snowpoint_and_heal: black screen not detected!", COLOR_RED); + return false; + } + stream.log("fly_to_snowpoint_and_heal: entered Snowpoint City Pokemon Center", COLOR_GREEN); + } + + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + context.wait_for_all_requests(); + stream.log("heal_at_snowpoint_pokemon_center: Healing at Snowpoint City Pokemon center and exiting..."); + + if (!heal_pokemon(stream, context, "Snowpoint City", &stats)){ + return false; + } + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + return true; +} + +static bool go_to_snowpoint_gym(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + context.wait_for_all_requests(); + stream.log("go_to_snowpoint_gym: Heading towards Snowpoint City gym..."); + + pbf_move_left_joystick(context, {-1, 0}, 1000ms, 100ms); // 5+ + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 8+ + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 7); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_snowpoint_gym: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_snowpoint_gym: entered Snowpoint Gym", COLOR_GREEN); + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + // TODO(07.07): Record the puzzle, trainers, Candice battle, + // rewards, and return route to the inside entrance for Segment 08. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "07.07 incomplete: Snowpoint Gym entrance reached. Puzzle, Candice battle, " + "rewards, and return path need recorded pathing. Segment 08 must not start.", + stream); +} + +// --------------------------------------------------------------------------- +// Segment wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Segment_07::name() const { + return "07: Canalave to Snowpoint"; +} + +std::string AutoStory_Segment_07::start_text() const { + return "Start: Inside Canalave Gym at the entrance, after Byron, with sixth-badge rewards and dialogue finished."; +} + +std::string AutoStory_Segment_07::end_text() const { + return "Target: Seventh badge and inside Snowpoint Gym at the entrance, with rewards finished. Lake Valor and Snowpoint Gym pathing are pending recording."; +} + +void AutoStory_Segment_07::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_0701().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0702().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0703().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0704().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0705().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0706().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0707().run_checkpoint(env, context, options, stats); + + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + +// --------------------------------------------------------------------------- +// Checkpoint wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Checkpoint_0701::name() const { + return "07.01 - Canalave library events"; +} + +std::string AutoStory_Checkpoint_0701::start_text() const { + return "Inside Canalave Gym at the entrance, after Byron, with sixth-badge rewards and dialogue finished."; +} + +std::string AutoStory_Checkpoint_0701::end_text() const { + return "Outside Canalave library after the meeting and Rowan follow-up, with dialogue finished."; +} + +void AutoStory_Checkpoint_0701::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0701(env, context, options, stats); +} + +void checkpoint_0701(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!leave_gym(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.01: leave_gym did not complete.", + env.console); + } + if (!go_to_the_library(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.01: go_to_the_library did not complete.", + env.console); + } + if (!drama_at_the_library(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.01: drama_at_the_library did not complete.", + env.console); + } + }, + true); +} + +std::string AutoStory_Checkpoint_0702::name() const { + return "07.02 - Canalave to Route 216"; +} + +std::string AutoStory_Checkpoint_0702::start_text() const { + return "Outside Canalave library after the meeting and Rowan follow-up, with dialogue finished."; +} + +std::string AutoStory_Checkpoint_0702::end_text() const { + return "Healed and outside the Route 216 rest house at the doorway."; +} + +void AutoStory_Checkpoint_0702::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0702(env, context, options, stats); +} + +void checkpoint_0702(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!fly_and_enter_mount_coronet(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.02: fly_and_enter_mount_coronet did not complete.", + env.console); + } + if (!go_through_mount_coronet(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.02: go_through_mount_coronet did not complete.", + env.console); + } + if (!go_through_route_216(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.02: go_through_route_216 did not complete.", + env.console); + } + if (!heal_small_house_route_216(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.02: heal_small_house_route_216 did not complete.", + env.console); + } + }, + true); +} + +std::string AutoStory_Checkpoint_0703::name() const { + return "07.03 - Route 217 and Snowpoint"; +} + +std::string AutoStory_Checkpoint_0703::start_text() const { + return "Healed and outside the Route 216 rest house at the doorway."; +} + +std::string AutoStory_Checkpoint_0703::end_text() const { + return "Healed and outside Veilstone Pokemon Center after the first Snowpoint visit and Rock Climb pickup."; +} + +void AutoStory_Checkpoint_0703::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0703(env, context, options, stats); +} + +void checkpoint_0703(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!continue_through_route_216(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.03: continue_through_route_216 did not complete.", + env.console); + } + if (!heal_and_exit_veilstone_pokemon_center(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.03: heal_and_exit_veilstone_pokemon_center did not complete.", + env.console); + } + }, + true); +} + +std::string AutoStory_Checkpoint_0704::name() const { + return "07.04 - Lake Valor (path pending)"; +} + +std::string AutoStory_Checkpoint_0704::start_text() const { + return "Healed and outside Veilstone Pokemon Center after the first Snowpoint visit and Rock Climb pickup."; +} + +std::string AutoStory_Checkpoint_0704::end_text() const { + return "Target: Saturn defeated; healed and outside the Twinleaf home. Currently stops at the unrecorded Lake Valor cave approach."; +} + +void AutoStory_Checkpoint_0704::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0704(env, context, options, stats); +} + +void checkpoint_0704(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!go_to_house_south_veilstone(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.04: go_to_house_south_veilstone did not complete.", + env.console); + } + if (!go_through_house_south_veilstone(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.04: go_through_house_south_veilstone did not complete.", + env.console); + } + if (!walk_through_route_214(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.04: walk_through_route_214 did not complete.", + env.console); + } + if (!go_through_lake_valor(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.04: go_through_lake_valor did not complete.", + env.console); + } + if (!return_to_twinleaf_and_heal(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.04: return_to_twinleaf_and_heal did not complete.", + env.console); + } + }, + true); +} + +std::string AutoStory_Checkpoint_0705::name() const { + return "07.05 - Lake Verity and Mars"; +} + +std::string AutoStory_Checkpoint_0705::start_text() const { + return "Outside the Twinleaf home, healed, after completing Lake Valor and Saturn."; +} + +std::string AutoStory_Checkpoint_0705::end_text() const { + return "At Lake Verity after Mars, with follow-up dialogue finished and outdoor control restored."; +} + +void AutoStory_Checkpoint_0705::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0705(env, context, options, stats); +} + +void checkpoint_0705(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!go_to_lake_verity(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.05: go_to_lake_verity did not complete.", + env.console); + } + if (!commander_mars_at_verity(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.05: commander_mars_at_verity did not complete.", + env.console); + } + }, + true); +} + +std::string AutoStory_Checkpoint_0706::name() const { + return "07.06 - Return to Snowpoint"; +} + +std::string AutoStory_Checkpoint_0706::start_text() const { + return "At Lake Verity after Mars, with follow-up dialogue finished and outdoor control restored."; +} + +std::string AutoStory_Checkpoint_0706::end_text() const { + return "Healed and outside Snowpoint Pokemon Center at the doorway."; +} + +void AutoStory_Checkpoint_0706::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0706(env, context, options, stats); +} + +void checkpoint_0706(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!fly_to_snowpoint_and_heal(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.06: fly_to_snowpoint_and_heal did not complete.", + env.console); + } + }, + true); +} + +std::string AutoStory_Checkpoint_0707::name() const { + return "07.07 - Snowpoint Gym (path pending)"; +} + +std::string AutoStory_Checkpoint_0707::start_text() const { + return "Healed and outside Snowpoint Pokemon Center at the doorway."; +} + +std::string AutoStory_Checkpoint_0707::end_text() const { + return "Target: Seventh badge, rewards finished, inside Snowpoint Gym at the entrance. Currently stops at the unrecorded Gym puzzle."; +} + +void AutoStory_Checkpoint_0707::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0707(env, context, options, stats); +} + +void checkpoint_0707(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + if (!go_to_snowpoint_gym(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "07.07: go_to_snowpoint_gym did not complete.", + env.console); + } + }, + true); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_07.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_07.h new file mode 100644 index 0000000000..f4ab8fe8c9 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_07.h @@ -0,0 +1,137 @@ +/* BDSP AutoStory - Segment 07 + * From after acquiring the sixth badge until after acquiring the seventh badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment07_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment07_H + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +class AutoStory_Segment_07 : 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_0701 : 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; +}; +void checkpoint_0701(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0702 : 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; +}; +void checkpoint_0702(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0703 : 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; +}; +void checkpoint_0703(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0704 : 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; +}; +void checkpoint_0704(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0705 : 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; +}; +void checkpoint_0705(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0706 : 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; +}; +void checkpoint_0706(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0707 : 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; +}; +void checkpoint_0707(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_08.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_08.cpp new file mode 100644 index 0000000000..57d3ceaa33 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_08.cpp @@ -0,0 +1,2606 @@ +/* BDSP AutoStory - Segment 08 + * From after acquiring the seventh badge until after acquiring the eighth badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/Exceptions/FatalProgramException.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/Globals.h" + +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "CommonTools/VisualDetectors/ImageMatchDetector.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_Superscalar.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MarkFinder.h" +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h" + +#include "PokemonBDSP_AutoStory_Segment_08.h" + +using namespace std::chrono_literals; + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static void approach_legendary(VideoStream& /*stream*/, ProControllerContext& context){ + pbf_wait(context, 2000ms); + pbf_press_button(context, BUTTON_A, 100ms, 300ms); + pbf_wait(context, 2000ms); +} + +static void enter_legendary_battle(VideoStream& /*stream*/, ProControllerContext& context){ + pbf_press_button(context, BUTTON_A, 100ms, 100ms); + pbf_wait(context, 3000ms); +} + +static void weaken_legendary(VideoStream& /*stream*/, ProControllerContext& context){ + pbf_press_button(context, BUTTON_A, 100ms, 100ms); + pbf_wait(context, 1500ms); + pbf_press_dpad(context, DPAD_UP, 100ms, 100ms); + pbf_press_button(context, BUTTON_A, 100ms, 100ms); + pbf_wait(context, 2000ms); +} + +static void throw_pokeball_at_legendary(VideoStream& /*stream*/, ProControllerContext& context){ + for (int i = 0; i < 3; i++){ + pbf_press_button(context, BUTTON_A, 100ms, 100ms); + pbf_wait(context, 1000ms); + pbf_press_dpad(context, DPAD_DOWN, 100ms, 100ms); + pbf_press_button(context, BUTTON_A, 100ms, 100ms); + pbf_wait(context, 2500ms); + } +} + +static void confirm_legendary_catch(VideoStream& /*stream*/, ProControllerContext& context){ + pbf_wait(context, 1500ms); + pbf_press_button(context, BUTTON_A, 100ms, 100ms); + pbf_wait(context, 2000ms); +} + +static void exit_legendary_encounter(VideoStream& /*stream*/, ProControllerContext& context){ + pbf_press_button(context, BUTTON_A, 100ms, 100ms); + pbf_wait(context, 2000ms); +} + +static void +move_back_after_unexpected_battle(VideoStream& stream, ProControllerContext& context, const std::string& trainerid){ + stream.log("Finishing battle with " + trainerid, COLOR_GREEN); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + if (trainerid == "veilstone_warehouse_warehouse_grunt_1"){ + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 5+ + }else if (trainerid == "galactic_hq_grunt_1"){ + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 3+ + }else if (trainerid == "route_207_hiker_justin"){ + pbf_move_left_joystick(context, {+1, 0}, 800ms, 100ms); // 3+ + } +} + +static bool handle_battle(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, const std::string& trainerid){ + fight_trainer_using_moves(stream, context, stats, trainerid, true); + return true; +} + +static bool heal_and_exit(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, const std::string& label){ + return heal_pokemon(stream, context, label, &stats); +} + +// --------------------------------------------------------------------------- +// Named route navigation blocks +// --------------------------------------------------------------------------- + +static bool leave_gym(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("leave_gym: leaving the gym..."); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("leave_gym: black screen not detected!", COLOR_RED); + return false; + } + stream.log("leave_gym: exited gym", COLOR_GREEN); + }; + return true; +} + +static bool heal_at_snowpoint_city_pokemon_center(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("heal_at_snowpoint_city_pokemon_center: flying towards the pokemon center..."); + + fly_to(stream, context, FlyPoint::CurrentCity); + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("heal_at_snowpoint_city_pokemon_center: black screen not detected!", COLOR_RED); + return false; + } + stream.log("heal_at_snowpoint_city_pokemon_center: entered Pokemon Center.", COLOR_GREEN); + }; + return true; +} + +static bool go_to_lake_acuity(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("heal_at_snowpoint_city_pokemon_center: flying towards the pokemon center..."); + + pbf_move_left_joystick(context, {-1, 0}, 1000ms, 100ms); // 5+ + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 8+ + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 8); + pbf_move_left_joystick(context, {0, -1}, 4000ms, 100ms); // 20+ + pbf_move_left_joystick(context, {-1, 0}, 1400ms, 100ms); // 7+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {-1, 0}, 1400ms, 100ms); // 7+ + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 8+ + pbf_move_left_joystick(context, {-1, 0}, 1800ms, 100ms); // 9+ + pbf_move_left_joystick(context, {0, -1}, 1600ms, 100ms); // 8+ + + context.wait_for_all_requests(); + activate_repel(stream, context); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {-1, 0}, 2400ms, 100ms); // 12+ + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {-1, 0}, 8400ms, 100ms); // 28++ (snow) + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 6); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_lake_acuity: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_lake_acuity: entered Acuity lakefront.", COLOR_GREEN); + }; + return true; +} + +static bool talk_to_clint_then_go_veilstone(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("talk_to_clint_then_go_veilstone: first talking to Clint and then flying to Veilstone city..."); + + { + ShortDialogWatcher listen_to_clint_and_jupiter(COLOR_BLUE); + int ret = wait_until(stream, context, std::chrono::seconds(30), {{listen_to_clint_and_jupiter}}); + if (ret < 0){ + stream.log("talk_to_clint_then_go_veilstone_listen_to_clint_and_jupiter: dialog box not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("talk_to_clint_then_go_veilstone_listen_to_clint_and_jupiter: dialog box detected.", + COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 22000ms); + context.wait_for_all_requests(); + } + } + + fly_to(stream, context, FlyPoint::VeilstoneCity); + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("talk_to_clint_then_go_veilstone: black screen not detected!", COLOR_RED); + return false; + } + stream.log("talk_to_clint_then_go_veilstone: entered Veilstone City Pokemon Center.", COLOR_GREEN); + }; + return true; +} + +static bool get_key_and_enter_warehouse(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("get_key_and_enter_warehouse: Getting the warehouse key then entering the warehouse..."); + + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 6); + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 4); + pbf_move_left_joystick(context, {+1, 0}, 3200ms, 100ms); // 16+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 4600ms, 100ms); // 23+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_through_mount_coronet: Not reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("go_through_mount_coronet: Didn't detect repel dialog box!", COLOR_RED); + return false; + } + } + + pbf_move_left_joystick(context, {0, +1}, 4600ms, 100ms); // 23+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + + { + ShortDialogWatcher talk_to_grunt(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_grunt}}); + if (ret == 0){ + stream.log("get_key_and_enter_warehouse: Talking to the grunt.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 9000ms); + }else if (ret < 0){ + stream.log("get_key_and_enter_warehouse: Didn't detect grunt dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + { + ShortDialogWatcher pick_up_key(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{pick_up_key}}); + if (ret == 0){ + stream.log("get_key_and_enter_warehouse: Got the key.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 5000ms); + }else if (ret < 0){ + stream.log("get_key_and_enter_warehouse: Didn't detect key pickup dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2); + + pbf_move_left_joystick(context, {0, -1}, 3200ms, 100ms); // 23 + pbf_move_left_joystick(context, {-1, 0}, 2700ms, 100ms); // 21 + pbf_move_left_joystick(context, {0, +1}, 1800ms, 100ms); // 9+ + pbf_move_left_joystick(context, {-1, 0}, 2600ms, 100ms); // 13+ + pbf_move_left_joystick(context, {0, +1}, 2600ms, 100ms); // 13+ + pbf_move_left_joystick(context, {+1, 0}, 2600ms, 100ms); // 12+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("get_key_and_enter_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("get_key_and_enter_warehouse: entered warehouse.", COLOR_GREEN); + }; + return true; +} + +static bool go_through_warehouse(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_warehouse: Going through the warehouse..."); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + + { + ShortDialogWatcher open_door(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{open_door}}); + if (ret == 0){ + stream.log("go_through_warehouse: Opened the door.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 4000ms); + }else if (ret < 0){ + stream.log("go_through_warehouse: Didn't detect door dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 4+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into the warehouse's B2F.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {+1, 0}, 7400ms, 100ms); // 37+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + + { + MarkDetector warehouse_grunt_1_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 5+ + pbf_move_left_joystick(context, {0, -1}, 400ms, 100ms); // 2+ + pbf_move_left_joystick(context, {+1, 0}, 10800ms, 100ms); // 54+ + }, + {{warehouse_grunt_1_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("veilstone_warehouse_warehouse_grunt_1: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "veilstone_warehouse_warehouse_grunt_1"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "veilstone_warehouse_warehouse_grunt_1"); + } + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, -1}, 200ms, 100ms); // 1 + + + { + MarkDetector warehouse_grunt_2_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 13000ms, 100ms); // 54+ + }, + {{warehouse_grunt_2_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("veilstone_warehouse_warehouse_grunt_2: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "veilstone_warehouse_warehouse_grunt_2"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "veilstone_warehouse_warehouse_grunt_2"); + } + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {+1, 0}, 1000ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 8+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into the warehouse's B1F.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 5); + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 5+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into the Galactic HQ 1F, first room.", COLOR_GREEN); + }; + + pbf_move_left_joystick(context, {+1, 0}, 1400ms, 100ms); // 6+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {0, -1}, 1400ms, 100ms); // 6+ + + { + BlackScreenFlashWatcher black_screen(COLOR_BLUE, {0.1, 0.1, 0.8, 0.8}); + MarkDetector galactic_hq_grunt_1_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 2200ms, 100ms); // 11+ + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 6 + }, + {{black_screen}, {galactic_hq_grunt_1_fight}}); + if (ret == 0){ + stream.log("go_through_warehouse: got into Galactic HQ 1F, second room.", COLOR_GREEN); + }else if (ret == 1){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen_2(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen_2}}); + if (ret < 0){ + stream.log("galactic_hq_grunt_1: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "galactic_hq_grunt_1"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "galactic_hq_grunt_1"); + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + { + BlackScreenFlashWatcher room_transition(COLOR_BLUE, {0.1, 0.1, 0.8, 0.8}); + int ret1 = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{room_transition}}); + if (ret1 < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into Galactic HQ 1F, second room.", COLOR_GREEN); + }; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {+1, 0}, 1000ms, 100ms); // 4+ + + { + BlackScreenFlashWatcher black_screen(COLOR_BLUE, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into the Galactic HQ 1F, third room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 5+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into the Galactic HQ 2F, first room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {-1, 0}, 1400ms, 100ms); // 6+ + + { + BlackScreenFlashWatcher black_screen(COLOR_BLUE, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into the Galactic HQ 2F, second room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {+1, 0}, 400ms, 100ms); // 2 + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 2+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into the Galactic HQ 1F, fourth room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 4); + + { + MarkDetector galactic_hq_scientist_fredrick_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 1000ms, 100ms); // 6 + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); // 6+ + }, + {{galactic_hq_scientist_fredrick_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("galactic_hq_scientist_fredrick: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "galactic_hq_scientist_fredrick"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 3+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into the Galactic Warehouse B2F.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {-1, 0}, 4600ms, 100ms); // 23+ + pbf_move_left_joystick(context, {0, -1}, 1600ms, 100ms); // 8+ + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 7); + + { + ShortDialogWatcher pick_up_galactic_key(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{pick_up_galactic_key}}); + if (ret == 0){ + stream.log("go_through_warehouse: Picked up the Galactic key.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("go_through_warehouse: Didn't detect grunt dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, -1}, 1200ms, 100ms); // 5+ + pbf_move_left_joystick(context, {-1, 0}, 1400ms, 100ms); // 6+ + + { + ShortDialogWatcher open_door(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{open_door}}); + if (ret == 0){ + stream.log("go_through_warehouse: Opened the first door with the Galactic Key.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 4000ms); + }else if (ret < 0){ + stream.log("go_through_warehouse: Didn't detect door opening dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 1400ms, 100ms); // 6+ + pbf_move_left_joystick(context, {-1, 0}, 1800ms, 100ms); // 8+ + pbf_move_left_joystick(context, {0, -1}, 3200ms, 100ms); // 16+ + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 3); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got into the Galactic Warehouse entrance area.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 5+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_warehouse: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_warehouse: got out of the Galactic Warehouse.", COLOR_GREEN); + }; + + return true; +} + +static bool heal_at_veilstone_pokemon_center(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("heal_at_veilstone_pokemon_center: Flying to Veilstone Pokemon Center and entering..."); + + fly_to(stream, context, FlyPoint::CurrentCity); + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("heal_at_veilstone_pokemon_center: black screen not detected!", COLOR_RED); + return false; + } + stream.log("heal_at_veilstone_pokemon_center: entered the Veilstone Pokemon Center.", COLOR_GREEN); + }; + + return true; +} + +static bool go_to_galactic_hq(VideoStream& stream, ProControllerContext& context){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_galactic_hq: Going through the warehouse..."); + + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 6); + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 4); + pbf_move_left_joystick(context, {+1, 0}, 3200ms, 100ms); // 16+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + + pbf_move_left_joystick(context, {0, +1}, 4600ms, 100ms); // 23+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 7); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_galactic_hq, entering the Galactic HQ from the front door: black screen not detected!", + COLOR_RED); + return false; + } + stream.log("go_to_galactic_hq: entered the Galactic HQ from the front door.", COLOR_GREEN); + }; + + return true; +} + +static bool go_through_galactic_hq(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, AutoStoryOptions options){ + // ------- This has returned one error and I couldn't reproduce it. It was somewhere at the other comment I left behind ----------- // + + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_galactic_hq: Going through the HQ again and battling Cyrus..."); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 6); + + { + ShortDialogWatcher open_door(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{open_door}}); + if (ret == 0){ + stream.log("go_through_galactic_hq: Opened the door.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 4000ms); + }else if (ret < 0){ + stream.log("go_through_galactic_hq, opening the door: Didn't detect door dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 5+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq, entering Galactic HQ's 2F, first room: black screen not detected!", + COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: entered Galactic HQ's 2F, first room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 4+ + + + bool grunt_2_done = false; + { + MarkDetector galactic_hq_grunt_2_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + // First run to the right, and see grunt 2 detects you + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 3200ms, 100ms); // 16+ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + }, + {{galactic_hq_grunt_2_fight}}); + if (ret < 0){ + // If it doesn't, move on, but consider that grunt_2 hasn't been done + stream.log("galactic_hq_grunt_2: mark not detected, moving on..."); + }else if (ret == 0){ + // If it does, handle the fight and consider that grunt_2 has been done + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("galactic_hq_grunt_2: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "galactic_hq_grunt_2"); + grunt_2_done = true; + } + } + } + + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + // Move to the end against the wall + pbf_move_left_joystick(context, {+1, 0}, 1000ms, 100ms); // 5+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + + if (options.help_mode == HelpMode::OUTSIDE_HELP){ + if (!grunt_2_done){ + { + // Now run downwards and check if either mark is detected (it should) + MarkDetector galactic_hq_grunt_2_3_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 5000ms, 100ms); // 16+ + }, + {{galactic_hq_grunt_2_3_fight}}); + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + if (ret < 0){ + // Mark isn't detected, there is an issue + stream.log("galactic_hq_grunt_2_3: mark not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + context.wait_for_all_requests(); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("galactic_hq_grunt_2: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "galactic_hq_grunt_2"); + } + } + } + } + + context.wait_for_all_requests(); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, -1}, 0ms, 10000ms); + ssf_mash1_button(context, BUTTON_A, 10000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("galactic_hq_grunt_2: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + context.wait_for_all_requests(); + + handle_battle(stream, context, stats, "galactic_hq_grunt_3"); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, -1}, 1200ms, 100ms); // 5+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 7); + pbf_move_left_joystick(context, {0, +1}, 2600ms, 100ms); // 13+ + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 3+ + + { + BlackScreenFlashWatcher black_screen(COLOR_BLUE, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: got into the Galactic HQ 2F, third room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 3+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: entered Galactic HQ's 3F, first room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + + if (options.help_mode == HelpMode::OUTSIDE_HELP){ + { + MarkDetector galactic_hq_grunt_4_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); // 5+ + }, + {{galactic_hq_grunt_4_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("galactic_hq_grunt_4: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "galactic_hq_grunt_4"); + } + } + } + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 10000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("galactic_hq_grunt_5: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "galactic_hq_grunt_5"); + } + } + }; + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {-1, 0}, 4400ms, 100ms); // 22+ + pbf_move_left_joystick(context, {0, -1}, 2200ms, 100ms); // 11+ + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 5+ + + { + BlackScreenFlashWatcher black_screen(COLOR_BLUE, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: got into the Galactic HQ 1F, fifth room.", COLOR_GREEN); + }; + + // ---------------------------------------- The error happened around here ---------------------------------------- // + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 3+ + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: entered Galactic HQ's 4F.", COLOR_GREEN); + }; + + // ---------------------------------------- Until here ---------------------------------------- // + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 5); + + { + ShortDialogWatcher open_door(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{open_door}}); + if (ret == 0){ + stream.log("go_through_galactic_hq: Opened the door with the Galactic Key.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 4000ms); + }else if (ret < 0){ + stream.log("go_through_galactic_hq: Didn't detect door opening dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + { + ShortDialogWatcher cyrus_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); // 5+ + }, + {{cyrus_dialog}}); + if (ret == 0){ + stream.log("go_through_galactic_hq: Cyrus dialog box detected.", COLOR_GREEN); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("galactic_hq_cyrus: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "galactic_hq_cyrus"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_mash_button(context, BUTTON_A, 17000ms); + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {+1, 0}, 1400ms, 100ms); // 6+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {+1, 0}, 1600ms, 100ms); // 8+ + + { + BlackScreenFlashWatcher black_screen(COLOR_BLUE, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: got into the Galactic HQ 4F, second room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {+1, 0}, 7600ms, 100ms); // 38+ + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 8+ + pbf_move_left_joystick(context, {-1, 0}, 1400ms, 100ms); // 6+ + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: entered Galactic HQ's 4F Back.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 7+ + + { + ShortDialogWatcher talk_to_commander_saturn(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {+1, 0}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_commander_saturn}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: Didn't detect Commander Saturn dialog box!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_through_galactic_hq: Starting the battle with Commander Saturn.", COLOR_GREEN); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("galactic_hq_commander_saturn: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "galactic_hq_commander_saturn"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {+1, 0}, 400ms, 100ms); // 1+ + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + + { + ShortDialogWatcher unlock_guardians(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {0, +1}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{unlock_guardians}}); + if (ret == 0){ + stream.log("go_through_galactic_hq: Released the Lake Guardians.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 4000ms); + pbf_mash_button(context, BUTTON_B, 8000ms); + }else if (ret < 0){ + stream.log("go_through_galactic_hq: Didn't detect button dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {0, -1}, 1600ms, 100ms); // 7+ + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: exited Galactic HQ's 4F Back.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 8); + + { + BlackScreenFlashWatcher black_screen(COLOR_BLUE, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: exited Galactic HQ 4F second room, entering 4F first room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, -1}, 1400ms, 100ms); // 6+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {0, -1}, 1800ms, 100ms); // 9+ + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 1); + + { + BlackScreenFlashWatcher black_screen(COLOR_BLUE, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: exited Galactic HQ 4F first room, entering 1F sixth room.", COLOR_GREEN); + }; + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, -1}, 1600ms, 100ms); // 8+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + + { + WhiteScreenOverWatcher white_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{white_screen}}); + if (ret < 0){ + stream.log("go_through_galactic_hq: white screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_galactic_hq: exited Galactic HQ.", COLOR_GREEN); + }; + + return true; +} + +static bool fly_and_heal_at_oreburgh(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("fly_and_heal_at_oreburgh: Flying to Oreburgh City and healing pokemon..."); + + fly_to(stream, context, FlyPoint::OreburghCity); + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("fly_and_heal_at_oreburgh: black screen not detected!", COLOR_RED); + return false; + } + stream.log("fly_and_heal_at_oreburgh: entered Oreburgh City's Pokemon Center.", COLOR_GREEN); + }; + + return true; +} + +static bool go_to_mount_coronet(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_mount_coronet: Heading towards Mount Coronet..."); + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 6); + pbf_move_left_joystick(context, {0, +1}, 6000ms, 100ms); // 30+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {+1, 0}, 1600ms, 100ms); // 7+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + + context.wait_for_all_requests(); + get_on_bicycle(stream, context, GearLevel::Fast); + + pbf_move_left_joystick(context, {0, +1}, 1300ms, 100ms); // 9- (bike fast gear) + pbf_press_button(context, BUTTON_PLUS, 80ms, 300ms); + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {+1, 0}, 3400ms, 100ms); // 26 + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 4+ + + { + MarkDetector hiker_justin_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 5000ms, 100ms); // 11+ + }, + {{hiker_justin_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_mount_coronet, route_207_hiker_justin: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_207_hiker_justin"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_207_hiker_justin"); + } + } + } + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_mount_coronet: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_mount_coronet: entered Mt. Coronet.", COLOR_GREEN); + }; + + return true; +} + +static bool go_through_mount_coronet(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options, bool summit_events = false){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_mount_coronet: Going through Mount Coronet..."); + + // TODO(08.05/08.06): Record the ascent and a stable pre-boss position, + // then the summit battles, story dialogue, and legendary approach/exit. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + summit_events + ? "08.06 incomplete: Spear Pillar battles, story and legendary route need recording." + : "08.05 incomplete: Coronet ascent and the pre-boss handoff need recording.", + stream); +} + +static bool fly_and_heal_at_veilstone(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("fly_and_heal_at_veilstone: Flying to Veilstone City and healing there..."); + + fly_to(stream, context, FlyPoint::VeilstoneCity); + + context.wait_for_all_requests(); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("fly_and_heal_at_veilstone: black screen not detected!", COLOR_RED); + return false; + } + stream.log("fly_and_heal_at_veilstone: entered Veilstone City Pokemon Center.", COLOR_GREEN); + }; + + return true; +} + +static bool go_to_house_south_veilstone(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_house_south_veilstone: heading South..."); + + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {-1, 0}, 4000ms, 100ms); // 20+ + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 5+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 9); + + pbf_move_left_joystick(context, {0, -1}, 1400ms, 100ms); // 8 + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {0, -1}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {+1, 0}, 1800ms, 100ms); // 7+ + + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 3, true); + + pbf_move_left_joystick(context, {+1, 0}, 2200ms, 100ms); // 15 + context.wait_for_all_requests(); + + { + BlackScreenOverWatcher black_screen_1(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen_1}}); + if (ret < 0){ + stream.log("go_to_route_214_enter_house: black screen not detected!", COLOR_RED); + return false; + } + } + + return true; +} + +static bool +go_through_house_south_veilstone(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_house_south_veilstone: moving towards exit..."); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_house_south_veilstone: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_house_south_veilstone: exited the house", COLOR_GREEN); + } + + return true; +} + +static bool go_to_house_west_sunyshore(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_house_west_sunyshore: going towards Sunyshore City..."); + + pbf_move_left_joystick(context, {+1, 0}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, -1}, 3800ms, 100ms); // 19+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 8); + + activate_repel(stream, context); + + pbf_move_left_joystick(context, {0, -1}, 2600ms, 100ms); // 13+ + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {0, -1}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {-1, 0}, 400ms, 100ms); // 2 + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 5+ + + pbf_move_left_joystick(context, {+1, 0}, 1600ms, 100ms); // 8+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {0, -1}, 1600ms, 100ms); // 8+ + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {0, -1}, 2200ms, 100ms); // 11+ + + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 3+ + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2); + + pbf_move_left_joystick(context, {0, -1}, 6200ms, 100ms); // 31+ + context.wait_for_all_requests(); + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 10+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_to_house_west_sunyshore: Reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 3000ms); + }else if (ret < 0){ + stream.log("go_to_house_west_sunyshore: Didn't detect repel dialog box (1)!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {+1, 0}, 3000ms, 100ms); // 15+ + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 9); + pbf_move_left_joystick(context, {0, -1}, 8400ms, 100ms); // 42+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 4); + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 5); + pbf_move_left_joystick(context, {+1, 0}, 7400ms, 100ms); // 37+ + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 7400ms, 100ms); // 37+ + pbf_move_left_joystick(context, {+1, 0}, 7400ms, 100ms); // 37+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_to_house_west_sunyshore: Not reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("go_to_house_west_sunyshore: Didn't detect repel dialog box (2)!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {+1, 0}, 7400ms, 100ms); // 37+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {+1, 0}, 1400ms, 100ms); // 7+ + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {+1, 0}, 5200ms, 100ms); // 26+ + pbf_move_left_joystick(context, {0, -1}, 400ms, 100ms); // 1+ + + use_HM(stream, context, "Rock Smash"); + + context.wait_for_all_requests(); + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2); + + { + MarkDetector route_222_sailor_luther_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); // 5+ + }, + {{route_222_sailor_luther_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_house_west_sunyshore_route_222_sailor_luther: black screen not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_222_sailor_luther"); + } + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {+1, 0}, 2400ms, 100ms); // 12+ + pbf_move_left_joystick(context, {0, -1}, 1400ms, 100ms); // 6+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 1); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_house_west_sunyshore: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_house_west_sunyshore: entered the house", COLOR_GREEN); + } + + return true; +} + +static bool +go_through_house_west_sunyshore(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_house_west_sunyshore: moving towards exit..."); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_house_south_veilstone: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_house_south_veilstone: exited the house", COLOR_GREEN); + } + + return true; +} + +static bool go_to_sunyshore_lighthouse(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_to_sunyshore_lighthouse: going towards the lighthouse to talk to Volkner..."); + + { + ShortDialogWatcher listen_to_flint(COLOR_BLUE); + int ret = wait_until(stream, context, std::chrono::seconds(30), {{listen_to_flint}}); + if (ret < 0){ + stream.log("go_to_house_west_sunyshore, listen_to_flint: dialog box not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("go_to_house_west_sunyshore, listen_to_flint: dialog box detected.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 15000ms); + context.wait_for_all_requests(); + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 10+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 2); + pbf_move_left_joystick(context, {0, +1}, 3000ms, 100ms); // 15+ + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 8); + pbf_move_left_joystick(context, {0, -1}, 3400ms, 100ms); // 17+ + pbf_move_left_joystick(context, {+1, 0}, 7600ms, 100ms); // 38+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_house_west_sunyshore: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_house_west_sunyshore: entered the lighthouse", COLOR_GREEN); + } + + return true; +} + +static bool +talk_to_volkner_at_the_lighthouse(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + stream.log("talk_to_volkner_at_the_lighthouse: Going up and talking to Volkner..."); + + { + BlackScreenFlashWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = wait_until(stream, context, std::chrono::seconds(30), {{black_screen}}); + if (ret < 0){ + stream.log("talk_to_volkner_at_the_lighthouse: black screen exiting elevator not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("talk_to_volkner_at_the_lighthouse: exited the elevator.", COLOR_GREEN); + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {0, +1}, 1400ms, 100ms); // 7+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + + { + ShortDialogWatcher talk_to_volkner(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {+1, 0}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_volkner}}); + if (ret == 0){ + stream.log("talk_to_volkner_at_the_lighthouse: Talking to Volkner.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 11000ms); + }else if (ret < 0){ + stream.log("talk_to_volkner_at_the_lighthouse: Didn't detect Volkner dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {0, -1}, 1400ms, 100ms); // 7+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 4); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("talk_to_volkner_at_the_lighthouse: entering elevator to exit black screen not detected!", + COLOR_RED); + return false; + } + stream.log("talk_to_volkner_at_the_lighthouse: entered the elevator", COLOR_GREEN); + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + { + BlackScreenFlashWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = wait_until(stream, context, std::chrono::seconds(30), {{black_screen}}); + if (ret < 0){ + stream.log("talk_to_volkner_at_the_lighthouse: black screen exiting elevator not detected!", COLOR_RED); + return false; + }else if (ret == 0){ + stream.log("talk_to_volkner_at_the_lighthouse: exited the elevator.", COLOR_GREEN); + } + } + + return true; +} + +static bool go_to_sunyshore_gym(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + stream.log("go_to_sunyshore_gym: heading towards Sunyshore City's gym..."); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 14); + pbf_move_left_joystick(context, {0, +1}, 4000ms, 100ms); // 20+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 4); + pbf_move_left_joystick(context, {0, +1}, 5400ms, 100ms); // 27+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 6); + pbf_move_left_joystick(context, {-1, 0}, 6800ms, 100ms); // 34+ + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 2+ + + { + ShortDialogWatcher talk_to_flint(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + ssf_press_left_joystick(context, {+1, 0}, 0ms, 5000ms); + ssf_mash1_button(context, BUTTON_A, 5000ms); + }, + {{talk_to_flint}}); + if (ret == 0){ + stream.log("go_to_sunyshore_gym: Talking to Flint.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 9000ms); + }else if (ret < 0){ + stream.log("go_to_sunyshore_gym: Didn't detect Flint dialog box!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_sunyshore_gym: black screen entering the gym not detected!", COLOR_RED); + return false; + } + stream.log("go_to_sunyshore_gym: entered the gym.", COLOR_GREEN); + } + + // TODO(08.09): Record the puzzle, trainers, Volkner, rewards and return + // to the inside entrance for Segment 09. The old second entry was not a puzzle. + context.wait_for_all_requests(); + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "08.09 incomplete: Sunyshore Gym entered. Puzzle, Volkner battle, rewards and return need recording.", + stream); +} + +// Retained box-legendary inputs; approach and exit positions require the summit recording. +static void recorded_box_legendary_encounter(SingleSwitchProgramEnvironment& env, + ProControllerContext& context){ + approach_legendary(env.console, context); + enter_legendary_battle(env.console, context); + weaken_legendary(env.console, context); + throw_pokeball_at_legendary(env.console, context); + confirm_legendary_catch(env.console, context); + exit_legendary_encounter(env.console, context); +} + +// --------------------------------------------------------------------------- +// Segment wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Segment_08::name() const { + return "08: Snowpoint to Sunyshore"; +} + +std::string AutoStory_Segment_08::start_text() const { + return "Start: Inside Snowpoint Gym at the entrance, with seventh-badge rewards and dialogue finished."; +} + +std::string AutoStory_Segment_08::end_text() const { + return "Target: Inside Sunyshore Gym entrance with eighth-badge rewards finished. Currently stops at the missing puzzle."; +} + +void AutoStory_Segment_08::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_0801().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0802().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0803().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0804().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0805().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0806().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0807().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0808().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0809().run_checkpoint(env, context, options, stats); + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + +// --------------------------------------------------------------------------- +// Checkpoint wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Checkpoint_0801::name() const { + return "08.01 - Snowpoint recovery"; +} + +std::string AutoStory_Checkpoint_0801::start_text() const { + return "Inside Snowpoint Gym at the entrance, with seventh-badge rewards and dialogue finished."; +} + +std::string AutoStory_Checkpoint_0801::end_text() const { + return "Healed and outside Snowpoint Pokemon Center at the doorway."; +} + +void AutoStory_Checkpoint_0801::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0801(env, context, options, stats); +} + +void checkpoint_0801(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!leave_gym(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "leave_gym: transition not detected.", + env.console); + } + } + { + if (!heal_at_snowpoint_city_pokemon_center(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_at_snowpoint_city_pokemon_center: transition not detected.", + env.console); + } + } + { + if (!heal_and_exit(env.console, context, stats, "Snowpoint City")){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_and_exit_snowpoint_city: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0802::name() const { + return "08.02 - Lake Acuity and Veilstone"; +} + +std::string AutoStory_Checkpoint_0802::start_text() const { + return "Healed and outside Snowpoint Pokemon Center at the doorway."; +} + +std::string AutoStory_Checkpoint_0802::end_text() const { + return "Healed and outside Veilstone Pokemon Center after the Lake Acuity events."; +} + +void AutoStory_Checkpoint_0802::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0802(env, context, options, stats); +} + +void checkpoint_0802(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_lake_acuity(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_lake_acuity: transition not detected.", + env.console); + } + } + { + if (!talk_to_clint_then_go_veilstone(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "talk_to_clint_then_go_veilstone: transition not detected.", + env.console); + } + } + { + if (!heal_and_exit(env.console, context, stats, "Veilstone City")){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_and_exit_veilstone_city: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0803::name() const { + return "08.03 - Galactic Warehouse"; +} + +std::string AutoStory_Checkpoint_0803::start_text() const { + return "Healed and outside Veilstone Pokemon Center after the Lake Acuity events."; +} + +std::string AutoStory_Checkpoint_0803::end_text() const { + return "Healed and outside Veilstone Pokemon Center with the Galactic Key obtained."; +} + +void AutoStory_Checkpoint_0803::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0803(env, context, options, stats); +} + +void checkpoint_0803(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!get_key_and_enter_warehouse(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "get_key_and_enter_warehouse: transition not detected.", + env.console); + } + } + { + if (!go_through_warehouse(env.console, context, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_warehouse: transition not detected.", + env.console); + } + } + { + if (!heal_at_veilstone_pokemon_center(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_at_veilstone_pokemon_center: transition not detected.", + env.console); + } + } + { + if (!heal_and_exit(env.console, context, stats, "Veilstone City")){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_and_exit_veilstone_city: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0804::name() const { + return "08.04 - Galactic HQ and Oreburgh"; +} + +std::string AutoStory_Checkpoint_0804::start_text() const { + return "Healed and outside Veilstone Pokemon Center with the Galactic Key obtained."; +} + +std::string AutoStory_Checkpoint_0804::end_text() const { + return "Healed and outside Oreburgh Pokemon Center after the Galactic HQ events."; +} + +void AutoStory_Checkpoint_0804::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0804(env, context, options, stats); +} + +void checkpoint_0804(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_galactic_hq(env.console, context)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_galactic_hq: transition not detected.", + env.console); + } + } + { + if (!go_through_galactic_hq(env.console, context, stats, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_galactic_hq: transition not detected.", + env.console); + } + } + { + if (!fly_and_heal_at_oreburgh(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "fly_and_heal_at_oreburgh: transition not detected.", + env.console); + } + } + { + if (!heal_and_exit(env.console, context, stats, "Oreburgh City")){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_and_exit_oreburgh_city: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0805::name() const { + return "08.05 - Coronet ascent (path pending)"; +} + +std::string AutoStory_Checkpoint_0805::start_text() const { + return "Healed and outside Oreburgh Pokemon Center after the Galactic HQ events."; +} + +std::string AutoStory_Checkpoint_0805::end_text() const { + return "Target: Stable pre-boss position at Spear Pillar. Currently stops at the missing Coronet ascent."; +} + +void AutoStory_Checkpoint_0805::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0805(env, context, options, stats); +} + +void checkpoint_0805(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_mount_coronet(env.console, context, stats, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_mount_coronet: transition not detected.", + env.console); + } + } + { + if (!go_through_mount_coronet(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_mount_coronet: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0806::name() const { + return "08.06 - Spear Pillar (path pending)"; +} + +std::string AutoStory_Checkpoint_0806::start_text() const { + return "At the recorded pre-boss Spear Pillar position; exact position is pending recording."; +} + +std::string AutoStory_Checkpoint_0806::end_text() const { + return "Target: Summit story and legendary encounter finished; healed outside Veilstone Center. Summit pathing is missing."; +} + +void AutoStory_Checkpoint_0806::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0806(env, context, options, stats); +} + +void checkpoint_0806(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + // Same existing helper, now split at the proposed summit boundary. + if (!go_through_mount_coronet(env.console, context, options, true)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "08.06: Summit story did not complete.", env.console); + } + // Retain the former encounter sequence for review with the summit recording. + // Capture/skip behavior must be verified before this missing route is enabled. + recorded_box_legendary_encounter(env, context); + { + if (!fly_and_heal_at_veilstone(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "fly_and_heal_at_veilstone: transition not detected.", + env.console); + } + } + { + if (!heal_and_exit(env.console, context, stats, "Veilstone City")){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_and_exit_veilstone_city: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0807::name() const { + return "08.07 - Route 222 to Sunyshore"; +} + +std::string AutoStory_Checkpoint_0807::start_text() const { + return "Healed and outside Veilstone Pokemon Center after completing the summit story."; +} + +std::string AutoStory_Checkpoint_0807::end_text() const { + return "In Sunyshore after the west gate and arrival dialogue."; +} + +void AutoStory_Checkpoint_0807::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0807(env, context, options, stats); +} + +void checkpoint_0807(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_house_south_veilstone(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_house_south_veilstone: transition not detected.", + env.console); + } + } + { + if (!go_through_house_south_veilstone(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_house_south_veilstone: transition not detected.", + env.console); + } + } + { + if (!go_to_house_west_sunyshore(env.console, context, stats, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_house_west_sunyshore: transition not detected.", + env.console); + } + } + { + if (!go_through_house_west_sunyshore(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_house_west_sunyshore: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0808::name() const { + return "08.08 - Volkner at the lighthouse"; +} + +std::string AutoStory_Checkpoint_0808::start_text() const { + return "In Sunyshore after the west gate and arrival dialogue."; +} + +std::string AutoStory_Checkpoint_0808::end_text() const { + return "Outside Sunyshore lighthouse after Volkner dialogue."; +} + +void AutoStory_Checkpoint_0808::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0808(env, context, options, stats); +} + +void checkpoint_0808(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_sunyshore_lighthouse(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_sunyshore_lighthouse: transition not detected.", + env.console); + } + } + { + if (!talk_to_volkner_at_the_lighthouse(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "talk_to_volkner_at_the_lighthouse: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0809::name() const { + return "08.09 - Sunyshore Gym (path pending)"; +} + +std::string AutoStory_Checkpoint_0809::start_text() const { + return "Outside Sunyshore lighthouse after Volkner dialogue."; +} + +std::string AutoStory_Checkpoint_0809::end_text() const { + return "Target: Inside Sunyshore Gym entrance with eighth-badge rewards finished. Currently stops at the missing puzzle."; +} + +void AutoStory_Checkpoint_0809::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0809(env, context, options, stats); +} + +void checkpoint_0809(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_sunyshore_gym(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_sunyshore_gym: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_08.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_08.h new file mode 100644 index 0000000000..b35b46b514 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_08.h @@ -0,0 +1,167 @@ +/* BDSP AutoStory - Segment 08 + * From after acquiring the seventh badge until after acquiring the eighth badge. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment08_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment08_H + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +class AutoStory_Segment_08 : 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_0801 : 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; +}; +void checkpoint_0801(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0802 : 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; +}; +void checkpoint_0802(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0803 : 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; +}; +void checkpoint_0803(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0804 : 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; +}; +void checkpoint_0804(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0805 : 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; +}; +void checkpoint_0805(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0806 : 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; +}; +void checkpoint_0806(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0807 : 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; +}; +void checkpoint_0807(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0808 : 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; +}; +void checkpoint_0808(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0809 : 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; +}; +void checkpoint_0809(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_09.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_09.cpp new file mode 100644 index 0000000000..a86c0d36db --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_09.cpp @@ -0,0 +1,1743 @@ +/* BDSP AutoStory - Segment 09 + * From after acquiring the eighth badge until defeating the Elite Four and Champion. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/Exceptions/FatalProgramException.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/Globals.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 "NintendoSwitch/Commands/NintendoSwitch_Commands_Superscalar.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MarkFinder.h" +#include "PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.h" +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h" + +#include "PokemonBDSP_AutoStory_Segment_09.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static void +move_back_after_unexpected_battle(VideoStream& stream, ProControllerContext& context, const std::string& trainerid){ + stream.log("Finishing battle with " + trainerid, COLOR_GREEN); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + if (trainerid == "route_223_swimmer_miranda"){ + pbf_move_left_joystick(context, {0, +1}, 3800ms, 100ms); // 19+ + }else if (trainerid == "route_223_swimmer_crystal"){ + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 5+ + }else if (trainerid == "route_223_swimmer_gabrielle"){ + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 2+ + }else if (trainerid == "route_223_swimmer_troy"){ + pbf_move_left_joystick(context, {0, +1}, 800ms, 100ms); // 3+ + } +} + +static bool handle_battle(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, const std::string& trainerid){ + fight_trainer_using_moves(stream, context, stats, trainerid, true); + return true; +} + +static bool heal_and_exit(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, const std::string& label){ + return heal_pokemon(stream, context, label, &stats); +} + +// --------------------------------------------------------------------------- +// Named route navigation blocks +// --------------------------------------------------------------------------- + +static bool +enter_sunyshore_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options, bool start_inside_gym = true){ + if (start_inside_gym){ + // TODO(09.01): Record the watched Gym exit before the retained outdoor flight. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "09.01 incomplete: Sunyshore Gym exit path needs recording before flying to the Center.", + stream); + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("enter_sunyshore_pokemon_center: flying to the Pokemon Center and entering..."); + + fly_to(stream, context, FlyPoint::CurrentCity); + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("enter_sunyshore_pokemon_center: black screen not detected!", COLOR_RED); + return false; + } + stream.log("enter_sunyshore_pokemon_center: entered the Pokemon Center.", COLOR_GREEN); + } + + return true; +} + +static bool +go_to_victory_road_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log( + "go_to_victory_road_pokemon_center: going through Victory Road's outdoor and heading to the Pokemon Center..."); + + // Going to the lady who gives you HM Waterfall + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 5); + pbf_move_left_joystick(context, {0, +1}, 3400ms, 100ms); // 17+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 4); + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 6+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 5); + + { + MarkDetector jasmine_dialog(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 6800ms, 100ms); // 34+ + }, + {{jasmine_dialog}}); + if (ret == 0){ + stream.log("go_to_victory_road_pokemon_center: talking to Jasmine.", COLOR_GREEN); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + pbf_mash_button(context, BUTTON_B, 14000ms); + }else if (ret < 0){ + stream.log("go_to_victory_road_pokemon_center: Jasmine dialog box not detected!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 6+ + + use_HM(stream, context, "Surf"); + context.wait_for_all_requests(); + + activate_repel(stream, context); + context.wait_for_all_requests(); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {-1, 0}, 1800ms, 100ms); // 9+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + + { + MarkDetector route_223_swimmer_miranda_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 5800ms, 100ms); // 29+ + }, + {{route_223_swimmer_miranda_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_victory_road_pokemon_center, route_223_swimmer_miranda: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_223_swimmer_miranda"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_223_swimmer_miranda"); + } + } + } + + context.wait_for_all_requests(); + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + + { + MarkDetector route_223_swimmer_oscar_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 5800ms, 100ms); // 29+ + }, + {{route_223_swimmer_oscar_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_victory_road_pokemon_center, route_223_swimmer_oscar: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_223_swimmer_oscar"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + pbf_move_left_joystick(context, {0, +1}, 1800ms, 100ms); // 9+ + pbf_move_left_joystick(context, {+1, 0}, 1800ms, 100ms); // 9+ + + { + MarkDetector route_223_swimmer_crystal_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 5600ms, 100ms); // 28+ + }, + {{route_223_swimmer_crystal_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_victory_road_pokemon_center, route_223_swimmer_crystal: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_223_swimmer_crystal"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_223_swimmer_crystal"); + } + } + } + + context.wait_for_all_requests(); + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 10+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_to_victory_road_pokemon_center: Reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 3000ms); + }else if (ret < 0){ + stream.log("go_to_victory_road_pokemon_center: Didn't detect repel dialog box (1)!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2); + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 4); + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {0, +1}, 1600ms, 100ms); // 4++ (getting out of the water) + pbf_move_left_joystick(context, {-1, 0}, 1000ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 3+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Surf"); + + context.wait_for_all_requests(); + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 5); + pbf_move_left_joystick(context, {0, +1}, 3000ms, 100ms); // 10++ (getting out of the water) + + context.wait_for_all_requests(); + use_HM(stream, context, "Surf"); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 5); + + { + MarkDetector route_223_swimmer_gabrielle_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + DpadState dpad; + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 4); + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 2+ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + }, + {{route_223_swimmer_gabrielle_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_victory_road_pokemon_center, route_223_swimmer_gabrielle: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_223_swimmer_gabrielle"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_223_swimmer_gabrielle"); + } + } + } + + context.wait_for_all_requests(); + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {-1, 0}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {0, +1}, 1400ms, 100ms); // 7+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 5); + + { + MarkDetector route_223_swimmer_troy_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 2400ms, 100ms); // 12+ + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + }, + {{route_223_swimmer_troy_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_victory_road_pokemon_center, route_223_swimmer_troy: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "route_223_swimmer_troy"); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + move_back_after_unexpected_battle(stream, context, "route_223_swimmer_troy"); + } + } + } + + context.wait_for_all_requests(); + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 5); + pbf_move_left_joystick(context, {0, +1}, 3000ms, 100ms); // 15+ + + context.wait_for_all_requests(); + + use_HM(stream, context, "Waterfall"); + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + pbf_move_left_joystick(context, {0, +1}, 2000ms, 100ms); // 5++ (getting out of the water) + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 10+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_to_victory_road_pokemon_center: Not reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("go_to_victory_road_pokemon_center: Didn't detect repel dialog box (1)!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_to_victory_road_pokemon_center: black screen not detected!", COLOR_RED); + return false; + } + stream.log("go_to_victory_road_pokemon_center: entered the Pokemon Center.", COLOR_GREEN); + } + + return true; +} + +static bool enter_victory_road_cave(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("enter_victory_road_cave: entering the cave..."); + + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 9); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("enter_victory_road_cave: black screen not detected!", COLOR_RED); + return false; + } + stream.log("enter_victory_road_cave: entered the Victory Road cave.", COLOR_GREEN); + } + + return true; +} + +static bool go_through_victory_road_cave(VideoStream& stream, ProControllerContext& context, AutoStoryStats& stats, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("go_through_victory_road_cave: going through Victory Road's cave and heading to the Elite 4..."); + + activate_repel(stream, context); + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, {0, +1}, 3200ms, 100ms); // 16+ + pbf_move_left_joystick(context, {+1, 0}, 3800ms, 100ms); // 19+ + pbf_move_left_joystick(context, {0, +1}, 3000ms, 100ms); // 15+ + pbf_move_left_joystick(context, {+1, 0}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, -1}, 400ms, 100ms); // 1+ + + use_HM(stream, context, "Rock Climb"); + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + pbf_move_left_joystick(context, {0, -1}, 400ms, 100ms); // 1+ + pbf_move_left_joystick(context, {-1, 0}, 2200ms, 100ms); // 11+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2); + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 12); + pbf_move_left_joystick(context, {0, +1}, 2600ms, 100ms); // 13+ + + { + MarkDetector victory_road_ace_mariah_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 5800ms, 100ms); // 29+ + }, + {{victory_road_ace_mariah_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave, victory_road_ace_mariah: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "victory_road_ace_mariah"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + pbf_move_left_joystick(context, {-1, 0}, 400ms, 100ms); // 1+ + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {0, -1}, 2000ms, 100ms); // 10+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_through_victory_road_cave: Reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 3000ms); + }else if (ret < 0){ + stream.log("go_through_victory_road_cave: Didn't detect repel dialog box (1)!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, -1}, 2000ms, 100ms); // 10+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 7); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave: black screen not detected (1)!", COLOR_RED); + return false; + } + stream.log("go_through_victory_road_cave: entered the second part of the cave.", COLOR_GREEN); + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {-1, 0}, 1000ms, 100ms); // 4+ + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Rock Smash"); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {+1, 0}, 400ms, 100ms); // 1+ + pbf_move_left_joystick(context, {0, -1}, 1200ms, 100ms); // 6+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Strength"); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, -1}, 1500ms, 100ms); // 1++ (move boulders) + pbf_move_left_joystick(context, {-1, 0}, 1500ms, 100ms); // 1++ (move boulders) + pbf_move_left_joystick(context, {0, -1}, 4000ms, 100ms); // 3++ (move boulders) + + { + MarkDetector victory_road_ace_omar_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 5800ms, 100ms); // 29+ + }, + {{victory_road_ace_omar_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave, victory_road_ace_omar: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "victory_road_ace_omar"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, -1}, 1000ms, 100ms); // 1++ (move boulders) + pbf_move_left_joystick(context, {-1, 0}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, -1}, 800ms, 100ms); // 3+ + pbf_move_left_joystick(context, {+1, 0}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Rock Smash"); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 4000ms, 100ms); // 2++ (move boulders) + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {+1, 0}, 1500ms, 100ms); // 4++ (move after boulder moving) + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Rock Smash"); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, -1}, 600ms, 100ms); // 2+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 2); + + { + MarkDetector victory_road_ace_sydney_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 5800ms, 100ms); // 29+ + }, + {{victory_road_ace_sydney_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave, victory_road_ace_sydney: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "victory_road_ace_sydney"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 2+ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 10+ + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {+1, 0}, 1500ms, 100ms); // 2++ (move boulders) + + { + MarkDetector victory_road_veteran_clayton_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 5800ms, 100ms); // 29+ + }, + {{victory_road_veteran_clayton_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave, victory_road_veteran_clayton: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "victory_road_veteran_clayton"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {-1, 0}, 400ms, 100ms); // 1+ + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {0, -1}, 2000ms, 100ms); // 10+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_through_victory_road_cave: Reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 3000ms); + }else if (ret < 0){ + stream.log("go_through_victory_road_cave: Didn't detect repel dialog box (1)!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 1800ms, 100ms); // 9+ + + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave: black screen not detected (2)!", COLOR_RED); + return false; + } + stream.log("go_through_victory_road_cave: entered the third part of the cave.", COLOR_GREEN); + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {+1, 0}, 3600ms, 100ms); // 18+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {0, -1}, 400ms, 100ms); // 1+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Rock Climb"); + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {0, +1}, 2400ms, 100ms); // 12+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + + { + MarkDetector victory_road_blackbelt_miles_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 5800ms, 100ms); // 29+ + }, + {{victory_road_blackbelt_miles_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave, victory_road_blackbelt_miles: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "victory_road_blackbelt_miles"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave: black screen not detected (3)!", COLOR_RED); + return false; + } + stream.log("go_through_victory_road_cave: entered the fourth part of the cave.", COLOR_GREEN); + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 1); + pbf_move_left_joystick(context, {+1, 0}, 2400ms, 100ms); // 12+ + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 4+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Surf"); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 1000ms, 100ms); // 4+ + pbf_move_left_joystick(context, {+1, 0}, 800ms, 100ms); // 3+ + + { + MarkDetector victory_road_psychic_valencia_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 5800ms, 100ms); // 29+ + }, + {{victory_road_psychic_valencia_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave, victory_road_psychic_valencia: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "victory_road_psychic_valencia"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Surf"); + + context.wait_for_all_requests(); + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 6+ + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 6+ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 6+ + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 6+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_through_victory_road_cave: Reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 3000ms); + }else if (ret < 0){ + stream.log("go_through_victory_road_cave: Didn't detect repel dialog box (2)!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {+1, 0}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {0, +1}, 2200ms, 100ms); // 11+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Waterfall"); + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + pbf_move_left_joystick(context, {0, +1}, 1200ms, 100ms); // 6+ + + { + MarkDetector victory_road_dragontamer_andre_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 5800ms, 100ms); // 29+ + }, + {{victory_road_dragontamer_andre_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave, victory_road_dragontamer_andre: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "victory_road_dragontamer_andre"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {-1, 0}, 400ms, 100ms); // 1+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Surf"); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {-1, 0}, 1200ms, 100ms); // 6+ + pbf_move_left_joystick(context, {0, -1}, 5000ms, 100ms); // 17++ (getting out of the water) + pbf_move_left_joystick(context, {+1, 0}, 600ms, 100ms); // 2+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 2); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave: black screen not detected (4)!", COLOR_RED); + return false; + } + stream.log("go_through_victory_road_cave: entered the fifth part of the cave.", COLOR_GREEN); + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 3000ms, 100ms); // 15+ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 10+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("go_through_victory_road_cave: Reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_A, 3000ms); + }else if (ret < 0){ + stream.log("go_through_victory_road_cave: Didn't detect repel dialog box (2)!", COLOR_RED); + return false; + } + } + + pbf_move_left_joystick(context, {-1, 0}, 3000ms, 100ms); // 15+ + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 3); + + { + MarkDetector victory_road_veteran_edgar_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, -1}, 5800ms, 100ms); // 29+ + }, + {{victory_road_veteran_edgar_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave, victory_road_veteran_edgar: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "victory_road_veteran_edgar"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, -1}, 2600ms, 100ms); // 13+ + dpad.last_dir = DPAD_DOWN; + repeat_dpad(context, dpad, DPAD_UP, 80ms, 300ms, 1); + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 9); + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Rock Climb"); + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + { + MarkDetector victory_road_dragontamer_clinton_fight(stream.overlay(), {0.1, 0.0, 0.8, 0.9}); + + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 5800ms, 100ms); // 29+ + }, + {{victory_road_dragontamer_clinton_fight}}); + if (ret == 0){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_A, 200000ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave, victory_road_dragontamer_clinton: black screen not detected!", + COLOR_RED); + return false; + }else if (ret == 0){ + handle_battle(stream, context, stats, "victory_road_dragontamer_clinton"); + } + } + } + + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + + pbf_move_left_joystick(context, {0, +1}, 5600ms, 100ms); // 28+ + dpad.last_dir = DPAD_UP; + repeat_dpad(context, dpad, DPAD_DOWN, 80ms, 300ms, 1); + + { + WhiteScreenOverWatcher white_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 10000ms, 100ms); + }, + {{white_screen}}); + if (ret < 0){ + stream.log("go_through_victory_road_cave: white screen not detected!", COLOR_RED); + return false; + } + stream.log("go_through_victory_road_cave: exited Victory Road's cave.", COLOR_GREEN); + } + + return true; +} + +static bool enter_elite_4_pokemon_center(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + DpadState dpad; + context.wait_for_all_requests(); + //pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("enter_elite_4_pokemon_center: Entering the Elite 4 building..."); + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 6); + pbf_move_left_joystick(context, {0, +1}, 2000ms, 100ms); // 10+ + + context.wait_for_all_requests(); + use_HM(stream, context, "Surf"); + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {0, +1}, 600ms, 100ms); // 2+ + context.wait_for_all_requests(); + use_HM(stream, context, "Waterfall"); + + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + + { + BlackScreenOverWatcher black_screen(COLOR_RED, {0.1, 0.1, 0.8, 0.8}); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {0, +1}, 10000ms, 100ms); + }, + {{black_screen}}); + if (ret < 0){ + stream.log("enter_elite_4_pokemon_center: black screen not detected!", COLOR_RED); + return false; + } + stream.log("enter_elite_4_pokemon_center: entered the Elite 4 building.", COLOR_GREEN); + } + + return true; +} + +static bool ready_in_front_of_elite_4(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options, AutoStoryStats& stats, bool before_rival = true){ + if (before_rival){ + // TODO(09.04): Record the rival encounter and return to the healing position. + // The retained nurse/shop route below starts after that missing encounter. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "09.04 incomplete: League rival battle and recovery path need recording before final preparation.", + stream); + } + + DpadState dpad; + context.wait_for_all_requests(); + pbf_wait(context, 6000ms); + context.wait_for_all_requests(); + stream.log("ready_in_front_of_elite_4: stocked up and healed up, ready for the Elite 4..."); + + { + ShortDialogWatcher repel_dialog(COLOR_RED); + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + pbf_move_left_joystick(context, {+1, 0}, 2000ms, 100ms); // 10+ + }, + {{repel_dialog}}); + if (ret == 0){ + stream.log("ready_in_front_of_elite_4: Not reactivating repel.", COLOR_GREEN); + pbf_mash_button(context, BUTTON_B, 3000ms); + }else if (ret < 0){ + stream.log("ready_in_front_of_elite_4: Didn't detect repel dialog box (1)!", COLOR_RED); + return false; + } + } + + context.wait_for_all_requests(); + pbf_move_left_joystick(context, {-1, 0}, 2000ms, 100ms); // 10+ + + dpad.last_dir = DPAD_LEFT; + repeat_dpad(context, dpad, DPAD_RIGHT, 80ms, 300ms, 1); + + pbf_move_left_joystick(context, {0, +1}, 1800ms, 100ms); // 7+ + context.wait_for_all_requests(); + + pbf_mash_button(context, BUTTON_A, 5000ms); + context.wait_for_all_requests(); + pbf_wait(context, 3000ms); + wait_for_dialogue(stream, context, "Elite 4 Pokemon Center - Pokemon healing"); + mash_until_dialogue_ends(stream, context, BUTTON_B); + context.wait_for_all_requests(); + stats.move_pp_remaining = stats.move_pp_capacity; + pbf_wait(context, 1000ms); + + pbf_move_left_joystick(context, {+1, 0}, 3200ms, 100ms); // 16+ + dpad.last_dir = DPAD_RIGHT; + repeat_dpad(context, dpad, DPAD_LEFT, 80ms, 300ms, 3); + pbf_move_left_joystick(context, {0, +1}, 400ms, 100ms); // 1+ + + + buy_items(stream, context, {{ShopItem::FullRestore, BUY_MAX}}, options.language); + + return true; +} + +static bool +get_into_the_elite_4_entrance(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("get_into_the_elite_4_entrance: entered the Elite 4..."); + + // TODO(09.04): Record this route and its verified ending position. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "09.04 League admission and the first-room handoff need recorded pathing; checkpoint is incomplete.", + stream); +} + +static bool deal_with_aaron_elite_4(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("deal_with_aaron_elite_4: battling Aaron of the Elite 4 and passing the gate..."); + + // TODO(09.05): Record this route and its verified ending position. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "09.05 Aaron battle and next-room handoff need recorded pathing; checkpoint is incomplete.", + stream); +} + +static bool deal_with_bertha_elite_4(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("deal_with_bertha_elite_4: battling Bertha of the Elite 4 and passing the gate..."); + + // TODO(09.06): Record this route and its verified ending position. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "09.06 Bertha battle and next-room handoff need recorded pathing; checkpoint is incomplete.", + stream); +} + +static bool deal_with_flint_elite_4(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("deal_with_flint_elite_4: battling Flint of the Elite 4 and passing the gate..."); + + // TODO(09.07): Record this route and its verified ending position. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "09.07 Flint battle and next-room handoff need recorded pathing; checkpoint is incomplete.", + stream); +} + +static bool deal_with_lucian_elite_4(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("deal_with_lucian_elite_4: battling Lucian of the Elite 4 and passing the gate..."); + + // TODO(09.08): Record this route and its verified ending position. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "09.08 Lucian battle and Champion-room handoff need recorded pathing; checkpoint is incomplete.", + stream); +} + +static bool deal_with_cynthia_and_finish(VideoStream& stream, ProControllerContext& context, AutoStoryOptions options){ + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + stream.log("deal_with_cynthia_and_finish: battling Cynthia and finishing the game..."); + + // TODO(09.09): Record this route and its verified ending position. + throw FatalProgramException(ErrorReportMode::NO_ERROR_REPORT, + "09.09 Cynthia battle, Hall of Fame and final completion need recorded pathing; checkpoint is incomplete.", + stream); +} + +// --------------------------------------------------------------------------- +// Segment wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Segment_09::name() const { + return "09: Sunyshore to Champion"; +} + +std::string AutoStory_Segment_09::start_text() const { + return "Start: Inside Sunyshore Gym at the entrance, with eighth-badge rewards and dialogue finished."; +} + +std::string AutoStory_Segment_09::end_text() const { + return "Target: Cynthia defeated and Hall of Fame completion confirmed. Battle and ending pathing are missing."; +} + +void AutoStory_Segment_09::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_0901().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0902().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0903().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0904().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0905().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0906().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0907().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0908().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0909().run_checkpoint(env, context, options, stats); + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + +// --------------------------------------------------------------------------- +// Checkpoint wrappers +// --------------------------------------------------------------------------- + +std::string AutoStory_Checkpoint_0901::name() const { + return "09.01 - Sunyshore recovery (exit pending)"; +} + +std::string AutoStory_Checkpoint_0901::start_text() const { + return "Inside Sunyshore Gym at the entrance, with eighth-badge rewards and dialogue finished."; +} + +std::string AutoStory_Checkpoint_0901::end_text() const { + return "Target: Healed and outside Sunyshore Pokemon Center. Gym exit pathing is missing."; +} + +void AutoStory_Checkpoint_0901::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0901(env, context, options, stats); +} + +void checkpoint_0901(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!enter_sunyshore_pokemon_center(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "enter_sunyshore_pokemon_center: transition not detected.", + env.console); + } + } + { + if (!heal_and_exit(env.console, context, stats, "Sunyshore City")){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_and_exit_1: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0902::name() const { + return "09.02 - Route 223 and Victory Road"; +} + +std::string AutoStory_Checkpoint_0902::start_text() const { + return "Healed and outside Sunyshore Pokemon Center at the doorway."; +} + +std::string AutoStory_Checkpoint_0902::end_text() const { + return "Healed and outside Victory Road Pokemon Center after Jasmine and Route 223."; +} + +void AutoStory_Checkpoint_0902::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0902(env, context, options, stats); +} + +void checkpoint_0902(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!go_to_victory_road_pokemon_center(env.console, context, stats, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_to_victory_road_pokemon_center: transition not detected.", + env.console); + } + } + { + if (!heal_and_exit(env.console, context, stats, "Victory Road")){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "heal_and_exit_2: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0903::name() const { + return "09.03 - Victory Road traversal"; +} + +std::string AutoStory_Checkpoint_0903::start_text() const { + return "Healed and outside Victory Road Pokemon Center at the doorway."; +} + +std::string AutoStory_Checkpoint_0903::end_text() const { + return "Inside the Pokemon League building at its arrival position."; +} + +void AutoStory_Checkpoint_0903::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0903(env, context, options, stats); +} + +void checkpoint_0903(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!enter_victory_road_cave(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "enter_victory_road_cave: transition not detected.", + env.console); + } + } + { + if (!go_through_victory_road_cave(env.console, context, stats, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "go_through_victory_road_cave: transition not detected.", + env.console); + } + } + { + if (!enter_elite_4_pokemon_center(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "enter_elite_4_pokemon_center: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0904::name() const { + return "09.04 - League preparation (path pending)"; +} + +std::string AutoStory_Checkpoint_0904::start_text() const { + return "Inside the Pokemon League building at its arrival position."; +} + +std::string AutoStory_Checkpoint_0904::end_text() const { + return "Target: Rival defeated, final healing and supplies confirmed, ready for admission. Rival and admission pathing are missing."; +} + +void AutoStory_Checkpoint_0904::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0904(env, context, options, stats); +} + +void checkpoint_0904(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!ready_in_front_of_elite_4(env.console, context, options, stats)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "ready_in_front_of_elite_4: transition not detected.", + env.console); + } + } + { + if (!get_into_the_elite_4_entrance(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "get_into_the_elite_4_entrance: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0905::name() const { + return "09.05 - Aaron (path pending)"; +} + +std::string AutoStory_Checkpoint_0905::start_text() const { + return "At the recorded Aaron room entry position; admission and position are pending recording."; +} + +std::string AutoStory_Checkpoint_0905::end_text() const { + return "Target: Aaron defeated, dialogue cleared, at the next room entry. Battle and handoff pathing are missing."; +} + +void AutoStory_Checkpoint_0905::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0905(env, context, options, stats); +} + +void checkpoint_0905(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!deal_with_aaron_elite_4(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "deal_with_aaron_elite_4: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0906::name() const { + return "09.06 - Bertha (path pending)"; +} + +std::string AutoStory_Checkpoint_0906::start_text() const { + return "At the recorded Bertha room entry position, after Aaron; exact position is pending recording."; +} + +std::string AutoStory_Checkpoint_0906::end_text() const { + return "Target: Bertha defeated, dialogue cleared, at the next room entry. Battle and handoff pathing are missing."; +} + +void AutoStory_Checkpoint_0906::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0906(env, context, options, stats); +} + +void checkpoint_0906(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!deal_with_bertha_elite_4(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "deal_with_bertha_elite_4: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0907::name() const { + return "09.07 - Flint (path pending)"; +} + +std::string AutoStory_Checkpoint_0907::start_text() const { + return "At the recorded Flint room entry position, after Bertha; exact position is pending recording."; +} + +std::string AutoStory_Checkpoint_0907::end_text() const { + return "Target: Flint defeated, dialogue cleared, at the next room entry. Battle and handoff pathing are missing."; +} + +void AutoStory_Checkpoint_0907::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0907(env, context, options, stats); +} + +void checkpoint_0907(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!deal_with_flint_elite_4(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "deal_with_flint_elite_4: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0908::name() const { + return "09.08 - Lucian (path pending)"; +} + +std::string AutoStory_Checkpoint_0908::start_text() const { + return "At the recorded Lucian room entry position, after Flint; exact position is pending recording."; +} + +std::string AutoStory_Checkpoint_0908::end_text() const { + return "Target: Lucian defeated, dialogue cleared, at the Champion room entry. Battle and handoff pathing are missing."; +} + +void AutoStory_Checkpoint_0908::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0908(env, context, options, stats); +} + +void checkpoint_0908(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!deal_with_lucian_elite_4(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "deal_with_lucian_elite_4: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +std::string AutoStory_Checkpoint_0909::name() const { + return "09.09 - Cynthia and Hall of Fame (path pending)"; +} + +std::string AutoStory_Checkpoint_0909::start_text() const { + return "At the recorded Champion room entry position, after Lucian; exact position is pending recording."; +} + +std::string AutoStory_Checkpoint_0909::end_text() const { + return "Target: Cynthia defeated and Hall of Fame completion confirmed. Battle and ending pathing are missing."; +} + +void AutoStory_Checkpoint_0909::run_checkpoint(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats) const { + checkpoint_0909(env, context, options, stats); +} + +void checkpoint_0909(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats){ + checkpoint_reattempt_loop( + env, + context, + options, + stats, + [&](size_t /*attempt*/){ + { + if (!deal_with_cynthia_and_finish(env.console, context, options)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "deal_with_cynthia_and_finish: transition not detected.", + env.console); + } + } + context.wait_for_all_requests(); + }, + true); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_09.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_09.h new file mode 100644 index 0000000000..2a91b50690 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_09.h @@ -0,0 +1,167 @@ +/* BDSP AutoStory - Segment 09 + * From after acquiring the eighth badge until defeating the Elite Four and Champion. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment09_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment09_H + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +class AutoStory_Segment_09 : 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_0901 : 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; +}; +void checkpoint_0901(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0902 : 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; +}; +void checkpoint_0902(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0903 : 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; +}; +void checkpoint_0903(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0904 : 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; +}; +void checkpoint_0904(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0905 : 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; +}; +void checkpoint_0905(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0906 : 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; +}; +void checkpoint_0906(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0907 : 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; +}; +void checkpoint_0907(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0908 : 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; +}; +void checkpoint_0908(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +class AutoStory_Checkpoint_0909 : 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; +}; +void checkpoint_0909(SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats); + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.cpp new file mode 100644 index 0000000000..14c1ac23bf --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.cpp @@ -0,0 +1,1021 @@ +/* BDSP AutoStory - Battle Utilities + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include +#include + +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" + +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/Images/SolidColorTest.h" +#include "CommonTools/OCR/OCR_Routines.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" + +#include "Pokemon/Inference/Pokemon_NameReader.h" +#include "Pokemon/Pokemon_StatsCalculation.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleOpponentNameReader.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_EndBattleDetector.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_ExperienceGainDetector.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_StartBattleDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_ShopItemNameReader.h" +#include "PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.h" +#include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" + +#include "VideoGames/PokemonSwSh/Resources/PokemonSwSh_TypeMatchup.h" + +#include "PokemonBDSP_AutoStory_Battle.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +using namespace Pokemon; +using namespace PokemonSwSh; +using namespace std::chrono_literals; + + +// --------------------------------------------------------------------------- +// run_battle_press_A +// --------------------------------------------------------------------------- + +void run_battle_press_A(const ProgramInfo& /*info*/, VideoStream& stream, ProControllerContext& context){ + EndBattleWatcher end_battle; + run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_mash_button(ctx, BUTTON_A, 120000ms); + }, + {{end_battle}}); + + OverworldWatcher overworld; + run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_mash_button(ctx, BUTTON_A, 120000ms); + }, + {{overworld}}); +} + + +// --------------------------------------------------------------------------- +// handle_unexpected_battle +// --------------------------------------------------------------------------- + +void handle_unexpected_battle(const ProgramInfo& info, + VideoStream& stream, + ProControllerContext& context, + std::function&& action, + std::function&& recovery_action, + size_t max_attempts){ + for (size_t attempt = 0; attempt < max_attempts; attempt++){ + StartBattleDetector start_battle(stream.overlay()); + int result = run_until(stream, + context, + [&](ProControllerContext& ctx){ + action(info, stream, ctx); + }, + {{start_battle}}); + if (result < 0){ + return; + } + run_battle_press_A(info, stream, context); + recovery_action(info, stream, context); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "handle_unexpected_battle: exceeded max attempts", + stream); +} + + +// --------------------------------------------------------------------------- +// Internal helpers for handle_battle_with_outside_help +// --------------------------------------------------------------------------- + +static constexpr int TORTERRA_ATK = 238; +static constexpr int TORTERRA_SPATK = 170; + +// ⚠️ Estimated PP regions (fight menu, 2×2 move grid); require calibration. +static const ImageFloatBox PP_BOX[4] = { + {0.36, 0.590, 0.10, 0.060}, + {0.75, 0.590, 0.10, 0.060}, + {0.36, 0.730, 0.10, 0.060}, + {0.75, 0.730, 0.10, 0.060}, +}; + +static int ocr_pp_slot(Logger& logger, const ImageViewRGB32& frame, const ImageFloatBox& box){ + ImageViewRGB32 region = extract_box_reference(frame, box); + std::string text = OCR::ocr_read(Language::English, region, OCR::PageSegMode::SINGLE_LINE); + text.erase(0, text.find_first_not_of(" \t\r\n")); + if (!text.empty()){ + text.erase(text.find_last_not_of(" \t\r\n") + 1); + } + + size_t slash = text.find('/'); + std::string num_str = (slash != std::string::npos) ? text.substr(0, slash) : text; + size_t digit_pos = num_str.find_first_of("0123456789"); + if (digit_pos == std::string::npos){ + logger.log("PP OCR: no digit in \"" + text + "\"", COLOR_YELLOW); + return -1; + } + try { + return std::stoi(num_str.substr(digit_pos)); + } catch (...){ + logger.log("PP OCR: parse failed \"" + num_str + "\"", COLOR_YELLOW); + return -1; + } +} + +static std::array read_all_pp(Logger& logger, VideoStream& stream){ + VideoSnapshot frame = stream.video().snapshot(); + std::array result; + for (int i = 0; i < 4; i++){ + result[i] = ocr_pp_slot(logger, frame, PP_BOX[i]); + } + return result; +} + +static std::pair damage_range(int level, int base_power, int atk, int def, double type_mult){ + if (base_power == 0){ + return {0, 0}; + } + int base = (((2 * level) / 5 + 2) * base_power * atk / def) / 50 + 2; + return {(int)(base * 85.0 / 100.0 * type_mult), (int)(base * type_mult)}; +} + +struct MoveScore { + int lower_sum = 0; + int upper_sum = 0; + bool ohko_all = false; +}; + +static MoveScore +score_move(const BDSPMoveData& mdata, const std::vector& on_field, const BDSPTrainerData& trainer_data){ + if (mdata.category == MoveCategory::STATUS){ + return {}; + } + + int atk_stat = (mdata.category == MoveCategory::PHYSICAL) ? TORTERRA_ATK : TORTERRA_SPATK; + + MoveScore score; + score.ohko_all = !on_field.empty(); + + for (const std::string& slug : on_field){ + if (slug.empty()){ + score.ohko_all = false; + continue; + } + + const BDSPTrainerPokemon* tp = nullptr; + for (const BDSPTrainer& trainer : trainer_data.trainers){ + for (const BDSPTrainerPokemon& mon : trainer.pokemon){ + if (mon.species == slug){ + tp = &mon; + break; + } + } + if (tp){ + break; + } + } + if (!tp){ + score.ohko_all = false; + continue; + } + + const BDSPPokemonData& pdata = bdsp_pokemon(slug); + uint16_t hp = calc_stats_hp(pdata.hp, tp->level, tp->ivs.hp, tp->evs.hp); + uint16_t def = (mdata.category == MoveCategory::PHYSICAL) + ? calc_stats_nonhp(pdata.defense, tp->level, tp->ivs.defense, tp->evs.defense, NatureAdjustment::NEUTRAL) + : calc_stats_nonhp(pdata.spdef, tp->level, tp->ivs.spdef, tp->evs.spdef, NatureAdjustment::NEUTRAL); + + double mult = type_multiplier(mdata.type, pdata.type[0], pdata.type[1]); + if (mdata.spread && trainer_data.battle_type == BDSPBattleType::DOUBLES){ + mult *= 0.75; + } + + auto [lo, hi] = damage_range(100, (int)mdata.base_power, atk_stat, (int)def, mult); + score.lower_sum += lo; + score.upper_sum += hi; + if (lo < (int)hp){ + score.ohko_all = false; + } + } + return score; +} + +static void navigate_to_slot(ProControllerContext& context, int slot){ + if (slot == 1 || slot == 3){ + pbf_press_dpad(context, DPAD_RIGHT, 10ms, 40ms); + } + if (slot == 2 || slot == 3){ + pbf_press_dpad(context, DPAD_DOWN, 10ms, 40ms); + } +} + + +// --------------------------------------------------------------------------- +// handle_battle_with_outside_help +// --------------------------------------------------------------------------- + +void handle_battle_with_outside_help(const ProgramInfo& /*info*/, + VideoStream& stream, + ProControllerContext& context, + const std::string& trainer_id, + const AutoStoryOptions& options){ + Logger& logger = stream.logger(); + const BDSPTrainerData& trainer_data = bdsp_trainer(trainer_id); + const OutsideHelpConfig& help = options.outside_help; + + std::array move_slugs; + for (int i = 0; i < 4; i++){ + move_slugs[i] = torterra_move_slug(help.attacks[i]); + } + + std::array pp_remaining; + for (int i = 0; i < 4; i++){ + pp_remaining[i] = (int)bdsp_move(move_slugs[i]).pp; + } + + std::vector on_field; + for (const BDSPTrainer& trainer : trainer_data.trainers){ + if (!trainer.pokemon.empty()){ + on_field.push_back(trainer.pokemon[0].species); + } + } + + bool battle_active = true; + while (battle_active){ + + // Wait for the lead Pokemon's battle menu + { + BattleMenuWatcher menu(BattleType::TRAINER); + EndBattleWatcher end; + int r = wait_until(stream, context, 120s, {{menu}, {end}}); + if (r == 1 || r < 0){ + battle_active = false; + break; + } + } + + // Open Fight menu + pbf_press_button(context, BUTTON_A, 10ms, 60ms); + context.wait_for_all_requests(); + context.wait_for(300ms); + + // OCR current PP + { + auto ocr_pp = read_all_pp(logger, stream); + for (int i = 0; i < 4; i++){ + if (ocr_pp[i] >= 0){ + pp_remaining[i] = ocr_pp[i]; + }else{ + logger.log("PP OCR failed slot " + std::to_string(i) + "; using tracked", COLOR_YELLOW); + } + } + } + + // Check for all-zero PP (Struggle) + bool all_zero = true; + for (int pp : pp_remaining){ + if (pp > 0){ + all_zero = false; + break; + } + } + + if (all_zero){ + logger.log("All PP at 0 — using Struggle", COLOR_YELLOW); + pbf_press_button(context, BUTTON_B, 10ms, 60ms); + context.wait_for_all_requests(); + }else{ + // Score all usable moves + int best_slot = -1, best_hi = -1; + bool best_ohko = false; + + for (int i = 0; i < 4; i++){ + if (pp_remaining[i] <= 0){ + continue; + } + MoveScore s = score_move(bdsp_move(move_slugs[i]), on_field, trainer_data); + if (bdsp_move(move_slugs[i]).category == MoveCategory::STATUS){ + continue; + } + + bool prefer = (best_slot < 0) || (s.ohko_all && !best_ohko) || + (s.ohko_all && best_ohko && s.upper_sum < best_hi) || + (!s.ohko_all && !best_ohko && s.upper_sum > best_hi); + + if (prefer){ + best_slot = i; + best_hi = s.upper_sum; + best_ohko = s.ohko_all; + } + } + if (best_slot < 0){ + for (int i = 0; i < 4; i++){ + if (pp_remaining[i] > 0){ + best_slot = i; + break; + } + } + } + + logger.log("Move slot " + std::to_string(best_slot) + " (" + move_slugs[best_slot] + ")" + + (best_ohko ? " [OHKO]" : " [best effort]"), + COLOR_WHITE); + + navigate_to_slot(context, best_slot); + pbf_mash_button(context, BUTTON_A, 500ms); + context.wait_for_all_requests(); + pp_remaining[best_slot]--; + + // Single-target in doubles: confirm default target + if (!bdsp_move(move_slugs[best_slot]).spread && trainer_data.battle_type == BDSPBattleType::DOUBLES){ + pbf_press_button(context, BUTTON_A, 10ms, 60ms); + context.wait_for_all_requests(); + } + + // Mash through target confirmation and partner's move selection + pbf_mash_button(context, BUTTON_A, 3000ms); + context.wait_for_all_requests(); + } + + // Wait for next event + { + BattleMenuWatcher menu2(BattleType::TRAINER); + ExperienceGainWatcher exp; + EndBattleWatcher end; + + int event = wait_until(stream, context, 60s, {{menu2}, {exp}, {end}}); + + if (event == 2 || event < 0){ + battle_active = false; + }else if (event == 1){ + // XP gain — mash through, then update on_field via OCR + BattleMenuWatcher menu3(BattleType::TRAINER); + EndBattleWatcher end2; + int after = run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_mash_button(ctx, BUTTON_A, 120000ms); + }, + {{menu3}, {end2}}); + if (after == 1 || after < 0){ + battle_active = false; + }else{ + // Update on_field via OCR + VideoSnapshot frame = stream.video().snapshot(); + std::vector names = + read_battle_opponent_names(logger, options.language, frame, trainer_data.battle_type); + for (size_t i = 0; i < names.size() && i < on_field.size(); i++){ + if (!names[i].empty() && names[i] != on_field[i]){ + logger.log("Slot " + std::to_string(i) + ": " + on_field[i] + " → " + names[i], + COLOR_WHITE); + on_field[i] = names[i]; + } + } + } + } + // event == 0: next turn, loop continues + } + } + + // Mash through XP/dialog until overworld + OverworldWatcher overworld; + run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_mash_button(ctx, BUTTON_A, 120000ms); + }, + {{overworld}}); +} + + +// --------------------------------------------------------------------------- +// Internal helpers for handle_expert_battle +// --------------------------------------------------------------------------- + +// ⚠️ Estimated player-side HP bar region — calibrate against a real capture. +static const ImageFloatBox PLAYER_HP_BAR{0.057, 0.652, 0.168, 0.008}; + +// ⚠️ Estimated player-side status badge region — calibrate against a real capture. +static const ImageFloatBox PLAYER_STATUS_BOX{0.140, 0.710, 0.080, 0.018}; + +// ⚠️ Estimated battle-bag item name region (highlighted row) — calibrate. +static const ImageFloatBox BATTLE_BAG_ITEM_NAME_BOX{0.470, 0.150, 0.350, 0.050}; + +// Returns the fraction [0.0, 1.0] of the player's HP bar that is filled. +// Scans each pixel column; a column with high colour saturation counts as "HP present". +static double read_player_hp_ratio(const ImageViewRGB32& frame){ + ImageViewRGB32 strip = extract_box_reference(frame, PLAYER_HP_BAR); + int total = (int)strip.width(); + int mid_row = (int)(strip.height() / 2); + if (total <= 0){ + return 1.0; + } + + int colored_cols = 0; + for (int x = 0; x < total; x++){ + uint32_t px = strip.pixel(x, mid_row); + uint8_t r = (px >> 16) & 0xFF; + uint8_t g = (px >> 8) & 0xFF; + uint8_t b = px & 0xFF; + int mx = std::max({(int)r, (int)g, (int)b}); + int mn = std::min({(int)r, (int)g, (int)b}); + if (mx - mn > 30){ + colored_cols++; + } + } + return (double)colored_cols / total; +} + +// Returns true if a coloured status badge is visible on the player's active pokemon. +static bool detect_player_status(const ImageViewRGB32& frame){ + return !is_white(extract_box_reference(frame, PLAYER_STATUS_BOX)); +} + +// Cycle through battle-bag pockets with BUTTON_R until the highlighted item +// matches "full-restore", then use it on the first pokemon. +// Called while the battle-bag is already open (navigate to Bag from battle menu first). +static void use_full_restore_in_open_bag(VideoStream& stream, ProControllerContext& context, Language language){ + Logger& logger = stream.logger(); + static constexpr size_t MAX_POCKETS = 9; + static constexpr size_t MAX_SCROLL = 25; + + // Cycle pockets until we see Full Restore in the highlighted row + bool pocket_found = false; + for (size_t tab = 0; tab < MAX_POCKETS; tab++){ + context.wait_for(500ms); + VideoSnapshot frame = stream.video().snapshot(); + ImageViewRGB32 region = extract_box_reference(frame, BATTLE_BAG_ITEM_NAME_BOX); + OCR::StringMatchResult result = ShopItemNameReader::instance().read_substring(logger, language, region); + for (const auto& [score, match] : result.results){ + if (match.token == "full-restore" && score <= ShopItemNameReader::MAX_LOG10P){ + pocket_found = true; + break; + } + } + if (pocket_found){ + break; + } + pbf_press_button(context, BUTTON_R, 10ms, 400ms); + } + + if (!pocket_found){ + logger.log("Full Restore pocket not found — scanning list", COLOR_YELLOW); + } + + // Scroll down within the pocket until the highlighted row is Full Restore + for (size_t i = 0; i < MAX_SCROLL; i++){ + context.wait_for(400ms); + VideoSnapshot frame = stream.video().snapshot(); + ImageViewRGB32 region = extract_box_reference(frame, BATTLE_BAG_ITEM_NAME_BOX); + OCR::StringMatchResult result = ShopItemNameReader::instance().read_substring(logger, language, region); + bool found = false; + for (const auto& [score, match] : result.results){ + if (match.token == "full-restore" && score <= ShopItemNameReader::MAX_LOG10P){ + found = true; + break; + } + } + if (found){ + pbf_press_button(context, BUTTON_A, 10ms, 600ms); // select item + pbf_press_button(context, BUTTON_A, 10ms, 600ms); // "Use" + pbf_press_button(context, BUTTON_A, 10ms, 800ms); // confirm on first pokemon + context.wait_for(500ms); + logger.log("Full Restore used", COLOR_WHITE); + pbf_mash_button(context, BUTTON_B, 1500ms); + return; + } + pbf_press_dpad(context, DPAD_DOWN, 10ms, 200ms); + } + + logger.log("Full Restore not found in bag — skipping", COLOR_YELLOW); + pbf_mash_button(context, BUTTON_B, 1500ms); +} + +// From the battle menu (Fight/Pokemon/Bag/Run), navigate to Bag and open it. +// In BDSP the battle menu is a 2×2 grid: Fight(top-left) Bag(top-right) +// Pokemon(bottom-left) Run(bottom-right). Cursor starts at Fight. +static void open_battle_bag(ProControllerContext& context){ + pbf_press_dpad(context, DPAD_RIGHT, 10ms, 200ms); + pbf_press_button(context, BUTTON_A, 10ms, 800ms); + context.wait_for(500ms); +} + +// Use a Full Restore on the first party pokemon from the battle menu. +// Returns immediately after item use; the caller is responsible for resuming the turn. +static void use_full_restore_in_battle(VideoStream& stream, ProControllerContext& context, Language language){ + open_battle_bag(context); + use_full_restore_in_open_bag(stream, context, language); + // Mash B to ensure we're back at the battle menu + context.wait_for(300ms); +} + +// OCR the opponent name box and restrict matches to `candidates`. +// Returns the best matching slug in candidates, or the best unrestricted match +// if no candidate qualifies. +static std::string ocr_opponent_restricted(Logger& logger, + Language language, + const ImageViewRGB32& frame, + const ImageFloatBox& box, + const std::vector& candidates){ + using namespace Pokemon; + ImageViewRGB32 cropped = extract_box_reference(frame, box); + OCR::StringMatchResult result = + PokemonNameReader::instance().read_substring(logger, language, cropped, OCR::BLACK_OR_WHITE_TEXT_FILTERS()); + if (result.results.empty()){ + return ""; + } + + // Prefer the best candidate-restricted match + for (const auto& [score, match] : result.results){ + if (score > PokemonNameReader::MAX_LOG10P){ + break; + } + for (const std::string& c : candidates){ + if (match.token == c){ + return c; + } + } + } + // Fallback: best unrestricted match (if it meets threshold) + const auto& best = result.results.begin(); + if (best->first <= PokemonNameReader::MAX_LOG10P){ + return best->second.token; + } + return ""; +} + + +// --------------------------------------------------------------------------- +// Pre-plan computation +// --------------------------------------------------------------------------- + +struct BattlePlan { + // species slug → move slot to use against that pokemon (for current trainer) + std::map move_for_species; + // Estimated remaining PP after the full trainer sequence is played out + std::array estimated_pp_after{}; +}; + +// Returns the Intimidate attack-stage multiplier for `count` Intimidate activations. +// Stage formula: 2 / (2 + count), capped at 6 drops. +static double intimidate_atk_mult(int count){ + if (count <= 0){ + return 1.0; + } + int capped = std::min(count, 6); + return 2.0 / (2.0 + capped); +} + +// Score move `slot` (0-3) against a single opponent pokemon. +// Returns {lower_damage, upper_damage, is_ohko}. +static std::tuple score_move_expert(const BDSPMoveData& mdata, + const BDSPTrainerPokemon& tp, + int effective_atk, + int effective_spatk, + bool doubles){ + if (mdata.category == MoveCategory::STATUS){ + return {0, 0, false}; + } + + const BDSPPokemonData& pdata = bdsp_pokemon(tp.species); + uint16_t hp = calc_stats_hp(pdata.hp, tp.level, tp.ivs.hp, tp.evs.hp); + + int atk_stat = (mdata.category == MoveCategory::PHYSICAL) ? effective_atk : effective_spatk; + uint16_t def = (mdata.category == MoveCategory::PHYSICAL) + ? calc_stats_nonhp(pdata.defense, tp.level, tp.ivs.defense, tp.evs.defense, NatureAdjustment::NEUTRAL) + : calc_stats_nonhp(pdata.spdef, tp.level, tp.ivs.spdef, tp.evs.spdef, NatureAdjustment::NEUTRAL); + + double mult = type_multiplier(mdata.type, pdata.type[0], pdata.type[1]); + if (mdata.spread && doubles){ + mult *= 0.75; + } + + // Attacker level is always 100 (helper Pokemon); defender level is tp.level + auto [lo, hi] = damage_range(100, (int)mdata.base_power, atk_stat, (int)def, mult); + bool ohko = (lo >= (int)hp); + return {lo, hi, ohko}; +} + +static BattlePlan build_expert_plan(Logger& logger, + const std::array& move_slugs, + const std::array& current_pp, + const std::vector& all_trainers, + Trainer current_trainer, + int base_atk, + int base_spatk){ + // Running PP budget across the full sequence + std::array pp = current_pp; + + BattlePlan plan; + plan.estimated_pp_after = pp; + + int intimidate_count = 0; + + // Iterate through all listed trainers in sequence order. + // For current_trainer, populate move_for_species. + for (Trainer t : all_trainers){ + const BDSPTrainerData& tdata = bdsp_trainer(trainer_slug(t)); + bool is_current = (t == current_trainer); + bool doubles = (tdata.battle_type == BDSPBattleType::DOUBLES); + + for (const BDSPTrainer& side : tdata.trainers){ + // Check first-out pokemon for Intimidate (activates before we attack) + if (!side.pokemon.empty() && side.pokemon[0].has_intimidate){ + intimidate_count++; + } + } + + int eff_atk = (int)(base_atk * intimidate_atk_mult(intimidate_count)); + int eff_spatk = base_spatk; // Intimidate doesn't affect SpAtk + + for (const BDSPTrainer& side : tdata.trainers){ + for (const BDSPTrainerPokemon& tp : side.pokemon){ + // Find best move for this pokemon + int best_slot = -1; + int best_hi = -1; + bool best_ohko = false; + + for (int i = 0; i < 4; i++){ + if (pp[i] <= 0){ + continue; + } + const BDSPMoveData& mdata = bdsp_move(move_slugs[i]); + if (mdata.category == MoveCategory::STATUS){ + continue; + } + + auto [lo, hi, ohko] = score_move_expert(mdata, tp, eff_atk, eff_spatk, doubles); + (void)lo; + + // Log adjusted accuracy (informational only, not used in OHKO decisions) + uint8_t acc = mdata.accuracy; + if (acc > 0 && acc < 100){ + int adj = 100 - 2 * (100 - acc); + logger.log(move_slugs[i] + " vs " + tp.species + ": accuracy=" + std::to_string(acc) + + "% (adjusted=" + std::to_string(adj) + "%)", + COLOR_YELLOW); + } + + bool prefer = (best_slot < 0) || (ohko && !best_ohko) // new OHKO > non-OHKO + || (ohko && best_ohko && hi < best_hi) // both OHKO: lower overkill + || (!ohko && !best_ohko && hi > best_hi); // no OHKO: higher damage + + if (prefer){ + best_slot = i; + best_hi = hi; + best_ohko = ohko; + } + } + + if (best_slot < 0){ + // All PP at 0 — Struggle will be used in practice + logger.log("PP exhausted for " + tp.species + " — Struggle expected", COLOR_YELLOW); + }else{ + pp[best_slot]--; + logger.log(std::string(is_current ? "[plan] " : "[pre-plan] ") + tp.species + " → slot " + + std::to_string(best_slot) + " (" + move_slugs[best_slot] + ")" + + (best_ohko ? " [OHKO]" : " [best effort, hi=" + std::to_string(best_hi) + "]"), + COLOR_WHITE); + if (is_current){ + // Only override if this species hasn't been planned yet + // (in case the same species appears twice — first occurrence wins) + if (plan.move_for_species.find(tp.species) == plan.move_for_species.end()){ + plan.move_for_species[tp.species] = best_slot; + } + } + } + + // Track Intimidate for subsequent pokemon (after the initial send-out) + if (tp.has_intimidate){ + intimidate_count++; + eff_atk = (int)(base_atk * intimidate_atk_mult(intimidate_count)); + } + } + } + } + + plan.estimated_pp_after = pp; + return plan; +} + + +// --------------------------------------------------------------------------- +// handle_expert_battle +// --------------------------------------------------------------------------- + +// Snapshot the current frame, OCR on-field names, and update `on_field`. +static void update_on_field(Logger& logger, + Language language, + VideoStream& stream, + BDSPBattleType battle_type, + const std::vector& candidates, + std::vector& on_field){ + VideoSnapshot frame = stream.video().snapshot(); + if (battle_type == BDSPBattleType::SINGLES){ + static const ImageFloatBox BOX(0.505, 0.010, 0.420, 0.085); + std::string name = ocr_opponent_restricted(logger, language, frame, BOX, candidates); + if (!name.empty() && !on_field.empty()){ + on_field[0] = name; + } + }else{ + static const ImageFloatBox BOX_L(0.505, 0.010, 0.200, 0.085); + static const ImageFloatBox BOX_R(0.710, 0.010, 0.260, 0.085); + std::string l = ocr_opponent_restricted(logger, language, frame, BOX_L, candidates); + std::string r = ocr_opponent_restricted(logger, language, frame, BOX_R, candidates); + if (!l.empty() && on_field.size() > 0){ + on_field[0] = l; + } + if (!r.empty() && on_field.size() > 1){ + on_field[1] = r; + } + } +} + + +void handle_expert_battle(const ProgramInfo& /*info*/, + VideoStream& stream, + ProControllerContext& context, + const std::vector& all_trainers, + Trainer current_trainer, + const AutoStoryOptions& options){ + using namespace Pokemon; + + Logger& logger = stream.logger(); + const BDSPTrainerData& trainer_data = bdsp_trainer(trainer_slug(current_trainer)); + const OutsideHelpConfig& help = options.outside_help; + bool doubles = (trainer_data.battle_type == BDSPBattleType::DOUBLES); + + // Move slugs for the helper's 4 moves + std::array move_slugs; + for (int i = 0; i < 4; i++){ + move_slugs[i] = torterra_move_slug(help.attacks[i]); + } + + // Helper stats — 0IV/0EV at level 100, neutral nature + const BDSPPokemonData& helper_base = bdsp_pokemon("torterra"); + int base_atk = (int)calc_stats_nonhp(helper_base.attack, 100, 0, 0, NatureAdjustment::NEUTRAL); + int base_spatk = (int)calc_stats_nonhp(helper_base.spatk, 100, 0, 0, NatureAdjustment::NEUTRAL); + + // Candidate species slugs for restricted OCR (all pokemon in current trainer's party) + std::vector candidates; + for (const BDSPTrainer& side : trainer_data.trainers){ + for (const BDSPTrainerPokemon& tp : side.pokemon){ + candidates.push_back(tp.species); + } + } + + // PP tracking — initialised to max PP from JSON; OCR updates each turn + std::array pp_remaining; + for (int i = 0; i < 4; i++){ + pp_remaining[i] = (int)bdsp_move(move_slugs[i]).pp; + } + + // Intimidate tracking across turns + int intimidate_count = 0; + std::set intimidate_seen; + + // Current on-field opponents (initialised to first pokemon of each side) + std::vector on_field; + for (const BDSPTrainer& side : trainer_data.trainers){ + on_field.push_back(side.pokemon.empty() ? "" : side.pokemon[0].species); + } + + // plan is built on the first turn after PP are OCR'd + BattlePlan plan; + bool plan_built = false; + + // ── Wait for the initial battle menu ───────────────────────────────────── + bool battle_active = true; + { + BattleMenuWatcher menu(BattleType::TRAINER); + EndBattleWatcher end; + int r = wait_until(stream, context, 60s, {{menu}, {end}}); + if (r != 0 || r < 0){ + battle_active = false; + } + } + + // ── Main battle loop ────────────────────────────────────────────────────── + // Each iteration starts with the battle menu (Fight/Pokemon/Bag/Run) visible. + // We check HP/status, open the fight menu, OCR PP, identify the opponent, + // select a move, then wait for the next event. + while (battle_active){ + + // 1. Check HP / status → use Full Restore if needed + { + VideoSnapshot frame = stream.video().snapshot(); + double hp = read_player_hp_ratio(frame); + bool has_status = detect_player_status(frame); + if (hp < 0.33 || has_status){ + logger.log("Full Restore needed (HP=" + std::to_string((int)(hp * 100)) + + "%, status=" + (has_status ? "yes" : "no") + ")", + COLOR_WHITE); + use_full_restore_in_battle(stream, context, options.language); + } + } + + // 2. Open fight menu and re-read PP + pbf_press_button(context, BUTTON_A, 10ms, 60ms); + context.wait_for_all_requests(); + context.wait_for(300ms); + { + auto ocr_pp = read_all_pp(logger, stream); + for (int i = 0; i < 4; i++){ + if (ocr_pp[i] >= 0){ + pp_remaining[i] = ocr_pp[i]; + }else{ + logger.log("PP OCR failed slot " + std::to_string(i), COLOR_YELLOW); + } + } + } + + // 3. Build plan on first turn (after we have real PP values from OCR) + if (!plan_built){ + plan = build_expert_plan(logger, + move_slugs, + pp_remaining, + all_trainers, + current_trainer, + base_atk, + base_spatk); + plan_built = true; + } + + // 4. Identify current on-field opponent(s) via restricted OCR + // (Fight menu is open at bottom; opponent info boxes are at the top — still readable) + update_on_field(logger, options.language, stream, trainer_data.battle_type, candidates, on_field); + + // 5. Track Intimidate from newly-observed opponents + { + int eff_atk = base_atk; + for (const std::string& slug : on_field){ + if (slug.empty() || intimidate_seen.count(slug)){ + continue; + } + for (const BDSPTrainer& side : trainer_data.trainers){ + for (const BDSPTrainerPokemon& tp : side.pokemon){ + if (tp.species == slug && tp.has_intimidate){ + intimidate_count++; + intimidate_seen.insert(slug); + eff_atk = (int)(base_atk * intimidate_atk_mult(intimidate_count)); + logger.log(slug + " has Intimidate — ATK stage -" + std::to_string(intimidate_count) + + " (effective ATK=" + std::to_string(eff_atk) + ")", + COLOR_YELLOW); + } + } + } + } + } + + // 6. Select move — prefer pre-plan; fall back to live scoring if plan misses + int best_slot = -1; + if (!on_field.empty() && !on_field[0].empty()){ + auto it = plan.move_for_species.find(on_field[0]); + if (it != plan.move_for_species.end() && pp_remaining[it->second] > 0){ + best_slot = it->second; + } + } + + if (best_slot < 0){ + logger.log("Plan miss for " + (on_field.empty() ? "?" : on_field[0]) + " — live scoring", COLOR_YELLOW); + int eff_atk = (int)(base_atk * intimidate_atk_mult(intimidate_count)); + int eff_spatk = base_spatk; + int best_hi = -1; + bool best_ohko = false; + + for (int i = 0; i < 4; i++){ + if (pp_remaining[i] <= 0){ + continue; + } + const BDSPMoveData& mdata = bdsp_move(move_slugs[i]); + if (mdata.category == MoveCategory::STATUS){ + continue; + } + + int hi = 0; + bool ohko = false; + if (!on_field.empty() && !on_field[0].empty()){ + for (const BDSPTrainer& side : trainer_data.trainers){ + for (const BDSPTrainerPokemon& tp : side.pokemon){ + if (tp.species != on_field[0]){ + continue; + } + auto [lo, h, ok] = score_move_expert(mdata, tp, eff_atk, eff_spatk, doubles); + hi = h; + ohko = ok; + } + } + } + + bool prefer = (best_slot < 0) || (ohko && !best_ohko) || (ohko && best_ohko && hi < best_hi) || + (!ohko && !best_ohko && hi > best_hi); + + if (prefer){ + best_slot = i; + best_hi = hi; + best_ohko = ohko; + } + } + // Last resort: first move with any PP remaining + if (best_slot < 0){ + for (int i = 0; i < 4; i++){ + if (pp_remaining[i] > 0){ + best_slot = i; + break; + } + } + } + } + + // 7. Execute selected move (or Struggle if all PP are gone) + if (best_slot < 0){ + logger.log("All PP at 0 — Struggle", COLOR_YELLOW); + pbf_press_button(context, BUTTON_B, 10ms, 60ms); // close fight menu + context.wait_for_all_requests(); + }else{ + logger.log("Move slot " + std::to_string(best_slot) + " (" + move_slugs[best_slot] + ") vs " + + (on_field.empty() ? "?" : on_field[0]), + COLOR_WHITE); + navigate_to_slot(context, best_slot); + pbf_mash_button(context, BUTTON_A, 500ms); + context.wait_for_all_requests(); + pp_remaining[best_slot]--; + + // Single-target move in doubles: confirm default target + if (!bdsp_move(move_slugs[best_slot]).spread && doubles){ + pbf_press_button(context, BUTTON_A, 10ms, 60ms); + context.wait_for_all_requests(); + } + // Mash through target confirm and partner's move selection + pbf_mash_button(context, BUTTON_A, 3000ms); + context.wait_for_all_requests(); + } + + // 8. Wait for next event: next battle menu, XP gain, or battle end + { + BattleMenuWatcher menu2(BattleType::TRAINER); + ExperienceGainWatcher exp; + EndBattleWatcher end; + + int event = wait_until(stream, context, 60s, {{menu2}, {exp}, {end}}); + + if (event == 2 || event < 0){ + // Battle over + break; + }else if (event == 1){ + // XP gain — mash until the NEXT battle menu appears (or battle ends) + BattleMenuWatcher menu3(BattleType::TRAINER); + EndBattleWatcher end2; + int after = run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_mash_button(ctx, BUTTON_A, 120000ms); + }, + {{menu3}, {end2}}); + if (after != 0){ + // Battle ended during XP screens + break; + } + // Update on-field now that the next pokemon is visible + update_on_field(logger, options.language, stream, trainer_data.battle_type, candidates, on_field); + for (size_t i = 0; i < on_field.size(); i++){ + logger.log("On-field[" + std::to_string(i) + "] = " + on_field[i], COLOR_WHITE); + } + // Continue loop — we're now at the next battle menu + } + // event == 0: next battle menu appeared (mid-turn switch or next turn) + // Loop continues from the top, which is the battle menu state + } + } + + // Mash through any remaining XP/dialog until we're back in the overworld + OverworldWatcher overworld; + run_until(stream, + context, + [](ProControllerContext& ctx){ + pbf_mash_button(ctx, BUTTON_A, 120000ms); + }, + {{overworld}}); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h new file mode 100644 index 0000000000..1ab3a67b78 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h @@ -0,0 +1,111 @@ +/* BDSP AutoStory - Battle Utilities + * + * From: https://github.com/PokemonAutomation/ + * + * Helpers for handling trainer battles and unexpected wild encounters. + * These wrap existing BDSP battle detectors: + * - BattleMenuDetector / BattleMenuWatcher (PokemonBDSP_BattleMenuDetector.h) + * - EndBattleWatcher (PokemonBDSP_EndBattleDetector.h) + * - StartBattleDetector (PokemonBDSP_StartBattleDetector.h) + * - ExperienceGainWatcher (PokemonBDSP_ExperienceGainDetector.h) + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Battle_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Battle_H + +#include +#include +#include + +#include "CommonFramework/Tools/VideoStream.h" + +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" + +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +struct ProgramInfo; +namespace NintendoSwitch { +namespace PokemonBDSP { + + +// --------------------------------------------------------------------------- +// Scripted (expected) battles +// --------------------------------------------------------------------------- + +// Mash A through a battle (trainer or scripted wild) until EndBattleWatcher fires. +// This does not attempt to catch — it simply presses A to select the first move +// repeatedly until the battle ends. +// After the black-screen transition and experience gain, returns to the caller. +void run_battle_press_A(const ProgramInfo& info, VideoStream& stream, ProControllerContext& context); + + +// --------------------------------------------------------------------------- +// Unexpected battle handling +// --------------------------------------------------------------------------- + +// Run `action`. If a StartBattleDetector fires during `action`, the action +// is aborted, the battle is resolved with run_battle_press_A(), and then +// `recovery_action` is called to restore the player's position before +// retrying `action`. +// The whole sequence retries up to `max_attempts` times. +// Throws OperationFailedException if max_attempts is exceeded. +void handle_unexpected_battle(const ProgramInfo& info, + VideoStream& stream, + ProControllerContext& context, + std::function&& action, + std::function&& recovery_action, + size_t max_attempts = 5); + + +// --------------------------------------------------------------------------- +// Outside-help battle handler +// --------------------------------------------------------------------------- + +// Handle a scripted trainer battle where the configured human helper joins. +// Each turn it: +// 1. OCRs current PP for all 4 configured moves. +// 2. Calculates damage against all on-field opponents. +// 3. Navigates to and selects the optimal move (guaranteed OHKO if possible; +// highest total damage otherwise). +// 4. Mashes A to cover target selection and partner's move. +// 5. After XP gain, updates on-field opponents via OCR. +// Returns when the battle ends and the player is back in the overworld. +void handle_battle_with_outside_help(const ProgramInfo& info, + VideoStream& stream, + ProControllerContext& context, + const std::string& trainer_id, + const AutoStoryOptions& options); + + +// --------------------------------------------------------------------------- +// Expert battle handler (sequence-aware, per-turn status/HP check) +// --------------------------------------------------------------------------- + +// Handle one scripted trainer battle with intelligent per-turn move selection. +// +// `all_trainers` — the full upcoming sequence of trainer battles. Used to +// pre-plan PP budget so moves are allocated efficiently across the sequence. +// `current_trainer` — the trainer being fought in this call. +// +// Each turn the function: +// 1. Checks player HP (<33%) and status condition; uses a Full Restore if needed. +// 2. OCRs current PP for all 4 configured moves. +// 3. Identifies the current opponent via restricted-dictionary OCR. +// 4. Tracks Intimidate activations and reduces effective ATK accordingly. +// 5. Selects the pre-planned optimal move and executes it. +// 6. After XP gain, OCRs the new opponent and loops. +// Returns when the battle ends and the player is back in the overworld. +void handle_expert_battle(const ProgramInfo& info, + VideoStream& stream, + ProControllerContext& context, + const std::vector& all_trainers, + Trainer current_trainer, + const AutoStoryOptions& options); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Dialog.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Dialog.cpp new file mode 100644 index 0000000000..5d1fde2c18 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Dialog.cpp @@ -0,0 +1,44 @@ +/* BDSP AutoStory - Dialog Utilities + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/Exceptions/OperationFailedException.h" + +#include "CommonTools/Async/InferenceRoutines.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" + +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" + +#include "PokemonBDSP_AutoStory_Dialog.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +void clear_dialog(VideoStream& stream, ProControllerContext& context, ClearDialogMode mode, uint16_t timeout_sec){ + // TODO: implement + // - use ShortDialogWatcher + (optionally) ShortDialogPromptDetector + // + OverworldDetector (from Detect/) with run_until<> + // - loop mashing A until stop condition fires + (void)stream; + (void)context; + (void)mode; + (void)timeout_sec; +} + +void wait_for_dialog(VideoStream& stream, ProControllerContext& context, uint16_t timeout_sec){ + // TODO: implement + // - use wait_until() with ShortDialogWatcher + (void)stream; + (void)context; + (void)timeout_sec; +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Dialog.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Dialog.h new file mode 100644 index 0000000000..50fc7d7204 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Dialog.h @@ -0,0 +1,61 @@ +/* BDSP AutoStory - Dialog Utilities + * + * From: https://github.com/PokemonAutomation/ + * + * Helpers for clearing NPC dialog and waiting for dialog to appear. + * These wrap the existing BDSP dialog detectors: + * - ShortDialogDetector / ShortDialogWatcher (PokemonBDSP_DialogDetector.h) + * - ShortDialogPromptDetector (PokemonBDSP_DialogDetector.h) + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Dialog_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Dialog_H + +#include + +#include "CommonFramework/Tools/VideoStream.h" + +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" + +namespace PokemonAutomation { +struct ProgramInfo; +namespace NintendoSwitch { +namespace PokemonBDSP { + + +// --------------------------------------------------------------------------- +// Stop conditions for clear_dialog() +// --------------------------------------------------------------------------- + +enum class ClearDialogMode { + // Stop once the dialog has closed and the overworld is detected. + STOP_OVERWORLD, + + // Stop when a YES/NO prompt (ShortDialogPromptDetector) is detected. + STOP_PROMPT, + + // Run for exactly `timeout_sec` seconds regardless of screen state. + STOP_TIMEOUT, +}; + + +// --------------------------------------------------------------------------- +// Dialog clearing +// --------------------------------------------------------------------------- + +// Mash A through NPC dialog until `mode` stop condition is met. +// Uses ShortDialogWatcher as the primary "still in dialog" signal. +// Throws OperationFailedException if no dialog is ever detected, or if +// the stop condition is not met within `timeout_sec` seconds. +void clear_dialog(VideoStream& stream, ProControllerContext& context, ClearDialogMode mode, uint16_t timeout_sec = 60); + +// Block until a short dialog box appears, then return. +// Throws OperationFailedException if no dialog appears within `timeout_sec`. +void wait_for_dialog(VideoStream& stream, ProControllerContext& context, uint16_t timeout_sec = 30); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Navigation.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Navigation.cpp new file mode 100644 index 0000000000..28a871f34a --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Navigation.cpp @@ -0,0 +1,57 @@ +/* BDSP AutoStory - Navigation Utilities + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/Exceptions/OperationFailedException.h" + +#include "CommonTools/Async/InferenceRoutines.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_StartBattleDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" + +#include "PokemonBDSP_AutoStory_Navigation.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + + +void walk_steps(ProControllerContext& context, uint16_t direction, uint16_t steps){ + // TODO: implement — press D-pad `direction` for `steps` tiles + (void)context; + (void)direction; + (void)steps; +} + +void walk_until_dialog(VideoStream& stream, ProControllerContext& context, uint16_t direction, uint16_t timeout_sec){ + // TODO: implement — walk in direction; stop when ShortDialogWatcher fires + (void)stream; + (void)context; + (void)direction; + (void)timeout_sec; +} + +void walk_until_battle(VideoStream& stream, ProControllerContext& context, uint16_t direction, uint16_t timeout_sec){ + // TODO: implement — walk in direction; stop when StartBattleDetector fires + (void)stream; + (void)context; + (void)direction; + (void)timeout_sec; +} + +void recalibrate_after_battle(const ProgramInfo& /*info*/, + VideoStream& /*stream*/, + ProControllerContext& context, + std::function&& recovery_action){ + // TODO: implement — wait for overworld, then run recovery_action + recovery_action(context); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Navigation.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Navigation.h new file mode 100644 index 0000000000..55bdab77f9 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Navigation.h @@ -0,0 +1,78 @@ +/* BDSP AutoStory - Navigation Utilities + * + * From: https://github.com/PokemonAutomation/ + * + * Helpers for moving the player character in the BDSP overworld. + * BDSP uses a tile-based grid, so most movement is fixed D-pad sequences. + * Detection-based stop conditions and post-battle recalibration are also here. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Navigation_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Navigation_H + +#include + +#include "CommonFramework/Tools/VideoStream.h" + +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" + +namespace PokemonAutomation { +struct ProgramInfo; +namespace NintendoSwitch { +namespace PokemonBDSP { + + +// --------------------------------------------------------------------------- +// Fixed-sequence walking (no detection — pure tile counting) +// --------------------------------------------------------------------------- + +// Walk `steps` tiles in the given D-pad direction. +// direction must be one of DPAD_UP / DPAD_DOWN / DPAD_LEFT / DPAD_RIGHT. +void walk_steps(ProControllerContext& context, uint16_t direction, uint16_t steps); + + +// --------------------------------------------------------------------------- +// Detection-guided walking (walk until a screen state is detected) +// --------------------------------------------------------------------------- + +// Walk in `direction` until a short dialog box is detected, or `timeout_sec` elapses. +// Throws OperationFailedException on timeout. +void walk_until_dialog(VideoStream& stream, + ProControllerContext& context, + uint16_t direction, + uint16_t timeout_sec = 30); + +// Walk in `direction` until a battle starts (StartBattleDetector fires), +// or `timeout_sec` elapses. +// Throws OperationFailedException on timeout. +void walk_until_battle(VideoStream& stream, + ProControllerContext& context, + uint16_t direction, + uint16_t timeout_sec = 30); + + +// --------------------------------------------------------------------------- +// Post-battle recalibration +// +// BDSP movement is deterministic on a grid, but a random encounter shifts +// the player's position unpredictably (post-battle exit tile varies). +// These functions restore the player to a known reference tile by navigating +// to a fixed landmark (e.g. a building entrance). +// --------------------------------------------------------------------------- + +// After an unexpected battle, navigate to the nearest known landmark and +// re-establish a known tile position. The exact recovery sequence depends +// on the calling segment and is passed as a lambda. +// `recovery_action` should move the player from wherever they ended up back +// to a defined starting tile for the current checkpoint. +void recalibrate_after_battle(const ProgramInfo& info, + VideoStream& stream, + ProControllerContext& context, + std::function&& recovery_action); + + +} +} +} +#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 new file mode 100644 index 0000000000..786bd5fee0 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp @@ -0,0 +1,2149 @@ +/* BDSP AutoStory - Segment 00 Tools + * + * Helper implementations for Segment 00. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include + +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/Tools/ErrorDumper.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 "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_SelectionArrow.h" +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.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; +} + +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); + } + + 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); + } + } + + // ------------------------------------------------- + // 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); + + 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; + } +}; + +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()); + } + // 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; +} + +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; +} + +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); + 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); + 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); + } + } + // 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); + } + } + 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(); + } + } +} + +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); + } + 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); + + // 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); + + context.wait_for_all_requests(); + context.wait_for(500ms); +} + + +// --------------------------------------------------------------------------- +// 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(); + } + 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); + + // Allow battle transition to finish. + pbf_wait(context, 5000ms); + + bool battle_menu_seen = false; + + // State machine loop. + while (true){ + + context.wait_for_all_requests(); + + BattleMenuWatcher battle_menu(BattleType::TRAINER); + EndBattleWatcher end_battle; + + // Detect move-learning arrows. + SelectionArrowFinder learn_move(stream.overlay(), {0.50, 0.62, 0.40, 0.18}, COLOR_YELLOW); + + 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); + }, + { + {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); + + battle_menu_seen = true; + + select_natalie_move_three(stream, context); + + break; + } + + // ------------------------------------------------- + // Battle finished. + // ------------------------------------------------- + case 1: { + stream.log("[AutoStory] Natalie battle complete.", COLOR_GREEN); + + pbf_mash_button(context, BUTTON_B, 5000ms); + + return; + } + + // ------------------------------------------------- + // Move learning detected. + // ------------------------------------------------- + case 2: { + stream.log("[AutoStory] Learn move detected.", COLOR_ORANGE); + + // Always refuse move learning. + pbf_move_right_joystick(context, {0, -1}, 160ms, 840ms); + + pbf_press_button(context, BUTTON_A, 160ms, 840ms); + + break; + } + + // ------------------------------------------------- + // Timeout. + // ------------------------------------------------- + default: { + stream.log("[AutoStory] Natalie battle timed out.", COLOR_RED); + + pbf_mash_button(context, BUTTON_B, 5000ms); + + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "[AutoStory] Natalie battle timed out.", + 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}, + }); + + switch (ret){ + case 0: { + stream.log("[AutoStory] Battle menu detected. Selecting move...", COLOR_BLUE); + + // 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}, + }); + + if (ret >= 0){ + encounter_found = true; + break; + } + + // Turn right. + pbf_move_left_joystick(context, {1, 0}, 120ms, 200ms); + context.wait_for_all_requests(); + + ret = wait_until(stream, + context, + 1500ms, + { + {battle_start}, + {battle_menu}, + {battle_dialog}, + }); + + if (ret >= 0){ + encounter_found = true; + break; + } + } + + 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); + } + + stream.log("[AutoStory] Battle menu detected.", COLOR_GREEN); + + context.wait_for_all_requests(); + pbf_wait(context, 500ms); +} + +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(); +} + +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, + std::array* remaining_pp = nullptr){ + 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); + } + + bool battle_menu_seen = false; + EndBattleWatcher end_battle; + + while (true){ + + context.wait_for_all_requests(); + + 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. + 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; + size_t selected_move = move_slots[move_index]; + if (remaining_pp){ + selected_move = 0; + for (size_t slot : move_slots){ + if ((*remaining_pp)[slot - 1] > 0){ + selected_move = slot; + break; + } + } + if (selected_move == 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Allowed move PP budgets exhausted; heal before continuing.", + stream); + } + } + + stream.log("[AutoStory] Battle menu detected.", COLOR_BLUE); + + if (remaining_pp){ + stream.log("[" + label + "] Using available move slot " + std::to_string(selected_move) + + " from this battle's priority list."); + }else{ + stream.log("[" + label + "] Move list position " + std::to_string(move_index + 1) + ": use move " + + std::to_string(selected_move) + "."); + } + select_battle_move_slot(stream, + context, + battle_type, + selected_move, + label, + confirm_default_target, + current_move); + // A disabled move leaves the Fight list open and consumes no PP. + // The single-slot policy has no legal alternative: retry the checkpoint + // rather than repeatedly charging an input the game rejected. + if (remaining_pp && move_slots.size() == 1){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (frame && detect_battle_move_menu(frame).move_count){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Move selection was rejected; PP budget unchanged. Retrying from the saved checkpoint.", + stream); + } + } + current_move = selected_move; + if (remaining_pp){ + --(*remaining_pp)[selected_move - 1]; + stream.log("[Move PP] Slot " + std::to_string(selected_move) + ": " + + std::to_string((*remaining_pp)[selected_move - 1]) + " remaining in the current budget."); + } + // Advance once per move selection; retain the final entry. + if (!remaining_pp && move_index + 1 < move_slots.size()){ + move_index++; + } + + break; + } + + // ------------------------------------------------- + // End battle detected. + // ------------------------------------------------- + case 1: { + stream.log("[AutoStory] " + label + " battle complete.", COLOR_GREEN); + + // EndBattleWatcher has confirmed the completed exit transition. + // Ordinary trainers need no scene-wide dialogue-free gate. + if (!post_battle_dialogue_cleanup){ + 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){ + 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; + } + + 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){ + 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)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Correction would approach the stair boundary.", + 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; + } + // 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); + } + 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, + AutoStoryStats& stats, + const std::string& label, + bool post_battle_dialogue_cleanup){ + // Preserve the list-based selection/target/exit handling. These priorities + // refer to the wiki's standardized slot roles, not move-name OCR. + std::vector priorities{1, 2, 4, 3}; + const bool grass_gym = label == "Gardenia" || label == "Lass Caroline" || + label == "Aroma Lady Jenna" || label == "Aroma Lady Angela" || + label == "Beauty Lindsay" || label == "Aroma Lady Teighler"; + const bool rock_battle = label == "Roark" || label.find("Hiker") != std::string::npos || + label.find("hiker_") != std::string::npos; + const bool water_battle = label == "Crasher Wake" || label.find("Pastoria Gym trainer") != std::string::npos || + label.find("tuber_") != std::string::npos || label.find("swimmer_") != std::string::npos; + const bool flying_battle = label.find("Bird") != std::string::npos || + label.find("birdkeeper_") != std::string::npos || label.find("Cycling Road") != std::string::npos; + const bool fighting_battle = label.find("Black Belt") != std::string::npos || + label.find("blackbelt_") != std::string::npos; + + switch (stats.starter_choice){ + case StarterChoice::TURTWIG: + if (label == "Fantina" || flying_battle){ + priorities = {1, 2, 4}; + }else if (rock_battle || water_battle){ + priorities = {2, 4, 1, 3}; + }else if (label == "Maylene"){ + priorities = {3, 1, 2, 4}; + } + break; + case StarterChoice::CHIMCHAR: + if (label == "Fantina" || label.find("psychic_") != std::string::npos || + label.find("Psychics ") != std::string::npos){ + // Psychic/Ghost parties rule out Fighting slots; Levitate users also + // rule out Earthquake. Do not spend a different slot on exhaustion. + priorities = {2}; + }else if (grass_gym || fighting_battle || label == "Maylene" || + label == "Route 215 Ruin Maniac Calvin" || + label == "Bug Catchers Donald and Phillip" || label == "victory_road_ace_sydney"){ + priorities = {2, 1, 4}; + }else if (flying_battle){ + priorities = {1, 4, 2}; + }else if (water_battle || rock_battle){ + priorities = {1, 4, 3, 2}; + } + break; + case StarterChoice::PIPLUP: + if (grass_gym || flying_battle || label.find("dragontamer_") != std::string::npos || + label == "victory_road_ace_sydney"){ + priorities = {2, 1, 4, 3}; + }else if (rock_battle || label == "Maylene" || label == "Fantina" || + label.find(" and ") != std::string::npos || + label == "veilstone_galactic_grunts_1" || label == "Route 215 Ruin Maniac Calvin"){ + priorities = {3, 2, 1, 4}; + }else if (water_battle || fighting_battle){ + priorities = {1, 4, 2, 3}; + } + break; + } + fight_using_move_list(stream, + context, + BattleType::TRAINER, + priorities, + label, + true, + post_battle_dialogue_cleanup, + &stats.move_pp_remaining); +} + +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_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 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); +} + +// --------------------------------------------------------------------------- +// 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); + } + + context.wait_for_all_requests(); + + 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_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; +} + +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; +} + +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 (detect_black_screen_after_movement(stream, context, 1200ms)){ + pbf_wait(context, 2340ms); + return; + } + + 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, 20)){ + 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); + + if (try_right_down_exit_search(stream, context, 30)){ + 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); +} + +// --------------------------------------------------------------------------- +// 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 + "."); + } + } +} + +} +} +} 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 new file mode 100644 index 0000000000..47a1de720e --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h @@ -0,0 +1,139 @@ +#pragma once + +#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); +} +} +} \ No newline at end of file diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.cpp new file mode 100644 index 0000000000..6d96ef3623 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.cpp @@ -0,0 +1,266 @@ +/* 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_AutoStory_Segment_00_tools.h" + +#include "PokemonBDSP_Autostory_Segment_01_tools.h" + +using namespace std::chrono_literals; + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { + +// Extracted from the Segment 01 Oreburgh Gate approach. Both callers use +// the same bounded down/right search and cancel queued input at the black fade. +// Oreburgh preserves its original north sweep; Coronet uses only down/right. +void enter_cave_with_down_right_search(VideoStream& stream, + ProControllerContext& context, + const std::string& label, + Milliseconds north_fallback){ + context.wait_for_all_requests(); + for (size_t attempt = 0; attempt < 3; attempt++){ + BlackScreenWatcher black_screen; + const int result = run_until(stream, + context, + [north_fallback](ProControllerContext& ctx){ + for (size_t i = 0; i < 10; i++){ + pbf_press_button(ctx, BUTTON_DOWN, 180ms, 0ms); + pbf_press_button(ctx, BUTTON_RIGHT, 180ms, 0ms); + pbf_press_button(ctx, BUTTON_RIGHT, 180ms, 0ms); + } + if (north_fallback > 0ms){ + pbf_move_left_joystick(ctx, {0, 1}, north_fallback, 0ms); + } + }, + {{black_screen}}); + context.wait_for_all_requests(); + if (result == 0){ + stream.log(label + ": Black screen detected."); + return; + } + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No black screen after 3 attempts.", + stream); +} + +static bool mine_confirm_escape_via_menu(VideoStream& stream, ProControllerContext& context, BattleType battle_type){ + MenuWatcher game_menu; + OverworldWatcher overworld; + BattleMenuWatcher battle_menu(battle_type, COLOR_YELLOW); + for (size_t attempt = 1; attempt <= 3; attempt++){ + // Do not accept the absence-based overworld detector before menu proof. + const int state = wait_until(stream, context, 300ms, {{game_menu}, {battle_menu}}); + if (state == 1){ + stream.log("[Mine escape] Battle menu still present; retrying Run."); + return false; + } + int detected = state; + if (state != 0){ + stream.log("[Mine escape] Main menu fallback attempt " + std::to_string(attempt) + "/3."); + detected = run_until(stream, + context, + [](ProControllerContext& controller){ + pbf_press_button(controller, BUTTON_X, 160ms, 840ms); + pbf_wait(controller, 3s); + controller.wait_for_all_requests(); + }, + {game_menu, battle_menu}); + if (detected == 1){ + return false; + } + } + if (detected != 0){ + continue; + } + stream.log("[Mine escape] Main game menu confirmed; battle has ended.", COLOR_GREEN); + // Close the confirmed menu before returning control to navigation. + MenuDetector menu_visible; + for (size_t close = 0; close < 3; close++){ + pbf_press_button(context, BUTTON_B, 80ms, 500ms); + context.wait_for_all_requests(); + if (wait_until(stream, context, 1s, {{overworld}}) == 0){ + return true; + } + BlackScreenWatcher visible(COLOR_YELLOW, + {0.1, 0.1, 0.8, 0.8}, + 100, + 10, + BlackScreenWatcher::FinderType::GONE, + 500ms); + if (wait_until(stream, context, 2s, {{visible}}) == 0){ + const VideoSnapshot frame = stream.video().snapshot(); + if (frame && !menu_visible.detect(frame)){ + stream.log("[Mine escape] Confirmed game menu closed; returning to navigation.", COLOR_GREEN); + return true; + } + } + } + stream.log("[Mine escape] Main menu detected but could not confirm it closed.", COLOR_ORANGE); + return false; + } + return false; +} + +static bool mine_select_run(VideoStream& stream, ProControllerContext& context, BattleType battle_type){ + BattleMenuDetector cursor(battle_type, COLOR_YELLOW); + size_t matching_frames = 0; + for (size_t check = 0; check < 16; check++){ + context.wait_for_all_requests(); + const VideoSnapshot frame = stream.video().snapshot(); + if (!frame){ + return false; + } + const BattleMenuOption selected = cursor.selected_option(frame); + stream.log( + "[Mine escape] Battle cursor row=" + std::to_string(static_cast(selected) + 1) + "; target Run=4."); + if (selected == BattleMenuOption::RUN){ + if (++matching_frames >= 3){ + stream.log("[Mine escape] Run cursor confirmed.", COLOR_GREEN); + return true; + } + pbf_wait(context, 100ms); + }else{ + matching_frames = 0; + if (selected == BattleMenuOption::UNKNOWN){ + // Do not send a direction or A without a known cursor. + pbf_wait(context, 150ms); + }else{ + const DpadPosition direction = selected == BattleMenuOption::BATTLE ? DPAD_UP : DPAD_DOWN; + pbf_press_dpad(context, direction, 80ms, 250ms); + } + } + } + stream.log("[Mine escape] Could not confirm Run selection; A was not pressed.", COLOR_ORANGE); + return false; +} + +void escape_wild_battle(VideoStream& stream, + ProControllerContext& context, + BattleType battle_type, + std::chrono::seconds timeout){ + EndBattleWatcher battle_end; + bool exit_transition = false; + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline){ + pbf_press_button(context, BUTTON_B, 40ms, 200ms); + context.wait_for_all_requests(); + BattleMenuWatcher menu_ready(battle_type, COLOR_YELLOW); + const int ready = wait_until(stream, context, 3s, {{battle_end}, {menu_ready}}); + if (ready == 0){ + exit_transition = true; + break; + } + if (ready != 1){ + continue; + } + if (!mine_select_run(stream, context, battle_type)){ + continue; + } + stream.log("[Mine escape] Run input sent; watching for black-screen exit."); + const int result = run_until(stream, + context, + [](ProControllerContext& controller){ + pbf_press_button(controller, BUTTON_A, 60ms, 0ms); + pbf_wait(controller, 4s); + controller.wait_for_all_requests(); + }, + {battle_end}); + if (result == 0){ + exit_transition = true; + break; + } + } + if (!exit_transition && wait_until(stream, context, 10s, {{battle_end}}) == 0){ + exit_transition = true; + } + if (exit_transition){ + // EndBattleWatcher requires black followed by a visible screen. + stream.log("[Mine escape] Black-screen exit transition confirmed.", COLOR_GREEN); + pbf_wait(context, 800ms); + context.wait_for_all_requests(); + return; + } + // Only probe X after normal escape attempts and the final transition wait. + // Absence of battle UI by itself is not evidence of an overworld return. + stream.log("[Mine escape] Exit transition missed; trying late main-menu fallback."); + if (mine_confirm_escape_via_menu(stream, context, battle_type)){ + return; + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "escape_wild_battle: Exit transition and late main-menu fallback could not confirm escape.", + stream); +} + +void Barry_1_fight(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ + stream.log("Starting Barry 1 battle..."); + + // State machine: loop until EndBattleWatcher fires + for (size_t c = 0; c < 20; c++){ + context.wait_for_all_requests(); + + BattleMenuWatcher battle_menu(BattleType::STANDARD); + EndBattleWatcher end_battle; + int ret = run_until(stream, + context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_ZL, 120000ms); + }, + { + battle_menu, + end_battle, + }); + switch (ret){ + case 0: + stream.log("Battle menu detected - using first move."); + pbf_press_button(context, BUTTON_ZL, 160ms, 1000ms); + break; + case 1: + stream.log("Barry 1 battle complete."); + pbf_mash_button(context, BUTTON_B, 5000ms); + context.wait_for_all_requests(); + return; + default: + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Barry_1_fight: Timed out waiting for battle menu or end.", + stream); + } + } + + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Barry_1_fight: Battle did not end after 20 turns.", + stream); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.h new file mode 100644 index 0000000000..0a5d2378f8 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.h @@ -0,0 +1,28 @@ +// Shared battle helpers used by AutoStory routes. +#pragma once + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { +// Reuse the Oreburgh Gate down/right entry search. Returns at fade start; +// caller owns destination-specific fade completion and interior confirmation. +// north_fallback preserves Oreburgh's final north sweep when explicitly set. +void enter_cave_with_down_right_search(VideoStream& stream, + ProControllerContext& context, + const std::string& label, + Milliseconds north_fallback = Milliseconds(0)); + +// Shared confirmed-Run escape: verifies exit transition or main menu. +void escape_wild_battle(VideoStream& stream, + ProControllerContext& context, + BattleType battle_type = BattleType::STANDARD, + std::chrono::seconds timeout = std::chrono::seconds(30)); + +// Shared legacy first-move battle routine; consolidation remains preliminary. +void Barry_1_fight(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.cpp new file mode 100644 index 0000000000..6b7c2b8182 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.cpp @@ -0,0 +1,3697 @@ + + +#include +#include +#include + +#include + +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/Globals.h" + +#include "PokemonBDSP_OverworldMatchers.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { +enum class LandmarkFeatures { + GRAYSCALE, + LOCAL_CONTRAST, + BLUE_FRACTION +}; +struct LandmarkConfig { + const char* name; + const char* path; + ImageFloatBox target, search; + double threshold, margin; + LandmarkFeatures features = LandmarkFeatures::GRAYSCALE; +}; +// Screen-relative half-widths of the accepted position box, and how closely +// two landmarks (or reference variants) must agree to be trusted together. +static constexpr double POSITION_TOLERANCE_X = 1.0 / 50; +static constexpr double POSITION_TOLERANCE_Y = 1.0 / 54; +static constexpr double AGREEMENT_X = 1.0 / 64; +static constexpr double AGREEMENT_Y = 1.0 / 54; +static cv::Mat gray_image(const ImageRGB32& image){ + cv::Mat pixels((int)image.height(), + (int)image.width(), + CV_8UC4, + const_cast(image.data()), + image.bytes_per_row()); + cv::Mat gray; + cv::cvtColor(pixels, gray, cv::COLOR_BGRA2GRAY); + return gray; +} +// Opt-in for selected outdoor structural anchors. Divide out slow local lighting +// changes so a moving cloud does not alter the printed sign/emblem structure. +static cv::Mat normalize_landmark_illumination(const cv::Mat& gray){ + cv::Mat pixels, background, normalized; + gray.convertTo(pixels, CV_32F); + cv::GaussianBlur(pixels, background, cv::Size(0, 0), 3.0); + cv::divide(pixels, background + 1.0, normalized); + cv::Mat structure; + normalized.convertTo(structure, CV_8U, 128.0); + return structure; +} +// White Pokeball versus yellow fascia is a color-ratio boundary. A cast +// shadow changes their brightness, so compare blue fraction rather than luma. +static cv::Mat landmark_blue_fraction(const ImageRGB32& image){ + cv::Mat pixels((int)image.height(), + (int)image.width(), + CV_8UC4, + const_cast(image.data()), + image.bytes_per_row()); + cv::Mat small, floating; + cv::resize(pixels, small, cv::Size(960, 540), 0, 0, cv::INTER_AREA); + small.convertTo(floating, CV_32F); + std::vector channels; + cv::split(floating, channels); + cv::Mat ratio, structure; + cv::divide(channels[0], channels[0] + channels[1] + channels[2] + 3.0, ratio); + ratio.convertTo(structure, CV_8U, 768.0); + return structure; +} +static OverworldMatchResult match_landmarks(const ImageRGB32& frame, + const std::vector& configs, + size_t required, + double tolerance_x, + double tolerance_y, + bool smooth_resize = false){ + OverworldMatchResult result; + if (!frame){ + return result; + } + cv::Mat gray; + if (smooth_resize){ + // Center matching uses explicit area averaging for stable emblem edges. + // Other matchers retain their existing ImageRGB32 resize behavior. + cv::resize(gray_image(frame), gray, cv::Size(960, 540), 0, 0, cv::INTER_AREA); + }else{ + ImageRGB32 scaled = frame.scale_to(960, 540); + gray = gray_image(scaled); + } + cv::Mat normalized_gray, chromaticity; + for (const auto& config : configs){ + OverworldLandmarkResult landmark; + landmark.name = config.name; + landmark.search = config.search; + ImageRGB32 reference(RESOURCE_PATH() + config.path); + // Convert normalized geometry to pixels only at the image-processing boundary. + const int template_width = std::max(1, (int)std::lround(config.target.width * gray.cols)); + const int template_height = std::max(1, (int)std::lround(config.target.height * gray.rows)); + cv::Mat templ; + if (smooth_resize){ + cv::resize(gray_image(reference), templ, cv::Size(template_width, template_height), 0, 0, cv::INTER_AREA); + }else{ + ImageRGB32 small = reference.scale_to(template_width, template_height); + templ = gray_image(small); + } + cv::Rect search((int)(config.search.x * 960), + (int)(config.search.y * 540), + (int)(config.search.width * 960), + (int)(config.search.height * 540)); + search &= cv::Rect(0, 0, 960, 540); + if (search.width < templ.cols || search.height < templ.rows){ + result.landmarks.push_back(landmark); + continue; + } + cv::Mat scores; + if (config.features == LandmarkFeatures::BLUE_FRACTION){ + if (chromaticity.empty()){ + chromaticity = landmark_blue_fraction(frame); + } + cv::matchTemplate(chromaticity(search), templ, scores, cv::TM_CCOEFF_NORMED); + }else if (config.features == LandmarkFeatures::LOCAL_CONTRAST){ + if (normalized_gray.empty()){ + normalized_gray = normalize_landmark_illumination(gray); + } + // These opt-in references are normalized from their full source + // frame before cropping, avoiding false edges at template borders. + cv::matchTemplate(normalized_gray(search), templ, scores, cv::TM_CCOEFF_NORMED); + }else{ + cv::matchTemplate(gray(search), templ, scores, cv::TM_CCOEFF_NORMED); + } + cv::Point location; + cv::minMaxLoc(scores, nullptr, &landmark.score, nullptr, &location); + cv::Mat competitors = scores.clone(); + const int peak_radius_x = (int)std::lround(0.015625 * gray.cols); + const int peak_radius_y = (int)std::lround((1.0 / 36) * gray.rows); + cv::Rect exclusion = cv::Rect(location.x - peak_radius_x, + location.y - peak_radius_y, + 2 * peak_radius_x + 1, + 2 * peak_radius_y + 1) & + cv::Rect(0, 0, scores.cols, scores.rows); + competitors(exclusion).setTo(-1); + double runner_up; + cv::minMaxLoc(competitors, nullptr, &runner_up); + landmark.margin = landmark.score - runner_up; + location += search.tl(); + landmark.dx = (double)location.x / gray.cols - config.target.x; + landmark.dy = (double)location.y / gray.rows - config.target.y; + landmark.accepted = landmark.score >= config.threshold && landmark.margin >= config.margin; + landmark.found = {(double)location.x / 960, + (double)location.y / 540, + (double)templ.cols / 960, + (double)templ.rows / 540}; + result.landmarks.push_back(landmark); + } + if (required == 1 && result.landmarks.size() == 1){ + result.located = result.landmarks[0].accepted; + result.dx = result.landmarks[0].dx; + result.dy = result.landmarks[0].dy; + }else{ + size_t best_support = 0; + bool ambiguous = false; + for (const auto& candidate : result.landmarks){ + if (!candidate.accepted){ + continue; + } + size_t support = 0; + double dx = 0, dy = 0; + for (const auto& other : result.landmarks){ + if (other.accepted && std::abs(candidate.dx - other.dx) <= AGREEMENT_X && + std::abs(candidate.dy - other.dy) <= AGREEMENT_Y){ + support++; + dx += other.dx; + dy += other.dy; + } + } + if (support < required){ + continue; + } + dx /= support; + dy /= support; + if (support > best_support){ + best_support = support; + result.dx = dx; + result.dy = dy; + ambiguous = false; + }else if (support == best_support && + (std::abs(dx - result.dx) > AGREEMENT_X || std::abs(dy - result.dy) > AGREEMENT_Y)){ + ambiguous = true; + } + } + result.located = best_support >= required && !ambiguous; + } + result.at_target = result.located && std::abs(result.dx) <= tolerance_x && std::abs(result.dy) <= tolerance_y; + return result; +} +OverworldMatchResult match_poketch_landmarks(const ImageRGB32& frame){ + // Same cropped reference emblems and local search windows as production. + OverworldMatchResult fine = match_landmarks(frame, + {{"Mart", + "PokemonBDSP/Autostory/PoketchInteraction/Mart.png", + {0.768750000000, 0.044444444444, 0.079166666667, 0.088888888889}, + {598.0 / 960, 0, 356.0 / 960, 212.0 / 540}, + .90, + 0}, + {"Center", + "PokemonBDSP/Autostory/PoketchInteraction/Center.png", + {0.900000000000, 0.748148148148, 0.075000000000, 0.137037037037}, + {724.0 / 960, 264.0 / 540, 236.0 / 960, 276.0 / 540}, + .90, + 0}, + {"School", + "PokemonBDSP/Autostory/PoketchInteraction/School.png", + {0.006250000000, 0.755555555556, 0.097916666667, 0.133333333333}, + {0, 268.0 / 540, 240.0 / 960, 272.0 / 540}, + .90, + 0}}, + 2, + 7.0 / 960, + 7.0 / 540); + if (fine.located){ + return fine; + } + // Coarse acquisition searches the whole frame. Agreement and a peak margin + // guard against confusing the Mart/Center emblems with another building. + OverworldMatchResult coarse = match_landmarks(frame, + {{"Mart", + "PokemonBDSP/Autostory/PoketchInteraction/Mart.png", + {0.768750000000, 0.044444444444, 0.079166666667, 0.088888888889}, + {0, 0, 1, 1}, + .93, + .12}, + {"Center", + "PokemonBDSP/Autostory/PoketchInteraction/Center.png", + {0.900000000000, 0.748148148148, 0.075000000000, 0.137037037037}, + {0, 0, 1, 1}, + .93, + .12}, + {"School", + "PokemonBDSP/Autostory/PoketchInteraction/School.png", + {0.006250000000, 0.755555555556, 0.097916666667, 0.133333333333}, + {0, 0, 1, 1}, + .92, + .40}}, + 2, + 7.0 / 960, + 7.0 / 540); + if (!coarse.located){ + // Acquisition may expose just one building. School keeps its strong + // spatial margin; Mart/Center require a higher score than pair matching. + // A single anchor guides coarse movement only, never interaction. + const OverworldLandmarkResult* anchor = nullptr; + for (const auto& landmark : coarse.landmarks){ + const bool strong = + landmark.accepted && (landmark.name == "School" || (landmark.score >= .95 && landmark.margin >= .12)); + if (strong && (!anchor || landmark.score > anchor->score)){ + anchor = &landmark; + } + } + if (anchor){ + bool contradictory = false; + for (const auto& other : coarse.landmarks){ + if (other.accepted && + (std::abs(other.dx - anchor->dx) > 1.0 / 192 || std::abs(other.dy - anchor->dy) > 1.0 / 108)){ + contradictory = true; + } + } + if (!contradictory){ + coarse.located = true; + coarse.dx = anchor->dx; + coarse.dy = anchor->dy; + } + } + } + coarse.coarse = true; + // A coarse match may guide movement, but never authorize interaction. + // Final position must pass the normal local two-landmark confirmation. + coarse.at_target = false; + return coarse; +} +OverworldMatchResult match_oreburgh_mart(const ImageRGB32& frame){ + const ImageFloatBox target{0.502083333333, 0.235185185185, 0.056250000000, 0.079629629630}; + auto original = match_landmarks(frame, + {{"Mart emblem", + "PokemonBDSP/Autostory/OreburghMart/MartEmblem.png", + target, + {0, 0, 1, 360.0 / 540}, + .93, + .10}}, + 1, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y); + // The recorded daytime roof appearance narrowly missed the old template. + // Preserve both confidence gates and reject disagreement between variants. + auto recorded = match_landmarks(frame, + {{"Mart emblem", + "PokemonBDSP/Autostory/OreburghMart/MartEmblemRecorded.png", + target, + {0, 0, 1, 360.0 / 540}, + .93, + .10}}, + 1, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y); + if (original.located && recorded.located && + (std::abs(original.dx - recorded.dx) > AGREEMENT_X || std::abs(original.dy - recorded.dy) > AGREEMENT_Y)){ + original.located = false; + original.at_target = false; + return original; + } + if ((recorded.located && !original.located) || + (recorded.located == original.located && recorded.landmarks[0].score > original.landmarks[0].score)){ + return recorded; + } + return original; +} +OverworldMatchResult match_jubilife_center_entrance(const ImageRGB32& frame){ + // Native lighting-test captures avoid resizing artifacts in the reference. + // The emblem is about 5.73% of screen width left of the player at approach: + // shift its target right to center the door, preserving the approach height. + OverworldMatchResult clear = match_landmarks(frame, + {{"Center entrance", + "PokemonBDSP/Autostory/JubilifeCenter/CenterEmblem.png", + {0.462500000000, 0.109259259259, 0.072916666667, 0.111111111111}, + {0, 0, 1, 1}, + .90, + .20}}, + 1, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + OverworldMatchResult shadow = match_landmarks(frame, + {{"Center entrance", + "PokemonBDSP/Autostory/JubilifeCenter/CenterEmblemShadow.png", + {0.462500000000, 0.109259259259, 0.072916666667, 0.111111111111}, + {0, 0, 1, 1}, + .90, + .20}}, + 1, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + // Lighting alternatives must identify the same position when both pass. + if (clear.located && shadow.located && + (std::abs(clear.dx - shadow.dx) > AGREEMENT_X || std::abs(clear.dy - shadow.dy) > AGREEMENT_Y)){ + clear.located = false; + clear.at_target = false; + return clear; + } + if (shadow.located && (!clear.located || shadow.landmarks[0].score > clear.landmarks[0].score)){ + return shadow; + } + return clear; +} +OverworldMatchResult match_route201_potion_exit(const ImageRGB32& frame){ + // The sign face is stable across lighting; its post and surrounding grass are not. + // Path shadows change shape, so validate the corner against four lighting states. + // Each alternative still requires the sign and path to agree on position. + const char* corners[] = {"PokemonBDSP/Autostory/Route201PotionExit/PathCornerHour0.png", + "PokemonBDSP/Autostory/Route201PotionExit/PathCornerHour9.png", + "PokemonBDSP/Autostory/Route201PotionExit/PathCornerHour12.png", + "PokemonBDSP/Autostory/Route201PotionExit/PathCornerHour17.png"}; + OverworldMatchResult best; + double best_score = -1; + for (const char* corner : corners){ + auto candidate = match_landmarks(frame, + {{"Route sign face", + "PokemonBDSP/Autostory/Route201PotionExit/RouteSignFace.png", + {0.292708333333, 0.470370370370, 0.109375, 0.072222222222}, + {0, 0, 1, 1}, + .94, + .20}, + {"Path corner", + corner, + {0.202083333333, 0.648148148148, 0.133333333333, 0.174074074074}, + {0, 0, 1, 1}, + .90, + .10}}, + 2, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + if (best.located && candidate.located && + (std::abs(best.dx - candidate.dx) > AGREEMENT_X || std::abs(best.dy - candidate.dy) > AGREEMENT_Y)){ + best.located = false; + best.at_target = false; + return best; + } + if (candidate.landmarks.size() != 2){ + continue; + } + const double score = std::min(candidate.landmarks[0].score, candidate.landmarks[1].score); + if ((candidate.located && !best.located) || (candidate.located == best.located && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + return best; +} +OverworldMatchResult match_sandgem_junction(const ImageRGB32& frame){ + // Building emblems contain no localized words. Ignore NPCs and door lights. + auto result = match_landmarks(frame, + {{"Center emblem", + "PokemonBDSP/Autostory/SandgemJunction/Center.png", + {0.171875, 0.2, 0.058333333333, 0.092592592593}, + {0, 0, 1, 1}, + .90, + .08}, + {"Mart emblem", + "PokemonBDSP/Autostory/SandgemJunction/Mart.png", + {0.726041666667, 0.222222222222, 0.0625, 0.088888888889}, + {0, 0, 1, 1}, + .90, + .08}}, + 2, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + if (result.located || result.landmarks.size() != 2){ + return result; + } + const auto& center = result.landmarks[0]; + const auto& mart = result.landmarks[1]; + const double dy = (center.dy + mart.dy) / 2; + const double horizontal_disagreement = center.dx - mart.dx; + // At a different approach height, perspective changes the buildings' spacing. + // Trust matching height offsets for vertical acquisition only. Bound the + // horizontal discrepancy and require its direction to agree with perspective: + // scenery above target contracts, scenery below target expands. + if (center.accepted && mart.accepted && std::abs(center.dy - mart.dy) <= AGREEMENT_Y && std::abs(dy) > 1.0 / 72 && + std::abs(dy) <= 0.20 && std::abs(horizontal_disagreement) <= 0.04 && + std::abs(horizontal_disagreement) <= 0.35 * std::abs(dy) + 1.0 / 192 && horizontal_disagreement * dy <= 0){ + result.located = true; + result.coarse = true; + result.dx = (center.dx + mart.dx) / 2; + result.dy = dy; + // Acquisition may move vertically; it never confirms the route endpoint. + result.at_target = false; + } + return result; +} +OverworldMatchResult match_route202_grass_exit(const ImageRGB32& frame){ + // Route marker and upper path bend avoid the player, NPCs, and localized text. + // Morning tree shadows need a second corner reference. Each alternative + // must still agree with the sign; neither can confirm the destination alone. + const char* corners[] = {"PokemonBDSP/Autostory/Route202GrassExit/PathCorner.png", + "PokemonBDSP/Autostory/Route202GrassExit/PathCornerMorning.png"}; + OverworldMatchResult best; + double best_score = -1; + for (const char* corner : corners){ + auto candidate = match_landmarks(frame, + {{"Route 202 sign", + "PokemonBDSP/Autostory/Route202GrassExit/RouteSign.png", + {0.59375, 0.194444444444, 0.091666666667, 0.059259259259}, + {0, 0, 1, 1}, + .94, + .20}, + {"Path corner", + corner, + {0.405208333333, 0.331481481481, 0.1375, 0.062962962963}, + {0, 0, 1, 1}, + .90, + .08}}, + 2, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + if (best.located && candidate.located && + (std::abs(best.dx - candidate.dx) > AGREEMENT_X || std::abs(best.dy - candidate.dy) > AGREEMENT_Y)){ + best.located = false; + best.at_target = false; + return best; + } + if (candidate.landmarks.size() != 2){ + continue; + } + const double score = std::min(candidate.landmarks[0].score, candidate.landmarks[1].score); + if ((candidate.located && !best.located) || (candidate.located == best.located && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + return best; +} +// Lighting alternatives describe one physical landmark, not extra votes. +// Select one reference per landmark before requiring the two positions to agree. +static OverworldMatchResult match_lighting_pair(const ImageRGB32& frame, + const std::vector& variants, + const char* first, + const char* second, + double tolerance_x = POSITION_TOLERANCE_X, + bool allow_vertical_acquisition = false, + double agreement_x = AGREEMENT_X){ + auto candidates = + match_landmarks(frame, variants, variants.size() + 1, POSITION_TOLERANCE_X, POSITION_TOLERANCE_Y, true); + OverworldMatchResult result; + for (const char* name : {first, second}){ + const OverworldLandmarkResult* best = nullptr; + for (const auto& candidate : candidates.landmarks){ + if (candidate.name != name){ + continue; + } + if (!best || (candidate.accepted && !best->accepted) || + (candidate.accepted == best->accepted && candidate.score > best->score)){ + best = &candidate; + } + } + if (!best){ + return result; + } + result.landmarks.push_back(*best); + auto& chosen = result.landmarks.back(); + // Similar strong references must not disagree about location. + for (const auto& candidate : candidates.landmarks){ + if (candidate.name == name && candidate.accepted && candidate.score >= chosen.score - 0.02 && + (std::abs(candidate.dx - chosen.dx) > AGREEMENT_X || + std::abs(candidate.dy - chosen.dy) > AGREEMENT_Y)){ + chosen.accepted = false; + } + } + } + const auto& a = result.landmarks[0]; + const auto& b = result.landmarks[1]; + result.located = + a.accepted && b.accepted && std::abs(a.dx - b.dx) <= agreement_x && std::abs(a.dy - b.dy) <= AGREEMENT_Y; + result.dx = (a.dx + b.dx) / 2; + result.dy = (a.dy + b.dy) / 2; + result.at_target = result.located && std::abs(a.dx - b.dx) <= AGREEMENT_X && std::abs(result.dx) <= tolerance_x && + std::abs(result.dy) <= POSITION_TOLERANCE_Y; + const double spacing_difference = a.dx - b.dx; + // At the street approach height, perspective can change column spacing. + // Agreeing vertical offsets may guide an upward correction, but cannot + // confirm the destination or authorize a horizontal correction yet. + if (!result.located && allow_vertical_acquisition && a.accepted && b.accepted && + std::abs(a.dy - b.dy) <= AGREEMENT_Y && std::abs(result.dy) > POSITION_TOLERANCE_Y && + std::abs(result.dy) <= 0.20 && std::abs(spacing_difference) <= 0.04 && + std::abs(spacing_difference) <= 0.35 * std::abs(result.dy) + 1.0 / 192 && spacing_difference * result.dy <= 0){ + result.located = true; + result.coarse = true; + result.at_target = false; + } + return result; +} +OverworldMatchResult match_eterna_center_entrance(const ImageRGB32& frame){ + // Native screenshot-20261016-214300767696 is the intended doorway pose. + // The Pokeball is primary. Sign and bush assist when visible, but cloud + // shadows must not prevent arrival from a strong, unique emblem match. + OverworldMatchResult best; + double best_score = -1; + bool best_primary_only = false; + // Recorded evening/daytime appearances retain the same destination and gates. + for (int appearance = 0; appearance < 5; appearance++){ + for (double scale : {.90, .95, 1.0, 1.05, 1.10}){ + std::vector configs = {{"Center Pokeball", + "PokemonBDSP/Autostory/EternaCenter/BallTarget.png", + {904.0 / 1920, 144.0 / 1080, 112.0 / 1920, 104.0 / 1080}, + {0, 0, 1, .65}, + .93, + .20}, + {"Right sign", + "PokemonBDSP/Autostory/EternaCenter/RightSignTarget.png", + {1762.0 / 1920, 304.0 / 1080, 128.0 / 1920, 138.0 / 1080}, + {0, 0, 1, .85}, + .93, + .20}, + {"Lower left bush", + "PokemonBDSP/Autostory/EternaCenter/BushTarget.png", + {248.0 / 1920, 854.0 / 1080, 180.0 / 1920, 146.0 / 1080}, + {0, .35, 1, .65}, + .93, + .20}}; + if (appearance == 1 || appearance == 2){ + configs = {{"Center Pokeball", + appearance == 1 ? "PokemonBDSP/Autostory/EternaCenter/BallEvening1.png" + : "PokemonBDSP/Autostory/EternaCenter/BallEvening2.png", + {920.0 / 1920, 154.0 / 1080, 82.0 / 1920, 84.0 / 1080}, + {0, 0, 1, .65}, + .93, + .20}, + {"Right sign", + appearance == 1 ? "PokemonBDSP/Autostory/EternaCenter/RightSignEvening1.png" + : "PokemonBDSP/Autostory/EternaCenter/RightSignEvening2.png", + {1770.0 / 1920, 310.0 / 1080, 116.0 / 1920, 63.0 / 1080}, + {0, 0, 1, .85}, + .93, + .20}, + {"Lower left bush", + appearance == 1 ? "PokemonBDSP/Autostory/EternaCenter/BushEvening1.png" + : "PokemonBDSP/Autostory/EternaCenter/BushEvening2.png", + {248.0 / 1920, 932.0 / 1080, 180.0 / 1920, 68.0 / 1080}, + {0, .35, 1, .65}, + .93, + .20}}; + } + if (appearance >= 3){ + // Recorded daylight changes the bush/planter contrast. These + // crops retain the original doorway geometry and all three votes. + configs[0].path = appearance == 3 ? "PokemonBDSP/Autostory/EternaCenter/BallDaytime1.png" + : "PokemonBDSP/Autostory/EternaCenter/BallDaytime2.png"; + configs[1].path = appearance == 3 ? "PokemonBDSP/Autostory/EternaCenter/RightSignDaytime1.png" + : "PokemonBDSP/Autostory/EternaCenter/RightSignDaytime2.png"; + configs[2].path = appearance == 3 ? "PokemonBDSP/Autostory/EternaCenter/BushDaytime1.png" + : "PokemonBDSP/Autostory/EternaCenter/BushDaytime2.png"; + } + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 3, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (candidate.located){ + double score = 1; + for (const auto& landmark : candidate.landmarks){ + score = std::min(score, landmark.score); + } + if (!best.located || best_primary_only || score > best_score){ + best = candidate; + best_score = score; + best_primary_only = false; + } + }else if (!best.located || best_primary_only){ + // Optional sign/bush can disappear under clouds or at frame edges. + // Retain the existing stricter primary-emblem confidence gate. + const auto& ball = candidate.landmarks[0]; + if (ball.accepted && ball.score >= .95 && (!best.located || ball.score > best_score)){ + best = candidate; + best.located = true; + best.coarse = false; + best.dx = ball.dx; + best.dy = ball.dy; + best.at_target = std::abs(best.dx) <= 1.0 / 128 && std::abs(best.dy) <= POSITION_TOLERANCE_Y; + best_score = ball.score; + best_primary_only = true; + }else if (best.landmarks.empty()){ + best = candidate; + } + } + } + } + return best; +} +OverworldMatchResult match_route208_stair_top(const ImageRGB32& frame){ + // Native screenshot 20261022-213928168268: centered above the east stairs. + // The isolated upper rock and lower rock pair must both agree before descent. + OverworldMatchResult best; + double best_score = -1; + // Stationary hourly captures supply lighting appearances, not new goals. + // Both physical rock groups still need the same confidence and agreement. + const char* references[][2] = { + {"PokemonBDSP/Autostory/Route208Stairs/UpperRock.png", "PokemonBDSP/Autostory/Route208Stairs/LowerRocks.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/Hour0Frame1Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Hour0Frame1Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/Hour4Frame1Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Hour4Frame1Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/Hour17Frame1Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Hour17Frame1Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/Hour11Frame3Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Hour11Frame3Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/Hour15Frame1Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Hour15Frame1Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/Hour11Frame5Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Hour11Frame5Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/Hour15Frame3Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Hour15Frame3Lower.png"}, + }; + for (const auto& reference : references){ + for (double scale : {.90, .95, 1.0, 1.05, 1.10}){ + std::vector configs = {{"Stair-top upper rock", + reference[0], + {170.0 / 1920, 350.0 / 1080, 120.0 / 1920, 120.0 / 1080}, + {0, 0, 1, 1}, + .93, + .20}, + {"Stair-top lower rock pair", + reference[1], + {240.0 / 1920, 425.0 / 1080, 130.0 / 1920, 180.0 / 1080}, + {0, 0, 1, 1}, + .93, + .20}}; + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + double score = 1; + for (const auto& landmark : candidate.landmarks){ + score = std::min(score, landmark.score); + } + if ((candidate.located && !best.located) || (candidate.located == best.located && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + } + if (best.located){ + return best; + } + const char* approaches[][2] = { + {"PokemonBDSP/Autostory/Route208Stairs/Approach0507Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Approach0507Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/Approach0538Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Approach0538Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/Approach0549Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/Approach0549Lower.png"}, + // The eastern cliff pose changes rock perspective and the local shadow + // pattern. These real-run appearances guide acquisition only: retain + // .92 acquisition NCC/.20 uniqueness and two-rock offset agreement. + // Arrival still requires the original .93 strict reference bank. + {"PokemonBDSP/Autostory/Route208Stairs/ApproachLive01Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/ApproachLive01Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/ApproachLive02Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/ApproachLive02Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/ApproachLive03Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/ApproachLive03Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/ApproachLive04Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/ApproachLive04Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/ApproachLive05Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/ApproachLive05Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/ApproachLive06Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/ApproachLive06Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/ApproachLive07Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/ApproachLive07Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/ApproachLive08Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/ApproachLive08Lower.png"}, + {"PokemonBDSP/Autostory/Route208Stairs/ApproachLive09Upper.png", + "PokemonBDSP/Autostory/Route208Stairs/ApproachLive09Lower.png"}, + }; + for (const auto& reference : approaches){ + for (double scale : {.90, .925, .95, .975, 1.0, 1.025, 1.05, 1.075, 1.10}){ + std::vector configs = {{"Stair-top approach upper rock", + reference[0], + {170.0 / 1920, 350.0 / 1080, 120.0 / 1920, 120.0 / 1080}, + {0, 0, .50, .85}, + .92, + .20}, + {"Stair-top approach lower rock pair", + reference[1], + {240.0 / 1920, 425.0 / 1080, 130.0 / 1920, 180.0 / 1080}, + {0, 0, .50, .85}, + .92, + .20}}; + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + candidate.coarse = true; + candidate.at_target = false; + const double score = std::min(candidate.landmarks[0].score, candidate.landmarks[1].score); + if (candidate.located && (!best.located || score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + } + return best; +} +OverworldMatchResult match_route208_lower_stair_lane(const ImageRGB32& frame){ + // Successful 1825ms descent endpoint at video 230s (2026-10-24 15-38-06). + // Independent cliff/tree anchors must agree before movement is authorized. + OverworldMatchResult best; + double best_score = -1; + for (double scale : {.90, .95, 1.0, 1.05, 1.10}){ + std::vector configs = {{"Lower stair cliff corner", + "PokemonBDSP/Autostory/Route208LowerLane/CliffCorner.png", + {690.0 / 1920, 540.0 / 1080, 110.0 / 1920, 150.0 / 1080}, + {0, 0, 1, 1}, + .93, + .20}, + {"Honey tree", + "PokemonBDSP/Autostory/Route208LowerLane/HoneyTree.png", + {1510.0 / 1920, 600.0 / 1080, 100.0 / 1920, 170.0 / 1080}, + {0, 0, 1, 1}, + .93, + .20}}; + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (candidate.landmarks.size() != 2){ + continue; + } + const auto& cliff = candidate.landmarks[0]; + const auto& tree = candidate.landmarks[1]; + // Perspective changes anchor spacing farther down the field. Two strong + // anchors can authorize north-only recovery while bracketing the player. + // This cue never confirms arrival or authorizes east travel. + if (!candidate.located && cliff.accepted && tree.accepted && cliff.dy < -1.0 / 24 && tree.dy < -1.0 / 24 && + std::abs(cliff.dy - tree.dy) <= AGREEMENT_Y && cliff.found.x + cliff.found.width < .5 && + tree.found.x > .65){ + candidate.located = true; + candidate.coarse = true; + candidate.at_target = false; + candidate.dx = 0; + candidate.dy = (cliff.dy + tree.dy) / 2; + } + double score = 1; + for (const auto& landmark : candidate.landmarks){ + score = std::min(score, landmark.score); + } + if ((candidate.located && !best.located) || + (candidate.located && best.located && best.coarse && !candidate.coarse) || + (candidate.located == best.located && candidate.coarse == best.coarse && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + return best; +} +OverworldMatchResult match_route209_repel_point(const ImageRGB32& frame){ + // butters' marked sign frame and rock pair; the cloud-sensitive pillar is excluded. + // Only the sign's left frame/post is used; lettering is excluded. + OverworldMatchResult best; + double best_score = -1; + // Lighting samples are translated back to the original marked target. + // Appearance 1 remains acquisition-only; all others describe that target. + static const std::array appearances[] = { + {"PokemonBDSP/Autostory/Route209Repel/SignFrame.png", "PokemonBDSP/Autostory/Route209Repel/RockPair.png"}, + {"PokemonBDSP/Autostory/Route209Repel/EastSignFrame.png", + "PokemonBDSP/Autostory/Route209Repel/EastRockPair.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H04F1.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H04F1.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H18F4.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H18F4.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H12F1.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H12F1.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H02F3.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H02F3.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H11F4.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H11F4.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H12F4.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H12F4.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H12F5.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H12F5.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H02F5.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H02F5.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H10F2.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H10F2.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H10F3.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H10F3.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H10F4.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H10F4.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H10F5.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H10F5.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H11F1.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H11F1.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H11F2.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H11F2.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H11F3.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H11F3.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H02F4.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H02F4.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H03F5.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H03F5.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H10F1.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H10F1.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H11F5.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H11F5.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H13F5.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H13F5.png"}, + {"PokemonBDSP/Autostory/Route209Repel/SignFrame-H14F5.png", + "PokemonBDSP/Autostory/Route209Repel/RockPair-H14F5.png"}, + }; + for (double scale : {1.0, .95, 1.05, .90, 1.10}){ + std::vector lighting_landmarks(2); + for (size_t appearance = 0; appearance < std::size(appearances); appearance++){ + std::vector configs = {{"Sign frame", + "PokemonBDSP/Autostory/Route209Repel/SignFrame.png", + {1242.0 / 1920, 580.0 / 1080, 28.0 / 1920, 80.0 / 1080}, + {0, 0, 1, 1}, + .93, + .15}, + {"Rock pair", + "PokemonBDSP/Autostory/Route209Repel/RockPair.png", + {580.0 / 1920, 690.0 / 1080, 155.0 / 1920, 165.0 / 1080}, + {0, 0, 1, 1}, + .93, + .15}}; + for (size_t i = 0; i < configs.size(); i++){ + configs[i].path = appearances[appearance][i]; + } + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (appearance != 1){ + for (size_t i = 0; i < candidate.landmarks.size(); i++){ + const auto& landmark = candidate.landmarks[i]; + if (landmark.accepted && + (!lighting_landmarks[i].accepted || landmark.score > lighting_landmarks[i].score)){ + lighting_landmarks[i] = landmark; + } + } + } + double score = 1; + // Both remaining anchors must pass confidence and agree spatially. + for (const auto& landmark : candidate.landmarks){ + score = std::min(score, landmark.score); + } + candidate.at_target = appearance != 1 && candidate.located && std::abs(candidate.dx) <= 1.0 / 128 && + std::abs(candidate.dy) <= POSITION_TOLERANCE_Y; + // East-side appearance is acquisition-only; the marked pose owns arrival. + if (appearance == 1 && std::abs(candidate.dx) <= 1.0 / 128 && + std::abs(candidate.dy) <= POSITION_TOLERANCE_Y){ + candidate.located = false; + } + if (candidate.located){ + return candidate; + } + if ((candidate.at_target && !best.at_target) || (candidate.located && !best.located) || + (candidate.at_target == best.at_target && candidate.located == best.located && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + // Combine lighting references only when the sign and rock agree. + OverworldMatchResult combined; + combined.landmarks = std::move(lighting_landmarks); + const auto& sign = combined.landmarks[0]; + const auto& rock = combined.landmarks[1]; + combined.located = sign.accepted && rock.accepted && std::abs(sign.dx - rock.dx) <= AGREEMENT_X && + std::abs(sign.dy - rock.dy) <= AGREEMENT_Y; + if (combined.located){ + combined.dx = (sign.dx + rock.dx) / 2; + combined.dy = (sign.dy + rock.dy) / 2; + combined.at_target = std::abs(combined.dx) <= 1.0 / 128 && std::abs(combined.dy) <= POSITION_TOLERANCE_Y; + return combined; + } + } + return best; +} +OverworldMatchResult match_route208_hannah_departure(const ImageRGB32& frame){ + OverworldMatchResult best; + double best_score = -1; + for (int appearance : {0, 865, 930, 965, 978}){ + for (double scale : {.90, .95, 1.0, 1.05, 1.10}){ + std::vector configs = {{"Field stair foot", + "PokemonBDSP/Autostory/Route208Hannah/StairFoot.png", + {225.0 / 1920, 275.0 / 1080, 250.0 / 1920, 110.0 / 1080}, + {0, 0, 1, 1}, + .93, + .20, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Field sign frame", + "PokemonBDSP/Autostory/Route208Hannah/TreeSign.png", + {1000.0 / 1920, 125.0 / 1080, 100.0 / 1920, 80.0 / 1080}, + {0, 0, 1, 1}, + .93, + .20}, + {"Field house sign frame", + "PokemonBDSP/Autostory/Route208Hannah/HouseSign.png", + {1440.0 / 1920, 110.0 / 1080, 110.0 / 1920, 110.0 / 1080}, + {0, 0, 1, 1}, + .93, + .20}}; + if (appearance){ + // Recorded dodges stand farther east, with the stairs clipped. + // These agreeing signs guide acquisition only; the successful + // departure reference above alone may confirm arrival. + configs.erase(configs.begin()); + configs[0].path = appearance == 865 ? "PokemonBDSP/Autostory/Route208Hannah/FieldSign865.png" + : appearance == 930 ? "PokemonBDSP/Autostory/Route208Hannah/FieldSign930.png" + : appearance == 965 ? "PokemonBDSP/Autostory/Route208Hannah/FieldSign965.png" + : "PokemonBDSP/Autostory/Route208Hannah/FieldSign978.png"; + configs[1].path = appearance == 865 ? "PokemonBDSP/Autostory/Route208Hannah/HouseSign865.png" + : appearance == 930 ? "PokemonBDSP/Autostory/Route208Hannah/HouseSign930.png" + : appearance == 965 ? "PokemonBDSP/Autostory/Route208Hannah/HouseSign965.png" + : "PokemonBDSP/Autostory/Route208Hannah/HouseSign978.png"; + } + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (appearance){ + candidate.coarse = true; + candidate.at_target = false; + } + double score = 1; + size_t accepted = 0; + for (const auto& landmark : candidate.landmarks){ + if (landmark.accepted){ + score = std::min(score, landmark.score); + accepted++; + } + } + if (!accepted){ + score = -1; + } + if ((candidate.located && !best.located) || + (candidate.located && best.located && !candidate.coarse && best.coarse) || + (candidate.located == best.located && candidate.coarse == best.coarse && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + } + return best; +} +OverworldMatchResult match_hearthome_center_entrance(const ImageRGB32& frame){ + // Real stationary hourly views cover daylight, night, dusk and cloud edges. + // The target is butters' street pose below the door; entry remains watched. + struct Reference { + const char* ball; + const char* awning; + double ball_width, ball_height; + }; + const Reference references[] = {{"PokemonBDSP/Autostory/HearthomeCenter/Lighting00Ball.png", + "PokemonBDSP/Autostory/HearthomeCenter/Lighting00Awning.png", + 144.0 / 1920, + 128.0 / 1080}, + {"PokemonBDSP/Autostory/HearthomeCenter/Lighting01Ball.png", + "PokemonBDSP/Autostory/HearthomeCenter/Lighting01Awning.png", + 144.0 / 1920, + 128.0 / 1080}, + {"PokemonBDSP/Autostory/HearthomeCenter/Lighting02Ball.png", + "PokemonBDSP/Autostory/HearthomeCenter/Lighting02Awning.png", + 144.0 / 1920, + 128.0 / 1080}, + {"PokemonBDSP/Autostory/HearthomeCenter/Lighting03Ball.png", + "PokemonBDSP/Autostory/HearthomeCenter/Lighting03Awning.png", + 144.0 / 1920, + 128.0 / 1080}, + {"PokemonBDSP/Autostory/HearthomeCenter/Lighting04Ball.png", + "PokemonBDSP/Autostory/HearthomeCenter/Lighting04Awning.png", + 112.0 / 1920, + 108.0 / 1080}}; + std::vector variants; + for (const auto& reference : references){ + for (double scale : {.90, .925, .95, .975, 1.0, 1.025, 1.05, 1.075, 1.10}){ + const double w = reference.ball_width * scale, h = reference.ball_height * scale; + const double aw = 272.0 / 1920 * scale, ah = 80.0 / 1080 * scale; + variants.push_back({"Hearthome Center roof ball", + reference.ball, + {972.0 / 1920 - w / 2, 208.0 / 1080 - h / 2, w, h}, + {0, 0, 1, .65}, + .95, + .20}); + variants.push_back({"Hearthome Center orange awning", + reference.awning, + {968.0 / 1920 - aw / 2, 308.0 / 1080 - ah / 2, aw, ah}, + {0, 0, 1, .65}, + .95, + .20}); + } + } + return match_lighting_pair(frame, + variants, + "Hearthome Center roof ball", + "Hearthome Center orange awning", + 1.0 / 128); +} +OverworldMatchResult match_hearthome_center_interior(const ImageRGB32& frame){ + // Three observed entry-camera angles. Perspective changes the machine + // and register independently, so require a pair from the same real view. + struct View { + const char* machine; + const char* desk; + ImageFloatBox a, b; + }; + const View views[] = {{"InteriorMachine.png", + "InteriorRegister.png", + {660.0 / 1920, 160.0 / 1080, 220.0 / 1920, 185.0 / 1080}, + {1150.0 / 1920, 155.0 / 1080, 140.0 / 1920, 175.0 / 1080}}, + {"EntryRightMachine.png", + "EntryRightRegister.png", + {390.0 / 1920, 160.0 / 1080, 240.0 / 1920, 185.0 / 1080}, + {905.0 / 1920, 155.0 / 1080, 130.0 / 1920, 175.0 / 1080}}, + {"EntryLeftMachine.png", + "EntryLeftRegister.png", + {1045.0 / 1920, 160.0 / 1080, 240.0 / 1920, 185.0 / 1080}, + {1550.0 / 1920, 220.0 / 1080, 155.0 / 1920, 110.0 / 1080}}}; + OverworldMatchResult best; + double best_score = -1; + for (const auto& view : views){ + const std::string machine = "PokemonBDSP/Autostory/HearthomeCenter/" + std::string(view.machine); + const std::string desk = "PokemonBDSP/Autostory/HearthomeCenter/" + std::string(view.desk); + for (double scale : {.90, .95, 1.0, 1.05, 1.10}){ + auto a = view.a, b = view.b; + a.x = .5 + (a.x - .5) * scale; + a.y = .5 + (a.y - .5) * scale; + a.width *= scale; + a.height *= scale; + b.x = .5 + (b.x - .5) * scale; + b.y = .5 + (b.y - .5) * scale; + b.width *= scale; + b.height *= scale; + auto candidate = match_landmarks(frame, + {{"Center healing machine", machine.c_str(), a, {0, 0, 1, .8}, .93, .20}, + {"Center register", desk.c_str(), b, {0, 0, 1, .8}, .93, .20}}, + 2, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + double score = 1; + for (const auto& anchor : candidate.landmarks){ + score = std::min(score, anchor.score); + } + if ((candidate.located && !best.located) || (candidate.located == best.located && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + } + return best; +} +OverworldMatchResult match_hearthome_contest_fantina(const ImageRGB32& frame){ + // Static sign rim above localized text and roof emblem; Fantina can move. + // Hourly captures vary the sign illumination. The roof remains reliable. + const char* signs[] = {"PokemonBDSP/Autostory/HearthomeContest/SignRim.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting00Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting01Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting02Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting03Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting04Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting05Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting06Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting07Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting08Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting09Sign.png", + "PokemonBDSP/Autostory/HearthomeContest/RimLighting10Sign.png"}; + std::vector variants; + for (double scale : {.90, .925, .95, .975, 1.0, 1.025, 1.05, 1.075, 1.10}){ + const double sw = 162.0 / 1920 * scale, sh = 62.0 / 1080 * scale; + const double rw = 136.0 / 1920 * scale, rh = 98.0 / 1080 * scale; + for (const char* sign : signs){ + variants.push_back({"Contest Hall sign frame", + sign, + {595.0 / 1920 - sw / 2, 467.0 / 1080 - sh / 2, sw, sh}, + {0, .15, .75, .65}, + .93, + .15}); + } + variants.push_back({"Contest Hall roof emblem", + "PokemonBDSP/Autostory/HearthomeContest/RoofEmblem.png", + {940.0 / 1920 - rw / 2, 175.0 / 1080 - rh / 2, rw, rh}, + {.1, 0, .8, .65}, + .93, + .15}); + } + return match_lighting_pair(frame, variants, "Contest Hall sign frame", "Contest Hall roof emblem", 1.0 / 128); +} +// Fixed city landmarks preserve the recorded passage and western stair lane. +OverworldMatchResult match_veilstone_city_passage(const ImageRGB32& frame){ + auto fine = match_landmarks(frame, + {{"Passage house", + "PokemonBDSP/Autostory/VeilstoneDawn/PassageHouse.png", + {42.0 / 960, 222.0 / 540, 210.0 / 960, 110.0 / 540}, + {0, 0, .55, 1}, + .93, + .12}, + {"Game Corner facade", + "PokemonBDSP/Autostory/VeilstoneDawn/PassageGameCorner.png", + {592.0 / 960, 227.0 / 540, 230.0 / 960, 110.0 / 540}, + {.45, 0, .55, 1}, + .93, + .12}}, + 2, + 1.0 / 128, + 1.0 / 108, + true); + if (fine.located){ + return fine; + } + // The former reference column lies just inside the left cliff in the + // live corridor. Recenter eight pixels horizontally and two vertically in the + // matcher image; keep both arrival tolerances unchanged. + // A farther-east expiry may expose only the house's right window. + std::vector variants; + for (double scale : {.85, .90, .95, 1.0, 1.05, 1.10}){ + const double hw = 95.0 / 960 * scale, hh = 80.0 / 540 * scale; + const double gw = 230.0 / 960 * scale, gh = 110.0 / 540 * scale; + variants.push_back({"House window", + "PokemonBDSP/Autostory/VeilstoneDawn/PassageHouseWindow.png", + {(165.0 + 95.0 / 2 - 8) / 960 - hw / 2, (250.0 + 80.0 / 2 + 2) / 540 - hh / 2, hw, hh}, + {0, 0, .55, 1}, + .94, + .15}); + variants.push_back({"Game Corner facade", + "PokemonBDSP/Autostory/VeilstoneDawn/PassageGameCorner.png", + {(600.0 + 230.0 / 2 - 8) / 960 - gw / 2, (225.0 + 110.0 / 2 + 2) / 540 - gh / 2, gw, gh}, + {.25, 0, .75, 1}, + .93, + .12}); + } + // At the east-side roof obstruction, perspective changes the facade. + // Calibrate this acquisition-only view to the simultaneously visible house + // window. It guides west off that roof corner; fine arrival stays mandatory. + variants.push_back({"Game Corner facade", + "PokemonBDSP/Autostory/VeilstoneDawn/GameCornerEastApproach.png", + {644.0 / 960, 228.0 / 540, 240.0 / 960, 100.0 / 540}, + {.25, 0, .75, 1}, + .93, + .12}); + auto approach = match_lighting_pair(frame, variants, "House window", "Game Corner facade", 1.0 / 128); + approach.coarse = true; + approach.at_target = false; + return approach; +} +OverworldMatchResult match_veilstone_dawn_stairs(const ImageRGB32& frame){ + // The residential terrace to the east has similar stairs. Require its + // distinct west corner together with the western staircase, not NPCs. + return match_landmarks(frame, + {{"Western stairs", + "PokemonBDSP/Autostory/VeilstoneDawn/GymStairs.png", + {445.0 / 960, 40.0 / 540, 165.0 / 960, 55.0 / 540}, + {.10, 0, .65, .70}, + .93, + .12}, + {"East terrace corner", + "PokemonBDSP/Autostory/VeilstoneDawn/EastTerraceCorner.png", + {620.0 / 960, 155.0 / 540, 200.0 / 960, 110.0 / 540}, + {.35, 0, .65, .85}, + .93, + .12}}, + 2, + 1.0 / 128, + 1.0 / 108, + true); +} + +OverworldMatchResult match_hearthome_gym_entrance(const ImageRGB32& frame){ + // Wider fixed facade distinguishes the window bays at night. Real hourly + // references cover the illuminated windows; cursor, NPCs and text stay out. + const char* facades[] = {"PokemonBDSP/Autostory/HearthomeGym/LeftFacade.png", + "PokemonBDSP/Autostory/HearthomeGym/Lighting00Facade.png", + "PokemonBDSP/Autostory/HearthomeGym/Lighting01Facade.png", + "PokemonBDSP/Autostory/HearthomeGym/Lighting02Facade.png", + "PokemonBDSP/Autostory/HearthomeGym/Lighting03Facade.png", + "PokemonBDSP/Autostory/HearthomeGym/Lighting04Facade.png"}; + std::vector variants; + for (double scale : {.90, .925, .95, .975, 1.0, 1.025, 1.05, 1.075, 1.10}){ + const double aw = 160.0 / 1920 * scale, ah = 84.0 / 1080 * scale; + const double fw = 218.0 / 1920 * scale, fh = 222.0 / 1080 * scale; + variants.push_back({"Hearthome Gym doorway arch", + "PokemonBDSP/Autostory/HearthomeGym/DoorArch.png", + {966.0 / 1920 - aw / 2, 236.0 / 1080 - ah / 2, aw, ah}, + {.15, 0, .75, .65}, + .93, + .20}); + for (const char* facade : facades){ + variants.push_back({"Hearthome Gym left facade", + facade, + {719.0 / 1920 - fw / 2, 315.0 / 1080 - fh / 2, fw, fh}, + {0, 0, .55, .65}, + .93, + .20}); + } + } + return match_lighting_pair(frame, variants, "Hearthome Gym doorway arch", "Hearthome Gym left facade", 1.0 / 128); +} + +OverworldMatchResult match_eterna_gym_entrance(const ImageRGB32& frame){ + // Recorded centered doorway pose. All three stationary landmarks must agree + // before entering; two can guide acquisition without confirming arrival. + OverworldMatchResult best; + double best_score = -1; + for (int appearance : {19, 16}){ + for (double scale : {.90, .95, 1.0, 1.05, 1.10}){ + std::vector configs = {{"Left gym sign", + appearance == 19 + ? "PokemonBDSP/Autostory/EternaGym/LeftSign19.png" + : "PokemonBDSP/Autostory/EternaGym/LeftSign16.png", + {458.0 / 1920, 358.0 / 1080, 140.0 / 1920, 150.0 / 1080}, + {0, 0, .65, .85}, + .93, + .20}, + {"Gym Pokeball", + appearance == 19 ? "PokemonBDSP/Autostory/EternaGym/Ball19.png" + : "PokemonBDSP/Autostory/EternaGym/Ball16.png", + {880.0 / 1920, 264.0 / 1080, 150.0 / 1920, 80.0 / 1080}, + {.15, 0, .7, .70}, + .93, + .20}, + {"Right bike post", + appearance == 19 ? "PokemonBDSP/Autostory/EternaGym/BikePost19.png" + : "PokemonBDSP/Autostory/EternaGym/BikePost16.png", + {1468.0 / 1920, 460.0 / 1080, 130.0 / 1920, 178.0 / 1080}, + {.35, .15, .65, .80}, + .93, + .20}}; + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 3, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (!candidate.located){ + candidate = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + candidate.coarse = true; + candidate.at_target = false; + } + double score = 1; + for (const auto& landmark : candidate.landmarks){ + if (landmark.accepted){ + score = std::min(score, landmark.score); + } + } + if (candidate.located && + (!best.located || (best.coarse && !candidate.coarse) || + (best.coarse == candidate.coarse && score > best_score))){ + best = candidate; + best_score = score; + }else if (best.landmarks.empty()){ + best = candidate; + } + } + } + if (best.located){ + return best; + } + // The gym first appears at the far right of the approach street. Search + // the whole frame for its left sign and emblem; the bike post may be offscreen. + // These broad matches guide travel only. Arrival still needs all three above. + double coarse_score = -1; + for (int appearance : {680, 19, 333, 303, 306, 339}){ + for (double scale : {.85, .90, 1.0, 1.10, 1.15}){ + std::vector configs = { + {"Left gym sign", + appearance == 680 ? "PokemonBDSP/Autostory/EternaGym/LeftSignApproach.png" + : appearance == 19 ? "PokemonBDSP/Autostory/EternaGym/LeftSign19.png" + : appearance == 333 ? "PokemonBDSP/Autostory/EternaGym/LeftSignWest.png" + : appearance == 306 ? "PokemonBDSP/Autostory/EternaGym/LeftSignSouthShadow.png" + : appearance == 339 ? "PokemonBDSP/Autostory/EternaGym/LeftSignWestShadow.png" + : "PokemonBDSP/Autostory/EternaGym/LeftSignSouth.png", + {458.0 / 1920, 358.0 / 1080, 140.0 / 1920, 150.0 / 1080}, + {0, 0, 1, .95}, + .93, + .20}, + {"Gym Pokeball", + appearance == 680 ? "PokemonBDSP/Autostory/EternaGym/BallApproach.png" + : appearance == 19 ? "PokemonBDSP/Autostory/EternaGym/Ball19.png" + : "PokemonBDSP/Autostory/EternaGym/BallWest.png", + {880.0 / 1920, 264.0 / 1080, 150.0 / 1920, 80.0 / 1080}, + {0, 0, 1, .90}, + .93, + .20}}; + if (appearance == 680){ + configs[0].target.width = 148.0 / 1920; + configs[0].target.height = 158.0 / 1080; + configs[1].target.width = 190.0 / 1920; + } + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + const auto& sign = candidate.landmarks[0]; + const auto& ball = candidate.landmarks[1]; + double score = std::min(sign.score, ball.score); + // Perspective changes their spacing while the gym is far right. + // Both accepted anchors must still agree on the rightward direction + // and vertical row before using that directional-only acquisition. + if (!candidate.located && sign.accepted && ball.accepted && sign.dx > .08 && ball.dx > .08 && + std::abs(sign.dy - ball.dy) <= .04 && std::min(sign.dx, ball.dx) > 2 * std::abs(sign.dx - ball.dx)){ + candidate.located = true; + candidate.dx = (sign.dx + ball.dx) / 2; + candidate.dy = (sign.dy + ball.dy) / 2; + } + if (!candidate.located){ + // One fully visible unique landmark is enough to guide the + // approach when the other is clipped at the right edge. + for (const auto& anchor : candidate.landmarks){ + if (anchor.accepted && anchor.score >= .97 && anchor.margin >= .30 && anchor.dx > .08 && + std::abs(anchor.dy) <= .22 && anchor.score > score){ + candidate.located = true; + candidate.dx = anchor.dx; + candidate.dy = anchor.dy; + score = anchor.score; + } + } + } + candidate.coarse = true; + candidate.at_target = false; + if (candidate.located && score > coarse_score){ + best = candidate; + coarse_score = score; + } + } + } + if (best.located){ + return best; + } + // The yellow-roofed house opposite the gym remains visible when the + // gym itself is clipped offscreen. Reuse paired landmark agreement to + // recover the doorway row and guide east until gym anchors take over. + // Its virtual target is off the left edge: these approach anchors can + // never satisfy near-door moderate confidence or confirm gym arrival. + double house_score = -1; + for (int perspective : {720, 734, 708}){ + for (double scale : {.90, .95, 1.0, 1.05, 1.10}){ + std::vector configs = { + {"Gym approach house door", + perspective == 708 ? "PokemonBDSP/Autostory/EternaGym/ApproachHouseDoorFarWest.png" + : perspective == 734 ? "PokemonBDSP/Autostory/EternaGym/ApproachHouseDoorWest.png" + : "PokemonBDSP/Autostory/EternaGym/ApproachHouseDoor.png", + {548.0 / 1920 - .55, 448.0 / 1080, 128.0 / 1920, 156.0 / 1080}, + {0, .15, .75, .70}, + .93, + .20}, + {"Gym approach house window", + perspective == 708 ? "PokemonBDSP/Autostory/EternaGym/ApproachHouseWindowFarWest.png" + : perspective == 734 ? "PokemonBDSP/Autostory/EternaGym/ApproachHouseWindowWest.png" + : "PokemonBDSP/Autostory/EternaGym/ApproachHouseWindow.png", + {718.0 / 1920 - .55, 462.0 / 1080, 132.0 / 1920, 112.0 / 1080}, + {0, .15, .85, .70}, + .93, + .20}}; + if (perspective == 734){ + configs[0].target = {338.0 / 1920 - .55, 450.0 / 1080, 132.0 / 1920, 150.0 / 1080}; + configs[1].target = {496.0 / 1920 - .55, 464.0 / 1080, 122.0 / 1920, 102.0 / 1080}; + } + if (perspective == 708){ + configs[0].target = {240.0 / 1920 - .55, 464.0 / 1080, 150.0 / 1920, 150.0 / 1080}; + configs[1].target = {416.0 / 1920 - .55, 478.0 / 1080, 118.0 / 1920, 90.0 / 1080}; + } + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + candidate.coarse = true; + candidate.at_target = false; + const double score = std::min(candidate.landmarks[0].score, candidate.landmarks[1].score); + if (candidate.located && candidate.dx > .08 && score > house_score){ + best = candidate; + house_score = score; + } + } + } + return best; +} +double overworld_entry_confidence(const OverworldMatchResult& result, + double tolerance_x, + double tolerance_y, + double minimum_margin){ + double confidence = 0; + for (size_t i = 0; i < result.landmarks.size(); i++){ + const auto& a = result.landmarks[i]; + if (a.margin < minimum_margin || std::abs(a.dx) > tolerance_x || std::abs(a.dy) > tolerance_y){ + continue; + } + for (size_t j = i + 1; j < result.landmarks.size(); j++){ + const auto& b = result.landmarks[j]; + if (b.margin < minimum_margin || std::abs(b.dx) > tolerance_x || std::abs(b.dy) > tolerance_y){ + continue; + } + if (std::abs(a.dx - b.dx) > AGREEMENT_X || std::abs(a.dy - b.dy) > AGREEMENT_Y){ + continue; + } + confidence = std::max(confidence, std::min(a.score, b.score)); + } + } + return confidence; +} +OverworldMatchResult match_galactic_hq_jupiter(const ImageRGB32& frame){ + // The supplied reference is the interaction pose directly north of Jupiter. + // Confirm the table-facing chair sides, adjacent table edges, and stairs. + // Exclude the NPC-facing chair edges where Buneary can overlap the reference. + const std::vector configs{{"LeftChairs", + "PokemonBDSP/Autostory/GalacticHQ/AboveLeftChairTableEdge.png", + {300.0 / 1920, 780.0 / 1080, 156.0 / 1920, 206.0 / 1080}, + {0, .40, .45, .60}, + .93, + .20}, + {"RightChairs", + "PokemonBDSP/Autostory/GalacticHQ/AboveRightChairTableEdge.png", + {1286.0 / 1920, 780.0 / 1080, 154.0 / 1920, 206.0 / 1080}, + {.50, .40, .50, .60}, + .93, + .20}, + {"StairBoundary", + "PokemonBDSP/Autostory/GalacticHQ/AboveStairs.png", + {424.0 / 1920, 310.0 / 1080, 214.0 / 1920, 198.0 / 1080}, + {0, 0, 1, .85}, + .93, + .20}}; + auto result = match_landmarks(frame, configs, 3, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (!result.located){ + // Two agreeing points guide the approach; all three still own arrival. + auto coarse = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (coarse.located){ + coarse.coarse = true; + coarse.at_target = false; + result = coarse; + } + } + if (!result.located){ + // Existing recorded chair views can guide recovery from the old row. + // Their coordinates still point to the new north-side destination; + // only the three canonical above-Jupiter references confirm arrival. + auto approach = std::vector{configs[0], configs[1]}; + approach[0].path = "PokemonBDSP/Autostory/GalacticHQ/LeftChairs.png"; + approach[1].path = "PokemonBDSP/Autostory/GalacticHQ/RightChairs.png"; + // Retain the original geometry for the old-row guide-only references. + approach[0].target = {334.0 / 1920, 756.0 / 1080, 156.0 / 1920, 228.0 / 1080}; + approach[1].target = {1238.0 / 1920, 756.0 / 1080, 164.0 / 1920, 230.0 / 1080}; + for (auto& chair : approach){ + chair.threshold = .95; + chair.margin = .25; + } + auto coarse = match_landmarks(frame, approach, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (coarse.located){ + result.located = true; + result.coarse = true; + result.at_target = false; + result.dx = coarse.dx; + result.dy = coarse.dy; + result.landmarks[0] = coarse.landmarks[0]; + result.landmarks[1] = coarse.landmarks[1]; + } + } + return result; +} + +OverworldMatchResult match_galactic_hq_cut(const ImageRGB32& frame){ + // The sign face and Center emblem identify the cuttable tree's column. + // Exclude the HQ door, whose open/night appearance changed in the failed run. + // Tight face/emblem crops avoid grass and moving cloud shadows on the roof. + // The stronger emblem needs a corroborating sign, with a lower identity + // gate for the smaller sign face. Both still must agree on the same pose. + auto result = match_lighting_pair(frame, + {{"HQ sign", + "PokemonBDSP/Autostory/GalacticHQ/CutSignStructure.png", + {810.0 / 1920, 500.0 / 1080, 80.0 / 1920, 58.0 / 1080}, + {0, .25, 1, .45}, + .85, + .10, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Center Pokeball", + "PokemonBDSP/Autostory/GalacticHQ/CutCenterBallStructure.png", + {1048.0 / 1920, 1004.0 / 1080, 136.0 / 1920, 66.0 / 1080}, + {0, .65, 1, .35}, + .93, + .20, + LandmarkFeatures::BLUE_FRACTION}}, + "HQ sign", + "Center Pokeball", + 1.0 / 128); + if (!result.located && result.landmarks.size() == 2){ + const auto& sign = result.landmarks[0]; + const auto& ball = result.landmarks[1]; + // In the recorded far-west misses, the low fascia emblem shifts 1.31 + // screen pixels per sign pixel. Calibrate that acquisition observation + // before applying the same position-agreement bound; never use it for arrival. + const double calibrated_ball_dx = ball.dx / 1.31; + if (sign.accepted && ball.accepted && sign.dx * ball.dx > 0 && std::abs(sign.dx) > .03 && + std::abs(sign.dx) <= .20 && std::abs(sign.dx - calibrated_ball_dx) <= AGREEMENT_X && + std::abs(sign.dy - ball.dy) <= AGREEMENT_Y){ + result.located = true; + result.coarse = true; + result.at_target = false; + result.dx = (sign.dx + calibrated_ball_dx) / 2; + } + } + // The recorded approach already clamps against the hedge. Correct only + // its column; the watched Up/A loop owns the final north interaction. + result.dy = 0; + result.at_target = result.located && !result.coarse && std::abs(result.dx) <= 1.0 / 128; + return result; +} + +OverworldMatchResult match_eterna_cycle_shop_entrance(const ImageRGB32& frame){ + const std::vector configs{ + {"Shop sign", + "PokemonBDSP/Autostory/EternaBikeShop/eterna_cycle_shop_entrance-0.png", + {555.0 / 1920, 350.0 / 1080, 100.0 / 1920, 120.0 / 1080}, + {0, 0.1, 0.65, 0.75}, + .93, + .20}, + {"Shop roof braces", + "PokemonBDSP/Autostory/EternaBikeShop/ShopRoofBraces.png", + {680.0 / 1920, 145.0 / 1080, 470.0 / 1920, 36.0 / 1080}, + {0, 0, .95, .75}, + .93, + .20}}; + return match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); +} + +OverworldMatchResult match_eterna_cycle_shop_interior(const ImageRGB32& frame){ + // Entry mat view: fixed wall tools and register, clear of NPCs/Pokemon. + // This identifies the room; it is not the counter interaction position. + const std::vector configs{{"Shop wall tools", + "PokemonBDSP/Autostory/EternaBikeShop/InteriorWallTools.png", + {920.0 / 1920, 20.0 / 1080, 350.0 / 1920, 150.0 / 1080}, + {.15, 0, .60, .55}, + .93, + .20}, + {"Shop register", + "PokemonBDSP/Autostory/EternaBikeShop/InteriorRegister.png", + {760.0 / 1920, 160.0 / 1080, 120.0 / 1920, 145.0 / 1080}, + {.20, 0, .55, .65}, + .93, + .20}}; + auto result = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (result.located){ + return result; + } + const std::vector north_configs{ + {"Shop wall tools", + "PokemonBDSP/Autostory/EternaBikeShop/InteriorNorthWallTools.png", + {928.0 / 1920, 84.0 / 1080, 350.0 / 1920, 150.0 / 1080}, + {.15, 0, .60, .55}, + .93, + .20}, + {"Shop register", + "PokemonBDSP/Autostory/EternaBikeShop/InteriorNorthRegister.png", + {754.0 / 1920, 230.0 / 1080, 120.0 / 1920, 145.0 / 1080}, + {.20, 0, .55, .65}, + .93, + .20}}; + return match_landmarks(frame, north_configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); +} + +OverworldMatchResult match_eterna_cycle_shop_reward(const ImageRGB32& frame){ + // Reward returns the player outside on the bicycle, at a separate pose. + // Reuse the stable roof braces; the nearby post changes appearance/shading. + const std::vector configs{{"Shop sign", + "PokemonBDSP/Autostory/EternaBikeShop/reward-sign.png", + {580.0 / 1920, 330.0 / 1080, 100.0 / 1920, 120.0 / 1080}, + {0, .1, .65, .75}, + .93, + .20}, + {"Shop roof braces", + "PokemonBDSP/Autostory/EternaBikeShop/ShopRoofBraces.png", + {706.0 / 1920, 128.0 / 1080, 470.0 / 1920, 36.0 / 1080}, + {0, 0, .95, .75}, + .93, + .20}}; + return match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); +} + +OverworldMatchResult match_eterna_bike_riding_card(const ImageRGB32& frame){ + // Fixed UI card: require both the blue illustration and B Close prompt. + // Small search boxes tolerate capture offsets without matching other menus. + const std::vector configs{{"Blue Bike illustration", + "PokemonBDSP/Autostory/EternaBikeShop/BikeRidingBlueFeature.png", + {104.0 / 1920, 314.0 / 1080, 520.0 / 1920, 450.0 / 1080}, + {.035, .26, .32, .48}, + .93, + .20, + LandmarkFeatures::BLUE_FRACTION}, + {"B Close prompt", + "PokemonBDSP/Autostory/EternaBikeShop/BikeRidingClosePrompt.png", + {1570.0 / 1920, 730.0 / 1080, 160.0 / 1920, 60.0 / 1080}, + {.79, .65, .14, .10}, + .93, + .20}}; + return match_landmarks(frame, configs, 2, 1.0 / 64, 1.0 / 54, true); +} + +OverworldMatchResult match_eterna_cycle_shop_counter(const ImageRGB32& frame){ + const std::vector configs{ + {"Red frame", + "PokemonBDSP/Autostory/EternaBikeShop/eterna_cycle_shop_counter-0.png", + {620.0 / 1920, 65.0 / 1080, 390.0 / 1920, 240.0 / 1080}, + {0.1, 0, 0.65, 0.55}, + .93, + .20}, + {"Counter detail", + "PokemonBDSP/Autostory/EternaBikeShop/eterna_cycle_shop_counter-1.png", + {1110.0 / 1920, 486.0 / 1080, 86.0 / 1920, 38.0 / 1080}, + {0.3, 0.15, 0.65, 0.65}, + .93, + .20}}; + return match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); +} + +// Two stationary architectural features confirm the intended gate interior. +// The doorway and west window avoid the moving player, NPCs and localized text. +OverworldMatchResult match_cycling_road_south_gate_interior(const ImageRGB32& frame){ + return match_landmarks(frame, + {{"Gate north doorway", + "PokemonBDSP/Autostory/CyclingRoadSouthGate/NorthDoorway.png", + {630.0 / 1920, 118.0 / 1080, 220.0 / 1920, 270.0 / 1080}, + {.15, 0, .70, .55}, + .93, + .20}, + {"Gate west window", + "PokemonBDSP/Autostory/CyclingRoadSouthGate/WestWindow.png", + {302.0 / 1920, 140.0 / 1080, 157.0 / 1920, 145.0 / 1080}, + {.05, 0, .35, .55}, + .93, + .20}}, + 2, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y); +} +OverworldMatchResult match_cycling_road_bridge_approach(const ImageRGB32& frame){ + // Use the actual bridge entrance: cyan right rail with a broad metal deck + // immediately to its left. Neither the Route 206 sign nor gate is involved. + OverworldMatchResult best; + if (!frame){ + return best; + } + cv::Mat pixels((int)frame.height(), + (int)frame.width(), + CV_8UC4, + const_cast(frame.data()), + frame.bytes_per_row()); + cv::Mat small, bgr, hsv, cyan; + cv::resize(pixels, small, cv::Size(960, 540), 0, 0, cv::INTER_AREA); + cv::cvtColor(small, bgr, cv::COLOR_BGRA2BGR); + cv::cvtColor(bgr, hsv, cv::COLOR_BGR2HSV); + // Hue/saturation retain the cyan rail under the recorded moving shadows. + cv::inRange(hsv, cv::Scalar(78, 95, 55), cv::Scalar(105, 255, 255), cyan); + cv::morphologyEx(cyan, cyan, cv::MORPH_CLOSE, cv::Mat::ones(3, 3, CV_8U)); + const int w = hsv.cols, h = hsv.rows; + std::vector> contours; + cv::findContours(cyan, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); + double best_support = 0; + bool ambiguous = false; + for (const auto& contour : contours){ + const auto rail = cv::boundingRect(contour); + if (rail.x < w * .25 || rail.x > w * .85 || rail.height < h * .10){ + continue; + } + // A left-hand rail has grass/cliff on this side and fails the deck test. + const int left = std::max(0, rail.x - (int)(w * .22)); + const int right = rail.x - (int)(w * .02); + if (right - left < w * .12){ + continue; + } + const int begin = std::max(0, rail.y + (int)(h * .035)); + const int end = std::min(h - (int)(h * .05), rail.y + (int)(h * .24)); + const int sustained = std::max(3, (int)(h * .018)); + int deck_y = -1; + double deck_support = 0; + for (int y = begin; y < end; y++){ + bool supported = true; + double total = 0; + // Both halves must see the same broad deck edge, not a flower, + // narrow painted line or one white patch beside a cyan object. + for (int half = 0; half < 2; half++){ + int neutral = 0; + const int x0 = left + (right - left) * half / 2; + const int x1 = left + (right - left) * (half + 1) / 2; + for (int row = y; row < y + sustained; row++){ + for (int x = x0; x < x1; x++){ + const auto p = hsv.at(row, x); + // Shadowed metal remains low-saturation; the grass edge + // and paired rail reject unrelated dark neutral panels. + neutral += p[1] <= 80 && p[2] >= 60; + } + } + const double fraction = (double)neutral / ((x1 - x0) * sustained); + if (fraction < .72){ + supported = false; + } + total += fraction / 2; + } + if (supported){ + deck_y = y; + deck_support = total; + break; + } + } + if (deck_y < 0 || deck_y < h * .15){ + continue; + } + // The entrance is a grass-to-metal boundary. A road panel or sky + // behind an unrelated cyan object cannot stand in for this edge. + int grass = 0; + const int above_begin = std::max(0, deck_y - (int)(h * .05)); + const int above_end = deck_y - (int)(h * .02); + if (above_end <= above_begin){ + continue; + } + for (int row = above_begin; row < above_end; row++){ + for (int x = left; x < right; x++){ + const auto p = hsv.at(row, x); + grass += p[0] >= 25 && p[0] <= 85 && p[1] >= 60 && p[2] >= 30; + } + } + if ((double)grass / ((right - left) * (above_end - above_begin)) < .45){ + continue; + } + // Measure the inner rail boundary at deck height, rather than using + // the cliff wall or the rail cap's perspective-dependent bounding box. + double inner = 0, ink = 0; + size_t rows = 0; + const int measure_begin = std::max(deck_y + (int)(h * .015), (int)(h * .52)); + const int measure_end = std::min(h, measure_begin + (int)(h * .02)); + for (int y = measure_begin; y < measure_end; y++){ + for (int x = rail.x; x < std::min(w, rail.x + rail.width); x++){ + if (cyan.at(y, x)){ + int last = x, count = 0; + for (int edge = x; edge < std::min(w, rail.x + rail.width); edge++){ + if (cyan.at(y, edge)){ + last = edge; + ++count; + } + } + const int width = last - x + 1; + if (width >= w * .008 && width <= w * .055 && (double)count / width >= .80){ + inner += x; + ink += (double)count / width; + ++rows; + } + break; + } + } + } + if (rows < 3 || rows < (measure_end - measure_begin) * .75){ + continue; + } + inner /= rows; + OverworldMatchResult candidate; + candidate.located = true; + candidate.dx = inner / w - .55; + candidate.dy = (double)deck_y / h - .36; + // Park on the upper white platform beside the right rail. The user + // demonstrated this stationary pose in the 12:33:35 recording; the + // downhill section starts well beyond it. Measure the rail at wheel + // height, not at its distant cap or the offset cliff wall. + candidate.at_target = std::abs(candidate.dx) <= .012 && std::abs(candidate.dy) <= 1.0 / 54; + OverworldLandmarkResult rail_anchor; + rail_anchor.name = "Bridge right rail inner edge"; + rail_anchor.accepted = true; + rail_anchor.score = ink / rows; + rail_anchor.dx = candidate.dx; + rail_anchor.dy = candidate.dy; + rail_anchor.search = {.25, 0, .68, 1}; + rail_anchor.found = {(double)inner / w, (double)rail.y / h, (double)rail.width / w, (double)rail.height / h}; + OverworldLandmarkResult deck_anchor; + deck_anchor.name = "Bridge deck leading edge"; + deck_anchor.accepted = true; + deck_anchor.score = deck_support; + deck_anchor.dx = candidate.dx; + deck_anchor.dy = candidate.dy; + deck_anchor.search = {0, (double)begin / h, (double)right / w, (double)(end - begin) / h}; + deck_anchor.found = {(double)left / w, (double)deck_y / h, (double)(right - left) / w, (double)sustained / h}; + candidate.landmarks = {rail_anchor, deck_anchor}; + const double support = ink / rows + deck_support; + if (best.located && + (std::abs(best.dx - candidate.dx) > AGREEMENT_X || std::abs(best.dy - candidate.dy) > AGREEMENT_Y)){ + ambiguous = true; + } + if (support > best_support){ + best_support = support; + best = std::move(candidate); + } + } + if (ambiguous){ + best.located = false; + best.at_target = false; + } + return best; +} + + +OverworldMatchResult match_galactic_hq_exterior(const ImageRGB32& frame){ + const std::vector configs{{"HQ sign", + "PokemonBDSP/Autostory/GalacticHQ/galactic_hq_exterior-0.png", + {660.0 / 1920, 615.0 / 1080, 118.0 / 1920, 124.0 / 1080}, + {0, 0.2, 0.65, 0.8}, + .93, + .20}, + {"HQ doorway lintel", + "PokemonBDSP/Autostory/GalacticHQ/galactic_hq_exterior-1.png", + {874.0 / 1920, 350.0 / 1080, 172.0 / 1920, 78.0 / 1080}, + {0.2, 0, 0.6, 0.7}, + .93, + .20}}; + return match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); +} + +OverworldMatchResult match_eterna_center_departure(const ImageRGB32& frame){ + // Center-exit pose in the recorded Center->Mart and bicycle-shop route. + // Reuse the existing shadow/appearance-aware Center identification. + auto result = match_eterna_center_entrance(frame); + result.dy -= .070463; + result.at_target = result.located && !result.coarse && std::abs(result.dx) <= 1.0 / 128 && + std::abs(result.dy) <= POSITION_TOLERANCE_Y; + return result; +} + +OverworldMatchResult match_eterna_gym_statues(const ImageRGB32& frame){ + // Actual crops from screenshot-20261018-171535781091. Separate left/right + // search regions prevent one identical statue from satisfying both votes. + OverworldMatchResult best; + double best_score = -1; + if (!frame){ + return best; + } + // Normalized correlation also matches almost-black fade frames. Require + // visible statue regions before allowing those shapes to verify entry. + const cv::Mat gray = gray_image(frame); + const auto visible = [&](double x, double width){ + const cv::Rect box((int)(x * gray.cols), + (int)(458.0 / 1080 * gray.rows), + (int)(width * gray.cols), + (int)(350.0 / 1080 * gray.rows)); + return cv::mean(gray(box))[0] >= 60; + }; + const bool statues_visible = visible(448.0 / 1920, 146.0 / 1920) && visible(1322.0 / 1920, 154.0 / 1920); + for (double scale : {.95, 1.0, 1.05}){ + std::vector configs = {{"Left gym statue", + "PokemonBDSP/Autostory/EternaGym/LeftStatue.png", + {448.0 / 1920, 458.0 / 1080, 146.0 / 1920, 350.0 / 1080}, + {.12, .30, .27, .53}, + .93, + .20}, + {"Right gym statue", + "PokemonBDSP/Autostory/EternaGym/RightStatue.png", + {1322.0 / 1920, 458.0 / 1080, 154.0 / 1920, 350.0 / 1080}, + {.60, .30, .28, .53}, + .93, + .20}}; + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 2, .04, .06, true); + // Interior verification requires both statues near the expected entry + // positions, not merely anywhere in the room or during a fade. + candidate.at_target = candidate.at_target && statues_visible; + candidate.located = candidate.at_target; + const double score = std::min(candidate.landmarks[0].score, candidate.landmarks[1].score); + if ((candidate.located && !best.located) || (candidate.located == best.located && score > best_score)){ + best = candidate; + best_score = score; + } + } + return best; +} +OverworldMatchResult match_eterna_underground_house(const ImageRGB32& frame){ + // User's doorway pose: the Center ball and the same house sign, viewed + // from the east. The sign is optional when a strong unique ball confirms + // the doorway pose; cloud shadow on the sign must not block entry. + OverworldMatchResult best; + double best_score = -1; + bool best_primary_only = false; + for (double scale : {.90, .95, 1.0, 1.05, 1.10}){ + std::vector configs{{"Center Pokeball", + "PokemonBDSP/Autostory/EternaUndergroundMan/HouseBall.png", + {332.0 / 1920, 224.0 / 1080, 112.0 / 1920, 104.0 / 1080}, + {0, 0, .65, .7}, + .93, + .20}, + {"House sign", + "PokemonBDSP/Autostory/EternaUndergroundMan/HouseSign.png", + {1240.0 / 1920, 396.0 / 1080, 110.0 / 1920, 106.0 / 1080}, + {.35, 0, .65, .85}, + .93, + .20}}; + for (auto& config : configs){ + const double w = config.target.width, h = config.target.height; + config.target.width *= scale; + config.target.height *= scale; + config.target.x += (w - config.target.width) / 2; + config.target.y += (h - config.target.height) / 2; + } + auto candidate = match_landmarks(frame, configs, 2, 1.0 / 128, POSITION_TOLERANCE_Y, true); + double score = std::min(candidate.landmarks[0].score, candidate.landmarks[1].score); + if (candidate.located){ + if (!best.located || best_primary_only || score > best_score){ + best = candidate; + best_score = score; + best_primary_only = false; + } + }else if (!best.located || best_primary_only){ + // The optional door-side sign can change perspective or disappear in + // cloud shadow. Preserve the high uniqueness margin on the primary ball. + const auto& ball = candidate.landmarks[0]; + if (ball.accepted && ball.score >= .95 && ball.margin >= .30 && std::abs(ball.dx) < .20 && + std::abs(ball.dy) < .20 && (!best.located || ball.score > best_score)){ + best = candidate; + best.located = true; + best.coarse = false; + best.dx = ball.dx; + best.dy = ball.dy; + best.at_target = std::abs(best.dx) <= 1.0 / 128 && std::abs(best.dy) <= POSITION_TOLERANCE_Y; + best_score = ball.score; + best_primary_only = true; + }else if (best.landmarks.empty()){ + best = candidate; + } + } + } + return best; +} +OverworldMatchResult match_eterna_underground_man(const ImageRGB32& frame){ + // Reuse fixed furniture, excluding people and the animated TV picture. + // The right-window search excludes the identical window above the stool. + const std::vector configs{{"Stool", + "PokemonBDSP/Autostory/EternaUndergroundMan/Stool.png", + {380.0 / 1920, 610.0 / 1080, 86.0 / 1920, 70.0 / 1080}, + {0, .3, .65, .5}, + .93, + .20}, + {"Right tree top", + "PokemonBDSP/Autostory/EternaUndergroundMan/TreeTop.png", + {1372.0 / 1920, 320.0 / 1080, 102.0 / 1920, 96.0 / 1080}, + {.5, 0, .4, .7}, + .93, + .20}, + {"Wall sign", + "PokemonBDSP/Autostory/EternaUndergroundMan/WallSign.png", + {642.0 / 1920, 354.0 / 1080, 114.0 / 1920, 70.0 / 1080}, + {.2, 0, .5, .6}, + .93, + .20}, + {"Window beside TV", + "PokemonBDSP/Autostory/EternaUndergroundMan/RightWindow.png", + {906.0 / 1920, 260.0 / 1080, 162.0 / 1920, 160.0 / 1080}, + {.35, 0, .55, .55}, + .93, + .20}}; + auto result = match_landmarks(frame, configs, 4, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (!result.located){ + // Three agreeing furniture anchors identify the room during entry and + // guide acquisition when a fourth is clipped. Never authorize talking. + auto coarse = match_landmarks(frame, configs, 3, 1.0 / 128, POSITION_TOLERANCE_Y, true); + if (coarse.located){ + coarse.coarse = true; + coarse.at_target = false; + return coarse; + } + } + return result; +} +OverworldMatchResult match_jubilife_street_approach(const ImageRGB32& frame){ + // The lighting-run pose was 1/108 screen height south of the original + // screenshot. Targets preserve the original destination height. + // Capture-derived references cover lamp, daylight, and moving tree shadows. + return match_lighting_pair(frame, + {{"BuildingCorner", + "PokemonBDSP/Autostory/JubilifeStreet/BuildingCornerHour3Frame4.png", + {0.588541666667, 0.351851851852, 0.088541666667, 0.259259259259}, + {0, 0, 1, 1}, + .93, + .08}, + {"BuildingCorner", + "PokemonBDSP/Autostory/JubilifeStreet/BuildingCornerHour14Frame2.png", + {0.588541666667, 0.351851851852, 0.088541666667, 0.259259259259}, + {0, 0, 1, 1}, + .93, + .08}, + {"BuildingCorner", + "PokemonBDSP/Autostory/JubilifeStreet/BuildingCornerHour17Frame4.png", + {0.588541666667, 0.351851851852, 0.088541666667, 0.259259259259}, + {0, 0, 1, 1}, + .93, + .08}, + {"BuildingBase", + "PokemonBDSP/Autostory/JubilifeStreet/BuildingBaseHour15Frame2.png", + {0.812500000000, 0.388888888889, 0.078125000000, 0.203703703704}, + {0, 0, 1, 1}, + .93, + .08}, + {"BuildingBase", + "PokemonBDSP/Autostory/JubilifeStreet/BuildingBaseHour17Frame5.png", + {0.812500000000, 0.388888888889, 0.078125000000, 0.203703703704}, + {0, 0, 1, 1}, + .93, + .08}, + {"BuildingBase", + "PokemonBDSP/Autostory/JubilifeStreet/BuildingBaseHour20Frame5.png", + {0.812500000000, 0.388888888889, 0.078125000000, 0.203703703704}, + {0, 0, 1, 1}, + .93, + .08}}, + "BuildingCorner", + "BuildingBase", + POSITION_TOLERANCE_X, + true); +} +OverworldMatchResult match_jubilife_plaza_approach(const ImageRGB32& frame){ + // Capture-derived references cover lamp, daylight, and moving tree shadows. + auto result = match_lighting_pair(frame, + { + {"RoofEdge", + "PokemonBDSP/Autostory/JubilifePlaza/RoofEdgeHour2Frame2.png", + {0.614583333333, 0.203703703704, 0.299479166667, 0.111111111111}, + {0, 0, 1, 1}, + .93, + .08}, + {"RoofEdge", + "PokemonBDSP/Autostory/JubilifePlaza/RoofEdgeHour11Frame1.png", + {0.614583333333, 0.203703703704, 0.299479166667, 0.111111111111}, + {0, 0, 1, 1}, + .93, + .08}, + {"RoofEdge", + "PokemonBDSP/Autostory/JubilifePlaza/RoofEdgeHour11Frame2.png", + {0.614583333333, 0.203703703704, 0.299479166667, 0.111111111111}, + {0, 0, 1, 1}, + .93, + .08}, + {"RoofEdge", + "PokemonBDSP/Autostory/JubilifePlaza/RoofEdgeHour11Frame5.png", + {0.614583333333, 0.203703703704, 0.299479166667, 0.111111111111}, + {0, 0, 1, 1}, + .93, + .08}, + {"RoofEdge", + "PokemonBDSP/Autostory/JubilifePlaza/RoofEdgeHour16Frame5.png", + {0.614583333333, 0.203703703704, 0.299479166667, 0.111111111111}, + {0, 0, 1, 1}, + .93, + .08}, + {"RoofEdge", + "PokemonBDSP/Autostory/JubilifePlaza/RoofEdgeHour23Frame2.png", + {0.614583333333, 0.203703703704, 0.299479166667, 0.111111111111}, + {0, 0, 1, 1}, + .93, + .08}, + {"Fence", + "PokemonBDSP/Autostory/JubilifePlaza/FenceHour3Frame1.png", + {0.044270833333, 0.328703703704, 0.169270833333, 0.106481481481}, + {0, 0, 1, 1}, + .93, + .08}, + {"Fence", + "PokemonBDSP/Autostory/JubilifePlaza/FenceHour5Frame4.png", + {0.044270833333, 0.328703703704, 0.169270833333, 0.106481481481}, + {0, 0, 1, 1}, + .93, + .08}, + {"Fence", + "PokemonBDSP/Autostory/JubilifePlaza/FenceHour11Frame3.png", + {0.044270833333, 0.328703703704, 0.169270833333, 0.106481481481}, + {0, 0, 1, 1}, + .93, + .08}, + {"Fence", + "PokemonBDSP/Autostory/JubilifePlaza/FenceHour14Frame1.png", + {0.044270833333, 0.328703703704, 0.169270833333, 0.106481481481}, + {0, 0, 1, 1}, + .93, + .08}, + {"Fence", + "PokemonBDSP/Autostory/JubilifePlaza/FenceHour19Frame1.png", + {0.044270833333, 0.328703703704, 0.169270833333, 0.106481481481}, + {0, 0, 1, 1}, + .93, + .08}, + {"Fence", + "PokemonBDSP/Autostory/JubilifePlaza/FenceHour21Frame4.png", + {0.044270833333, 0.328703703704, 0.169270833333, 0.106481481481}, + {0, 0, 1, 1}, + .93, + .08} // Small screen-relative quantization allowance avoids bouncing across the + // plaza target when one movement step spans both sides of the tolerance. + }, + "RoofEdge", + "Fence", + POSITION_TOLERANCE_X, + false, + 1.0 / 32); + if (!result.located && result.landmarks.size() == 2){ + const auto& fence = result.landmarks[1]; + // A moving roof shadow must not trigger a blind lateral sweep when the + // distinctive fence still gives a strong, nearby position estimate. + // Fence-only guidance can reacquire the roof; it cannot confirm arrival. + if (fence.accepted && fence.score >= .95 && fence.margin >= .30 && std::abs(fence.dx) <= .20 && + std::abs(fence.dy) <= .20){ + result.located = true; + result.coarse = true; + result.at_target = false; + result.dx = fence.dx; + result.dy = fence.dy; + } + } + return result; +} +OverworldMatchResult match_route204_sign(const ImageRGB32& frame){ + // One vote per physical landmark. Lighting variants avoid treating the + // moving tree shadow over the fence as a missing route destination. + const ImageFloatBox sign_target{0.468750000000, 0.376851851852, 0.072916666667, 0.052777777778}; + const ImageFloatBox fence_target{0.598958333333, 0.421296296296, 0.041666666667, 0.152777777778}; + std::vector variants{ + {"Route sign", "PokemonBDSP/Autostory/Route204Sign/Sign.png", sign_target, {0, 0, 1, 1}, .93, .10}, + {"Fence corner", "PokemonBDSP/Autostory/Route204Sign/FenceCorner.png", fence_target, {0, 0, 1, 1}, .93, .10}, + {"Route sign", "PokemonBDSP/Autostory/Route204Sign/SignHour0Frame3.png", sign_target, {0, 0, 1, 1}, .93, .10}, + {"Fence corner", + "PokemonBDSP/Autostory/Route204Sign/FenceCornerHour0Frame3.png", + fence_target, + {0, 0, 1, 1}, + .93, + .10}, + {"Route sign", "PokemonBDSP/Autostory/Route204Sign/SignHour8Frame3.png", sign_target, {0, 0, 1, 1}, .93, .10}, + {"Fence corner", + "PokemonBDSP/Autostory/Route204Sign/FenceCornerHour8Frame3.png", + fence_target, + {0, 0, 1, 1}, + .93, + .10}, + {"Route sign", "PokemonBDSP/Autostory/Route204Sign/SignHour13Frame3.png", sign_target, {0, 0, 1, 1}, .93, .10}, + {"Fence corner", + "PokemonBDSP/Autostory/Route204Sign/FenceCornerHour13Frame3.png", + fence_target, + {0, 0, 1, 1}, + .93, + .10}, + {"Route sign", "PokemonBDSP/Autostory/Route204Sign/SignHour17Frame3.png", sign_target, {0, 0, 1, 1}, .93, .10}, + {"Fence corner", + "PokemonBDSP/Autostory/Route204Sign/FenceCornerHour17Frame3.png", + fence_target, + {0, 0, 1, 1}, + .93, + .10}, + {"Route sign", "PokemonBDSP/Autostory/Route204Sign/SignHour2Frame1.png", sign_target, {0, 0, 1, 1}, .93, .10}, + {"Fence corner", + "PokemonBDSP/Autostory/Route204Sign/FenceCornerHour2Frame1.png", + fence_target, + {0, 0, 1, 1}, + .93, + .10}, + {"Fence corner", + "PokemonBDSP/Autostory/Route204Sign/FenceCornerHour10Frame3.png", + fence_target, + {0, 0, 1, 1}, + .93, + .10}, + {"Fence corner", + "PokemonBDSP/Autostory/Route204Sign/FenceCornerRecordedCloud.png", + fence_target, + {0, 0, 1, 1}, + .93, + .10}, + }; + auto result = match_lighting_pair(frame, variants, "Route sign", "Fence corner"); + if (!result.located && result.landmarks.size() == 2){ + const auto& sign = result.landmarks[0]; + // A distinctive high-confidence sign can guide bounded acquisition. + // It cannot confirm the endpoint without the fence agreeing as well. + if (sign.accepted && sign.score >= .95 && sign.margin >= .30 && std::abs(sign.dx) <= .20 && + std::abs(sign.dy) <= .25){ + result.located = true; + result.coarse = true; + result.at_target = false; + result.dx = sign.dx; + result.dy = sign.dy; + } + } + return result; +} +static OverworldMatchResult match_route205_marker(const ImageRGB32& frame, double cx, double cy){ + OverworldMatchResult best; + // Native sign faces at the two route positions. The post, moving NPCs, + // grass and water are excluded from the acquisition references. + for (int variant : {0, 1, 2, 3, 4, 5, 6}){ + for (double scale : {.9, 1.0, 1.1, 1.2}){ + const double w = (variant == 6 ? 188.0 + : variant == 5 ? 184.0 + : variant == 4 ? 220.0 + : variant == 3 ? 211.0 + : variant == 2 ? 208.0 + : variant == 1 ? 206.0 + : 205.0) / + 1920 * scale; + const double h = (variant == 6 ? 74.0 : variant >= 3 ? 72.0 : variant ? 80.0 : 82.0) / 1080 * scale; + auto candidate = match_landmarks(frame, + {{"Route 205 marker", + variant == 6 ? "PokemonBDSP/Autostory/Route205/BridgeSignMidCloud.png" + : variant == 5 ? "PokemonBDSP/Autostory/Route205/BridgeSignCloud.png" + : variant == 4 ? "PokemonBDSP/Autostory/Route205/BridgeSignLow.png" + : variant == 3 ? "PokemonBDSP/Autostory/Route205/BridgeSignLive.png" + : variant == 2 ? "PokemonBDSP/Autostory/Route205/NorthSignRecorded.png" + : variant == 1 ? "PokemonBDSP/Autostory/Route205/NorthSign.png" + : "PokemonBDSP/Autostory/Route205/BridgeSign.png", + {cx - w / 2, cy - h / 2, w, h}, + {0, 0, 1, 1}, + .93, + .20}}, + 1, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + if (best.landmarks.empty() || (candidate.located && !best.located) || + (candidate.located == best.located && candidate.landmarks[0].score > best.landmarks[0].score)){ + best = candidate; + } + } + } + return best; +} +OverworldMatchResult match_route205_zackary(const ImageRGB32& frame){ + OverworldMatchResult best; + double best_score = -1; + for (int view : {0, 1, 2, 3}){ + for (double scale : {.9, 1.0, 1.1}){ + const double sw = 91.0 / 1920 * scale, sh = 65.0 / 1080 * scale; + // Include the fixed cliff edge beside both pipes: the repeated blue caps + // alone produce a second strong peak in cloud shadow. + const double cw = 300.0 / 1920 * scale, ch = 300.0 / 1080 * scale; + auto candidate = match_landmarks(frame, + {{"Zackary bridge sign", + view == 2 ? "PokemonBDSP/Autostory/Route205/PatrolSignCloud.png" + : view >= 1 ? "PokemonBDSP/Autostory/Route205/PatrolSignLive.png" + : "PokemonBDSP/Autostory/Route205/PatrolSign.png", + {959.5 / 1920 - sw / 2, 67.5 / 1080 - sh / 2, sw, sh}, + {0, 0, 1, 1}, + .93, + .20}, + {"Zackary twin chimneys", + view == 3 ? "PokemonBDSP/Autostory/Route205/PatrolChimneysPartialCloud.png" + : view == 2 ? "PokemonBDSP/Autostory/Route205/PatrolChimneysCloud.png" + : view == 1 ? "PokemonBDSP/Autostory/Route205/PatrolChimneysLive.png" + : "PokemonBDSP/Autostory/Route205/PatrolChimneys.png", + {1210.0 / 1920 - cw / 2, 290.0 / 1080 - ch / 2, cw, ch}, + {0, 0, 1, 1}, + .93, + .20}}, + 2, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + const double score = std::min(candidate.landmarks[0].score, candidate.landmarks[1].score); + if (best.located && candidate.located && + (std::abs(best.dx - candidate.dx) > AGREEMENT_X || std::abs(best.dy - candidate.dy) > AGREEMENT_Y)){ + best.located = false; + best.at_target = false; + return best; + } + if ((candidate.located && !best.located) || (candidate.located == best.located && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + } + return best; +} +OverworldMatchResult match_route205_kelsey(const ImageRGB32& frame){ + OverworldMatchResult best; + // This item is retained on the route. Restrict acquisition to the left of + // the player, excluding the red cap and the northern bridge's item ball. + for (double scale : {.9, 1.0, 1.1}){ + const double w = 56.0 / 1920 * scale, h = 53.0 / 1080 * scale; + auto candidate = match_landmarks(frame, + {{"Kelsey west item ball", + "PokemonBDSP/Autostory/Route205/KelseyItem.png", + {280.0 / 1920 - w / 2, 574.5 / 1080 - h / 2, w, h}, + {0, .2, .48, .65}, + .93, + .20}}, + 1, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + if (best.landmarks.empty() || (candidate.located && !best.located) || + (candidate.located == best.located && candidate.landmarks[0].score > best.landmarks[0].score)){ + best = std::move(candidate); + } + } + return best; +} +OverworldMatchResult match_route205_bank_tree(const ImageRGB32& frame){ + // Use the tree crown above the player's reach, plus the stationary child. + // Recorded cloud-edge views retain both anchor gates and agreement. + // Trunk crops can include the player's cap when he stands under the tree. + OverworldMatchResult best; + double best_score = -1; + for (int view : {0, 1, 2, 3, 4, 5, 6, 7, 8}){ + for (double scale : {.9, 1.0, 1.1}){ + const double tw = 140.0 / 1920 * scale, th = 130.0 / 1080 * scale; + const double nw = 120.0 / 1920 * scale, nh = 170.0 / 1080 * scale; + auto candidate = match_landmarks(frame, + {{"Bank tree crown", + view == 8 ? "PokemonBDSP/Autostory/Route205/BankTreeCrownWest.png" + : view == 7 ? "PokemonBDSP/Autostory/Route205/BankTreeCrownCloud333.png" + : view == 6 ? "PokemonBDSP/Autostory/Route205/BankTreeCrownCloud76.png" + : view == 5 ? "PokemonBDSP/Autostory/Route205/BankTreeCrownCloud336.png" + : view == 4 ? "PokemonBDSP/Autostory/Route205/BankTreeCrownCloud328.png" + : view == 3 ? "PokemonBDSP/Autostory/Route205/BankTreeCrownCloud80.png" + : view == 2 ? "PokemonBDSP/Autostory/Route205/BankTreeCrownCloud72.png" + : view == 1 ? "PokemonBDSP/Autostory/Route205/BankTreeCrownLive.png" + : "PokemonBDSP/Autostory/Route205/BankTreeCrown.png", + {770.0 / 1920 - tw / 2, 235.0 / 1080 - th / 2, tw, th}, + {0, 0, 1, 1}, + .93, + .20}, + {"Bank blue-hat child", + view == 8 ? "PokemonBDSP/Autostory/Route205/BankBlueHatWest.png" + : view == 7 ? "PokemonBDSP/Autostory/Route205/BankBlueHatCloud333.png" + : view == 6 ? "PokemonBDSP/Autostory/Route205/BankBlueHatCloud76.png" + : view == 5 ? "PokemonBDSP/Autostory/Route205/BankBlueHatCloud336.png" + : view == 4 ? "PokemonBDSP/Autostory/Route205/BankBlueHatCloud328.png" + : view == 3 ? "PokemonBDSP/Autostory/Route205/BankBlueHatCloud80.png" + : view == 2 ? "PokemonBDSP/Autostory/Route205/BankBlueHatCloud72.png" + : view == 1 ? "PokemonBDSP/Autostory/Route205/BankBlueHatLive.png" + : "PokemonBDSP/Autostory/Route205/BankBlueHat.png", + {700.0 / 1920 - nw / 2, 525.0 / 1080 - nh / 2, nw, nh}, + {0, 0, 1, 1}, + .93, + .20}}, + 2, + 1.0 / 96, + POSITION_TOLERANCE_Y, + true); + const double score = std::min(candidate.landmarks[0].score, candidate.landmarks[1].score); + if (best.located && candidate.located && + (std::abs(best.dx - candidate.dx) > AGREEMENT_X || std::abs(best.dy - candidate.dy) > AGREEMENT_Y)){ + best.located = false; + best.at_target = false; + return best; + } + if ((candidate.located && !best.located) || (candidate.located == best.located && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + } + return best; +} +OverworldMatchResult match_route205_bridge(const ImageRGB32& frame){ + return match_route205_marker(frame, 722.5 / 1920, 465.0 / 1080); +} +OverworldMatchResult match_route205_forest_entrance(const ImageRGB32& frame){ + return match_route205_marker(frame, 1245.0 / 1920, 508.0 / 1080); +} +OverworldMatchResult match_windworks_interior_grunt(const ImageRGB32& frame){ + OverworldMatchResult result; + cv::Mat pixels((int)frame.height(), (int)frame.width(), CV_8UC4, (void*)frame.data(), frame.bytes_per_row()); + cv::Mat small, bgr, hsv; + cv::resize(pixels, small, cv::Size(960, 540), 0, 0, cv::INTER_AREA); + cv::cvtColor(small, bgr, cv::COLOR_BGRA2BGR); + cv::cvtColor(bgr, hsv, cv::COLOR_BGR2HSV); + // Grey indoor tiles distinguish this scene from the outdoor grunt, battle + // screens and fades. Pale turquoise partitions have too little saturation + // to qualify as the grunt's hair. + cv::Mat floor; + cv::inRange(hsv, cv::Scalar(0, 0, 100), cv::Scalar(179, 65, 255), floor); + const cv::Rect room(288, 81, 624, 405); + if ((double)cv::countNonZero(floor(room)) / room.area() < .30){ + return result; + } + auto head = [&](cv::Scalar low, cv::Scalar high, bool player, cv::Point2d& center){ + cv::Mat mask, labels, stats, centroids; + cv::inRange(hsv, low, high, mask); + const int count = cv::connectedComponentsWithStats(mask, labels, stats, centroids); + int best = -1, best_area = 0, runner_area = 0; + for (int i = 1; i < count; i++){ + int area = stats.at(i, cv::CC_STAT_AREA); + int w = stats.at(i, cv::CC_STAT_WIDTH), h = stats.at(i, cv::CC_STAT_HEIGHT); + double x = centroids.at(i, 0), y = centroids.at(i, 1); + if (area < 80 || area > 4000 || w < 12 || w > 110 || h < 8 || h > 90 || (double)area / (w * h) < .35 || + x < 288 || x > (player ? 672 : 864) || y < (player ? 135 : 15) || y > 459){ + continue; + } + if (area > best_area){ + runner_area = best_area; + best_area = area; + best = i; + }else{ + runner_area = std::max(runner_area, area); + } + } + if (best < 0 || runner_area > best_area * .70){ + return false; + } + center = {centroids.at(best, 0), centroids.at(best, 1)}; + return true; + }; + cv::Point2d player, grunt; + if (!head({0, 120, 120}, {22, 255, 255}, true, player) || !head({75, 100, 120}, {105, 255, 255}, false, grunt)){ + return result; + } + if (std::abs(player.x - grunt.x) > 288 || std::abs(player.y - grunt.y) > 350){ + return result; + } + result.located = true; + result.dx = (grunt.x - player.x) / 960; + result.dy = (grunt.y - player.y) / 540; + return result; +} +OverworldMatchResult match_windworks_sign_approach(const ImageRGB32& frame){ + // Sign beside the open lane west of the doorway turbine. Keep the player + // east of the sign/grass before the blind building-wall approach. + OverworldMatchResult best; + // The sign grows as it approaches the bottom of the camera view. Scale + // acquisition templates around the same target center, keeping all gates. + for (int view : {0, 1, 2}){ + for (double scale : {1.0, 1.1, 1.2, 1.3}){ + const double w = (view == 2 ? 124.0 : view == 1 ? 144.0 : 121.0) / 1920 * scale; + const double h = (view == 1 ? 140.0 : 122.0) / 1080 * scale; + auto candidate = match_landmarks(frame, + {{"Windworks approach sign", + view == 2 ? "PokemonBDSP/Autostory/WindworksApproach/SignNight.png" + : view == 1 ? "PokemonBDSP/Autostory/WindworksApproach/SignLow.png" + : "PokemonBDSP/Autostory/WindworksApproach/Sign.png", + {836.5 / 1920 - w / 2, 515.0 / 1080 - h / 2, w, h}, + {0, 0, 1, 1}, + .93, + .20}}, + 1, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + if (best.landmarks.empty() || (candidate.located && !best.located) || + (candidate.located == best.located && candidate.landmarks[0].score > best.landmarks[0].score)){ + best = candidate; + } + } + } + // Face-only references cover the high/mid camera perspectives without + // relying on the moving grass that can cover the sign's lower border. + for (bool high_view : {false, true}){ + for (double scale : {.9, 1.0, 1.1}){ + const double w = (high_view ? 86.0 : 106.0) / 1920 * scale; + const double h = (high_view ? 72.0 : 94.0) / 1080 * scale; + auto candidate = match_landmarks(frame, + {{"Windworks approach sign face", + high_view ? "PokemonBDSP/Autostory/WindworksApproach/SignHighFace.png" + : "PokemonBDSP/Autostory/WindworksApproach/SignMidFace.png", + {838.0 / 1920 - w / 2, 516.0 / 1080 - h / 2, w, h}, + {0, 0, 1, 1}, + .93, + .20}}, + 1, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + if ((candidate.located && !best.located) || + (candidate.located == best.located && candidate.landmarks[0].score > best.landmarks[0].score)){ + best = candidate; + } + } + } + return best; +} +OverworldMatchResult match_route204_floaroma_turn(const ImageRGB32& frame){ + OverworldMatchResult best; + // The northern 204 marker is wider in this camera view. Match its face, + // placing it above the player so the westward turn clears its post and + // the flower-bed fence. Align horizontally with the sign before the + // subsequent westward route, rather than staying in the eastern lane. + for (double scale_x : {1.0, 1.05, 1.10, 1.15, 1.20}){ + for (double scale_y : {.90, 1.0, 1.10}){ + const double w = 140.0 / 1920 * scale_x, h = 57.0 / 1080 * scale_y; + auto candidate = match_landmarks(frame, + {{"Northern Route 204 sign", + "PokemonBDSP/Autostory/Route204Sign/SignHour13Frame3.png", + {.5 - w / 2, 420.0 / 1080 - h / 2, w, h}, + {0, 0, 1, 1}, + .93, + .20}}, + 1, + POSITION_TOLERANCE_X, + POSITION_TOLERANCE_Y, + true); + if (best.landmarks.empty() || (candidate.located && !best.located) || + (candidate.located == best.located && candidate.landmarks[0].score > best.landmarks[0].score)){ + best = candidate; + } + } + } + return best; +} +OverworldMatchResult match_route204_north_approach(const ImageRGB32& frame){ + auto result = match_route204_sign(frame); + // The pre-battle reference stands below the sign. After Tyler, place + // the player one body-width west of it before starting north. + constexpr double west_offset = .04; + for (auto& anchor : result.landmarks){ + anchor.dx -= west_offset; + } + result.dx -= west_offset; + if (!result.located && !result.landmarks.empty()){ + const auto& sign = result.landmarks[0]; + if (sign.accepted && sign.score >= .93 && sign.margin >= .30 && std::abs(sign.dx) <= .45 && + std::abs(sign.dy) <= .25){ + result.located = true; + result.coarse = true; + result.dx = sign.dx; + result.dy = sign.dy; + } + } + result.at_target = result.located && !result.coarse; + for (const auto& anchor : result.landmarks){ + if (!anchor.accepted || std::abs(anchor.dx) > 1.0 / 128 || std::abs(anchor.dy) > POSITION_TOLERANCE_Y){ + result.at_target = false; + } + } + return result; +} +OverworldMatchResult match_floaroma_house_entrance(const ImageRGB32& frame){ + // Fixed facade details above/aside the player avoid door/player occlusion. + // Both must agree on the pose recorded just outside the central house. + return match_landmarks(frame, + {{"House window", + "PokemonBDSP/Autostory/FloaromaHouse/Window.png", + {1060.0 / 1920, 370.0 / 1080, 128.0 / 1920, 106.0 / 1080}, + {0, 0, 1, 1}, + .93, + .10}, + {"House gable", + "PokemonBDSP/Autostory/FloaromaHouse/Gable.png", + {796.0 / 1920, 196.0 / 1080, 460.0 / 1920, 154.0 / 1080}, + {0, 0, 1, 1}, + .93, + .10}}, + 2, + 1.0 / 64, + 1.0 / 54, + true); +} +OverworldMatchResult match_floaroma_center_entrance(const ImageRGB32& frame){ + OverworldMatchResult best; + // Perspective changes the emblem size as we approach the doorway gap. + for (double scale : {.90, .95, 1.0, 1.05, 1.10}){ + const double w = 105.0 / 1920 * scale, h = 86.0 / 1080 * scale; + auto candidate = match_landmarks(frame, + {{"Center emblem", + "PokemonBDSP/Autostory/FloaromaCenter/Emblem.png", + {.5 - w / 2, 135.0 / 1080 - h / 2, w, h}, + {0, 0, 1, 1}, + .93, + .20}}, + 1, + 1.0 / 64, + 1.0 / 36, + true); + if (best.landmarks.empty() || (candidate.located && !best.located) || + (candidate.located == best.located && candidate.landmarks[0].score > best.landmarks[0].score)){ + best = candidate; + } + } + // At the west wall the camera makes the emblem nearly circular, unlike + // the vertically compressed doorway reference. Search that recorded + // perspective without lowering the score or uniqueness requirements. + if (!best.located){ + for (double scale_x : {1.0, 1.05, 1.10, 1.15, 1.20}){ + for (double scale_y : {1.15, 1.25, 1.35, 1.45}){ + const double w = 105.0 / 1920 * scale_x, h = 86.0 / 1080 * scale_y; + auto candidate = match_landmarks(frame, + {{"Center emblem west perspective", + "PokemonBDSP/Autostory/FloaromaCenter/Emblem.png", + {.5 - w / 2, 135.0 / 1080 - h / 2, w, h}, + {0, 0, 1, 1}, + .93, + .20}}, + 1, + 1.0 / 64, + 1.0 / 36, + true); + candidate.coarse = true; + candidate.at_target = false; + if ((candidate.located && !best.located) || + (candidate.located == best.located && candidate.landmarks[0].score > best.landmarks[0].score)){ + best = candidate; + } + } + } + } + // A lower-face reference excludes the tester's burned-in overlay line + // and covers the doorway-facing perspective, retaining score/margin gates. + auto lower = match_landmarks(frame, + {{"Center emblem lower face", + "PokemonBDSP/Autostory/FloaromaCenter/EmblemLower.png", + {.5 - 52.0 / 1920, 114.0 / 1080, 104.0 / 1920, 65.0 / 1080}, + {0, 0, 1, 1}, + .93, + .20}}, + 1, + 1.0 / 64, + 1.0 / 36, + true); + if ((lower.located && (!best.located || best.coarse)) || + (lower.located == best.located && lower.landmarks[0].score > best.landmarks[0].score)){ + best = lower; + } + return best; +} +OverworldMatchResult match_route204_north_stairs(const ImageRGB32& frame){ + OverworldMatchResult best; + // Fixed rails/steps, with sunny and cloud-covered perspectives. + for (const char* path : {"PokemonBDSP/Autostory/Route204NorthStairs/Stairs.png", + "PokemonBDSP/Autostory/Route204NorthStairs/StairsCloud.png"}){ + // Camera pitch changes height and width by different amounts. + for (double scale_x : {.90, 1.0, 1.10}){ + for (double scale_y : {.80, .90, 1.0, 1.10}){ + const double w = 298.0 / 1920 * scale_x, h = 282.0 / 1080 * scale_y; + auto candidate = match_landmarks(frame, + {{"North route stairs", path, {.5 - w / 2, 650.0 / 1080, w, h}, {0, 0, 1, 1}, .93, .10}}, + 1, + 1.0 / 64, + 1.0 / 54, + true); + if (best.landmarks.empty() || (candidate.located && !best.located) || + (candidate.located == best.located && candidate.landmarks[0].score > best.landmarks[0].score)){ + best = candidate; + } + } + } + } + if (!best.located){ + // The lower steps distort as the camera moves north. The rail tops, + // grass edge and first steps retain a stable physical upper edge. + for (const auto& config : + std::vector{{"North route stairs top", + "PokemonBDSP/Autostory/Route204NorthStairs/Top.png", + {.5 - 149.0 / 1920, 640.0 / 1080, 298.0 / 1920, 70.0 / 1080}, + {0, 0, 1, 1}, + .93, + .10}, + {"North route stairs top", + "PokemonBDSP/Autostory/Route204NorthStairs/TopRaised.png", + {.5 - 157.0 / 1920, 640.0 / 1080, 314.0 / 1920, 70.0 / 1080}, + {0, 0, 1, 1}, + .93, + .10}}){ + for (double scale_x : {.95, 1.0, 1.05}){ + for (double scale_y : {.90, 1.0, 1.10}){ + auto variant = config; + variant.target.width *= scale_x; + variant.target.height *= scale_y; + variant.target.x = .5 - variant.target.width / 2; + auto candidate = match_landmarks(frame, {variant}, 1, 1.0 / 64, 1.0 / 54, true); + if ((candidate.located && !best.located) || + (candidate.located == best.located && candidate.landmarks[0].score > best.landmarks[0].score)){ + best = candidate; + } + } + } + } + } + return best; +} +OverworldMatchResult match_teighler_alcove_corner(const ImageRGB32& frame){ + auto result = match_route204_north_stairs(frame); + // Reuse the fixed stairs only as an anchor. The destination is the + // bottom-left of Teighler's alcove, east of the stairs, not their center. + // Geometry comes from the recorded 1920x1080 corner reference. + constexpr double shift_x = 393.0 / 1920, shift_y = 182.0 / 1080; + result.dx += shift_x; + result.dy += shift_y; + for (auto& anchor : result.landmarks){ + anchor.dx += shift_x; + anchor.dy += shift_y; + } + result.at_target = result.located && std::abs(result.dx) <= 1.0 / 64 && std::abs(result.dy) <= 1.0 / 54; + return result; +} +OverworldMatchResult match_ravaged_path_south_entrance(const ImageRGB32& frame){ + const ImageFloatBox mouth{860.0 / 1920, 220.0 / 1080, 200.0 / 1920, 155.0 / 1080}; + const ImageFloatBox sign{810.0 / 1920, 380.0 / 1080, 100.0 / 1920, 110.0 / 1080}; + std::vector variants; + // A different approach height changes perspective. Compare a small range + // of sizes, keeping each physical anchor's reference center fixed. + for (double scale : {.90, 1.0, 1.10}){ + for (const auto& config : + std::vector{{"Cave mouth", + "PokemonBDSP/Autostory/RavagedPathSouthEntrance/Mouth.png", + mouth, + {0, 0, 1, 1}, + .93, + .10}, + {"Entrance sign", + "PokemonBDSP/Autostory/RavagedPathSouthEntrance/Sign.png", + sign, + {0, 0, 1, 1}, + .93, + .10}, + {"Cave mouth", + "PokemonBDSP/Autostory/RavagedPathSouthEntrance/MouthShadow.png", + mouth, + {0, 0, 1, 1}, + .93, + .10}, + {"Entrance sign", + "PokemonBDSP/Autostory/RavagedPathSouthEntrance/SignShadow.png", + sign, + {0, 0, 1, 1}, + .93, + .10}, + {"Entrance sign", + "PokemonBDSP/Autostory/RavagedPathSouthEntrance/SignPartialShadow.png", + sign, + {0, 0, 1, 1}, + .93, + .10}, + {"Entrance sign", + "PokemonBDSP/Autostory/RavagedPathSouthEntrance/SignFace.png", + {828.0 / 1920, 400.0 / 1080, 64.0 / 1920, 62.0 / 1080}, + {0, 0, 1, 1}, + .93, + .10}, + {"Entrance sign", + "PokemonBDSP/Autostory/RavagedPathSouthEntrance/SignFaceLowApproach.png", + {828.0 / 1920, 400.0 / 1080, 64.0 / 1920, 62.0 / 1080}, + {0, 0, 1, 1}, + .93, + .10}, + {"Entrance sign", + "PokemonBDSP/Autostory/RavagedPathSouthEntrance/SignFaceCloud.png", + {828.0 / 1920, 400.0 / 1080, 64.0 / 1920, 62.0 / 1080}, + {0, 0, 1, 1}, + .93, + .10}}){ + auto variant = config; + variant.target.width *= scale; + variant.target.height *= scale; + variant.target.x += (config.target.width - variant.target.width) / 2; + variant.target.y += (config.target.height - variant.target.height) / 2; + variants.push_back(variant); + } + } + auto result = match_lighting_pair(frame, variants, "Cave mouth", "Entrance sign", 1.0 / 64, true); + if (!result.located && result.landmarks.size() == 2){ + for (const auto& landmark : result.landmarks){ + if (landmark.accepted && landmark.score >= .95 && landmark.margin >= .20 && std::abs(landmark.dx) <= .35 && + std::abs(landmark.dy) <= .40){ + result.located = true; + result.coarse = true; + result.at_target = false; + result.dx = landmark.dx; + result.dy = landmark.dy; + break; + } + } + } + // The moving blue-hatted boy is deliberately absent from the references. + // The lass is optional evidence for the southern movement boundary, never + // a replacement for the fixed mouth/sign arrival anchors. + auto boundary = match_landmarks(frame, + {{"Lass south boundary", + "PokemonBDSP/Autostory/RavagedPathSouthEntrance/Lass.png", + {910.0 / 1920, 788.0 / 1080, 100.0 / 1920, 142.0 / 1080}, + {0, 0, 1, 1}, + .88, + .08}}, + 1, + 1.0 / 64, + 1.0 / 36, + true); + if (!boundary.landmarks.empty()){ + result.landmarks.push_back(boundary.landmarks[0]); + } + return result; +} +OverworldMatchResult match_route215_bridge_renewal(const ImageRGB32& frame){ + // Restore the successful west bridge approach after variable expiry movement. + if (!frame){ + return {}; + } + cv::Mat gray, smoothed; + cv::resize(gray_image(frame), gray, cv::Size(960, 540), 0, 0, cv::INTER_AREA); + cv::GaussianBlur(gray, smoothed, cv::Size(0, 0), .8); + ImageRGB32 filtered(960, 540); + cv::Mat pixels(540, 960, CV_8UC4, filtered.data(), filtered.bytes_per_row()); + cv::cvtColor(smoothed, pixels, cv::COLOR_GRAY2BGRA); + auto result = match_lighting_pair(filtered, + {{"Bridge west edge", + "PokemonBDSP/Autostory/Route215Renewal/BridgeEdge.png", + {538.0 / 960, 302.0 / 540, 50.0 / 960, 100.0 / 540}, + {.15, .25, .85, .75}, + .90, + .10}, + {"Landing cliff corner", + "PokemonBDSP/Autostory/Route215Renewal/BridgeCliff.png", + {333.0 / 960, 410.0 / 540, 64.0 / 960, 88.0 / 540}, + {.10, .25, .85, .75}, + .90, + .10}, + {"Bridge west edge", + "PokemonBDSP/Autostory/Route215Renewal/BridgeFarEdge.png", + {538.0 / 960, 302.0 / 540, 50.0 / 960, 100.0 / 540}, + {.15, .25, .85, .75}, + .90, + .10}, + {"Landing cliff corner", + "PokemonBDSP/Autostory/Route215Renewal/BridgeFarCliff.png", + {333.0 / 960, 410.0 / 540, 64.0 / 960, 88.0 / 540}, + {.10, .25, .85, .75}, + .90, + .10}, + {"Bridge west edge", + "PokemonBDSP/Autostory/Route215Renewal/BridgeNightEdge.png", + {538.0 / 960, 302.0 / 540, 50.0 / 960, 100.0 / 540}, + {.15, .25, .85, .75}, + .90, + .10}, + {"Landing cliff corner", + "PokemonBDSP/Autostory/Route215Renewal/BridgeNightCliff.png", + {333.0 / 960, 410.0 / 540, 64.0 / 960, 88.0 / 540}, + {.10, .25, .85, .75}, + .90, + .10}, + {"Bridge west edge", + "PokemonBDSP/Autostory/Route215Renewal/BridgeNightFarEdge.png", + {538.0 / 960, 302.0 / 540, 50.0 / 960, 100.0 / 540}, + {.15, .25, .85, .75}, + .90, + .10}, + {"Landing cliff corner", + "PokemonBDSP/Autostory/Route215Renewal/BridgeNightFarCliff.png", + {333.0 / 960, 410.0 / 540, 64.0 / 960, 88.0 / 540}, + {.10, .25, .85, .75}, + .90, + .10}}, + "Bridge west edge", + "Landing cliff corner", + 1.0 / 128); + result.at_target = result.located && std::abs(result.dx) <= 1.0 / 128 && std::abs(result.dy) <= 1.0 / 108; + return result; +} +OverworldMatchResult match_route215_ace_renewal(const ImageRGB32& frame){ + // The road bend and fence corner exclude the patrolling trainers. + if (!frame){ + return {}; + } + cv::Mat gray, smoothed; + cv::resize(gray_image(frame), gray, cv::Size(960, 540), 0, 0, cv::INTER_AREA); + cv::GaussianBlur(gray, smoothed, cv::Size(0, 0), .8); + ImageRGB32 filtered(960, 540); + cv::Mat pixels(540, 960, CV_8UC4, filtered.data(), filtered.bytes_per_row()); + cv::cvtColor(smoothed, pixels, cv::COLOR_GRAY2BGRA); + auto result = match_landmarks(filtered, + {{"Road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceRoad.png", + {352.0 / 960, 180.0 / 540, 60.0 / 960, 80.0 / 540}, + {.10, .15, .85, .80}, + .92, + .08}, + {"Fence corner", + "PokemonBDSP/Autostory/Route215Renewal/AceFence.png", + {165.0 / 960, 389.0 / 540, 110.0 / 960, 78.0 / 540}, + {.05, .25, .90, .75}, + .90, + .10, + LandmarkFeatures::LOCAL_CONTRAST}}, + 2, + 1.0 / 128, + 1.0 / 108, + true); + if (result.located){ + return result; + } + // Expiry may finish east of the fence's visible range. Two separate road + // bends establish a horizontal recovery direction along this clear row. + // Perspective changes their spacing, so this pair cannot confirm arrival. + // Suppress thin rain streaks and puddle rings in the broader road shapes; + // retain the original input and confidence gates for the fine fence pair. + cv::medianBlur(gray, smoothed, 5); + cv::GaussianBlur(smoothed, smoothed, cv::Size(0, 0), .8); + cv::cvtColor(smoothed, pixels, cv::COLOR_GRAY2BGRA); + auto approach = match_lighting_pair(filtered, + {{"Upper road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireRoadGoal.png", + {352.0 / 960, 180.0 / 540, 60.0 / 960, 80.0 / 540}, + {0, .25, 1, .30}, + .92, + .08}, + {"Upper road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireRoadNear.png", + {352.0 / 960, 180.0 / 540, 60.0 / 960, 80.0 / 540}, + {0, .25, 1, .30}, + .92, + .08}, + {"Upper road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireRoadFar.png", + {352.0 / 960, 180.0 / 540, 60.0 / 960, 80.0 / 540}, + {0, .25, 1, .30}, + .92, + .08}, + {"Upper road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireRoadWestFar.png", + {352.0 / 960, 180.0 / 540, 60.0 / 960, 80.0 / 540}, + {0, .25, 1, .30}, + .92, + .08}, + {"Upper road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireRoadWestNear.png", + {352.0 / 960, 180.0 / 540, 60.0 / 960, 80.0 / 540}, + {0, .25, 1, .30}, + .92, + .08}, + {"Upper road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireRoadWest.png", + {352.0 / 960, 180.0 / 540, 60.0 / 960, 80.0 / 540}, + {0, .25, 1, .30}, + .92, + .08}, + {"Upper road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireRoadWestNearRain.png", + {352.0 / 960, 180.0 / 540, 60.0 / 960, 80.0 / 540}, + {0, .25, 1, .30}, + .92, + .08}, + {"Upper road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireRoadFarRain.png", + {352.0 / 960, 180.0 / 540, 60.0 / 960, 80.0 / 540}, + {0, .25, 1, .30}, + .92, + .08}, + {"Lower road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireLowerRoadEast.png", + {500.0 / 960, 420.0 / 540, 80.0 / 960, 70.0 / 540}, + {0, .70, 1, .30}, + .93, + .10}, + {"Lower road bend", + "PokemonBDSP/Autostory/Route215Renewal/AceAcquireLowerRoad.png", + {500.0 / 960, 420.0 / 540, 80.0 / 960, 70.0 / 540}, + {0, .70, 1, .30}, + .93, + .10}}, + "Upper road bend", + "Lower road bend", + 1.0 / 128); + // At the eastern extreme, the original bends leave the screen. The two + // corners of the same grass bed remain visible above the clear road. + auto east = match_lighting_pair(filtered, + {{"Grass west corner", + "PokemonBDSP/Autostory/Route215Renewal/AceGrassWest.png", + {665.0 / 960, 145.0 / 540, 110.0 / 960, 75.0 / 540}, + {0, .20, 1, .40}, + .92, + .10}, + {"Grass east corner", + "PokemonBDSP/Autostory/Route215Renewal/AceGrassEast.png", + {890.0 / 960, 175.0 / 540, 65.0 / 960, 62.0 / 540}, + {0, .20, 1, .40}, + .92, + .10}, + {"Grass west corner", + "PokemonBDSP/Autostory/Route215Renewal/AceGrassFarWest.png", + {665.0 / 960, 145.0 / 540, 110.0 / 960, 75.0 / 540}, + {0, .20, 1, .40}, + .92, + .10}, + {"Grass east corner", + "PokemonBDSP/Autostory/Route215Renewal/AceGrassFarEast.png", + {890.0 / 960, 175.0 / 540, 65.0 / 960, 62.0 / 540}, + {0, .20, 1, .40}, + .92, + .10}, + {"Grass east corner", + "PokemonBDSP/Autostory/Route215Renewal/AceGrassFarEastShoulder.png", + {850.0 / 960, 145.0 / 540, 105.0 / 960, 80.0 / 540}, + {0, .20, 1, .40}, + .92, + .10}}, + "Grass west corner", + "Grass east corner", + 1.0 / 128); + const auto& west = east.landmarks[0]; + const auto& right = east.landmarks[1]; + const double east_nearest = std::min(std::abs(west.dx), std::abs(right.dx)); + east.located = west.accepted && right.accepted && std::abs(west.dy - right.dy) <= AGREEMENT_Y && + west.dx < -1.0 / 12 && right.dx < -1.0 / 12 && east_nearest > 2 * std::abs(west.dx - right.dx) && + east.dy >= -1.0 / 27 && east.dy <= 1.0 / 9; + if (east.located){ + east.coarse = true; + east.at_target = false; + return east; + } + // From the eastern end, the fixed route sign remains unique while the + // road bends are off-screen. Its virtual goal lies beyond the right edge; + // this pair guides acquisition only and can never confirm arrival. + auto far_east = match_lighting_pair(filtered, + {{"Grass west corner", + "PokemonBDSP/Autostory/Route215Renewal/AceGrassFarWest.png", + {665.0 / 960, 145.0 / 540, 110.0 / 960, 75.0 / 540}, + {0, .20, 1, .40}, + .92, + .10}, + {"Route 215 sign", + "PokemonBDSP/Autostory/Route215Renewal/AceRoute215Sign.png", + {1128.0 / 960, 359.0 / 540, 90.0 / 960, 45.0 / 540}, + {.50, .40, .50, .50}, + .94, + .20}, + {"Route 215 sign", + "PokemonBDSP/Autostory/Route215Renewal/AceRoute215SignRain.png", + {1128.0 / 960, 359.0 / 540, 90.0 / 960, 45.0 / 540}, + {.50, .40, .50, .50}, + .94, + .20}}, + "Grass west corner", + "Route 215 sign", + 1.0 / 128); + const auto& corner = far_east.landmarks[0]; + const auto& sign = far_east.landmarks[1]; + const double far_nearest = std::min(std::abs(corner.dx), std::abs(sign.dx)); + far_east.located = corner.accepted && sign.accepted && std::abs(corner.dy - sign.dy) <= AGREEMENT_Y && + corner.dx < -1.0 / 4 && sign.dx < -1.0 / 4 && far_nearest > 2 * std::abs(corner.dx - sign.dx) && + far_east.dy >= -1.0 / 27 && far_east.dy <= 1.0 / 9; + if (far_east.located){ + far_east.coarse = true; + far_east.at_target = false; + return far_east; + } + const auto& upper = approach.landmarks[0]; + const auto& lower = approach.landmarks[1]; + // Keep the shared row when both bends already agree. In the recorded + // perspective-shifted rows the nearer bend moves 1.35 times as far; + // compare those acquisition errors in the farther bend's plane. + // Neither model changes the strict fine-arrival checks above. + const double lower_plane_dy = std::abs(upper.dy - lower.dy) <= AGREEMENT_Y ? lower.dy : lower.dy / 1.35; + approach.dy = (upper.dy + lower_plane_dy) / 2; + const double nearest = std::min(std::abs(upper.dx), std::abs(lower.dx)); + const bool horizontal_support = + upper.dx * lower.dx > 0 && nearest > 1.0 / 64 && nearest > 2 * std::abs(upper.dx - lower.dx); + // Expiry can also finish one row south, already near the goal column. + // Both accepted bends must agree before correcting that row vertically. + const bool vertical_support = std::abs(upper.dx - lower.dx) <= AGREEMENT_X && + std::max(std::abs(upper.dx), std::abs(lower.dx)) <= 1.0 / 12 && + std::abs(upper.dy - lower_plane_dy) <= 1.0 / 108 && upper.dy * lower_plane_dy > 0 && + std::abs(approach.dy) > 1.0 / 54; + approach.located = upper.accepted && lower.accepted && std::abs(upper.dy - lower_plane_dy) <= AGREEMENT_Y && + std::abs(approach.dy) <= 1.0 / 9 && (horizontal_support || vertical_support); + approach.coarse = true; + approach.at_target = false; + return approach; +} +OverworldMatchResult match_route215_derek_stairs_renewal(const ImageRGB32& frame){ + if (!frame){ + return {}; + } + cv::Mat gray, smoothed; + cv::resize(gray_image(frame), gray, cv::Size(960, 540), 0, 0, cv::INTER_AREA); + cv::GaussianBlur(gray, smoothed, cv::Size(0, 0), .8); + ImageRGB32 filtered(960, 540); + cv::Mat pixels(540, 960, CV_8UC4, filtered.data(), filtered.bytes_per_row()); + cv::cvtColor(smoothed, pixels, cv::COLOR_GRAY2BGRA); + // Clear landing below the short stairs; neither tree nor player is an anchor. + auto result = match_lighting_pair(filtered, + {{"White stair railing", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairsRailing.png", + {500.0 / 960, 78.0 / 540, 90.0 / 960, 110.0 / 540}, + {.10, .05, .85, .85}, + .93, + .12}, + {"Lower stair corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairsCorner.png", + {589.0 / 960, 315.0 / 540, 95.0 / 960, 105.0 / 540}, + {.10, .20, .85, .80}, + .93, + .12}, + {"White stair railing", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairsNightRailing.png", + {500.0 / 960, 78.0 / 540, 90.0 / 960, 110.0 / 540}, + {.10, .05, .85, .85}, + .93, + .12}, + {"Lower stair corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairsNightCorner.png", + {589.0 / 960, 315.0 / 540, 95.0 / 960, 105.0 / 540}, + {.10, .20, .85, .80}, + .93, + .12}}, + "White stair railing", + "Lower stair corner", + 1.0 / 128); + result.at_target = result.located && std::abs(result.dx) <= 1.0 / 128 && std::abs(result.dy) <= 1.0 / 108; + if (result.located){ + return result; + } + // Far west of this landing, perspective shifts the two structures by + // different amounts. Use that view only to acquire the horizontal column; + // the original close pair above remains the sole arrival check. + auto approach = match_landmarks(filtered, + {{"White stair railing", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairsWestRailing.png", + {500.0 / 960, 78.0 / 540, 90.0 / 960, 110.0 / 540}, + {.10, .05, .85, .85}, + .93, + .12}, + {"Lower stair corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairsWestCorner.png", + {589.0 / 960, 315.0 / 540, 95.0 / 960, 105.0 / 540}, + {.10, .20, .85, .80}, + .93, + .12}}, + 2, + 1.0 / 128, + 1.0 / 108, + true); + const auto& rail = approach.landmarks[0]; + const auto& stair = approach.landmarks[1]; + const double nearest = std::min(std::abs(rail.dx), std::abs(stair.dx)); + approach.located = rail.accepted && stair.accepted && std::abs(rail.dy - stair.dy) <= AGREEMENT_Y && + std::abs((rail.dy + stair.dy) / 2) <= 1.0 / 108 && rail.dx * stair.dx > 0 && nearest > 1.0 / 64 && + nearest > 2 * std::abs(rail.dx - stair.dx); + approach.dx = (rail.dx + stair.dx) / 2; + approach.dy = (rail.dy + stair.dy) / 2; + approach.coarse = true; + approach.at_target = false; + return approach; +} +OverworldMatchResult match_route215_derek_stair_top_renewal(const ImageRGB32& frame){ + if (!frame){ + return {}; + } + cv::Mat gray, smoothed; + cv::resize(gray_image(frame), gray, cv::Size(960, 540), 0, 0, cv::INTER_AREA); + cv::GaussianBlur(gray, smoothed, cv::Size(0, 0), .8); + ImageRGB32 filtered(960, 540); + cv::Mat pixels(540, 960, CV_8UC4, filtered.data(), filtered.bytes_per_row()); + cv::cvtColor(smoothed, pixels, cv::COLOR_GRAY2BGRA); + // The road row above the stairs is clear west/east, below the tall grass. + auto result = match_lighting_pair(filtered, + {{"Stair-top barrier", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopBarrier.png", + {384.0 / 960, 401.0 / 540, 127.0 / 960, 70.0 / 540}, + {.05, .15, .90, .85}, + .93, + .12}, + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopFence.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Stair-top barrier", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopBarrierEast.png", + {384.0 / 960, 401.0 / 540, 127.0 / 960, 70.0 / 540}, + {.05, .15, .90, .85}, + .93, + .12}, + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopFenceEast.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Stair-top barrier", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopNightBarrier.png", + {384.0 / 960, 401.0 / 540, 127.0 / 960, 70.0 / 540}, + {.05, .15, .90, .85}, + .93, + .12}, + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopNightFence.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Stair-top barrier", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopNightEastBarrier.png", + {384.0 / 960, 401.0 / 540, 127.0 / 960, 70.0 / 540}, + {.05, .15, .90, .85}, + .93, + .12}, + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopNightEastFence.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Stair-top barrier", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopNightWestBarrier.png", + {384.0 / 960, 401.0 / 540, 127.0 / 960, 70.0 / 540}, + {.05, .15, .90, .85}, + .93, + .12}, + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopNightWestFence.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopNorthFence.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Stair-top barrier", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopFarEastNorthBarrier.png", + {384.0 / 960, 401.0 / 540, 127.0 / 960, 70.0 / 540}, + {.05, .15, .90, .85}, + .93, + .12}, + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopFarEastNorthFence.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Stair-top barrier", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopFarEastBarrier.png", + {384.0 / 960, 401.0 / 540, 127.0 / 960, 70.0 / 540}, + {.05, .15, .90, .85}, + .93, + .12}, + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopFarEastFence.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST} // Raw-luminance alternatives preserve fence structure when local + // contrast normalization amplifies a passing rain streak. + , + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopFarEastNorthGrayFence.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12}, + {"West fence corner", + "PokemonBDSP/Autostory/Route215Renewal/DerekStairTopFarEastGrayFence.png", + {274.0 / 960, 275.0 / 540, 69.0 / 960, 125.0 / 540}, + {.05, .15, .90, .85}, + .90, + .12}}, + "Stair-top barrier", + "West fence corner", + 1.0 / 128); + result.at_target = result.located && std::abs(result.dx) <= 1.0 / 128 && std::abs(result.dy) <= 1.0 / 108; + if (!result.located && result.landmarks.size() == 2){ + const auto& barrier = result.landmarks[0]; + const auto& fence = result.landmarks[1]; + const double nearest = std::min(std::abs(barrier.dx), std::abs(fence.dx)); + // Far-east perspective changes spacing: acquire the column first, + // then use the original strict two-anchor arrival check. + if (barrier.accepted && fence.accepted && std::abs(barrier.dy - fence.dy) <= AGREEMENT_Y && + std::abs(result.dy) <= 1.0 / 20 && barrier.dx * fence.dx > 0 && nearest > 1.0 / 64 && + nearest > 2 * std::abs(barrier.dx - fence.dx)){ + result.located = true; + result.coarse = true; + result.at_target = false; + } + } + return result; +} +OverworldMatchResult match_route215_jogger_corner(const ImageRGB32& frame){ + if (!frame){ + return {}; + } + // These references describe the game capture, without the recording canvas. + // Smooth only this locator before contrast normalization to suppress rain + // streaks and capture resampling noise; other detectors retain their inputs. + cv::Mat gray, smoothed; + cv::resize(gray_image(frame), gray, cv::Size(960, 540), 0, 0, cv::INTER_AREA); + cv::GaussianBlur(gray, smoothed, cv::Size(0, 0), .8); + ImageRGB32 filtered(960, 540); + cv::Mat pixels(540, 960, CV_8UC4, filtered.data(), filtered.bytes_per_row()); + cv::cvtColor(smoothed, pixels, cv::COLOR_GRAY2BGRA); + // Fixed fence cap and road bend; neither the moving jogger nor the item + // supplies position evidence. Each view maps back to the same lane-4 corner. + // Search only the cap/bend height of lanes 4 and 3. This excludes similar + // lower fence sections; a missing cap never authorizes a vertical search. + // Low-frequency cap/cliff alternatives require higher correlation (.95/.97) + // as well as a unique peak and an independently agreeing structural anchor. + const std::vector appearances = { + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceGoal.png", + {0.534375, 0.312962962963, 32.0 / 960, 42.0 / 540}, + {0, .24, 1, .26}, + .93, + .08, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceRainWest.png", + {0.535416666667, 0.311111111111, 32.0 / 960, 42.0 / 540}, + {0, .24, 1, .26}, + .93, + .08, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceCloudWest.png", + {0.535416666667, 0.311111111111, 32.0 / 960, 42.0 / 540}, + {0, .24, 1, .26}, + .93, + .08, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceLane3.png", + {0.535416666667, 0.312962962963, 32.0 / 960, 42.0 / 540}, + {0, .24, 1, .26}, + .93, + .08, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceMid.png", + {0.534375, 0.312962962963, 32.0 / 960, 42.0 / 540}, + {0, .24, 1, .26}, + .93, + .08, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceNear.png", + {0.534375, 0.312962962963, 32.0 / 960, 42.0 / 540}, + {0, .24, 1, .26}, + .93, + .08, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceApproachFar.png", + {0.534375, 0.312962962963, 32.0 / 960, 42.0 / 540}, + {0, .24, 1, .26}, + .93, + .08, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceApproachMiddle.png", + {0.534375, 0.312962962963, 32.0 / 960, 42.0 / 540}, + {0, .24, 1, .26}, + .93, + .08, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceApproachNear.png", + {0.534375, 0.312962962963, 32.0 / 960, 42.0 / 540}, + {0, .24, 1, .26}, + .93, + .08, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Fence top", + "PokemonBDSP/Autostory/Route215JoggerV2/FenceGrayLane3.png", + {0.535416666667, 0.312962962963, 32.0 / 960, 24.0 / 540}, + {0, .24, 1, .26}, + .95, + .05}, + {"Road bend", + "PokemonBDSP/Autostory/Route215JoggerV2/RoadUpperDayGoal.png", + {422.0 / 960, 164.0 / 540, 80.0 / 960, 25.0 / 540}, + {0, .24, 1, .26}, + .90, + .15}, + // In lane 3 the cap overlaps the lower part of the full road patch. + // Upper-bend alternatives keep position evidence clear of the player. + {"Road bend", + "PokemonBDSP/Autostory/Route215JoggerV2/RoadUpperGoal.png", + {0.439583333333, 0.303703703704, 80.0 / 960, 25.0 / 540}, + {0, .24, 1, .26}, + .90, + .15}, + {"Road bend", + "PokemonBDSP/Autostory/Route215JoggerV2/RoadUpperRainWest.png", + {0.439583333333, 0.303703703704, 80.0 / 960, 25.0 / 540}, + {0, .24, 1, .26}, + .90, + .15}, + {"Road bend", + "PokemonBDSP/Autostory/Route215JoggerV2/RoadUpperCloudWest.png", + {0.447916666667, 0.307407407407, 80.0 / 960, 25.0 / 540}, + {0, .24, 1, .26}, + .90, + .15}, + {"Road bend", + "PokemonBDSP/Autostory/Route215JoggerV2/RoadGoal.png", + {0.439583333333, 0.303703703704, 80.0 / 960, 41.0 / 540}, + {0, .24, 1, .26}, + .90, + .15}, + {"Road bend", + "PokemonBDSP/Autostory/Route215JoggerV2/RoadRainWest.png", + {0.439583333333, 0.303703703704, 80.0 / 960, 41.0 / 540}, + {0, .24, 1, .26}, + .90, + .15}, + {"Road bend", + "PokemonBDSP/Autostory/Route215JoggerV2/RoadCloudWest.png", + {0.447916666667, 0.307407407407, 80.0 / 960, 41.0 / 540}, + {0, .24, 1, .26}, + .90, + .15}, + }; + std::vector variants; + for (const auto& appearance : appearances){ + if (std::string(appearance.name) == "Road bend"){ + variants.push_back(appearance); + continue; + } + // Horizontal perspective changes as the fence crosses the screen. + for (double scale : {.96, 1.0, 1.04}){ + auto variant = appearance; + variant.target.width *= scale; + variant.target.x += (appearance.target.width - variant.target.width) / 2; + variants.push_back(variant); + } + } + // A road bend can disambiguate repeated fence sections. Choose complete + // agreeing pairs, rather than rejecting one anchor before considering the other. + const auto candidates = + match_landmarks(filtered, variants, variants.size() + 1, 1.0 / 128, POSITION_TOLERANCE_Y, true); + OverworldMatchResult best; + double best_score = -1; + bool supported = false, ambiguous = false; + double first_x = 0, first_y = 0; + for (const auto& fence : candidates.landmarks){ + if (fence.name != "Fence top"){ + continue; + } + for (const auto& road : candidates.landmarks){ + if (road.name != "Road bend"){ + continue; + } + const double score = std::min(fence.score, road.score); + const bool agrees = + std::abs(fence.dx - road.dx) <= AGREEMENT_X && std::abs(fence.dy - road.dy) <= AGREEMENT_Y; + const bool valid = fence.accepted && road.accepted && agrees; + const double dx = (fence.dx + road.dx) / 2, dy = (fence.dy + road.dy) / 2; + if (valid){ + if (supported && (std::abs(dx - first_x) > AGREEMENT_X || std::abs(dy - first_y) > AGREEMENT_Y)){ + ambiguous = true; + } + if (!supported){ + first_x = dx; + first_y = dy; + } + } + if ((valid && !supported) || (valid == supported && score > best_score)){ + best.landmarks = {fence, road}; + best.dx = dx; + best.dy = dy; + best_score = score; + } + supported |= valid; + } + } + best.located = supported && !ambiguous; + best.at_target = best.located && std::abs(best.dx) <= 1.0 / 128 && std::abs(best.dy) <= POSITION_TOLERANCE_Y; + if (best.located){ + return best; + } + // West of the visible fence, bridge/cliff pairs establish the walking row. + // Their x coordinates are acquisition-only and can never confirm arrival. + const std::vector> approaches = { + {// The observed lane-4 to lane-3 step changes the bridge-foot view. + // Both corners must agree; this pair never confirms arrival. + {"Bridge west corner", + "PokemonBDSP/Autostory/Route215JoggerV2/BridgeWestNightLane3.png", + {106.0 / 960 - .435625, 297.0 / 540 - 70.0 / 1080, 60.0 / 960, 75.0 / 540}, + {0, .2, 1, .6}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Bridge east corner", + "PokemonBDSP/Autostory/Route215JoggerV2/BridgeEastNightLane3.png", + {260.0 / 960 - .435625, 295.0 / 540 - 70.0 / 1080, 65.0 / 960, 75.0 / 540}, + {0, .2, 1, .6}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}}, + {// When the east fence is off-screen, these two full bridge-foot + // corners establish lane 4. They never authorize arrival. + {"Bridge west corner", + "PokemonBDSP/Autostory/Route215JoggerV2/BridgeWestCurrent.png", + {106.0 / 960 - .435, 257.0 / 540, 60.0 / 960, 75.0 / 540}, + {0, .2, 1, .6}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Bridge east corner", + "PokemonBDSP/Autostory/Route215JoggerV2/BridgeEastCurrent.png", + {260.0 / 960 - .435, 255.0 / 540, 65.0 / 960, 75.0 / 540}, + {0, .2, 1, .6}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}}, + { + + {"Bridge west corner", + "PokemonBDSP/Autostory/Route215JoggerV2/BridgeWestBridgeFar.png", + {240.0 / 960 - .61, 246.0 / 540, 64.0 / 960, 60.0 / 540}, + {0, .2, 1, .6}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"Bridge east corner", + "PokemonBDSP/Autostory/Route215JoggerV2/BridgeEastBridgeFar.png", + {388.0 / 960 - .61, 246.0 / 540, 64.0 / 960, 60.0 / 540}, + {0, .2, 1, .6}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}}, + {{"Bridge west corner", + "PokemonBDSP/Autostory/Route215JoggerV2/BridgeWestBridgeMid.png", + {108.0 / 960 - .445, 245.0 / 540, 64.0 / 960, 60.0 / 540}, + {0, .2, 1, .6}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"South cliff corner", + "PokemonBDSP/Autostory/Route215JoggerV2/SouthCliffBridgeMid.png", + {77.0 / 960 - .445, 295.0 / 540, 90.0 / 960, 80.0 / 540}, + {0, .2, 1, .6}, + .97, + .05}}, + {{"Bridge west corner", + "PokemonBDSP/Autostory/Route215JoggerV2/BridgeWestBridgeNear.png", + {68.0 / 960 - .405, 245.0 / 540, 64.0 / 960, 60.0 / 540}, + {0, .2, 1, .6}, + .90, + .12, + LandmarkFeatures::LOCAL_CONTRAST}, + {"South cliff corner", + "PokemonBDSP/Autostory/Route215JoggerV2/SouthCliffBridgeNear.png", + {37.0 / 960 - .405, 295.0 / 540, 90.0 / 960, 80.0 / 540}, + {0, .2, 1, .6}, + .97, + .05}}}; + for (const auto& approach : approaches){ + auto candidate = match_landmarks(filtered, approach, 2, 1.0 / 128, 1.0 / 54, true); + const double score = std::min(candidate.landmarks[0].score, candidate.landmarks[1].score); + if (best.located && candidate.located && std::abs(best.dy - candidate.dy) > AGREEMENT_Y){ + best.located = false; + best.at_target = false; + best.coarse = true; + return best; + } + candidate.coarse = true; + candidate.at_target = false; + if ((candidate.located && !best.located) || (candidate.located == best.located && score > best_score)){ + best = std::move(candidate); + best_score = score; + } + } + return best; +} +const std::vector& overworld_matchers(){ + static const std::vector matchers = { + {"route204-north-approach", "Route 204 left of sign after Tyler", match_route204_north_approach}, + {"teighler-alcove-corner", "Teighler alcove bottom-left corner", match_teighler_alcove_corner}, + {"route204-north-stairs", "Route 204 north stairs junction", match_route204_north_stairs}, + {"floaroma-center-entrance", "Floaroma Pokemon Center entrance", match_floaroma_center_entrance}, + {"ravaged-path-south-entrance", "Ravaged Path south entrance", match_ravaged_path_south_entrance}, + {"route204-sign", "Route 204 sign before the next trainer", match_route204_sign}, + {"windworks-sign", "Valley Windworks west of the door grunt", match_windworks_sign_approach}, + {"route205-bridge", "Route 205 south of the bridge", match_route205_bridge}, + {"route205-bank-tree", "Route 205 north bank beside the golden tree", match_route205_bank_tree}, + {"route205-zackary", "Route 205 Zackary patrol intercept", match_route205_zackary}, + {"route205-kelsey", "Route 205 Kelsey patrol intercept", match_route205_kelsey}, + {"route205-forest", "Route 205 outside Eterna Forest", match_route205_forest_entrance}, + {"jubilife-street-approach", "Jubilife street before Galactic plaza", match_jubilife_street_approach}, + {"jubilife-plaza-approach", "Jubilife Galactic plaza approach", match_jubilife_plaza_approach}, + {"poketch-landmarks", "Jubilife Poketch landmarks", match_poketch_landmarks}, + {"oreburgh-mart", "Oreburgh Mart emblem", match_oreburgh_mart}, + {"jubilife-center", "Jubilife Pokemon Center entrance", match_jubilife_center_entrance}, + {"eterna-center", "Eterna Pokemon Center doorway", match_eterna_center_entrance}, + {"hearthome-center", "Hearthome Pokemon Center doorway", match_hearthome_center_entrance}, + {"hearthome-center-interior", "Hearthome Pokemon Center interior", match_hearthome_center_interior}, + {"hearthome-fantina", "Hearthome Contest Hall sign + roof emblem (Fantina)", match_hearthome_contest_fantina}, + {"veilstone-city-passage", "Veilstone city passage before the west turn", match_veilstone_city_passage}, + {"veilstone-dawn-stairs", "Veilstone western stairs toward Dawn", match_veilstone_dawn_stairs}, + {"hearthome-gym", "Hearthome Gym doorway (arch + facade)", match_hearthome_gym_entrance}, + {"route208-stair-top", "Route 208 stair-top rocks", match_route208_stair_top}, + {"route209-repel-point", "Route 209 Repel renewal point", match_route209_repel_point}, + {"route215-jogger-corner", "Route 215 east corner beyond the jogger", match_route215_jogger_corner}, + {"route215-bridge-renewal", "Route 215 west bridge renewal", match_route215_bridge_renewal}, + {"route215-ace-renewal", "Route 215 renewal before Ace Trainers", match_route215_ace_renewal}, + {"route215-derek-stairs-renewal", "Route 215 renewal below Derek stairs", match_route215_derek_stairs_renewal}, + {"route215-derek-stair-top-renewal", + "Route 215 renewal above Derek stairs", + match_route215_derek_stair_top_renewal}, + {"route208-hannah-departure", "Route 208 field after Hannah", match_route208_hannah_departure}, + {"eterna-gym", "Eterna Gym doorway", match_eterna_gym_entrance}, + {"galactic-hq-jupiter", "Galactic HQ Jupiter interaction", match_galactic_hq_jupiter}, + {"eterna-gym-statues", "Eterna Gym interior statues", match_eterna_gym_statues}, + {"eterna-underground-house", "Eterna Underground Man house doorway", match_eterna_underground_house}, + {"eterna-underground-man", "Eterna Underground Man interaction", match_eterna_underground_man}, + {"route201-potion-exit", "Route 201 after Potion pickup", match_route201_potion_exit}, + {"sandgem-junction", "Sandgem Center/Mart junction", match_sandgem_junction}, + {"route202-grass-exit", "Route 202 after catching: grass exit", match_route202_grass_exit}, + }; + return matchers; +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h new file mode 100644 index 0000000000..6377da938c --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include + +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" + +namespace PokemonAutomation { +namespace NintendoSwitch { +namespace PokemonBDSP { +struct OverworldLandmarkResult { + std::string name; + double score = 0, margin = 0; + // Signed fractions of screen width/height, independent of capture resolution. + double dx = 0, dy = 0; + bool accepted = false; + ImageFloatBox search, found; +}; +struct OverworldMatchResult { + std::vector landmarks; + bool located = false, at_target = false; + bool coarse = false; + // Signed fractions of screen width/height, independent of capture resolution. + double dx = 0, dy = 0; +}; +struct OverworldMatcher { + std::string slug, name; + std::function match; +}; +// Register additional functions here; each owns its landmarks and coordinate system. +const std::vector& overworld_matchers(); +OverworldMatchResult match_route204_north_approach(const ImageRGB32& frame); +OverworldMatchResult match_teighler_alcove_corner(const ImageRGB32& frame); +OverworldMatchResult match_route204_north_stairs(const ImageRGB32& frame); +OverworldMatchResult match_floaroma_center_entrance(const ImageRGB32& frame); +OverworldMatchResult match_floaroma_house_entrance(const ImageRGB32& frame); +OverworldMatchResult match_ravaged_path_south_entrance(const ImageRGB32& frame); +OverworldMatchResult match_route204_sign(const ImageRGB32& frame); +OverworldMatchResult match_windworks_sign_approach(const ImageRGB32& frame); +// Interior only: offsets from the player's red cap to the grunt's cyan hair. +// Positive dy > .025 means the player is above him; otherwise below/level. +OverworldMatchResult match_windworks_interior_grunt(const ImageRGB32& frame); +OverworldMatchResult match_route205_bridge(const ImageRGB32& frame); +OverworldMatchResult match_route205_bank_tree(const ImageRGB32& frame); +OverworldMatchResult match_route205_zackary(const ImageRGB32& frame); +OverworldMatchResult match_route205_kelsey(const ImageRGB32& frame); +OverworldMatchResult match_route205_forest_entrance(const ImageRGB32& frame); +// Northern sign: align primarily westward, then correct height below the flower bed. +OverworldMatchResult match_route204_floaroma_turn(const ImageRGB32& frame); +OverworldMatchResult match_jubilife_street_approach(const ImageRGB32& frame); +OverworldMatchResult match_jubilife_plaza_approach(const ImageRGB32& frame); +OverworldMatchResult match_poketch_landmarks(const ImageRGB32& frame); +OverworldMatchResult match_oreburgh_mart(const ImageRGB32& frame); +OverworldMatchResult match_jubilife_center_entrance(const ImageRGB32& frame); +OverworldMatchResult match_eterna_center_entrance(const ImageRGB32& frame); +OverworldMatchResult match_hearthome_center_entrance(const ImageRGB32& frame); +OverworldMatchResult match_hearthome_center_interior(const ImageRGB32& frame); +OverworldMatchResult match_hearthome_contest_fantina(const ImageRGB32& frame); +OverworldMatchResult match_veilstone_city_passage(const ImageRGB32& frame); +OverworldMatchResult match_veilstone_dawn_stairs(const ImageRGB32& frame); +OverworldMatchResult match_hearthome_gym_entrance(const ImageRGB32& frame); +OverworldMatchResult match_route208_stair_top(const ImageRGB32& frame); +OverworldMatchResult match_route208_lower_stair_lane(const ImageRGB32& frame); +// User-marked sign frame and rock pair at the Route 209 renewal point. +OverworldMatchResult match_route209_repel_point(const ImageRGB32& frame); +// Lane 4 east corner; coarse bridge references only guide approach acquisition. +OverworldMatchResult match_route215_jogger_corner(const ImageRGB32& frame); +OverworldMatchResult match_route215_bridge_renewal(const ImageRGB32& frame); +OverworldMatchResult match_route215_ace_renewal(const ImageRGB32& frame); +OverworldMatchResult match_route215_derek_stairs_renewal(const ImageRGB32& frame); +OverworldMatchResult match_route215_derek_stair_top_renewal(const ImageRGB32& frame); +OverworldMatchResult match_route208_hannah_departure(const ImageRGB32& frame); +OverworldMatchResult match_eterna_gym_entrance(const ImageRGB32& frame); +OverworldMatchResult match_galactic_hq_cut(const ImageRGB32& frame); +OverworldMatchResult match_galactic_hq_jupiter(const ImageRGB32& frame); +OverworldMatchResult match_eterna_cycle_shop_entrance(const ImageRGB32& frame); +OverworldMatchResult match_eterna_cycle_shop_interior(const ImageRGB32& frame); +OverworldMatchResult match_eterna_cycle_shop_reward(const ImageRGB32& frame); +OverworldMatchResult match_eterna_bike_riding_card(const ImageRGB32& frame); +OverworldMatchResult match_eterna_cycle_shop_counter(const ImageRGB32& frame); +OverworldMatchResult match_cycling_road_bridge_approach(const ImageRGB32& frame); +OverworldMatchResult match_cycling_road_south_gate_interior(const ImageRGB32& frame); +OverworldMatchResult match_galactic_hq_exterior(const ImageRGB32& frame); +OverworldMatchResult match_eterna_center_departure(const ImageRGB32& frame); +OverworldMatchResult match_eterna_gym_statues(const ImageRGB32& frame); +// Two unique, agreeing landmarks close to the doorway; this confidence only +// authorizes a watched entry attempt, never proves the interior location. +double overworld_entry_confidence(const OverworldMatchResult& result, + double tolerance_x = 1.0 / 50, + double tolerance_y = 1.0 / 27, + double minimum_margin = .15); +OverworldMatchResult match_eterna_underground_house(const ImageRGB32& frame); +OverworldMatchResult match_eterna_underground_man(const ImageRGB32& frame); +OverworldMatchResult match_route201_potion_exit(const ImageRGB32& frame); +OverworldMatchResult match_sandgem_junction(const ImageRGB32& frame); +OverworldMatchResult match_route202_grass_exit(const ImageRGB32& frame); +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/PokemonBDSP_GameNavigation.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/PokemonBDSP_GameNavigation.cpp index 35d8083ce7..a9f7635dbe 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/PokemonBDSP_GameNavigation.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/PokemonBDSP_GameNavigation.cpp @@ -5,6 +5,8 @@ */ #include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" #include "CommonTools/Async/InferenceRoutines.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "PokemonBDSP/PokemonBDSP_Settings.h" @@ -74,6 +76,12 @@ void overworld_to_menu(VideoStream& stream, ProControllerContext& context){ {{detector}} ); if (ret < 0){ + const VideoSnapshot snapshot = stream.video().snapshot(); + if (snapshot){ + stream.log(detector.debug_report(snapshot), COLOR_ORANGE); + }else{ + stream.log("MenuDetector report: no video frame available.", COLOR_ORANGE); + } OperationFailedExceptionWithScreenshot::fire( ErrorReportMode::SEND_ERROR_REPORT, "Menu not detected after 10 seconds.", diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.cpp new file mode 100644 index 0000000000..7a745f12cf --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.cpp @@ -0,0 +1,200 @@ +/* Overworld Matcher Lighting Test + * + * From: https://github.com/PokemonAutomation/ + */ + +#include +#include +#include +#include "Common/Cpp/Json/JsonObject.h" +#include +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/Filesystem/FileIO.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonBDSP_OverworldLightingTest.h" +#include "../AutoStory/Utils/PokemonBDSP_OverworldMatchers.h" +#include "../AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" +namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonBDSP{ +using namespace std::chrono_literals; +static const StringSelectDatabase& lighting_matcher_database(size_t segment){ + static const std::array databases=[](){ + // Include shared locations in each segment that visits them. + const std::array,10> slugs{{ + {"poketch-landmarks","jubilife-center","route201-potion-exit", + "sandgem-junction","route202-grass-exit"}, + {"oreburgh-mart"}, + {"oreburgh-mart","jubilife-street-approach","jubilife-plaza-approach", + "route204-north-approach","teighler-alcove-corner","route204-north-stairs", + "floaroma-center-entrance","ravaged-path-south-entrance","route204-sign", + "windworks-sign","route205-bridge","route205-bank-tree","route205-zackary", + "route205-kelsey","route205-forest","eterna-center","eterna-gym", + "eterna-gym-statues","eterna-underground-house","eterna-underground-man"}, + {"eterna-center","galactic-hq-jupiter","hearthome-center","hearthome-center-interior", + "hearthome-fantina","hearthome-gym","route208-stair-top", + "route208-hannah-departure","route209-repel-point","route215-jogger-corner", + "route215-bridge-renewal","route215-ace-renewal", + "route215-derek-stairs-renewal","route215-derek-stair-top-renewal", + "veilstone-city-passage","veilstone-dawn-stairs"}, + {}, + {"hearthome-gym"}, + {}, {}, {}, {} + }}; + std::array result; + for(size_t i=0;iSegment:",{ + {0,"00","00: Intro to Poketch"}, + {1,"01","01: Jubilife City to First Badge"}, + {2,"02","02: First Badge to Second Badge"}, + {3,"03","03: Second Badge to Third Badge"}, + {4,"04","04: Third Badge to Fourth Badge"}, + {5,"05","05: Fourth Badge to Fifth Badge"}, + {6,"06","06: Fifth Badge to Sixth Badge"}, + {7,"07","07: Sixth Badge to Seventh Badge"}, + {8,"08","08: Seventh Badge to Eighth Badge"}, + {9,"09","09: Eighth Badge to Champion"} + },LockMode::LOCK_WHILE_RUNNING,0) + , START_HOUR("Starting hour:
Establishes the initial time, then advances one hour for each of 24 samples.",LockMode::LOCK_WHILE_RUNNING,0,0,23) + , SETTLE_SECONDS("Wait after returning to game (seconds):",LockMode::LOCK_WHILE_RUNNING,5,1,60) +{ + PA_ADD_OPTION(SEGMENT); + for(size_t i=0;i("Landmark matcher:", + database,LockMode::LOCK_WHILE_RUNNING,i==0 ? "poketch-landmarks" : database[0].slug); + add_option(*MATCHERS[i],"MATCHER_"+std::to_string(i)); + } + PA_ADD_OPTION(START_HOUR); + PA_ADD_OPTION(SETTLE_SECONDS); + SEGMENT.add_listener(*this); + on_config_value_changed(nullptr); +} +void OverworldLightingTest::on_config_value_changed(void*){ + for(size_t i=0;iset_visibility(i!=SEGMENT.current_value() ? ConfigOptionState::HIDDEN : + MATCHERS[i]->slug()=="none" ? ConfigOptionState::DISABLED : ConfigOptionState::ENABLED); + } +} +std::string OverworldLightingTest::check_validity() const{ + const auto error=SingleSwitchProgramInstance::check_validity(); + if(!error.empty())return error; + if(MATCHERS[SEGMENT.current_value()]->slug()=="none") + return "The selected segment has no landmark matchers available."; + return {}; +} +void OverworldLightingTest::load_json(const JsonValue& json){ + SingleSwitchProgramInstance::load_json(json); + // Preserve the selected matcher from settings saved before segment filtering. + const auto* object=json.to_object(); + if(!object || object->get_value("SEGMENT"))return; + const auto* legacy=object->get_string("MATCHER"); + if(!legacy)return; + for(size_t i=0;iset_by_slug(*legacy); + SEGMENT.set_value(i); + return; + } + } +} +void OverworldLightingTest::program(SingleSwitchProgramEnvironment& env,ProControllerContext& context){ + const auto& slug=MATCHERS[SEGMENT.current_value()]->slug(); + const auto& matchers=overworld_matchers(); + const auto selected=std::find_if(matchers.begin(),matchers.end(), + [&slug](const OverworldMatcher& entry){return entry.slug==slug;}); + if(selected==matchers.end()) + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::NO_ERROR_REPORT, + "The selected segment has no landmark matcher available.",env.console); + const auto& matcher=*selected; + Filesystem::Path output=Filesystem::Path(USER_FILE_PATH())/"Screenshots/OverworldLightingTest"/ + (matcher.slug+"-"+std::to_string(std::time(nullptr))); + Filesystem::create_directories(output); + FileIO results(output/"results.csv",FileMode::WRITE); + std::ostringstream csv; + if(!results.is_open())OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::NO_ERROR_REPORT,"Cannot create lighting-test results file.",env.console); + csv<<"sample,hour,frame,landmark,score,margin,accepted,dx_screen,dy_screen,located,at_target,consensus_dx_screen,consensus_dy_screen,coarse\n"; + csv<save((output/name).string()))OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::NO_ERROR_REPORT,"Lighting test: Screenshot save failed.",env.console); + const std::string rows=csv.str(); + if(results.write(rows)!=rows.size() || !results.flush()){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::NO_ERROR_REPORT,"Lighting test: CSV write failed.",env.console); + } + csv.str(""); + csv.clear(); + pbf_wait(context,250ms);context.wait_for_all_requests(); + } + env.log("[Lighting test] Hour "+std::to_string(hour)+": "+ + std::to_string(located_this_hour)+"/5 located; "+std::to_string(target_this_hour)+ + "/5 at target; "+std::to_string(located_this_hour-target_this_hour)+ + "/5 off target; "+std::to_string(5-located_this_hour)+"/5 missing landmarks."); + } + env.log("[Lighting test] Complete: "+std::to_string(located_frames)+"/120 located; "+ + std::to_string(target_frames)+"/120 at target; "+ + std::to_string(located_frames-target_frames)+"/120 off target; "+ + std::to_string(120-located_frames)+"/120 missing landmarks. Results: "+output.string()); +} +}}} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.h b/SerialPrograms/Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.h new file mode 100644 index 0000000000..1a9b6456c2 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include "Common/Cpp/Options/EnumDropdownOption.h" +#include "Common/Cpp/Options/SimpleIntegerOption.h" +#include "CommonTools/Options/StringSelectOption.h" +#include "NintendoSwitch/NintendoSwitch_SingleSwitchProgram.h" +namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonBDSP{ +class OverworldLightingTest_Descriptor : public SingleSwitchProgramDescriptor{ +public: OverworldLightingTest_Descriptor(); +}; +class OverworldLightingTest : public SingleSwitchProgramInstance, public ConfigOption::Listener{ +public: + using Descriptor = OverworldLightingTest_Descriptor; + ~OverworldLightingTest(); + OverworldLightingTest(); + std::string check_validity() const override; + void load_json(const JsonValue& json) override; + void program(SingleSwitchProgramEnvironment& env, ProControllerContext& context) override; +private: + void on_config_value_changed(void* object) override; + IntegerEnumDropdownOption SEGMENT; + std::array,10> MATCHERS; + SimpleIntegerOption START_HOUR; + SimpleIntegerOption SETTLE_SECONDS; +}; +}}} diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 9e4e0895bb..cce6604a51 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1487,6 +1487,16 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/Resources/Pokemon_PokemonNames.h Source/Pokemon/Resources/Pokemon_PokemonSlugs.cpp Source/Pokemon/Resources/Pokemon_PokemonSlugs.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h + Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.cpp + Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.h + Source/PokemonBDSP/PokemonBDSP_Tests.cpp + Source/PokemonBDSP/PokemonBDSP_Tests.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.h Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleBallReader.cpp Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleBallReader.h Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.cpp @@ -1497,6 +1507,8 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Inference/Battles/PokemonBDSP_ExperienceGainDetector.h Source/PokemonBDSP/Inference/Battles/PokemonBDSP_StartBattleDetector.cpp Source/PokemonBDSP/Inference/Battles/PokemonBDSP_StartBattleDetector.h + Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleOpponentNameReader.cpp + Source/PokemonBDSP/Inference/Battles/PokemonBDSP_BattleOpponentNameReader.h Source/PokemonBDSP/Inference/BoxSystem/PokemonBDSP_BoxDetector.cpp Source/PokemonBDSP/Inference/BoxSystem/PokemonBDSP_BoxDetector.h Source/PokemonBDSP/Inference/BoxSystem/PokemonBDSP_BoxGenderDetector.cpp @@ -1513,6 +1525,8 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Inference/PokemonBDSP_MapDetector.h Source/PokemonBDSP/Inference/PokemonBDSP_MarkFinder.cpp Source/PokemonBDSP/Inference/PokemonBDSP_MarkFinder.h + Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.cpp + Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h Source/PokemonBDSP/Inference/PokemonBDSP_PokeballSpriteMatcher.cpp @@ -1521,6 +1535,12 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Inference/PokemonBDSP_ReceivePokemonDetector.h Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.cpp Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h + Source/PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.cpp + Source/PokemonBDSP/Inference/PokemonBDSP_ShopDetectors.h + Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemNameReader.cpp + Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemNameReader.h + Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemPriceReader.cpp + Source/PokemonBDSP/Inference/PokemonBDSP_ShopItemPriceReader.h Source/PokemonBDSP/Inference/PokemonBDSP_VSSeekerReaction.cpp Source/PokemonBDSP/Inference/PokemonBDSP_VSSeekerReaction.h Source/PokemonBDSP/Inference/Rng/PokemonBDSP_BlinkExtraction.cpp @@ -1563,8 +1583,42 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/PokemonBDSP_Panels.h Source/PokemonBDSP/PokemonBDSP_Settings.cpp Source/PokemonBDSP/PokemonBDSP_Settings.h - Source/PokemonBDSP/PokemonBDSP_Tests.cpp - Source/PokemonBDSP/PokemonBDSP_Tests.h + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_BDSPData.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_02.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_02.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_03.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_03.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_04.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_04.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_05.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_05.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_06.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_06.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_07.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_07.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_08.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_08.h + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_09.cpp + Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_09.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Battle.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Dialog.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Dialog.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Navigation.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Navigation.h + Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_MenuCursorDetector.cpp + Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_MenuCursorDetector.h + Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.cpp + Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h Source/PokemonBDSP/Programs/Eggs/PokemonBDSP_EggAutonomous.cpp Source/PokemonBDSP/Programs/Eggs/PokemonBDSP_EggAutonomous.h Source/PokemonBDSP/Programs/Eggs/PokemonBDSP_EggAutonomousState.cpp