diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_PcScreenReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_PcScreenReader.cpp new file mode 100644 index 0000000000..8368700143 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_PcScreenReader.cpp @@ -0,0 +1,291 @@ +/* PC Screen Reader + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonTools/Images/SolidColorTest.h" +#include "Tests/ImageUnitTest.h" +#include "Tests/TestUtils.h" +#include "VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_BoxCellOccupiedDetector.h" +#include "VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_BoxSelectionModeDetector.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_StartMenuDetector.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_DigitReader.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" +#include "PokemonFRLG_PcScreenReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// pokefirered: TOTAL_BOXES_COUNT, IN_BOX_COLUMNS. +constexpr size_t BOX_COUNT = 14; +constexpr size_t BOX_COLUMNS = 6; +// The party panel: the lead, five slots, CANCEL. +constexpr size_t PARTY_PANEL_ROWS = 7; + +ImageFloatBox gba(double x, double y, double width, double height){ + return ImageFloatBox(x / 240, y / 160, width / 240, height / 160); +} + +// Patches measured on the captures (see the header), each plain on its +// screen: inside the PC list's window right of LOG OFF; inside the +// storage menu's window past the list's; the PKMN DATA panel's frame +// (151, 155, 182); a Pokemon's menu right of MARK; the YES/NO box above +// YES; the box chooser's yellow (255, 223, 94) right of the box's name; +// the party panel's teal (50, 157, 160) below the lead; the message box +// under its second line. +const ImageFloatBox PC_LIST_PATCH = gba(100, 58, 20, 14); +const ImageFloatBox STORAGE_MENU_PATCH = gba(128, 58, 18, 30); +const ImageFloatBox DATA_PANEL_PATCH = gba(3, 100, 4, 40); +const ImageFloatBox MON_MENU_PATCH = gba(205, 72, 25, 14); +const ImageFloatBox YES_NO_PATCH = gba(200, 86, 32, 4); +const ImageFloatBox CHOOSER_PATCH = gba(160, 68, 30, 18); +const ImageFloatBox PARTY_PANEL_PATCH = gba(122, 96, 12, 36); +const ImageFloatBox MESSAGE_PATCH = gba(10, 148, 160, 4); +// The box chooser's box number, after "BOX". +const ImageFloatBox CHOOSER_BOX_NUMBER = gba(144, 74, 16, 12); + +// The share of an arrow's box an arrow darkens (0.31 to 0.37 measured; +// text and frames nearby under 0.25). +constexpr double ARROW_DARK = 0.25; +// The share of a party slot's hand area the hand's white covers (0.10 to +// 0.16 measured, the top right slot's cut by the screen's edge; a +// Pokemon's icon at most 0.03). +constexpr double HAND_WHITE = 0.06; +// The game screen's average under this (of 765): a fade. +constexpr double DARK_SCREEN = 150; + +double dark_share(const ImageViewRGB32& game, const ImageFloatBox& box){ + const ImageViewRGB32 image = extract_box_reference(game, box); + size_t dark = 0; + for (size_t y = 0; y < image.height(); y++){ + for (size_t x = 0; x < image.width(); x++){ + const Color pixel(image.pixel(x, y)); + dark += pixel.red() + pixel.green() + pixel.blue() < 330 ? 1 : 0; + } + } + const size_t area = image.width() * image.height(); + return area == 0 ? 0 : double(dark) / double(area); +} + +double white_share(const ImageViewRGB32& game, const ImageFloatBox& box){ + const ImageViewRGB32 image = extract_box_reference(game, box); + size_t white = 0; + for (size_t y = 0; y < image.height(); y++){ + for (size_t x = 0; x < image.width(); x++){ + const Color pixel(image.pixel(x, y)); + const uint8_t low = std::min({pixel.red(), pixel.green(), pixel.blue()}); + const uint8_t high = std::max({pixel.red(), pixel.green(), pixel.blue()}); + white += low >= 235 && high - low < 20 ? 1 : 0; + } + } + const size_t area = image.width() * image.height(); + return area == 0 ? 0 : double(white) / double(area); +} + +// The one row of `rows` whose arrow box (x, y + `step` * row) holds an +// arrow; nothing if none or several do. +std::optional arrow_row(const ImageViewRGB32& game, double x, double y, double step, size_t rows){ + std::optional found; + for (size_t row = 0; row < rows; row++){ + if (dark_share(game, gba(x, y + step * double(row), 7, 10)) < ARROW_DARK){ + continue; + } + if (found){ + return std::nullopt; + } + found = row; + } + return found; +} + +// The party panel's slot under the hand: the lead's hand area above its +// box; a right slot's above and on its box, 24 apart (the top one cut by +// the screen's top); CANCEL's above CANCEL. +std::optional party_panel_cursor(const ImageViewRGB32& game){ + std::vector areas{gba(92, 36, 28, 26)}; + for (size_t slot = 1; slot < PARTY_PANEL_ROWS - 1; slot++){ + const double bottom = 24.0 * double(slot - 1) + 11; + const double top = std::max(0.0, bottom - 25); + areas.push_back(gba(138, top, 28, bottom - top)); + } + areas.push_back(gba(138, 118, 28, 24)); + std::optional best; + double best_share = HAND_WHITE; + for (size_t slot = 0; slot < areas.size(); slot++){ + const double share = white_share(game, areas[slot]); + if (share >= best_share){ + best_share = share; + best = slot; + } + } + return best; +} + +PcView box_screen(Logger& logger, const ImageViewRGB32& screen, const ImageViewRGB32& game){ + if (is_white(extract_box_reference(game, MON_MENU_PATCH))){ + return {.screen = PcScreen::MON_MENU, .cursor = arrow_row(game, 159, 44, 16, 5)}; + } + if (is_white(extract_box_reference(game, YES_NO_PATCH))){ + return {.screen = PcScreen::YES_NO, .cursor = arrow_row(game, 191, 91, 14, 2)}; + } + if (is_solid(extract_box_reference(game, CHOOSER_PATCH), {0.445, 0.390, 0.165}, 0.08, 30)){ + // The chooser's yellow is the stat boxes' (DigitTemplateType::StatBox). + const int number = read_digits_waterfill_template( + logger, extract_box_reference(game, CHOOSER_BOX_NUMBER), DigitTemplateType::StatBox + ); + PcView view{.screen = PcScreen::BOX_CHOOSER}; + if (number >= 1 && size_t(number) <= BOX_COUNT){ + view.box = size_t(number - 1); + } + return view; + } + if (is_solid(extract_box_reference(game, PARTY_PANEL_PATCH), {0.137, 0.426, 0.437}, 0.10, 80)){ + return {.screen = PcScreen::PARTY_PANEL, .cursor = party_panel_cursor(game)}; + } + PcView view{.screen = PcScreen::BOX_GRID}; + const std::optional selection = BoxSelectionModeDetector(COLOR_RED).detect(screen); + if (selection && selection->location == BoxSelectionLocation::Grid){ + view.cursor = selection->row * BOX_COLUMNS + selection->column; + view.occupied = BoxCellOccupiedDetector(COLOR_RED).detect(screen); + } + return view; +} + +} + + +const char* pc_screen_name(PcScreen screen){ + switch (screen){ + case PcScreen::OVERWORLD: return "overworld"; + case PcScreen::TEXT: return "text"; + case PcScreen::PC_LIST: return "PC list"; + case PcScreen::STORAGE_MENU: return "storage menu"; + case PcScreen::PARTY_PANEL: return "party panel"; + case PcScreen::BOX_GRID: return "box grid"; + case PcScreen::MON_MENU: return "Pokemon's menu"; + case PcScreen::BOX_CHOOSER: return "box chooser"; + case PcScreen::YES_NO: return "YES/NO"; + case PcScreen::OTHER: return "other"; + } + return "?"; +} + +PcView read_pc_screen(Logger& logger, const ImageViewRGB32& screen){ + const ImageViewRGB32 game = extract_box_reference(screen, GameSettings::instance().GAME_BOX); + if (is_solid(extract_box_reference(game, DATA_PANEL_PATCH), {0.311, 0.319, 0.370}, 0.06, 80)){ + return box_screen(logger, screen, game); + } + if (is_white(extract_box_reference(game, PC_LIST_PATCH))){ + const bool storage = is_white(extract_box_reference(game, STORAGE_MENU_PATCH)); + const std::optional cursor = arrow_row(game, 9, 12, 16, storage ? 5 : 4); + if (cursor){ + return {.screen = storage ? PcScreen::STORAGE_MENU : PcScreen::PC_LIST, .cursor = cursor}; + } + } + if (is_white(extract_box_reference(game, MESSAGE_PATCH), 500, 40)){ + return {.screen = PcScreen::TEXT}; + } + if (StartMenuDetector(COLOR_RED).detect(screen) || image_stats(game).average.sum() < DARK_SCREEN){ + return {}; + } + return {.screen = PcScreen::OVERWORLD}; +} + + +namespace{ + +std::string screen_slug(PcScreen screen){ + switch (screen){ + case PcScreen::OVERWORLD: return "overworld"; + case PcScreen::TEXT: return "text"; + case PcScreen::PC_LIST: return "pc-list"; + case PcScreen::STORAGE_MENU: return "storage-menu"; + case PcScreen::PARTY_PANEL: return "party-panel"; + case PcScreen::BOX_GRID: return "box-grid"; + case PcScreen::MON_MENU: return "mon-menu"; + case PcScreen::BOX_CHOOSER: return "box-chooser"; + case PcScreen::YES_NO: return "yes-no"; + case PcScreen::OTHER: return "other"; + } + return "?"; +} + +std::string number_slug(const std::optional& value){ + return value ? std::to_string(*value) : "none"; +} + +// A capture named ___, the cursor and the +// box from 0 or "none", e.g. StorageDeposit_storage-menu_1_none.png. +class Test_PcScreen : public ImageUnitTest{ +public: + Test_PcScreen(const std::string& image) + : ImageUnitTest("PokemonFRLG::PcScreenReader - " + image, {image}) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + const std::vector words = parse_words(Filesystem::Path(image_paths()[0]).stem().string()); + if (words.size() < 4){ + return "Error: filename must be ___."; + } + const PcView view = read_pc_screen(logger, images[0]); + TEST_RESULT_COMPONENT_EQUAL_STR(screen_slug(view.screen), words[words.size() - 3], "screen"); + TEST_RESULT_COMPONENT_EQUAL_STR(number_slug(view.cursor), words[words.size() - 2], "cursor"); + TEST_RESULT_COMPONENT_EQUAL_STR(number_slug(view.box), words.back(), "box"); + return true; + } +}; + +} + +void add_tests_PcScreenReader(UnitTestDatabase& database){ + // FireRed on console B, 2026-11-02, Route 4's Pokemon Center: a deposit + // of the Magikarp (party slot 3), then of the Rattata (slot 2). A press + // sent before a menu was drawn went to the message behind it, so the + // messages must not read as the menus they come before. + for (const char* image : { + "PcBoot_text_none_none.png", + "PcList_pc-list_0_none.png", + "PcAccessed_text_none_none.png", + "StorageOpened_text_none_none.png", + "StorageWithdraw_storage-menu_0_none.png", + "StorageDeposit_storage-menu_1_none.png", + "PartyLead_party-panel_0_none.png", + "PartySlot3_party-panel_2_none.png", + // The step presses A only on this menu's top row: RELEASE is on it. + "MagikarpMenu_mon-menu_0_none.png", + "RattataMenu_mon-menu_0_none.png", + "DepositWhichBox_box-chooser_none_0.png", + "MagikarpStored_party-panel_2_none.png", + "RattataStored_party-panel_1_none.png", + "ContinueDeposit_yes-no_1_none.png", + // WITHDRAW's box grid, the hand on slot 0: it is drawn above the + // Pokemon it points at. + "WithdrawBox_box-grid_0_none.png", + "ContinueWithdraw_yes-no_1_none.png", + "Overworld_overworld_none_none.png", + "StartMenu_other_none_none.png", + }){ + database.add(std::string("PokemonFRLG/PcScreenReader/") + image); + } +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_PcScreenReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_PcScreenReader.h new file mode 100644 index 0000000000..ec8bcf03f1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_PcScreenReader.h @@ -0,0 +1,88 @@ +/* PC Screen Reader + * + * From: https://github.com/PokemonAutomation/ + * + * Tells which screen of a Pokemon Center's PC shows, and where its cursor + * is, from one frame. Made from captures of FireRed on console B + * (2026-11-02, Route 4's Pokemon Center), in GBA pixels: + * + * - The PC list ("Which PC should be accessed?") and the storage menu + * (WITHDRAW ... SEE YA!) are windows at the top left, their arrow at x 9 + * and rows 16 apart from y 12; the list's window ends at x 126, the + * menu's at x 150. Both have the white message box below them. + * - The box screens have the PKMN DATA panel at the left. DEPOSIT shows + * the party in a teal column (the lead at the left, five slots at the + * right, CANCEL below), the hand over the slot it points at; WITHDRAW + * shows the box's grid. A Pokemon's menu (STORE or WITHDRAW, SUMMARY, + * MARK, RELEASE, CANCEL) opens at (154, 34), its arrow at x 160, rows + * 16 apart from y 44; "Deposit in which BOX?" a yellow box with the + * box's name; "Continue BOX operations?" a YES/NO at (188, 85). + * + * A message box with nothing else is TEXT; a frame with none of these, no + * START menu and not dark is the OVERWORLD. A screen between two of them + * can read as the overworld: the caller confirms it on a later frame. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_PcScreenReader_H +#define PokemonAutomation_PokemonFRLG_PcScreenReader_H + +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// What shows during a PC step, as far as its decisions go. +enum class PcScreen{ + // The overworld, with no message. + OVERWORLD, + // A message waiting for a button: the PC booting, "Accessed ...". + TEXT, + // "Which PC should be accessed?": SOMEONE'S PC on top. + PC_LIST, + // WITHDRAW, DEPOSIT, MOVE POKEMON, MOVE ITEMS, SEE YA!. + STORAGE_MENU, + // DEPOSIT's box screen: the hand on the party panel (0-5, 6 CANCEL). + PARTY_PANEL, + // WITHDRAW's box screen: the hand on the box (0-29, row by row). + BOX_GRID, + // A Pokemon's menu: STORE or WITHDRAW, SUMMARY, MARK, RELEASE, CANCEL. + MON_MENU, + // "Deposit in which BOX?". + BOX_CHOOSER, + // "Continue BOX operations?". + YES_NO, + // Anything else: a fade, a frame between two screens. + OTHER, +}; +const char* pc_screen_name(PcScreen screen); + +struct PcView{ + PcScreen screen = PcScreen::OTHER; + // The cursor's row (a menu, the party panel; YES/NO: 0 YES, 1 NO) or + // cell (the box grid), if read. + std::optional cursor = std::nullopt; + // BOX_GRID, BOX_CHOOSER: the box shown (from 0), if read. + std::optional box = std::nullopt; + // BOX_GRID: whether the cell under the hand holds a Pokemon, if read. + std::optional occupied = std::nullopt; +}; + +// The PC's screen on `screen`, with its cursor where it can be read. +PcView read_pc_screen(Logger& logger, const ImageViewRGB32& screen); + + +void add_tests_PcScreenReader(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index b6428c1f00..8ab074b5ee 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -13,6 +13,7 @@ #include "Inference/Box/PokemonFRLG_BoxCellReader.h" #include "Inference/Box/PokemonFRLG_BoxGenderReader.h" #include "Inference/Box/PokemonFRLG_BoxSelectionModeDetector.h" +#include "Inference/Box/PokemonFRLG_PcScreenReader.h" #include "Inference/Dialogs/PokemonFRLG_BattleDialogs.h" #include "Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "Inference/Dialogs/PokemonFRLG_PrizeSelectDetector.h" @@ -41,6 +42,8 @@ #include "Programs/PokemonFRLG_NamingScreenKeyboard.h" #include "PokemonFRLG_Navigation.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h" +#include "Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h" +#include "Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryTable.h" #include "Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.h" @@ -701,6 +704,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_BoxCellReader(database); add_tests_BoxGenderReader(database); add_tests_BoxSelectionModeDetector(database); + add_tests_PcScreenReader(database); add_tests_SelectionDialogDetector(database); add_tests_ShinySymbolDetector(database); add_tests_PrizeSelectDetector(database); @@ -731,6 +735,8 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStorySimulation(database); add_tests_AutoStoryItems(database); add_tests_AutoStoryRngLoop(database); + add_tests_AutoStoryCapture(database); + add_tests_AutoStoryPcStorage(database); add_tests_NamingCursor(database); add_tests_NamingPlan(database); add_tests_NamingKeys(database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp new file mode 100644 index 0000000000..7bb032b45a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp @@ -0,0 +1,593 @@ +/* AutoStory Capture + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/ProgramFinishedException.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_BattleDialogs.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_BagDetector.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_WildEncounterReader.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_BattleMenuNavigation.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryCapture.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace std::chrono_literals; +using Pokemon::Direction; + + +namespace{ + +constexpr size_t PARTY_SIZE = 6; +constexpr size_t MOVE_COUNT = 4; + +std::vector words_of(std::string_view text){ + std::vector words; + while (!text.empty()){ + const size_t space = text.find(' '); + const std::string_view word = text.substr(0, space); + if (!word.empty()){ + words.push_back(word); + } + if (space == std::string_view::npos){ + break; + } + text.remove_prefix(space + 1); + } + return words; +} + +std::optional number_of(std::string_view text){ + if (text.empty() || text.size() > 4){ + return std::nullopt; + } + size_t value = 0; + for (char c : text){ + if (c < '0' || c > '9'){ + return std::nullopt; + } + value = value * 10 + size_t(c - '0'); + } + return value; +} + +std::optional direction_of(std::string_view text){ + for (Direction direction : Pokemon::ALL_DIRECTIONS){ + if (text == direction_name(direction)){ + return direction; + } + } + return std::nullopt; +} + +Direction opposite(Direction direction){ + switch (direction){ + case Direction::NORTH: return Direction::SOUTH; + case Direction::EAST: return Direction::WEST; + case Direction::SOUTH: return Direction::NORTH; + case Direction::WEST: return Direction::EAST; + } + return direction; +} + +} + + +const char* destination_name(CatchDestination destination){ + switch (destination){ + case CatchDestination::PARTY: return "party"; + case CatchDestination::PC: return "PC"; + } + return "?"; +} + +const char* capture_end_name(CaptureEnd end){ + switch (end){ + case CaptureEnd::CAUGHT: return "caught"; + case CaptureEnd::OUT_OF_ENCOUNTERS: return "out_of_encounters"; + case CaptureEnd::OUT_OF_BALLS: return "out_of_balls"; + case CaptureEnd::NO_ENCOUNTER: return "no_encounter"; + case CaptureEnd::SHINY: return "shiny"; + case CaptureEnd::PLAYER_FAINTED: return "player_fainted"; + case CaptureEnd::FAILED: return "failed"; + } + return "?"; +} + + +std::string capture_variant(const CapturePlan& plan){ + std::string variant = std::format( + "{}{} catches={} encounters={} balls={} pace={}", + CAPTURE_VARIANT_PREFIX, plan.species, plan.catches, plan.max_encounters, plan.max_balls, direction_name(plan.pace) + ); + if (plan.weaken_move){ + variant += std::format(" move={} turns={}", *plan.weaken_move + 1, plan.max_weaken_turns); + } + return variant; +} + +std::optional parse_capture_variant(std::string_view variant){ + if (!variant.starts_with(CAPTURE_VARIANT_PREFIX)){ + return std::nullopt; + } + const std::vector words = words_of(variant.substr(CAPTURE_VARIANT_PREFIX.size())); + if (words.empty() || words[0].find('=') != std::string_view::npos){ + return std::nullopt; + } + CapturePlan plan; + plan.species = std::string(words[0]); + for (size_t c = 1; c < words.size(); c++){ + const size_t equals = words[c].find('='); + if (equals == std::string_view::npos){ + return std::nullopt; + } + const std::string_view key = words[c].substr(0, equals); + const std::string_view value = words[c].substr(equals + 1); + if (key == "pace"){ + const std::optional pace = direction_of(value); + if (!pace){ + return std::nullopt; + } + plan.pace = *pace; + continue; + } + const std::optional number = number_of(value); + if (!number){ + return std::nullopt; + } + if (key == "catches" && *number >= 1){ + plan.catches = *number; + }else if (key == "encounters" && *number >= 1){ + plan.max_encounters = *number; + }else if (key == "balls" && *number >= 1){ + plan.max_balls = *number; + }else if (key == "move" && *number >= 1 && *number <= MOVE_COUNT){ + plan.weaken_move = *number - 1; + }else if (key == "turns"){ + plan.max_weaken_turns = *number; + }else{ + return std::nullopt; + } + } + return plan; +} + +Pokemon::StoryStep capture_step(std::string name, std::string why, const CapturePlan& plan){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = std::move(name); + step.why = std::move(why); + step.variant = capture_variant(plan); + return step; +} + +Pokemon::StoryStep pc_step(std::string name, std::string why, const PcTransfer& transfer){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = std::move(name); + step.why = std::move(why); + step.variant = pc_variant(transfer); + return step; +} + + +namespace{ + +// What a turn of the target's battle left. +enum class Turn{ + AGAIN, + // The battle is over; the capture goes on. + BATTLE_OVER, + // The capture is over: its result is set. + CAPTURE_OVER, +}; + +// One capture under way: its counts, and the battles it plays. +class CaptureRun{ +public: + CaptureRun(Logger& logger, CaptureActions& actions, const CapturePlan& plan, size_t party_size) + : m_logger(logger) + , m_actions(actions) + , m_plan(plan) + , m_party(party_size) + {} + + CaptureResult run(){ + m_logger.log(std::format("Capture: {} {}, party of {}.", m_plan.catches, m_plan.species, m_party)); + while (m_result.caught.size() < m_plan.catches){ + if (!next_encounter() || !battle()){ + return m_result; + } + } + m_result.end = CaptureEnd::CAUGHT; + return m_result; + } + +private: + // Ends the capture as `end`. False, for the caller to return. + bool end(CaptureEnd end, std::string detail = ""){ + m_result.end = end; + m_result.detail = std::move(detail); + return false; + } + // RUN, then ends the capture as `end`. + bool run_and_end(CaptureEnd end, std::string detail = ""){ + if (!m_actions.flee()){ + return this->end(CaptureEnd::FAILED, "couldn't run from " + m_plan.species); + } + return this->end(end, std::move(detail)); + } + + // Into the next wild battle, if the bounds allow one. False if the + // capture ends. + bool next_encounter(){ + if (m_result.encounters >= m_plan.max_encounters){ + return end(CaptureEnd::OUT_OF_ENCOUNTERS); + } + if (m_result.balls_thrown >= m_plan.max_balls){ + return end(CaptureEnd::OUT_OF_BALLS); + } + if (!m_actions.find_encounter(m_plan.pace)){ + return end(CaptureEnd::NO_ENCOUNTER); + } + m_result.encounters++; + return true; + } + + // One wild battle, from its start to the overworld. False if the + // capture ends. + bool battle(){ + const EncounterSeen seen = m_actions.read_encounter(); + if (seen.shiny){ + // Nothing more is pressed: a person decides. + m_logger.log("Capture: a shiny! Stopping in its battle.", COLOR_BLUE); + return end(CaptureEnd::SHINY, seen.species); + } + if (seen.species == m_plan.species){ + m_logger.log(std::format("Capture: encounter {}: {}.", m_result.encounters, seen.species)); + return target_battle(); + } + const std::string species = seen.species.empty() ? "an unread species" : seen.species; + m_logger.log(std::format("Capture: encounter {}: {}, running.", m_result.encounters, species)); + if (!m_actions.flee()){ + return end(CaptureEnd::FAILED, "couldn't run from " + species); + } + return true; + } + + // The target's battle, from the battle menu: the weakening move while + // its HP bar is green, so the move can't knock it out, then balls. + // False if the capture ends. + bool target_battle(){ + size_t weakened = 0; + for (size_t turn = 0; turn < CAPTURE_MAX_TURNS; turn++){ + if (m_result.balls_thrown >= m_plan.max_balls){ + return run_and_end(CaptureEnd::OUT_OF_BALLS); + } + const bool weaken = m_plan.weaken_move && weakened < m_plan.max_weaken_turns + && m_actions.read_foe_hp() == FoeHp::GREEN; + weakened += weaken ? 1 : 0; + const Turn end = weaken ? attack() : throw_ball(); + if (end != Turn::AGAIN){ + return end == Turn::BATTLE_OVER; + } + } + return run_and_end(CaptureEnd::FAILED, std::format("not caught in {} turns", CAPTURE_MAX_TURNS)); + } + + Turn attack(){ + switch (m_actions.attack(*m_plan.weaken_move)){ + case TurnEnd::MENU: + return Turn::AGAIN; + case TurnEnd::FOE_FAINTED: + m_logger.log("Capture: the target fainted.", COLOR_RED); + return Turn::BATTLE_OVER; + case TurnEnd::PLAYER_FAINTED: + end(CaptureEnd::PLAYER_FAINTED); + return Turn::CAPTURE_OVER; + case TurnEnd::FAILED: + break; + } + end(CaptureEnd::FAILED, "the turn didn't end at the battle menu"); + return Turn::CAPTURE_OVER; + } + + Turn throw_ball(){ + switch (m_actions.throw_ball()){ + case ThrowEnd::CAUGHT: + m_result.balls_thrown++; + return caught(); + case ThrowEnd::BROKE_FREE: + m_result.balls_thrown++; + return Turn::AGAIN; + case ThrowEnd::NO_BALLS: + run_and_end(CaptureEnd::OUT_OF_BALLS, "the bag has no Poke Ball"); + return Turn::CAPTURE_OVER; + case ThrowEnd::PLAYER_FAINTED: + end(CaptureEnd::PLAYER_FAINTED); + return Turn::CAPTURE_OVER; + case ThrowEnd::FAILED: + break; + } + end(CaptureEnd::FAILED, "the throw didn't end in a catch or at the battle menu"); + return Turn::CAPTURE_OVER; + } + + // To the party, or to the PC when the party is full. + Turn caught(){ + const CatchDestination destination = m_party < PARTY_SIZE ? CatchDestination::PARTY : CatchDestination::PC; + if (!m_actions.finish_catch(destination)){ + end(CaptureEnd::FAILED, "the overworld didn't come back after the catch"); + return Turn::CAPTURE_OVER; + } + m_party += destination == CatchDestination::PARTY ? 1 : 0; + m_result.caught.push_back(destination); + m_logger.log(std::format("Capture: caught {}, to the {}.", m_plan.species, destination_name(destination))); + return Turn::BATTLE_OVER; + } + + Logger& m_logger; + CaptureActions& m_actions; + const CapturePlan& m_plan; + size_t m_party; + CaptureResult m_result; +}; + +} + +CaptureResult run_capture(Logger& logger, CaptureActions& actions, const CapturePlan& plan, size_t party_size){ + return CaptureRun(logger, actions, plan, party_size).run(); +} + + +std::string capture_message(const CapturePlan& plan, const CaptureResult& result){ + const std::string tally = std::format( + "{} encounters, {} balls thrown, {} of {} caught", + result.encounters, result.balls_thrown, result.caught.size(), plan.catches + ); + switch (result.end){ + case CaptureEnd::CAUGHT:{ + std::string where; + for (CatchDestination destination : result.caught){ + where += (where.empty() ? "" : ", ") + std::string(destination_name(destination)); + } + return std::format("Capture: caught {} {} ({}): {}.", plan.catches, plan.species, where, tally); + } + case CaptureEnd::SHINY: + return std::format( + "Capture: a shiny {} in encounter {}. Stopped in its battle without running, for you to decide: " + "catch it or run by hand, then save.", + result.detail.empty() ? "Pokemon" : result.detail, result.encounters + ); + case CaptureEnd::OUT_OF_ENCOUNTERS: + return std::format("Capture of {}: out of encounters: {}.", plan.species, tally); + case CaptureEnd::OUT_OF_BALLS: + return std::format( + "Capture of {}: out of balls{}: {}.", + plan.species, result.detail.empty() ? "" : " (" + result.detail + ")", tally + ); + case CaptureEnd::NO_ENCOUNTER: + return std::format("Capture of {}: no wild battle started: {}.", plan.species, tally); + case CaptureEnd::PLAYER_FAINTED: + return std::format("Capture of {}: the player's Pokemon fainted: {}.", plan.species, tally); + case CaptureEnd::FAILED: + return std::format("Capture of {}: {}: {}.", plan.species, result.detail, tally); + } + return "Capture: ?"; +} + + +std::optional run_storage_game_step( + Logger& logger, VideoStream* stream, const Pokemon::StoryStep& step, + CaptureActions& capture, PcStorageActions& pc +){ + if (step.variant.starts_with(CAPTURE_VARIANT_PREFIX)){ + const std::optional plan = parse_capture_variant(step.variant); + if (!plan){ + return Pokemon::outcome_failed("bad_variant", step.variant); + } + const CaptureResult result = run_capture(logger, capture, *plan, pc.read_party().size()); + const std::string message = capture_message(*plan, result); + logger.log(message, result.end == CaptureEnd::CAUGHT ? COLOR_BLACK : COLOR_RED); + if (result.end == CaptureEnd::SHINY){ + // Not a failed Outcome, which would reload the save. + if (stream != nullptr){ + throw ProgramFinishedException(message, *stream); + } + throw ProgramFinishedException(message); + } + if (result.end == CaptureEnd::CAUGHT){ + return Pokemon::outcome_ok(); + } + return Pokemon::outcome_failed(std::string("capture_") + capture_end_name(result.end), message); + } + if (step.variant.starts_with(PC_VARIANT_PREFIX)){ + const std::optional transfer = parse_pc_variant(step.variant); + if (!transfer){ + return Pokemon::outcome_failed("bad_variant", step.variant); + } + const PcResult result = run_pc_transfer(logger, pc, *transfer); + switch (result.end){ + case PcEnd::DONE: + case PcEnd::SKIPPED: + return Pokemon::outcome_ok(); + case PcEnd::REFUSED: + return Pokemon::outcome_failed("pc_refused", result.detail); + case PcEnd::FAILED: + return Pokemon::outcome_failed("pc_failed", result.detail); + } + } + return std::nullopt; +} + + +ConsoleCaptureActions::ConsoleCaptureActions(ConsoleHandle& console, ProControllerContext& context, Language language) + : m_console(console) + , m_context(context) + , m_language(language) +{} + +bool ConsoleCaptureActions::find_encounter(Direction pace){ + // Steps in the patch before giving up: about 10 % of steps in grass + // start a battle at Route 4's rates. + constexpr size_t MAX_STEPS = 200; + std::optional facing; + Direction way = pace; + for (size_t c = 0; c < MAX_STEPS; c++){ + BattleFlashWatcher battle(COLOR_RED); + const int ret = run_until( + m_console, m_context, + [&](ProControllerContext& context){ + step_once(context, way, facing); + }, + {battle} + ); + if (ret == 0){ + return true; + } + facing = way; + way = opposite(way); + } + return false; +} + +EncounterSeen ConsoleCaptureActions::read_encounter(){ + EncounterSeen seen; + // The shiny sound plays as the wild Pokemon slides in; the lead is + // sent out only if none was heard. + seen.shiny = handle_encounter(m_console, m_context, true); + if (seen.shiny){ + return seen; + } + m_context.wait_for_all_requests(); + VideoSnapshot screen = m_console.video().snapshot(); + seen.species = WildEncounterReader(COLOR_RED).read_encounter(m_console.logger(), m_language, screen, {}).name; + return seen; +} + +bool ConsoleCaptureActions::flee(){ + try{ + flee_battle(m_console, m_context); + return true; + }catch (OperationFailedException& e){ + m_console.log("Capture: " + e.message(), COLOR_RED); + return false; + } +} + +FoeHp ConsoleCaptureActions::read_foe_hp(){ + // No reader for the wild Pokemon's HP bar yet. + return FoeHp::UNKNOWN; +} + +TurnEnd ConsoleCaptureActions::attack(size_t move){ + // Only called with the HP bar read green, which the console can't read + // yet (read_foe_hp()): not run on the console. + if (!move_cursor_to_option(m_console, m_context, BattleMenuOption::FIGHT)){ + return TurnEnd::FAILED; + } + pbf_press_button(m_context, BUTTON_A, 160ms, 640ms); + if (!move_cursor_to_option(m_console, m_context, BattleMoveOption(move))){ + return TurnEnd::FAILED; + } + pbf_press_button(m_context, BUTTON_A, 160ms, 640ms); + BattleMenuWatcher menu(COLOR_RED); + const int ret = run_until( + m_console, m_context, + [](ProControllerContext& context){ + pbf_mash_button(context, BUTTON_B, 30000ms); + }, + {menu} + ); + return ret == 0 ? TurnEnd::MENU : TurnEnd::FAILED; +} + +ThrowEnd ConsoleCaptureActions::throw_ball(){ + if (!move_cursor_to_option(m_console, m_context, BattleMenuOption::BAG)){ + return ThrowEnd::FAILED; + } + pbf_press_button(m_context, BUTTON_A, 160ms, 1200ms); + m_context.wait_for_all_requests(); + if (!open_ball_pocket()){ + m_console.log("Capture: no Poke Ball found in the bag.", COLOR_RED); + pbf_mash_button(m_context, BUTTON_B, 2000ms); + m_context.wait_for_all_requests(); + return ThrowEnd::NO_BALLS; + } + // The top row, USE, the throw. + pbf_press_button(m_context, BUTTON_A, 160ms, 640ms); + pbf_press_button(m_context, BUTTON_A, 160ms, 640ms); + m_context.wait_for_all_requests(); + return wait_for_throw(); +} + +bool ConsoleCaptureActions::open_ball_pocket(){ + // The bag opens on the pocket used last: Items, Key Items and Poke + // Balls, RIGHT from one to the next. + for (size_t c = 0; c < 3; c++){ + VideoSnapshot screen = m_console.video().snapshot(); + const std::vector rows = read_bag_pocket(m_console.logger(), m_language, screen); + if (!rows.empty() && rows.front().item == "poke-ball"){ + return true; + } + pbf_press_dpad(m_context, DPAD_RIGHT, 160ms, 640ms); + m_context.wait_for_all_requests(); + } + return false; +} + +ThrowEnd ConsoleCaptureActions::wait_for_throw(){ + // Either the battle menu (it broke free) or, after "Gotcha!" and the + // Pokedex page, "Give a nickname?". B moves the messages on; one that + // answers the nickname NO leads to the overworld. + const WallClock deadline = current_time() + 60000ms; + for (size_t polls = 1; current_time() < deadline; polls++){ + m_context.wait_for_all_requests(); + VideoSnapshot screen = m_console.video().snapshot(); + if (screen && yes_no_cursor(screen)){ + return ThrowEnd::CAUGHT; + } + if (screen && BattleMenuDetector(COLOR_RED).detect(screen)){ + return ThrowEnd::BROKE_FREE; + } + if (polls % 4 != 0){ + pbf_wait(m_context, 500ms); + continue; + } + if (overworld_shows(m_console, m_context)){ + return ThrowEnd::CAUGHT; + } + pbf_press_button(m_context, BUTTON_B, 160ms, 340ms); + } + return ThrowEnd::FAILED; +} + +bool ConsoleCaptureActions::finish_catch(CatchDestination destination){ + m_console.log(std::string("Capture: no nickname; it goes to the ") + destination_name(destination) + "."); + try{ + // B answers the nickname NO and moves the messages on. + mash_until_overworld(m_console, m_context, 30000ms); + return true; + }catch (OperationFailedException& e){ + m_console.log("Capture: " + e.message(), COLOR_RED); + return false; + } +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h new file mode 100644 index 0000000000..21d145aeab --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h @@ -0,0 +1,233 @@ +/* AutoStory Capture + * + * From: https://github.com/PokemonAutomation/ + * + * A story step that catches a named species where the player stands, in + * grass or a cave: step back and forth until a wild battle starts, read + * its species, run from any other, weaken the target only with a move the + * table names and only while its HP bar is green, and throw Poke Balls + * until it is caught. A catch goes to the party, or to the PC when the + * party is full. The step is bounded: it stops after so many encounters + * or so many balls thrown, or when the bag has none left. + * + * A shiny, of any species, stops the step where it is: nothing is pressed + * after the shiny is seen, not even RUN, so a person decides what to do + * with it. The program stops without reloading. + * + * The decisions take their game steps from a CaptureActions, so they run + * in tests against a fake game; ConsoleCaptureActions does them on the + * console with the battle detectors the shiny hunts use. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryCapture_H +#define PokemonAutomation_PokemonFRLG_AutoStoryCapture_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Language.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "PokemonFRLG_AutoStoryPcStorage.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// What a capture step catches, and its bounds. +struct CapturePlan{ + // The species' slug, e.g. "rattata". + std::string species = {}; + // How many to catch. + size_t catches = 1; + // Wild battles, of any species, before the step gives up. + size_t max_encounters = 20; + // Poke Balls thrown before the step gives up. + size_t max_balls = 5; + // The way the player steps to look for a battle, then back: the table + // stands the player in the patch with a free tile that way. + Pokemon::Direction pace = Pokemon::Direction::EAST; + // The FIGHT list's move (0-3) to weaken the target with: one that + // can't knock it out from green HP. None: only balls. + std::optional weaken_move = std::nullopt; + // The most turns spent weakening each target. + size_t max_weaken_turns = 2; +}; + +// The prefix of a GAME step's variant that names a capture step. +inline constexpr std::string_view CAPTURE_VARIANT_PREFIX = "capture "; + +// `plan` as a GAME step's variant, e.g. "capture rattata catches=1 +// encounters=20 balls=5 pace=east", with " move=1 turns=2" when it +// weakens (the move counted from 1). +std::string capture_variant(const CapturePlan& plan); +// A variant from capture_variant(), whose keys after the species may come +// in any order or be left out (their defaults); nothing if `variant` isn't +// one or has a key or value it doesn't know. +std::optional parse_capture_variant(std::string_view variant); + +// The table's step that runs `plan`. +Pokemon::StoryStep capture_step(std::string name, std::string why, const CapturePlan& plan); +// The table's step that runs `transfer` at the PC the player faces. +Pokemon::StoryStep pc_step(std::string name, std::string why, const PcTransfer& transfer); + + +// What a wild battle's opening showed. +struct EncounterSeen{ + // The species' slug; empty if it couldn't be read. + std::string species = {}; + bool shiny = false; +}; + +// The wild Pokemon's HP bar. +enum class FoeHp{ + UNKNOWN, + GREEN, + YELLOW, + RED, +}; + +enum class TurnEnd{ + // The battle menu is back. + MENU, + // The wild Pokemon fainted; the battle is over and the overworld back. + FOE_FAINTED, + // The player's Pokemon fainted. + PLAYER_FAINTED, + FAILED, +}; + +enum class ThrowEnd{ + // Caught: its first message shows. + CAUGHT, + // It broke free; the battle menu is back. + BROKE_FREE, + // The bag has no Poke Ball; the battle menu is back. + NO_BALLS, + PLAYER_FAINTED, + FAILED, +}; + +enum class CatchDestination{ + PARTY, + PC, +}; +const char* destination_name(CatchDestination destination); + +// The game steps a capture takes, injected so its decisions run in tests. +class CaptureActions{ +public: + virtual ~CaptureActions() = default; + + // From the overworld, in the patch: steps `pace` and back until a wild + // battle starts. False if none did within its bound. + virtual bool find_encounter(Pokemon::Direction pace) = 0; + // From a wild battle's start: whether it is shiny and, if not, its + // species, read at the battle menu. A shiny is left as it shows. + virtual EncounterSeen read_encounter() = 0; + // From the battle menu: RUN, until the overworld is back. False if it + // didn't get away. + virtual bool flee() = 0; + // At the battle menu: the wild Pokemon's HP bar. + virtual FoeHp read_foe_hp() = 0; + // From the battle menu: FIGHT, the move `move` (0-3), the turn. + virtual TurnEnd attack(size_t move) = 0; + // From the battle menu: BAG, a Poke Ball, the throw. + virtual ThrowEnd throw_ball() = 0; + // From the catch's first message: no nickname, and the messages to the + // overworld, the last saying it went to `destination`. False if the + // overworld didn't come back. + virtual bool finish_catch(CatchDestination destination) = 0; +}; + +enum class CaptureEnd{ + CAUGHT, + OUT_OF_ENCOUNTERS, + OUT_OF_BALLS, + NO_ENCOUNTER, + // A shiny showed: stopped in its battle, nothing pressed. + SHINY, + PLAYER_FAINTED, + FAILED, +}; +const char* capture_end_name(CaptureEnd end); + +struct CaptureResult{ + CaptureEnd end = CaptureEnd::FAILED; + size_t encounters = 0; + size_t balls_thrown = 0; + // Where each catch went, in order. + std::vector caught = {}; + // SHINY: its species, if read. FAILED: what failed. + std::string detail = {}; +}; + +// Turns of one target's battle before the step gives up on it. +constexpr size_t CAPTURE_MAX_TURNS = 20; + +// From the overworld in the patch, with `party_size` Pokemon in the party: +// catches as `plan` says. +CaptureResult run_capture(Logger& logger, CaptureActions& actions, const CapturePlan& plan, size_t party_size); + +// What the run says when a capture of `plan` ends with `result`. +std::string capture_message(const CapturePlan& plan, const CaptureResult& result); + + +// A capture or PC GAME step (see capture_step(), pc_step()) with these +// actions, the party read from `pc`: nothing for another variant. A capture that ends on a shiny +// throws ProgramFinishedException, so the segment stops without +// reloading, with a screenshot from `stream` if given; any other end but +// CAUGHT is a failed Outcome, as is a PC step that fails or is refused. +std::optional run_storage_game_step( + Logger& logger, VideoStream* stream, const Pokemon::StoryStep& step, + CaptureActions& capture, PcStorageActions& pc +); + + +// The console's capture steps. Encounters are found as the overworld +// shiny hunt finds them, a shiny heard as the shiny hunts hear it +// (handle_encounter()), and runs as they run (flee_battle()). The wild +// Pokemon's HP bar has no reader yet, so it reads UNKNOWN and nothing is +// weakened on the console. +class ConsoleCaptureActions : public CaptureActions{ +public: + ConsoleCaptureActions(ConsoleHandle& console, ProControllerContext& context, Language language); + + virtual bool find_encounter(Pokemon::Direction pace) override; + virtual EncounterSeen read_encounter() override; + virtual bool flee() override; + virtual FoeHp read_foe_hp() override; + virtual TurnEnd attack(size_t move) override; + virtual ThrowEnd throw_ball() override; + virtual bool finish_catch(CatchDestination destination) override; + +private: + // In the bag: to the Poke Balls pocket. False if it wasn't found. + bool open_ball_pocket(); + // After the throw: caught, broke free, or neither in time. + ThrowEnd wait_for_throw(); + + ConsoleHandle& m_console; + ProControllerContext& m_context; + Language m_language; +}; + + +void add_tests_AutoStoryCapture(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp new file mode 100644 index 0000000000..e021d50c17 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp @@ -0,0 +1,531 @@ +/* AutoStory Capture Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The capture step's decisions against a fake game: which encounters are + * run from, when balls or encounters run out, where a catch goes, what a + * shiny stops; its table variant; and a capture and a PC step run by the + * shared segment runner, as the program runs them, to show which failures + * reload the save and which stop the program. + * + */ + +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/Exceptions/ProgramFinishedException.h" +#include "Pokemon/AutoStory/Pokemon_StorySegments.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "PokemonFRLG_AutoStoryCapture.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using Pokemon::Direction; +using Pokemon::StoryStep; + +// Keeps the lines logged, for the messages a test checks. +class LineLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string& msg, Color) override{ + lines.push_back(msg); + } + std::vector lines; +}; + +std::string joined(const std::vector& items){ + std::string text; + for (const std::string& item : items){ + text += (text.empty() ? "" : ", ") + item; + } + return text; +} + + +// A wild Pokemon the fake game sends, in order. +struct FakeEncounter{ + std::string species = {}; + bool shiny = false; + // Throws it breaks free from before one catches it. + size_t breaks = 0; + // Its HP bar after each attack, the last repeated; GREEN before any. + std::vector hp_after = {}; + bool faints_on_attack = false; + bool flee_fails = false; +}; + +// The game a capture plays, as a trace of what it did. +class FakeCaptureGame : public CaptureActions{ +public: + size_t party = 2; + size_t bag_balls = 10; + std::vector encounters; + std::vector trace; + // Set by a step taken where the game wouldn't take it. + std::string error; + + virtual bool find_encounter(Direction pace) override{ + if (pace != Direction::EAST && pace != Direction::NORTH){ + error = "paced " + std::string(direction_name(pace)); + } + if (m_next >= encounters.size()){ + trace.push_back("none"); + return false; + } + m_current = encounters[m_next++]; + m_hp = FoeHp::GREEN; + m_attacks = 0; + m_in_battle = true; + trace.push_back((m_current.shiny ? "shiny " : "") + (m_current.species.empty() ? "?" : m_current.species)); + return true; + } + // A shiny's species isn't read: the console hears it before the menu. + virtual EncounterSeen read_encounter() override{ + return {m_current.shiny ? "" : m_current.species, m_current.shiny}; + } + virtual bool flee() override{ + check_in_battle("run"); + trace.push_back("run"); + if (m_current.flee_fails){ + return false; + } + m_in_battle = false; + return true; + } + virtual FoeHp read_foe_hp() override{ + return m_hp; + } + virtual TurnEnd attack(size_t move) override{ + check_in_battle("attack"); + if (m_hp != FoeHp::GREEN){ + error = "attacked at a HP bar that wasn't green"; + } + trace.push_back(std::format("move {}", move + 1)); + if (m_current.faints_on_attack){ + m_in_battle = false; + return TurnEnd::FOE_FAINTED; + } + if (!m_current.hp_after.empty()){ + m_hp = m_current.hp_after[std::min(m_attacks, m_current.hp_after.size() - 1)]; + } + m_attacks++; + return TurnEnd::MENU; + } + virtual ThrowEnd throw_ball() override{ + check_in_battle("throw"); + if (bag_balls == 0){ + trace.push_back("no ball"); + return ThrowEnd::NO_BALLS; + } + bag_balls--; + trace.push_back("ball"); + if (m_current.breaks > 0){ + m_current.breaks--; + return ThrowEnd::BROKE_FREE; + } + m_caught = true; + return ThrowEnd::CAUGHT; + } + virtual bool finish_catch(CatchDestination destination) override{ + if (!m_caught){ + error = "finished a catch that wasn't made"; + } + const CatchDestination where = party < 6 ? CatchDestination::PARTY : CatchDestination::PC; + if (destination != where){ + error = std::string("expected the catch to go to the ") + destination_name(where); + } + if (where == CatchDestination::PARTY){ + party++; + } + trace.push_back(std::string("to ") + destination_name(where)); + m_caught = false; + m_in_battle = false; + return true; + } + +private: + void check_in_battle(const char* step){ + if (!m_in_battle){ + error = std::string(step) + " outside a battle"; + } + } + + size_t m_next = 0; + FakeEncounter m_current; + FoeHp m_hp = FoeHp::GREEN; + size_t m_attacks = 0; + bool m_in_battle = false; + bool m_caught = false; +}; + + +class Test_AutoStoryCapture : public UnitTest{ +public: + Test_AutoStoryCapture(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStoryCapture - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + std::function m_body; +}; + + +const CapturePlan RATTATA{.species = "rattata", .max_encounters = 6, .max_balls = 4}; + + +// The capture loop's decisions, by what the game sends. +struct CaptureCase{ + std::string name = {}; + CapturePlan plan = RATTATA; + size_t party = 2; + size_t bag_balls = 10; + std::vector encounters = {}; + CaptureEnd end = CaptureEnd::FAILED; + std::string trace = {}; + size_t caught = 0; +}; +const std::vector CAPTURE_CASES = { + { + .name = "the target on the first encounter, caught to the party", + .encounters = {{.species = "rattata"}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, ball, to party", + .caught = 1, + }, + { + .name = "other species and an unread one run from", + .encounters = {{.species = "spearow"}, {.species = ""}, {.species = "rattata", .breaks = 1}}, + .end = CaptureEnd::CAUGHT, + .trace = "spearow, run, ?, run, rattata, ball, ball, to party", + .caught = 1, + }, + { + .name = "a full party sends the catch to the PC", + .party = 6, + .encounters = {{.species = "rattata"}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, ball, to PC", + .caught = 1, + }, + { + .name = "two catches with a party of 5: the first to the party, the second to the PC", + .plan = {.species = "rattata", .catches = 2, .max_encounters = 6, .max_balls = 4}, + .party = 5, + .encounters = {{.species = "rattata"}, {.species = "rattata"}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, ball, to party, rattata, ball, to PC", + .caught = 2, + }, + { + .name = "the step's ball cap: run once it is reached", + .encounters = {{.species = "rattata", .breaks = 10}}, + .end = CaptureEnd::OUT_OF_BALLS, + .trace = "rattata, ball, ball, ball, ball, run", + }, + { + .name = "the bag runs out first", + .bag_balls = 2, + .encounters = {{.species = "rattata", .breaks = 10}}, + .end = CaptureEnd::OUT_OF_BALLS, + .trace = "rattata, ball, ball, no ball, run", + }, + { + .name = "the encounter cap", + .plan = {.species = "rattata", .max_encounters = 3}, + .encounters = {{.species = "spearow"}, {.species = "ekans"}, {.species = "spearow"}, {.species = "rattata"}}, + .end = CaptureEnd::OUT_OF_ENCOUNTERS, + .trace = "spearow, run, ekans, run, spearow, run", + }, + { + .name = "no wild battle starts", + .encounters = {{.species = "spearow"}}, + .end = CaptureEnd::NO_ENCOUNTER, + .trace = "spearow, run, none", + }, + { + .name = "a shiny of another species: nothing pressed after it", + .encounters = {{.species = "spearow", .shiny = true}, {.species = "rattata"}}, + .end = CaptureEnd::SHINY, + .trace = "shiny spearow", + }, + { + .name = "a shiny target: no ball thrown either", + .encounters = {{.species = "rattata", .shiny = true}}, + .end = CaptureEnd::SHINY, + .trace = "shiny rattata", + }, + { + .name = "a run that fails", + .encounters = {{.species = "spearow", .flee_fails = true}}, + .end = CaptureEnd::FAILED, + .trace = "spearow, run", + }, + { + .name = "weakened from green only, then balls", + .plan = {.species = "rattata", .max_balls = 4, .weaken_move = 0, .max_weaken_turns = 3}, + .encounters = {{.species = "rattata", .breaks = 1, .hp_after = {FoeHp::GREEN, FoeHp::YELLOW}}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, move 1, move 1, ball, ball, to party", + .caught = 1, + }, + { + .name = "weakening stops at its turn cap", + .plan = {.species = "rattata", .max_balls = 4, .weaken_move = 2, .max_weaken_turns = 1}, + .encounters = {{.species = "rattata", .hp_after = {FoeHp::GREEN}}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, move 3, ball, to party", + .caught = 1, + }, + { + .name = "a target knocked out: on to the next encounter", + .plan = {.species = "rattata", .max_balls = 4, .weaken_move = 0}, + .encounters = {{.species = "rattata", .faints_on_attack = true}, {.species = "rattata", .hp_after = {FoeHp::RED}}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, move 1, rattata, move 1, ball, to party", + .caught = 1, + }, +}; + +// Each decision of the capture loop, by what the game sends. +UnitTestResult test_capture_decisions(){ + for (const CaptureCase& item : CAPTURE_CASES){ + LineLogger logger; + FakeCaptureGame game; + game.party = item.party; + game.bag_balls = item.bag_balls; + game.encounters = item.encounters; + const CaptureResult result = run_capture(logger, game, item.plan, item.party); + if (!game.error.empty()){ + return item.name + ": " + game.error + ". Trace: " + joined(game.trace); + } + if (result.end != item.end || joined(game.trace) != item.trace || result.caught.size() != item.caught){ + return std::format( + "{}: expected {} with {} caught after \"{}\"; got {} with {} caught after \"{}\".", + item.name, capture_end_name(item.end), item.caught, item.trace, + capture_end_name(result.end), result.caught.size(), joined(game.trace) + ); + } + } + return true; +} + + +// The table's variant, both ways, and the variants it refuses. +UnitTestResult test_capture_variant(){ + const std::vector plans = { + RATTATA, + {.species = "pidgey", .catches = 2, .max_encounters = 30, .max_balls = 8, .pace = Direction::NORTH, + .weaken_move = 3, .max_weaken_turns = 1}, + }; + for (const CapturePlan& plan : plans){ + const std::string variant = capture_variant(plan); + const std::optional back = parse_capture_variant(variant); + if (!back || back->species != plan.species || back->catches != plan.catches + || back->max_encounters != plan.max_encounters || back->max_balls != plan.max_balls + || back->pace != plan.pace || back->weaken_move != plan.weaken_move + || (plan.weaken_move && back->max_weaken_turns != plan.max_weaken_turns) + ){ + return "\"" + variant + "\" didn't read back as the plan it was made from."; + } + } + const std::optional short_form = parse_capture_variant("capture rattata"); + if (!short_form || short_form->species != "rattata" || short_form->max_balls != 5 || short_form->weaken_move){ + return "Expected \"capture rattata\" to read with the defaults."; + } + for (std::string_view bad : { + "capture", "capture ", "capture balls=3", "capture rattata balls=0", "capture rattata move=5", + "capture rattata pace=up", "capture rattata colour=red", "capture rattata balls", "pc deposit slot=2 box=1", + }){ + if (parse_capture_variant(bad)){ + return "Expected \"" + std::string(bad) + "\" refused."; + } + } + const Pokemon::StoryStep step = capture_step("catch_rattata", "Cut user", RATTATA); + if (step.kind != Pokemon::StoryStepKind::GAME || !parse_capture_variant(step.variant)){ + return "Expected capture_step() to be a GAME step with a capture variant."; + } + return true; +} + + +// What the run says at each end. +UnitTestResult test_capture_messages(){ + struct Case{ + CaptureResult result; + std::string expected; + }; + const std::vector cases = { + { + .result = {.end = CaptureEnd::CAUGHT, .encounters = 3, .balls_thrown = 2, .caught = {CatchDestination::PC}}, + .expected = "Capture: caught 1 rattata (PC): 3 encounters, 2 balls thrown, 1 of 1 caught.", + }, + { + .result = {.end = CaptureEnd::SHINY, .encounters = 4, .detail = ""}, + .expected = "Capture: a shiny Pokemon in encounter 4. Stopped in its battle without running, for you to " + "decide: catch it or run by hand, then save.", + }, + { + .result = {.end = CaptureEnd::OUT_OF_BALLS, .encounters = 1, .balls_thrown = 2, .detail = "the bag has no Poke Ball"}, + .expected = "Capture of rattata: out of balls (the bag has no Poke Ball): 1 encounters, 2 balls thrown, 0 of 1 caught.", + }, + { + .result = {.end = CaptureEnd::OUT_OF_ENCOUNTERS, .encounters = 6}, + .expected = "Capture of rattata: out of encounters: 6 encounters, 0 balls thrown, 0 of 1 caught.", + }, + }; + for (const Case& item : cases){ + const std::string got = capture_message(RATTATA, item.result); + if (got != item.expected){ + return "Expected \"" + item.expected + "\", got \"" + got + "\"."; + } + } + return true; +} + + +// A PC that does nothing: the segment tests below only use its party. +class IdlePc : public PcStorageActions{ +public: + std::vector party = {{.species = "squirtle"}, {.species = "magikarp"}}; + size_t views = 0; + virtual std::vector read_party() override{ + return party; + } + virtual PcView read_view() override{ + views++; + return {}; + } + virtual void press(PcPress) override{} +}; + +// Runs capture and PC GAME steps as the program's executor does. +class StorageExecutor : public Pokemon::StoryStepExecutor{ +public: + LineLogger logger; + FakeCaptureGame capture; + IdlePc pc; + +protected: + virtual Pokemon::Outcome game(Pokemon::RunState&, const StoryStep& step) override{ + const std::optional outcome = run_storage_game_step(logger, nullptr, step, capture, pc); + return outcome ? *outcome : Pokemon::unsupported_step(step); + } +}; + +struct SegmentRun{ + Pokemon::SegmentReport report; + size_t reloads = 0; + // The program stopped: ProgramFinishedException. + bool finished = false; +}; + +// One checkpoint of `steps`, run by the shared runner with the program's +// retry policy. +SegmentRun run_steps(StorageExecutor& executor, const std::vector& steps){ + const Pokemon::StoryCheckpoint data{.id = "test.01", .title = "capture", .start = std::nullopt, .steps = steps}; + Pokemon::Segment segment; + segment.id = "test"; + segment.checkpoints.push_back(Pokemon::CheckpointSpec{ + .id = data.id, .title = data.title, .saves = false, + .build = [&]{ return Pokemon::build_story_checkpoint(data, executor); }, + }); + SegmentRun run; + Pokemon::SegmentHooks hooks; + hooks.save = [](Pokemon::RunState&){ return Pokemon::outcome_ok(); }; + hooks.reload = [&](Pokemon::RunState&){ + run.reloads++; + return Pokemon::outcome_ok(); + }; + hooks.log = [](const std::string&){}; + Pokemon::RunState state; + try{ + run.report = Pokemon::run_segment(state, segment, {}, Pokemon::SegmentPolicy{}, hooks); + }catch (ProgramFinishedException&){ + run.finished = true; + } + return run; +} + +// A capture that runs out reloads the save and tries again; a shiny stops +// the program where it is, without a reload; a refused deposit fails +// the checkpoint before the PC is touched. +UnitTestResult test_capture_in_segment(){ + { + StorageExecutor executor; + executor.capture.encounters = {{.species = "spearow"}, {.species = "rattata"}}; + const SegmentRun run = run_steps(executor, {capture_step("catch_rattata", "", RATTATA)}); + if (!run.report.completed || run.reloads != 0 || joined(executor.capture.trace) != "spearow, run, rattata, ball, to party"){ + return "Expected the catch in one attempt; got: " + joined(executor.capture.trace); + } + } + { + StorageExecutor executor; + executor.capture.bag_balls = 0; + executor.capture.encounters = {{.species = "rattata"}, {.species = "rattata"}, {.species = "rattata"}}; + const SegmentRun run = run_steps(executor, {capture_step("catch_rattata", "", RATTATA)}); + if (run.report.completed || run.reloads != 2 || run.report.failure.failure != "capture_out_of_balls"){ + return std::format( + "Expected 3 attempts out of balls, reloading between; got {} reloads, failure {}.", + run.reloads, run.report.failure.failure + ); + } + } + { + StorageExecutor executor; + executor.capture.encounters = {{.species = "pidgey", .shiny = true}, {.species = "rattata"}}; + const SegmentRun run = run_steps(executor, {capture_step("catch_rattata", "", RATTATA)}); + const bool told = std::find_if(executor.logger.lines.begin(), executor.logger.lines.end(), [](const std::string& line){ + return line.starts_with("Capture: a shiny Pokemon in encounter 1."); + }) != executor.logger.lines.end(); + if (!run.finished || !told || run.reloads != 0 || joined(executor.capture.trace) != "shiny pidgey"){ + return "Expected the program stopped at the shiny with no reload; got: " + joined(executor.capture.trace) + + std::format(" ({} reloads)", run.reloads); + } + } + { + StorageExecutor executor; + executor.pc.party = {{.species = "squirtle", .fainted = true}, {.species = "magikarp"}}; + const PcTransfer deposit{.action = PcAction::DEPOSIT, .party_slot = 1}; + const SegmentRun run = run_steps(executor, {pc_step("deposit_magikarp", "", deposit)}); + if (run.report.completed || run.report.failure.failure != "pc_refused" || executor.pc.views != 0){ + return "Expected the deposit of the last Pokemon that can fight refused before the PC was read."; + } + } + return true; +} + +} + + +void add_tests_AutoStoryCapture(UnitTestDatabase& database){ + const auto add = [&](std::string name, UnitTestResult (*body)()){ + database.add(std::move(name), body); + }; + add("decisions by encounter", test_capture_decisions); + add("the table's variant", test_capture_variant); + add("messages", test_capture_messages); + add("capture and PC steps in a segment: reloads and stops", test_capture_in_segment); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index fedb2c7713..3142ce314b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -16,6 +16,7 @@ #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" #include "PokemonFRLG_AutoStoryBattleData.h" +#include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStorySummary.h" #include "PokemonFRLG_AutoStoryWinChance.h" @@ -456,6 +457,12 @@ Outcome StoryExecutor::game(RunState&, const StoryStep& step){ leave_gift_summary(m_env.console, m_env.context); return Pokemon::outcome_ok(); } + ConsoleCaptureActions capture(m_env.console, m_env.context, m_env.language); + ConsolePcStorageActions pc(m_env.console, m_env.context); + const std::optional stored = run_storage_game_step(m_env.console.logger(), &m_env.console, step, capture, pc); + if (stored){ + return *stored; + } return Pokemon::unsupported_step(step); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index 627e5a5914..3981c37b97 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -237,6 +237,9 @@ class StoryExecutor : public Pokemon::StoryStepExecutor{ virtual Pokemon::Outcome ready(Pokemon::RunState& state, const StoryStep& step) override; // CLOSE_TRAINER_CARD: back from SID Helper (close_trainer_card()). // LEAVE_GIFT_SUMMARY: back from Gift RNG (leave_gift_summary()). + // A capture or a PC deposit or withdrawal (capture_step(), pc_step() + // in PokemonFRLG_AutoStoryCapture.h); a shiny met in a capture stops + // the program without reloading. virtual Pokemon::Outcome game(Pokemon::RunState& state, const StoryStep& step) override; private: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.cpp new file mode 100644 index 0000000000..1e2122dc7d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.cpp @@ -0,0 +1,422 @@ +/* AutoStory PC Storage + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryPcStorage.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace std::chrono_literals; + + +namespace{ + +// pokefirered: PARTY_SIZE, TOTAL_BOXES_COUNT, IN_BOX_COUNT (6 columns). +constexpr size_t PARTY_SIZE = 6; +constexpr size_t BOX_COUNT = 14; +constexpr size_t BOX_SLOTS = 30; +constexpr size_t BOX_COLUMNS = 6; +// The storage menu's options: WITHDRAW, DEPOSIT, MOVE POKEMON, MOVE ITEMS, SEE YA!. +constexpr size_t STORAGE_MENU_ROWS = 5; +// A second look at a frame that reads as the overworld. +constexpr Milliseconds OVERWORLD_CONFIRM = 800ms; + +std::vector split_words(std::string_view text){ + std::vector words; + while (!text.empty()){ + const size_t space = text.find(' '); + const std::string_view word = text.substr(0, space); + if (!word.empty()){ + words.push_back(word); + } + if (space == std::string_view::npos){ + break; + } + text.remove_prefix(space + 1); + } + return words; +} + +// "key=N" with N from 1 to `max`, as N - 1. +std::optional counted_value(std::string_view word, std::string_view key, size_t max){ + if (!word.starts_with(key) || word.size() <= key.size() + 1 || word[key.size()] != '='){ + return std::nullopt; + } + size_t value = 0; + for (char c : word.substr(key.size() + 1)){ + if (c < '0' || c > '9' || value > max){ + return std::nullopt; + } + value = value * 10 + size_t(c - '0'); + } + if (value < 1 || value > max){ + return std::nullopt; + } + return value - 1; +} + +} + + +std::string pc_variant(const PcTransfer& transfer){ + if (transfer.action == PcAction::DEPOSIT){ + return std::format( + "{}deposit slot={} box={}{}", PC_VARIANT_PREFIX, transfer.party_slot + 1, transfer.box + 1, + transfer.if_missing == PcMissing::SKIP ? " if_present" : "" + ); + } + return std::format("{}withdraw box={} slot={}", PC_VARIANT_PREFIX, transfer.box + 1, transfer.box_slot + 1); +} + +std::optional parse_pc_variant(std::string_view variant){ + if (!variant.starts_with(PC_VARIANT_PREFIX)){ + return std::nullopt; + } + std::vector words = split_words(variant.substr(PC_VARIANT_PREFIX.size())); + PcTransfer transfer; + if (words.size() == 4 && words[0] == "deposit" && words[3] == "if_present"){ + transfer.if_missing = PcMissing::SKIP; + words.pop_back(); + } + if (words.size() != 3){ + return std::nullopt; + } + std::optional slot; + std::optional box; + if (words[0] == "deposit"){ + transfer.action = PcAction::DEPOSIT; + slot = counted_value(words[1], "slot", PARTY_SIZE); + box = counted_value(words[2], "box", BOX_COUNT); + }else if (words[0] == "withdraw"){ + transfer.action = PcAction::WITHDRAW; + box = counted_value(words[1], "box", BOX_COUNT); + slot = counted_value(words[2], "slot", BOX_SLOTS); + } + if (!slot || !box){ + return std::nullopt; + } + transfer.box = *box; + if (transfer.action == PcAction::DEPOSIT){ + transfer.party_slot = *slot; + }else{ + transfer.box_slot = *slot; + } + return transfer; +} + + +std::string pc_transfer_error(const PcTransfer& transfer, const std::vector& party){ + if (transfer.action == PcAction::WITHDRAW){ + return party.size() >= PARTY_SIZE ? "the party is full" : ""; + } + if (transfer.party_slot >= party.size()){ + return std::format("the party has no slot {} ({} Pokemon)", transfer.party_slot + 1, party.size()); + } + if (party.size() == 1){ + return "it is the party's last Pokemon"; + } + for (size_t c = 0; c < party.size(); c++){ + if (c != transfer.party_slot && !party[c].fainted){ + return ""; + } + } + return std::format("slot {} is the party's last Pokemon that can fight", transfer.party_slot + 1); +} + + +const char* pc_press_name(PcPress press){ + switch (press){ + case PcPress::A: return "A"; + case PcPress::B: return "B"; + case PcPress::UP: return "UP"; + case PcPress::DOWN: return "DOWN"; + case PcPress::LEFT: return "LEFT"; + case PcPress::RIGHT: return "RIGHT"; + case PcPress::WAIT: return "wait"; + case PcPress::DONE: return "done"; + case PcPress::FAIL: return "fail"; + } + return "?"; +} + + +PcStorageDialog::PcStorageDialog(const PcTransfer& transfer) + : m_transfer(transfer) +{} + +PcDecision PcStorageDialog::next(const PcView& view){ + if (view.screen != PcScreen::OTHER){ + m_others_in_a_row = 0; + } + switch (view.screen){ + case PcScreen::OVERWORLD: + return on_overworld(); + case PcScreen::TEXT: + m_booting_reads = PC_BOOT_READS; + return {PcPress::B}; + case PcScreen::PC_LIST: + // The list opens on its top row each time. + if (m_transferred){ + return {PcPress::B}; + } + return {view.cursor && *view.cursor > 0 ? PcPress::UP : PcPress::A}; + case PcScreen::STORAGE_MENU: + return on_storage_menu(view); + case PcScreen::PARTY_PANEL: + return on_party_panel(view); + case PcScreen::BOX_GRID: + return on_box_grid(view); + case PcScreen::MON_MENU: + return on_mon_menu(view); + case PcScreen::BOX_CHOOSER: + return on_box_chooser(view); + case PcScreen::YES_NO: + // "Continue BOX operations?": B leaves the box. + if (m_transferred){ + return {PcPress::B}; + } + return {PcPress::FAIL, "a YES/NO before the transfer"}; + case PcScreen::OTHER: + if (++m_others_in_a_row >= PC_MAX_OTHER_IN_A_ROW){ + return {PcPress::FAIL, std::format("no PC screen recognized in {} reads", m_others_in_a_row)}; + } + return {PcPress::WAIT}; + } + return {PcPress::FAIL, "unknown screen"}; +} + +PcDecision PcStorageDialog::on_overworld(){ + if (m_transferred){ + return {PcPress::DONE}; + } + if (!m_booted){ + m_booted = true; + return {PcPress::A}; + } + // Still the overworld while the PC boots, or again after a step. + if (++m_booting_reads < PC_BOOT_READS){ + return {PcPress::WAIT}; + } + return {PcPress::FAIL, "the overworld came back before the transfer"}; +} + +PcDecision PcStorageDialog::on_storage_menu(const PcView& view){ + if (m_transferred){ + return {PcPress::B}; + } + // It opens on the last option used when it comes back from a box, so + // the cursor must be read. + if (!view.cursor || *view.cursor >= STORAGE_MENU_ROWS){ + return {PcPress::FAIL, "the storage menu's cursor wasn't read"}; + } + const size_t target = m_transfer.action == PcAction::WITHDRAW ? 0 : 1; + if (*view.cursor == target){ + return {PcPress::A}; + } + // It wraps: the shorter way round. + const size_t down = (target + STORAGE_MENU_ROWS - *view.cursor) % STORAGE_MENU_ROWS; + return {down <= STORAGE_MENU_ROWS / 2 ? PcPress::DOWN : PcPress::UP}; +} + +PcDecision PcStorageDialog::on_party_panel(const PcView& view){ + if (m_transferred){ + return {PcPress::B}; + } + if (m_transfer.action != PcAction::DEPOSIT){ + return {PcPress::FAIL, "the party panel opened for a withdrawal"}; + } + if (!view.cursor){ + return {PcPress::FAIL, "the hand wasn't read on the party panel"}; + } + if (*view.cursor < m_transfer.party_slot){ + return {PcPress::DOWN}; + } + if (*view.cursor > m_transfer.party_slot){ + return {PcPress::UP}; + } + return {PcPress::A}; +} + +PcDecision PcStorageDialog::on_box_grid(const PcView& view){ + if (m_transferred){ + return {PcPress::B}; + } + if (m_transfer.action != PcAction::WITHDRAW){ + return {PcPress::FAIL, "the box grid opened for a deposit"}; + } + if (view.box && *view.box != m_transfer.box){ + return {PcPress::FAIL, std::format("the PC shows box {}, not box {}", *view.box + 1, m_transfer.box + 1)}; + } + if (!view.cursor || *view.cursor >= BOX_SLOTS){ + return {PcPress::FAIL, "the hand wasn't read on the box"}; + } + const size_t row = *view.cursor / BOX_COLUMNS; + const size_t column = *view.cursor % BOX_COLUMNS; + const size_t target_row = m_transfer.box_slot / BOX_COLUMNS; + const size_t target_column = m_transfer.box_slot % BOX_COLUMNS; + if (row != target_row){ + return {row < target_row ? PcPress::DOWN : PcPress::UP}; + } + if (column != target_column){ + return {column < target_column ? PcPress::RIGHT : PcPress::LEFT}; + } + if (view.occupied == false){ + return {PcPress::FAIL, std::format("box {} slot {} is empty", m_transfer.box + 1, m_transfer.box_slot + 1)}; + } + return {PcPress::A}; +} + +PcDecision PcStorageDialog::on_mon_menu(const PcView& view){ + // RELEASE is on this menu: the cursor is never moved on it. + if (m_transferred){ + return {PcPress::B}; + } + if (view.cursor && *view.cursor != 0){ + return {PcPress::FAIL, std::format("the Pokemon's menu is on row {}, not its top", *view.cursor + 1)}; + } + if (m_transfer.action == PcAction::WITHDRAW){ + m_transferred = true; + } + return {PcPress::A}; +} + +PcDecision PcStorageDialog::on_box_chooser(const PcView& view){ + if (m_transferred){ + return {PcPress::B}; + } + if (m_transfer.action != PcAction::DEPOSIT){ + return {PcPress::FAIL, "the box chooser opened for a withdrawal"}; + } + std::optional box = view.box; + // It opens on BOX 1 each time the PC is entered. + if (!box && !m_chooser_seen){ + box = 0; + } + if (!box){ + return {PcPress::FAIL, "the box chooser's box wasn't read"}; + } + m_chooser_seen = true; + if (*box < m_transfer.box){ + return {PcPress::RIGHT}; + } + if (*box > m_transfer.box){ + return {PcPress::LEFT}; + } + m_transferred = true; + return {PcPress::A}; +} + + +PcResult run_pc_transfer(Logger& logger, PcStorageActions& actions, const PcTransfer& transfer){ + const std::string name = pc_variant(transfer); + const std::vector party = actions.read_party(); + if (transfer.action == PcAction::DEPOSIT && transfer.if_missing == PcMissing::SKIP && transfer.party_slot >= party.size()){ + logger.log(std::format("PC: skipped \"{}\": the party has {} Pokemon.", name, party.size())); + return {PcEnd::SKIPPED, ""}; + } + const std::string refused = pc_transfer_error(transfer, party); + if (!refused.empty()){ + logger.log("PC: refused \"" + name + "\": " + refused + ".", COLOR_RED); + return {PcEnd::REFUSED, refused}; + } + PcStorageDialog dialog(transfer); + for (size_t c = 0; c < PC_MAX_PRESSES; c++){ + const PcView view = actions.read_view(); + const PcDecision decision = dialog.next(view); + if (decision.press == PcPress::DONE){ + logger.log("PC: \"" + name + "\" done."); + return {PcEnd::DONE, ""}; + } + if (decision.press == PcPress::FAIL){ + logger.log("PC: \"" + name + "\" failed on the " + pc_screen_name(view.screen) + ": " + decision.reason + ".", COLOR_RED); + return {PcEnd::FAILED, decision.reason}; + } + logger.log(std::format( + "PC: {} (cursor {}): {}.", + pc_screen_name(view.screen), + view.cursor ? std::to_string(*view.cursor) : "-", + pc_press_name(decision.press) + )); + actions.press(decision.press); + } + return {PcEnd::FAILED, std::format("not back on the overworld after {} presses", PC_MAX_PRESSES)}; +} + + +size_t read_party_size(ConsoleHandle& console, ProControllerContext& context){ + open_party_menu_from_overworld(console, context); + context.wait_for_all_requests(); + const size_t size = size_t(detect_last_occupied_party_slot(console)) + 1; + mash_until_overworld(console, context, 15000ms); + return size; +} + + +ConsolePcStorageActions::ConsolePcStorageActions(ConsoleHandle& console, ProControllerContext& context) + : m_console(console) + , m_context(context) +{} + +std::vector ConsolePcStorageActions::read_party(){ + return std::vector(read_party_size(m_console, m_context)); +} + +PcView ConsolePcStorageActions::read_view(){ + m_context.wait_for_all_requests(); + VideoSnapshot screen = m_console.video().snapshot(); + if (!screen){ + return {}; + } + const PcView view = read_pc_screen(m_console, screen); + if (view.screen != PcScreen::OVERWORLD){ + return view; + } + pbf_wait(m_context, OVERWORLD_CONFIRM); + m_context.wait_for_all_requests(); + VideoSnapshot again = m_console.video().snapshot(); + return again ? read_pc_screen(m_console, again) : PcView{}; +} + +void ConsolePcStorageActions::press(PcPress press){ + switch (press){ + case PcPress::A: + pbf_press_button(m_context, BUTTON_A, 160ms, 640ms); + break; + case PcPress::B: + pbf_press_button(m_context, BUTTON_B, 160ms, 640ms); + break; + case PcPress::UP: + pbf_press_dpad(m_context, DPAD_UP, 160ms, 320ms); + break; + case PcPress::DOWN: + pbf_press_dpad(m_context, DPAD_DOWN, 160ms, 320ms); + break; + case PcPress::LEFT: + pbf_press_dpad(m_context, DPAD_LEFT, 160ms, 320ms); + break; + case PcPress::RIGHT: + pbf_press_dpad(m_context, DPAD_RIGHT, 160ms, 320ms); + break; + case PcPress::WAIT: + case PcPress::DONE: + case PcPress::FAIL: + pbf_wait(m_context, 500ms); + break; + } + m_context.wait_for_all_requests(); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h new file mode 100644 index 0000000000..d05b4092f7 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h @@ -0,0 +1,233 @@ +/* AutoStory PC Storage + * + * From: https://github.com/PokemonAutomation/ + * + * A story step at a Pokemon Center's PC: deposit a party member in a box, + * or withdraw one from a box, as pokefirered's Pokemon Storage System runs + * it (data/scripts/pc.inc, src/pokemon_storage_system_*.c), confirmed by + * hand on console B (2026-11-02, Route 4's Pokemon Center): + * + * - A at the PC: " booted up the PC.", then "Which PC should be + * accessed?" with SOMEONE'S PC (BILL'S PC later) on top, always opened + * on it. A on it: "Accessed SOMEONE's PC." and "POKEMON Storage System + * opened.", then the storage menu: WITHDRAW, DEPOSIT, MOVE POKEMON, + * MOVE ITEMS, SEE YA!. It opens on WITHDRAW from the PC, but on the last + * option used when it comes back from a box, and UP/DOWN wrap. + * - DEPOSIT: the box screen with the party panel, the hand on the party's + * first slot. DOWN steps through the six slots, then CANCEL, and wraps. + * A on a Pokemon: STORE, SUMMARY, MARK, RELEASE, CANCEL, on STORE. + * STORE: "Deposit in which BOX?", on BOX 1 each time the PC is entered; + * RIGHT/LEFT change the box, A stores it there. + * - WITHDRAW: the box screen on the current box (the game remembers it), + * the hand on its top left Pokemon; RIGHT/DOWN move it by one of six + * columns and five rows. A on a Pokemon: WITHDRAW, SUMMARY, MARK, + * RELEASE, CANCEL, on WITHDRAW; WITHDRAW puts it in the party. + * - B on a box screen: "Continue BOX operations?" YES/NO on YES; B leaves + * for the storage menu, B again the PC list, B again the overworld. + * + * The step never moves the cursor on a Pokemon's menu, whose RELEASE is + * three rows below STORE: it confirms the top row and presses A there. + * The game refuses to deposit its last Pokemon and to withdraw into a full + * party; the step refuses first, and also refuses to leave the party with + * no Pokemon that can fight. + * + * The step's decisions take what shows from a PcStorageActions, so they + * run in tests against a fake PC. On the console, each screen is read + * (PokemonFRLG_PcScreenReader.h) before the press it gets: a press sent + * before a menu is drawn goes to the message behind it (console B, + * 2026-11-02: DOWN, A before the storage menu showed opened WITHDRAW). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryPcStorage_H +#define PokemonAutomation_PokemonFRLG_AutoStoryPcStorage_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_PcScreenReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +enum class PcAction{ + DEPOSIT, + WITHDRAW, +}; + +// What a DEPOSIT does when the party hasn't got its slot: refuse, or +// skip it as done (the Magikarp from Gift RNG, when Gift RNG didn't run). +enum class PcMissing{ + REFUSE, + SKIP, +}; + +// What a PC step does. Slots and boxes count from 0. +struct PcTransfer{ + PcAction action = PcAction::DEPOSIT; + // DEPOSIT: the party slot to store. + size_t party_slot = 0; + // DEPOSIT: the box to store it in. WITHDRAW: the box to take from, + // which must be the box the PC shows (the last one viewed). + size_t box = 0; + // WITHDRAW: the box slot, row by row, six to a row. + size_t box_slot = 0; + PcMissing if_missing = PcMissing::REFUSE; +}; + +// The prefix of a GAME step's variant that names a PC step. +inline constexpr std::string_view PC_VARIANT_PREFIX = "pc "; + +// `transfer` as a GAME step's variant: "pc deposit slot=2 box=1" or +// "pc withdraw box=1 slot=7", slots and boxes counted from 1 as the game +// shows them; a deposit with PcMissing::SKIP ends in " if_present". +std::string pc_variant(const PcTransfer& transfer); +// A variant from pc_variant(); nothing if `variant` isn't one, or names a +// slot or box the game hasn't got. +std::optional parse_pc_variant(std::string_view variant); + + +// A party member as far as a PC step needs to know. +struct PcPartyMember{ + // Its species' slug, or empty if not read. + std::string species = {}; + bool fainted = false; +}; + +// Why `transfer` can't be done with `party`, or empty if it can: a +// deposit of a slot the party hasn't got, of its last Pokemon, or of its +// last one that can fight; a withdrawal into a full party. +std::string pc_transfer_error(const PcTransfer& transfer, const std::vector& party); + + +enum class PcPress{ + A, + B, + UP, + DOWN, + LEFT, + RIGHT, + // Nothing: look again. + WAIT, + // The transfer is made and the overworld is back. + DONE, + FAIL, +}; +const char* pc_press_name(PcPress press); + +struct PcDecision{ + PcPress press = PcPress::WAIT; + // FAIL: why. + std::string reason = {}; +}; + +// What the PC step presses on each screen it reads, from the overworld +// facing the PC to the overworld again with the transfer made. +class PcStorageDialog{ +public: + explicit PcStorageDialog(const PcTransfer& transfer); + + PcDecision next(const PcView& view); + + // Whether the transfer was made: the last press that makes it was sent. + bool transferred() const{ + return m_transferred; + } + +private: + PcDecision on_overworld(); + PcDecision on_storage_menu(const PcView& view); + PcDecision on_party_panel(const PcView& view); + PcDecision on_box_grid(const PcView& view); + PcDecision on_mon_menu(const PcView& view); + PcDecision on_box_chooser(const PcView& view); + + PcTransfer m_transfer; + bool m_booted = false; + // The overworld read since A at the PC, while the PC boots. + size_t m_booting_reads = 0; + bool m_transferred = false; + // BOX_CHOOSER opens on BOX 1 each time the PC is entered. + bool m_chooser_seen = false; + size_t m_others_in_a_row = 0; +}; + + +// The game steps a PC step takes, injected so its decisions run in tests. +class PcStorageActions{ +public: + virtual ~PcStorageActions() = default; + + // From the overworld: the party, read from the party menu, which is + // closed again. + virtual std::vector read_party() = 0; + // What shows now. + virtual PcView read_view() = 0; + // One press (A, B or the D-pad), and the wait for the screen to answer. + virtual void press(PcPress press) = 0; +}; + +enum class PcEnd{ + DONE, + // A DEPOSIT with PcMissing::SKIP whose slot the party hasn't got: the + // PC wasn't touched. + SKIPPED, + // pc_transfer_error() refused it before the PC was touched. + REFUSED, + FAILED, +}; +struct PcResult{ + PcEnd end = PcEnd::FAILED; + std::string detail = {}; +}; + +// Reads after this many presses without the overworld back: a failure. +constexpr size_t PC_MAX_PRESSES = 80; +// OTHER read this many times in a row: a failure. +constexpr size_t PC_MAX_OTHER_IN_A_ROW = 10; +// The overworld read this many times after A at the PC: it didn't boot. +constexpr size_t PC_BOOT_READS = 4; + +// From the overworld, facing the PC: the transfer, back to the overworld. +PcResult run_pc_transfer(Logger& logger, PcStorageActions& actions, const PcTransfer& transfer); + + +// From the overworld: the party's size, read from the party menu, which is +// closed again. +size_t read_party_size(ConsoleHandle& console, ProControllerContext& context); + +// The console's PC steps. Party members are read by count only: none is +// taken as fainted, since the story heals before it uses the PC. Screens +// are read with read_pc_screen(); the overworld only once a second frame, +// a moment later, shows it too, since a frame between two menus can look +// like it. +class ConsolePcStorageActions : public PcStorageActions{ +public: + ConsolePcStorageActions(ConsoleHandle& console, ProControllerContext& context); + + virtual std::vector read_party() override; + virtual PcView read_view() override; + virtual void press(PcPress press) override; + +private: + ConsoleHandle& m_console; + ProControllerContext& m_context; +}; + + +void add_tests_AutoStoryPcStorage(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage_Tests.cpp new file mode 100644 index 0000000000..0923fca9d1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage_Tests.cpp @@ -0,0 +1,553 @@ +/* AutoStory PC Storage Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The PC step against a fake PC that answers presses as pokefirered's + * Pokemon Storage System does (see PokemonFRLG_AutoStoryPcStorage.h): + * deposits and withdrawals end with the Pokemon moved and the overworld + * back; the refusals; a screen that isn't read fails instead of pressing + * on; and RELEASE is never reached. + * + */ + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryPcStorage.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +constexpr size_t BOXES = 14; +constexpr size_t BOX_SLOTS = 30; +constexpr size_t COLUMNS = 6; + +// The PC's menus and box screens, as pokefirered runs them. +class FakePc : public PcStorageActions{ +public: + std::vector party; + // Each box's slots: a species, or empty. + std::vector> boxes = std::vector>(BOXES, std::vector(BOX_SLOTS)); + // The box the PC shows on WITHDRAW: the last one viewed. + size_t current_box = 0; + // Screens whose cursor the step can't read. + std::vector cursor_unread = {}; + std::vector presses; + // Set by a press the game would take some other way than the step means. + std::string error; + + virtual std::vector read_party() override{ + return party; + } + virtual PcView read_view() override{ + PcView view{.screen = m_screen}; + if (std::find(cursor_unread.begin(), cursor_unread.end(), m_screen) == cursor_unread.end() + && m_screen != PcScreen::OVERWORLD && m_screen != PcScreen::TEXT + ){ + view.cursor = m_cursor; + } + if (m_screen == PcScreen::BOX_GRID){ + view.box = current_box; + view.occupied = !boxes[current_box][m_cursor].empty(); + } + if (m_screen == PcScreen::BOX_CHOOSER){ + view.box = m_chooser_box; + } + return view; + } + virtual void press(PcPress press) override{ + presses.push_back(pc_press_name(press)); + switch (m_screen){ + case PcScreen::OVERWORLD: + if (press == PcPress::A){ + show_text(PcScreen::PC_LIST, 1); + } + return; + case PcScreen::TEXT: + if ((press == PcPress::A || press == PcPress::B) && --m_texts == 0){ + m_screen = m_after_text; + m_cursor = 0; + } + return; + case PcScreen::PC_LIST: press_pc_list(press); return; + case PcScreen::STORAGE_MENU: press_storage_menu(press); return; + case PcScreen::PARTY_PANEL: press_party_panel(press); return; + case PcScreen::BOX_GRID: press_box_grid(press); return; + case PcScreen::MON_MENU: press_mon_menu(press); return; + case PcScreen::BOX_CHOOSER: press_box_chooser(press); return; + case PcScreen::YES_NO: press_yes_no(press); return; + case PcScreen::OTHER: return; + } + } + +private: + // SOMEONE'S PC, the player's PC, PROF. OAK's PC, LOG OFF. + void press_pc_list(PcPress press){ + if (press == PcPress::B){ + m_screen = PcScreen::OVERWORLD; + }else if (press == PcPress::A){ + if (m_cursor != 0){ + error = std::format("opened PC list row {}", m_cursor + 1); + } + // The storage menu opens on WITHDRAW from here. + m_chooser_box = 0; + show_text(PcScreen::STORAGE_MENU, 2); + }else{ + m_cursor = move_wrapping(m_cursor, press, 4); + } + } + void press_storage_menu(PcPress press){ + if (press == PcPress::B){ + m_screen = PcScreen::PC_LIST; + m_cursor = 0; + return; + } + if (press != PcPress::A){ + m_cursor = move_wrapping(m_cursor, press, 5); + return; + } + m_option = m_cursor; + if (m_cursor > 1){ + error = std::format("chose storage menu row {}", m_cursor + 1); + } + m_screen = m_cursor == 0 ? PcScreen::BOX_GRID : PcScreen::PARTY_PANEL; + m_cursor = 0; + } + // The party's six slots, then CLOSE BOX. + void press_party_panel(PcPress press){ + if (press == PcPress::B){ + m_back = PcScreen::PARTY_PANEL; + m_screen = PcScreen::YES_NO; + }else if (press == PcPress::A && m_cursor == 6){ + error = "pressed CLOSE BOX"; + }else if (press == PcPress::A && m_cursor < party.size()){ + open_mon_menu(PcScreen::PARTY_PANEL); + }else if (press == PcPress::UP || press == PcPress::DOWN){ + m_cursor = move_wrapping(m_cursor, press, 7); + }else if (press != PcPress::A){ + error = "LEFT or RIGHT on the party panel"; + } + } + void press_box_grid(PcPress press){ + const size_t row = m_cursor / COLUMNS * COLUMNS; + if (press == PcPress::B){ + m_back = PcScreen::BOX_GRID; + m_screen = PcScreen::YES_NO; + }else if (press == PcPress::A && !boxes[current_box][m_cursor].empty()){ + m_grid_cursor = m_cursor; + open_mon_menu(PcScreen::BOX_GRID); + }else if (press == PcPress::DOWN){ + m_cursor = (m_cursor + COLUMNS) % BOX_SLOTS; + }else if (press == PcPress::UP){ + m_cursor = (m_cursor + BOX_SLOTS - COLUMNS) % BOX_SLOTS; + }else if (press == PcPress::RIGHT){ + m_cursor = row + (m_cursor + 1) % COLUMNS; + }else if (press == PcPress::LEFT){ + m_cursor = row + (m_cursor + COLUMNS - 1) % COLUMNS; + } + } + void open_mon_menu(PcScreen from){ + m_back = from; + m_held = m_cursor; + m_screen = PcScreen::MON_MENU; + m_cursor = 0; + } + // STORE or WITHDRAW, SUMMARY, MARK, RELEASE, CANCEL. + void press_mon_menu(PcPress press){ + if (press == PcPress::B){ + m_screen = m_back; + m_cursor = m_held; + return; + } + if (press != PcPress::A){ + m_cursor = move_wrapping(m_cursor, press, 5); + return; + } + if (m_cursor != 0){ + error = m_cursor == 3 ? "RELEASE" : std::format("chose the Pokemon's menu row {}", m_cursor + 1); + return; + } + if (m_back == PcScreen::PARTY_PANEL){ + m_screen = PcScreen::BOX_CHOOSER; + return; + } + party.push_back({.species = boxes[current_box][m_held]}); + boxes[current_box][m_held].clear(); + m_screen = PcScreen::BOX_GRID; + m_cursor = m_grid_cursor; + } + void press_box_chooser(PcPress press){ + if (press == PcPress::RIGHT || press == PcPress::LEFT){ + m_chooser_box = (m_chooser_box + (press == PcPress::RIGHT ? 1 : BOXES - 1)) % BOXES; + }else if (press == PcPress::B){ + m_screen = PcScreen::PARTY_PANEL; + m_cursor = m_held; + }else if (press == PcPress::A){ + store(); + } + } + void store(){ + std::vector& box = boxes[m_chooser_box]; + const auto free = std::find(box.begin(), box.end(), std::string()); + if (free == box.end()){ + error = "stored in a full box"; + return; + } + *free = party[m_held].species; + party.erase(party.begin() + ptrdiff_t(m_held)); + m_screen = PcScreen::PARTY_PANEL; + m_cursor = 0; + } + // "Continue BOX operations?" on YES; B leaves for the storage menu, on + // the option used. + void press_yes_no(PcPress press){ + if (press == PcPress::A){ + m_screen = m_back; + m_cursor = 0; + }else if (press == PcPress::B){ + m_screen = PcScreen::STORAGE_MENU; + m_cursor = m_option; + } + } + + void show_text(PcScreen after, size_t pages){ + m_screen = PcScreen::TEXT; + m_after_text = after; + m_texts = pages; + } + static size_t move_wrapping(size_t cursor, PcPress press, size_t rows){ + if (press == PcPress::DOWN){ + return (cursor + 1) % rows; + } + if (press == PcPress::UP){ + return (cursor + rows - 1) % rows; + } + return cursor; + } + + PcScreen m_screen = PcScreen::OVERWORLD; + size_t m_cursor = 0; + PcScreen m_after_text = PcScreen::OVERWORLD; + size_t m_texts = 0; + size_t m_option = 0; + size_t m_chooser_box = 0; + PcScreen m_back = PcScreen::OVERWORLD; + size_t m_held = 0; + size_t m_grid_cursor = 0; +}; + + +class Test_AutoStoryPcStorage : public UnitTest{ +public: + Test_AutoStoryPcStorage(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStoryPcStorage - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + std::function m_body; +}; + + +std::string joined(const std::vector& items){ + std::string text; + for (const std::string& item : items){ + text += (text.empty() ? "" : " ") + item; + } + return text; +} + +std::string party_species(const std::vector& party){ + std::vector species; + for (const PcPartyMember& member : party){ + species.push_back(member.species); + } + return joined(species); +} + +const std::vector SQUIRTLE_MAGIKARP = {{.species = "squirtle"}, {.species = "magikarp"}}; + + +struct PcCase{ + std::string name = {}; + PcTransfer transfer = {}; + std::vector party = {}; + // Box 1's first slots, then box 2's. + std::vector box1 = {}; + std::vector box2 = {}; + size_t current_box = 0; + std::vector cursor_unread = {}; + PcEnd end = PcEnd::DONE; + std::string party_after = {}; + std::string box1_after = {}; + std::string box2_after = {}; + // The presses, when the case checks them. + std::string presses = {}; +}; +const std::vector PC_CASES = { + { + .name = "deposit the Magikarp in box 1 (console B's inputs, 2026-11-02)", + .transfer = {.action = PcAction::DEPOSIT, .party_slot = 1, .box = 0}, + .party = SQUIRTLE_MAGIKARP, + .end = PcEnd::DONE, + .party_after = "squirtle", + .box1_after = "magikarp", + .presses = "A B A B B DOWN A DOWN A A A B B B B", + }, + { + .name = "deposit the third Pokemon in box 2, after one already there", + .transfer = {.action = PcAction::DEPOSIT, .party_slot = 2, .box = 1}, + .party = {{.species = "squirtle"}, {.species = "magikarp"}, {.species = "rattata"}}, + .box2 = {"pidgey"}, + .end = PcEnd::DONE, + .party_after = "squirtle magikarp", + .box2_after = "pidgey rattata", + }, + { + .name = "withdraw box 1 slot 8 (second row)", + .transfer = {.action = PcAction::WITHDRAW, .box = 0, .box_slot = 7}, + .party = {{.species = "squirtle"}}, + .box1 = {"pidgey", "spearow", "ekans", "", "", "", "", "magikarp"}, + .end = PcEnd::DONE, + .party_after = "squirtle magikarp", + .box1_after = "pidgey spearow ekans", + }, + { + .name = "withdraw an empty slot", + .transfer = {.action = PcAction::WITHDRAW, .box = 0, .box_slot = 3}, + .party = {{.species = "squirtle"}}, + .box1 = {"magikarp"}, + .end = PcEnd::FAILED, + .party_after = "squirtle", + .box1_after = "magikarp", + }, + { + .name = "withdraw from a box the PC doesn't show", + .transfer = {.action = PcAction::WITHDRAW, .box = 1, .box_slot = 0}, + .party = {{.species = "squirtle"}}, + .box2 = {"magikarp"}, + .end = PcEnd::FAILED, + .party_after = "squirtle", + .box2_after = "magikarp", + }, + { + .name = "refuse to deposit the last Pokemon", + .transfer = {.action = PcAction::DEPOSIT, .party_slot = 0}, + .party = {{.species = "squirtle"}}, + .end = PcEnd::REFUSED, + .party_after = "squirtle", + .presses = "", + }, + { + .name = "refuse to deposit the last Pokemon that can fight", + .transfer = {.action = PcAction::DEPOSIT, .party_slot = 1}, + .party = {{.species = "squirtle", .fainted = true}, {.species = "magikarp"}}, + .end = PcEnd::REFUSED, + .party_after = "squirtle magikarp", + .presses = "", + }, + { + .name = "skip a slot the party hasn't got, when told to", + .transfer = {.action = PcAction::DEPOSIT, .party_slot = 1, .box = 0, .if_missing = PcMissing::SKIP}, + .party = {{.species = "squirtle"}}, + .end = PcEnd::SKIPPED, + .party_after = "squirtle", + .presses = "", + }, + { + .name = "refuse a slot the party hasn't got", + .transfer = {.action = PcAction::DEPOSIT, .party_slot = 2}, + .party = SQUIRTLE_MAGIKARP, + .end = PcEnd::REFUSED, + .party_after = "squirtle magikarp", + .presses = "", + }, + { + .name = "refuse to withdraw into a full party", + .transfer = {.action = PcAction::WITHDRAW, .box = 0, .box_slot = 0}, + .party = {{.species = "a"}, {.species = "b"}, {.species = "c"}, {.species = "d"}, {.species = "e"}, {.species = "f"}}, + .box1 = {"magikarp"}, + .end = PcEnd::REFUSED, + .party_after = "a b c d e f", + .box1_after = "magikarp", + .presses = "", + }, + { + .name = "a storage menu whose cursor isn't read: stop there", + .transfer = {.action = PcAction::DEPOSIT, .party_slot = 1}, + .party = SQUIRTLE_MAGIKARP, + .cursor_unread = {PcScreen::STORAGE_MENU}, + .end = PcEnd::FAILED, + .party_after = "squirtle magikarp", + .presses = "A B A B B", + }, +}; + +// Each transfer on the fake PC, by its end and what moved. +UnitTestResult test_pc_transfers(){ + for (const PcCase& item : PC_CASES){ + QuietLogger logger; + FakePc pc; + pc.party = item.party; + for (size_t c = 0; c < item.box1.size(); c++){ + pc.boxes[0][c] = item.box1[c]; + } + for (size_t c = 0; c < item.box2.size(); c++){ + pc.boxes[1][c] = item.box2[c]; + } + pc.current_box = item.current_box; + pc.cursor_unread = item.cursor_unread; + const PcResult result = run_pc_transfer(logger, pc, item.transfer); + if (!pc.error.empty()){ + return item.name + ": " + pc.error + " after " + joined(pc.presses); + } + std::vector box1; + std::vector box2; + for (size_t c = 0; c < BOX_SLOTS; c++){ + if (!pc.boxes[0][c].empty()){ + box1.push_back(pc.boxes[0][c]); + } + if (!pc.boxes[1][c].empty()){ + box2.push_back(pc.boxes[1][c]); + } + } + const bool presses_ok = item.presses.empty() && item.end != PcEnd::REFUSED + ? true + : joined(pc.presses) == item.presses; + if (result.end != item.end || party_species(pc.party) != item.party_after + || joined(box1) != item.box1_after || joined(box2) != item.box2_after || !presses_ok + ){ + return std::format( + "{}: expected end {} with party \"{}\", box 1 \"{}\", box 2 \"{}\"; got end {} ({}) with party \"{}\", " + "box 1 \"{}\", box 2 \"{}\" after \"{}\".", + item.name, int(item.end), item.party_after, item.box1_after, item.box2_after, + int(result.end), result.detail, party_species(pc.party), joined(box1), joined(box2), joined(pc.presses) + ); + } + } + return true; +} + + +// The dialog alone on screens the fake PC doesn't reach: a storage menu +// left on another option, a Pokemon's menu not on its top row, nothing +// recognized. +UnitTestResult test_pc_dialog_screens(){ + struct Case{ + std::string name = {}; + PcTransfer transfer = {}; + std::vector views = {}; + // The last view's decision. + PcPress press = PcPress::WAIT; + }; + const PcTransfer deposit{.action = PcAction::DEPOSIT, .party_slot = 1}; + const PcTransfer withdraw{.action = PcAction::WITHDRAW}; + std::vector others(PC_MAX_OTHER_IN_A_ROW, PcView{}); + const std::vector cases = { + {.name = "DEPOSIT from MOVE ITEMS: up", .transfer = deposit, + .views = {{.screen = PcScreen::STORAGE_MENU, .cursor = 3}}, .press = PcPress::UP}, + {.name = "DEPOSIT from SEE YA: down, wrapping", .transfer = deposit, + .views = {{.screen = PcScreen::STORAGE_MENU, .cursor = 4}}, .press = PcPress::DOWN}, + {.name = "WITHDRAW from SEE YA: down, wrapping", .transfer = withdraw, + .views = {{.screen = PcScreen::STORAGE_MENU, .cursor = 4}}, .press = PcPress::DOWN}, + {.name = "a Pokemon's menu not on its top row: never moved", .transfer = deposit, + .views = {{.screen = PcScreen::MON_MENU, .cursor = 3}}, .press = PcPress::FAIL}, + {.name = "a Pokemon's menu with its cursor unread: A, the menu opens on its top", .transfer = deposit, + .views = {{.screen = PcScreen::MON_MENU}}, .press = PcPress::A}, + {.name = "a YES/NO before the transfer", .transfer = deposit, + .views = {{.screen = PcScreen::YES_NO, .cursor = 0}}, .press = PcPress::FAIL}, + {.name = "nothing recognized for long", .transfer = deposit, .views = others, .press = PcPress::FAIL}, + {.name = "the PC still booting after A: wait", .transfer = deposit, + .views = {{.screen = PcScreen::OVERWORLD}, {.screen = PcScreen::OVERWORLD}}, .press = PcPress::WAIT}, + {.name = "the overworld again before the transfer", .transfer = deposit, + .views = {{.screen = PcScreen::OVERWORLD}, {.screen = PcScreen::TEXT}, {.screen = PcScreen::OVERWORLD}}, .press = PcPress::FAIL}, + {.name = "the PC never booting", .transfer = deposit, + .views = std::vector(PC_BOOT_READS + 1, PcView{.screen = PcScreen::OVERWORLD}), .press = PcPress::FAIL}, + {.name = "the box chooser after a move, unread", .transfer = {.action = PcAction::DEPOSIT, .box = 2}, + .views = {{.screen = PcScreen::BOX_CHOOSER}, {.screen = PcScreen::BOX_CHOOSER}}, .press = PcPress::FAIL}, + }; + for (const Case& item : cases){ + PcStorageDialog dialog(item.transfer); + PcDecision decision; + for (const PcView& view : item.views){ + decision = dialog.next(view); + } + if (decision.press != item.press){ + return std::format("{}: expected {}, got {} ({}).", item.name, + pc_press_name(item.press), pc_press_name(decision.press), decision.reason); + } + } + return true; +} + + +// The table's variant, both ways, and the variants it refuses. +UnitTestResult test_pc_variant(){ + const std::vector transfers = { + {.action = PcAction::DEPOSIT, .party_slot = 1, .box = 0}, + {.action = PcAction::WITHDRAW, .box = 13, .box_slot = 29}, + {.action = PcAction::DEPOSIT, .party_slot = 1, .box = 0, .if_missing = PcMissing::SKIP}, + }; + for (const PcTransfer& transfer : transfers){ + const std::string variant = pc_variant(transfer); + const std::optional back = parse_pc_variant(variant); + if (!back || back->action != transfer.action || back->box != transfer.box + || back->party_slot != transfer.party_slot || back->box_slot != transfer.box_slot + || back->if_missing != transfer.if_missing + ){ + return "\"" + variant + "\" didn't read back as the transfer it was made from."; + } + } + if (pc_variant(transfers[0]) != "pc deposit slot=2 box=1"){ + return "Expected \"pc deposit slot=2 box=1\", got \"" + pc_variant(transfers[0]) + "\"."; + } + for (std::string_view bad : { + "pc", "pc deposit", "pc deposit slot=7 box=1", "pc deposit slot=0 box=1", "pc deposit slot=2 box=15", + "pc withdraw box=1 slot=31", "pc release slot=1 box=1", "pc deposit box=1 slot=2", "capture rattata", + }){ + if (parse_pc_variant(bad)){ + return "Expected \"" + std::string(bad) + "\" refused."; + } + } + return true; +} + +} + + +void add_tests_AutoStoryPcStorage(UnitTestDatabase& database){ + const auto add = [&](std::string name, UnitTestResult (*body)()){ + database.add(std::move(name), body); + }; + add("deposits, withdrawals and refusals on a fake PC", test_pc_transfers); + add("the dialog on other screens", test_pc_dialog_screens); + add("the table's variant", test_pc_variant); +} + + + +} +} +} diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 19d9f6b5f0..09845c48e3 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1730,6 +1730,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_BoxGenderReader.h Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_BoxSelectionModeDetector.cpp Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_BoxSelectionModeDetector.h + Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_PcScreenReader.cpp + Source/VideoGames/PokemonFRLG/Inference/Box/PokemonFRLG_PcScreenReader.h Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_BattleDialogs.cpp Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_BattleDialogs.h Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp @@ -1849,6 +1851,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryDamage.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryDamage.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -1859,6 +1864,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp