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/Inference/Menus/PokemonFRLG_SaveCardReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp index 702d314298..0793744ff9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp @@ -68,6 +68,8 @@ class PlaceNameMatcher : public OCR::SmallDictionaryMatcher{ {"pewter-city", "Pewter City"}, {"route-1", "Route 1"}, {"route-2", "Route 2"}, + {"route-3", "Route 3"}, + {"route-4", "Route 4"}, }){ JsonArray names; names.push_back(name); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h index 8a037e0242..e7f81aa521 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h @@ -4,8 +4,9 @@ * * Reads where the game would be saved: the place name in blue at the top * of the card the START menu's SAVE shows ("PALLET TOWN" over PLAYER, - * BADGES and TIME). It knows the places from a new game to Pewter City, - * in English only so far: in another language nothing is read. + * BADGES and TIME). It knows the places from a new game to Route 4, in + * English only so far: in another language nothing is read. Route 3 and + * Route 4 have no test capture yet. * */ @@ -30,7 +31,7 @@ namespace PokemonFRLG{ ImageFloatBox save_card_title_box(); // The place on the save card: "pallet-town", "viridian-city", -// "viridian-forest", "pewter-city", "route-1" or "route-2"; empty if it +// "viridian-forest", "pewter-city", "route-1" to "route-4"; empty if it // can't be read, as in a language the reader has no names for. std::string read_save_card_location(Logger& logger, Language language, const ImageViewRGB32& screen); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp new file mode 100644 index 0000000000..f25c255d12 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp @@ -0,0 +1,517 @@ +/* Teach Screen Reader + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "Common/Cpp/Color.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/Json/JsonArray.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonTools/OCR/OCR_SmallDictionaryMatcher.h" +#include "Tests/ImageUnitTest.h" +#include "Tests/TestUtils.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Tests.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_OcrPreprocessing.h" +#include "PokemonFRLG_ItemListReader.h" +#include "PokemonFRLG_SummaryMovesReader.h" +#include "PokemonFRLG_TeachScreenReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// A box in the game's 240x160 pixels. +struct GameBox{ + double x; + double y; + double width; + double height; +}; + +ImageViewRGB32 game_box(const ImageViewRGB32& screen){ + return extract_box_reference(screen, GameSettings::instance().GAME_BOX); +} + +ImageViewRGB32 game_crop(const ImageViewRGB32& screen, const GameBox& box){ + return extract_box_reference( + game_box(screen), + ImageFloatBox(box.x / 240, box.y / 160, box.width / 240, box.height / 160) + ); +} + +// The box is all one color, near `color`. +bool solid(const ImageViewRGB32& screen, const GameBox& box, const FloatPixel& color){ + const ImageStats stats = image_stats(game_crop(screen, box)); + return euclidean_distance(stats.average, color) < 40 && stats.stddev.sum() < 40; +} + +// Measured on the captures (console B, 2026-11-03). Left of the title, the +// frame's color tells the screen: the bag's orange, the TM CASE's mint, +// the party menu's dark teal. +constexpr GameBox CORNER{6, 14, 8, 4}; +const FloatPixel BAG_ORANGE(255, 210, 94); +const FloatPixel TM_CASE_MINT(193, 234, 207); +const FloatPixel PARTY_TEAL(18, 109, 107); +const FloatPixel WHITE(255, 255, 255); + +// The item menu's box, between its rows (OPEN at y 109, REGISTER 125, +// CANCEL 141); blue text box or list below it when it is closed. +constexpr GameBox ITEM_MENU_GAP_1{186, 118, 40, 4}; +constexpr GameBox ITEM_MENU_GAP_2{186, 134, 40, 4}; +// Over the party menu: the YES/NO box above the message box (YES at +// y 77, NO at 93); the message box's right part, past the prompt box's +// end (x 180) and its CANCEL; the prompt box under its text. +constexpr GameBox YES_NO_TOP{180, 72, 30, 4}; +constexpr GameBox MESSAGE_RIGHT{200, 150, 30, 3}; +constexpr GameBox PROMPT_BOTTOM{60, 150, 80, 3}; + +// The cursors' dark arrows, 5 pixels wide and 9 high, rows 16 apart: the +// bag's at x 90, y 12; the TM CASE's at x 81, y 12; the item menu's at +// x 177, y 108; the party YES/NO's at x 169, y 76. +struct ArrowColumn{ + double x; + double top; + size_t rows; +}; +constexpr double ARROW_ROW_STEP = 16; + +std::optional arrow_column(TeachScreen screen){ + switch (screen){ + case TeachScreen::BAG: return ArrowColumn{89, 11, TEACH_LIST_ROWS}; + case TeachScreen::TM_CASE: return ArrowColumn{80, 11, TM_CASE_ROWS}; + case TeachScreen::ITEM_MENU: return ArrowColumn{176, 107, ITEM_MENU_ROWS}; + case TeachScreen::PARTY_YES_NO: return ArrowColumn{168, 75, 2}; + default: return std::nullopt; + } +} + +// The share of the arrow's cell that is dark: about 0.35 with the arrow, +// nothing without (the cell is left of the rows' text). +double dark_share(const ImageViewRGB32& cell){ + size_t dark = 0; + for (size_t y = 0; y < cell.height(); y++){ + for (size_t x = 0; x < cell.width(); x++){ + const Color pixel(cell.pixel(x, y)); + dark += pixel.red() + pixel.green() + pixel.blue() < 330 ? 1 : 0; + } + } + const size_t total = cell.width() * cell.height(); + return total == 0 ? 0 : double(dark) / double(total); +} + + +// Small OCR dictionaries of English names: slug -> how the game writes it. +class NameMatcher : public OCR::SmallDictionaryMatcher{ +public: + NameMatcher(std::initializer_list> names) + : SmallDictionaryMatcher(dictionary(names)) + {} + + // The best match's log10p and slug, if any. + std::optional> match( + Logger& logger, Language language, const ImageViewRGB32& image, + const std::vector& filters + ) const{ + if (!languages()[language]){ + return std::nullopt; + } + const ImageRGB32 ready = preprocess_for_ocr(image); + const OCR::StringMatchResult result = match_substring_from_image_multifiltered( + &logger, language, ready, filters, -1.40, 0.50, 0.01, 0.50 + ); + if (result.results.empty()){ + return std::nullopt; + } + return std::make_pair(result.results.begin()->first, result.results.begin()->second.token); + } + std::string read( + Logger& logger, Language language, const ImageViewRGB32& image, + const std::vector& filters + ) const{ + const std::optional> best = match(logger, language, image, filters); + return best ? best->second : ""; + } + +private: + static JsonObject dictionary(std::initializer_list> names){ + JsonObject english; + for (const auto& [slug, name] : names){ + JsonArray candidates; + candidates.push_back(name); + english[slug] = std::move(candidates); + } + JsonObject json; + json["eng"] = std::move(english); + return json; + } +}; + +const NameMatcher& pocket_matcher(){ + static const NameMatcher matcher{ + {"items", "Items"}, + {"key-items", "Key Items"}, + {"poke-balls", "Poke Balls"}, + }; + return matcher; +} + +// The key items a story meets up to the Rocket Hideout (pokefirered +// src/data/items.h). +const NameMatcher& key_item_matcher(){ + static const NameMatcher matcher{ + {"tm-case", "TM Case"}, + {"teachy-tv", "Teachy TV"}, + {"helix-fossil", "Helix Fossil"}, + {"dome-fossil", "Dome Fossil"}, + {"old-amber", "Old Amber"}, + {"oaks-parcel", "Oak's Parcel"}, + {"town-map", "Town Map"}, + {"bike-voucher", "Bike Voucher"}, + {"bicycle", "Bicycle"}, + {"ss-ticket", "S.S. Ticket"}, + {"old-rod", "Old Rod"}, + {"vs-seeker", "VS Seeker"}, + {"fame-checker", "Fame Checker"}, + {"berry-pouch", "Berry Pouch"}, + {"coin-case", "Coin Case"}, + {"cancel", "Cancel"}, + }; + return matcher; +} + +// A phrase from each message, as the captures show it. +const NameMatcher& text_matcher(){ + static const NameMatcher matcher{ + {"teach-which", "Teach which"}, + {"wants-to-learn", "wants to learn the"}, + {"knows-four", "knows four moves"}, + {"delete-question", "Should a move be deleted"}, + {"which-move", "Which move should be forgotten"}, + {"stop-question", "Stop trying to teach"}, + {"did-not-learn", "did not learn the"}, + {"not-compatible", "are not compatible"}, + }; + return matcher; +} + +TeachText teach_text(const std::string& slug){ + static const std::vector> texts{ + {"teach-which", TeachText::TEACH_WHICH}, + {"wants-to-learn", TeachText::WANTS_TO_LEARN}, + {"knows-four", TeachText::KNOWS_FOUR}, + {"delete-question", TeachText::DELETE_QUESTION}, + {"which-move", TeachText::WHICH_MOVE}, + {"stop-question", TeachText::STOP_QUESTION}, + {"did-not-learn", TeachText::DID_NOT_LEARN}, + {"not-compatible", TeachText::NOT_COMPATIBLE}, + }; + for (const auto& [name, text] : texts){ + if (name == slug){ + return text; + } + } + return TeachText::UNKNOWN; +} + +// The text's lines: the pocket's title from x 16, white on the bag's +// bar; a list row's name; the TM CASE's move after "No03 " (x 88 to 111); +// the message box's two lines, y 124 and 139, from x 8; the prompt's line +// at y 141. +constexpr GameBox POCKET_TITLE{8, 10, 76, 12}; +constexpr double TM_MOVE_LEFT = 110; +constexpr GameBox MESSAGE_LINE_1{6, 121, 226, 13}; +constexpr GameBox MESSAGE_LINE_2{6, 136, 226, 13}; +constexpr GameBox PROMPT_LINE{6, 138, 170, 13}; + +// A list's rows: dark text, and the cursor's row in blue (32, 80, 224). +const std::vector& LIST_TEXT_FILTERS(){ + static const std::vector filters = [](){ + std::vector all = DARK_TEXT_FILTERS(); + all.emplace_back(combine_rgb(0, 0, 128), combine_rgb(128, 144, 255)); + return all; + }(); + return filters; +} + +TeachScreen read_party_screen(const ImageViewRGB32& screen){ + if (solid(screen, YES_NO_TOP, WHITE) && solid(screen, MESSAGE_RIGHT, WHITE)){ + return TeachScreen::PARTY_YES_NO; + } + if (solid(screen, MESSAGE_RIGHT, WHITE)){ + return TeachScreen::PARTY_TEXT; + } + if (solid(screen, PROMPT_BOTTOM, WHITE)){ + return TeachScreen::PARTY_PROMPT; + } + return TeachScreen::OTHER; +} + +} + + +const char* teach_screen_name(TeachScreen screen){ + switch (screen){ + case TeachScreen::OTHER: return "other"; + case TeachScreen::BAG: return "bag"; + case TeachScreen::ITEM_MENU: return "item_menu"; + case TeachScreen::TM_CASE: return "tm_case"; + case TeachScreen::PARTY_PROMPT: return "party_prompt"; + case TeachScreen::PARTY_TEXT: return "party_text"; + case TeachScreen::PARTY_YES_NO: return "party_yes_no"; + case TeachScreen::KNOWN_MOVES: return "known_moves"; + } + return "?"; +} + +const char* teach_text_name(TeachText text){ + switch (text){ + case TeachText::UNKNOWN: return "unknown"; + case TeachText::TEACH_WHICH: return "teach_which"; + case TeachText::WANTS_TO_LEARN: return "wants_to_learn"; + case TeachText::KNOWS_FOUR: return "knows_four"; + case TeachText::DELETE_QUESTION: return "delete_question"; + case TeachText::WHICH_MOVE: return "which_move"; + case TeachText::STOP_QUESTION: return "stop_question"; + case TeachText::DID_NOT_LEARN: return "did_not_learn"; + case TeachText::NOT_COMPATIBLE: return "not_compatible"; + } + return "?"; +} + +TeachScreen read_teach_screen(const ImageViewRGB32& screen){ + const bool menu = solid(screen, ITEM_MENU_GAP_1, WHITE) && solid(screen, ITEM_MENU_GAP_2, WHITE); + if (solid(screen, CORNER, BAG_ORANGE)){ + return menu ? TeachScreen::ITEM_MENU : TeachScreen::BAG; + } + if (solid(screen, CORNER, TM_CASE_MINT)){ + return menu ? TeachScreen::ITEM_MENU : TeachScreen::TM_CASE; + } + if (solid(screen, CORNER, PARTY_TEAL)){ + return read_party_screen(screen); + } + if (read_summary_move_cursor(screen)){ + return TeachScreen::KNOWN_MOVES; + } + return TeachScreen::OTHER; +} + +std::optional read_teach_cursor(TeachScreen screen, const ImageViewRGB32& image){ + if (screen == TeachScreen::KNOWN_MOVES){ + return read_summary_move_cursor(image); + } + const std::optional column = arrow_column(screen); + if (!column){ + return std::nullopt; + } + std::optional cursor; + for (size_t row = 0; row < column->rows; row++){ + const GameBox cell{column->x, column->top + ARROW_ROW_STEP * double(row), 7, 10}; + const double share = dark_share(game_crop(image, cell)); + if (share < 0.15){ + continue; + } + if (cursor){ + return std::nullopt; + } + cursor = row; + } + return cursor; +} + +std::string read_pocket_name(Logger& logger, Language language, const ImageViewRGB32& screen){ + return pocket_matcher().read(logger, language, game_crop(screen, POCKET_TITLE), BRIGHT_TEXT_FILTERS()); +} + +std::string read_key_item(Logger& logger, Language language, const ImageViewRGB32& screen, size_t row){ + const ImageViewRGB32 name = extract_box_reference(game_box(screen), item_list_name_box(int(row))); + return key_item_matcher().read(logger, language, name, LIST_TEXT_FILTERS()); +} + +std::string read_tm_case_move(Logger& logger, Language language, const ImageViewRGB32& screen, size_t row){ + const ImageFloatBox name = item_list_name_box(int(row)); + const GameBox box{TM_MOVE_LEFT, name.y * 160, 200 - TM_MOVE_LEFT, name.height * 160}; + return read_move_name(logger, language, game_crop(screen, box)); +} + +TeachText read_teach_text(Logger& logger, Language language, const ImageViewRGB32& screen){ + const TeachScreen kind = read_teach_screen(screen); + if (kind == TeachScreen::PARTY_PROMPT){ + return teach_text(text_matcher().read(logger, language, game_crop(screen, PROMPT_LINE), DARK_TEXT_FILTERS())); + } + if (kind != TeachScreen::PARTY_TEXT && kind != TeachScreen::PARTY_YES_NO){ + return TeachText::UNKNOWN; + } + // The phrase either line matches best. + std::optional> best; + for (const GameBox& line : {MESSAGE_LINE_1, MESSAGE_LINE_2}){ + const std::optional> match = + text_matcher().match(logger, language, game_crop(screen, line), DARK_TEXT_FILTERS()); + if (match && (!best || match->first < best->first)){ + best = match; + } + } + return best ? teach_text(best->second) : TeachText::UNKNOWN; +} + +std::vector read_teach_rows(Logger& logger, Language language, const ImageViewRGB32& image, TeachScreen screen){ + std::vector rows; + const size_t rows_shown = screen == TeachScreen::BAG ? TEACH_LIST_ROWS : TM_CASE_ROWS; + for (size_t row = 0; row < rows_shown; row++){ + std::string name = screen == TeachScreen::BAG + ? read_key_item(logger, language, image, row) + : read_tm_case_move(logger, language, image, row); + if (name.empty()){ + break; + } + rows.push_back(std::move(name)); + if (rows.back() == "cancel"){ + break; + } + } + return rows; +} + + +namespace{ + +// The language a test image's name ends with: _. +Language language_of_test_image(const std::string& image){ + const std::vector words = parse_words(Filesystem::Path(image).stem().string()); + return words.size() < 2 ? Language::None : language_code_to_enum(words.back()); +} + +std::string cursor_value(const std::optional& cursor){ + return cursor ? std::to_string(*cursor + 1) : "none"; +} + +// What a list or menu shows: its rows from the top to the first not read, +// and its cursor. +void add_list( + std::vector& labels, std::vector& values, + const std::vector& rows, const std::optional& cursor +){ + for (size_t c = 0; c < rows.size(); c++){ + labels.push_back(std::format("row {}", c + 1)); + values.push_back(rows[c]); + } + labels.push_back("cursor"); + values.push_back(cursor_value(cursor)); +} + +// A capture of a screen on the way to a TM taught, named +// _.png. Its golden file holds the screen read, +// then: in the bag, its pocket, the key items from the top (KEY ITEMS +// only) and the cursor's row (from 1, or "none"); in the TM CASE, its +// moves from the top and the cursor's row; in an item menu or YES/NO, the +// cursor's row; over the party menu, the message; on KNOWN MOVES, the five +// rows' moves and the cursor's row. +class Test_TeachScreen : public ImageUnitTest{ +public: + Test_TeachScreen(const std::string& image) + : ImageUnitTest("PokemonFRLG::TeachScreenReader - " + image, {image}) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + const Language language = language_of_test_image(image_paths()[0]); + if (language == Language::None || language == Language::EndOfList){ + return "Error: filename must be _."; + } + const ImageViewRGB32& screen = images[0]; + const TeachScreen kind = read_teach_screen(screen); + std::vector labels{"screen"}; + std::vector values{teach_screen_name(kind)}; + add_details(logger, language, screen, kind, labels, values); + return check_against_golden_file(UNIT_TEST_RESOURCE_PATH() + image_paths()[0], labels, values); + } + +private: + static void add_details( + Logger& logger, Language language, const ImageViewRGB32& screen, TeachScreen kind, + std::vector& labels, std::vector& values + ){ + switch (kind){ + case TeachScreen::BAG:{ + const std::string pocket = read_pocket_name(logger, language, screen); + labels.push_back("pocket"); + values.push_back(pocket.empty() ? "none" : pocket); + const std::vector rows = pocket == "key-items" + ? read_teach_rows(logger, language, screen, kind) + : std::vector{}; + add_list(labels, values, rows, read_teach_cursor(kind, screen)); + return; + } + case TeachScreen::TM_CASE: + add_list(labels, values, read_teach_rows(logger, language, screen, kind), read_teach_cursor(kind, screen)); + return; + case TeachScreen::ITEM_MENU: + add_list(labels, values, {}, read_teach_cursor(kind, screen)); + return; + case TeachScreen::PARTY_YES_NO: + labels.push_back("cursor"); + values.push_back(cursor_value(read_teach_cursor(kind, screen))); + [[fallthrough]]; + case TeachScreen::PARTY_PROMPT: + case TeachScreen::PARTY_TEXT: + labels.push_back("text"); + values.push_back(teach_text_name(read_teach_text(logger, language, screen))); + return; + case TeachScreen::KNOWN_MOVES: + for (size_t row = 0; row < SUMMARY_MOVE_ROWS; row++){ + const std::string move = read_summary_move_name(logger, language, screen, row); + labels.push_back(std::format("move {}", row + 1)); + values.push_back(move.empty() ? "none" : move); + } + labels.push_back("cursor"); + values.push_back(cursor_value(read_teach_cursor(kind, screen))); + return; + case TeachScreen::OTHER: + return; + } + } +}; + +} + +void add_tests_TeachScreenReader(UnitTestDatabase& database){ + // FireRed on console B, 2026-11-03: TM03 taught to the lead from the + // bag, B on the move to forget, and YES to stop. + for (const char* image : { + "01_BagItems_eng.png", + "02_BagKeyItems_eng.png", + "03_KeyItemsTmCase_eng.png", + "04_TmCaseMenu_eng.png", + "05_TmCaseList_eng.png", + "06_Tm03Menu_eng.png", + "07_TeachWhich_eng.png", + "08_WantsToLearn_eng.png", + "09_KnowsFour_eng.png", + "10_DeleteQuestion_eng.png", + "11_WhichMove_eng.png", + "12_KnownMoves_eng.png", + "13_StopQuestion_eng.png", + "14_DidNotLearn_eng.png", + }){ + database.add(std::string("PokemonFRLG/TeachScreenReader/") + image); + } +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h new file mode 100644 index 0000000000..2e1dc393f5 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h @@ -0,0 +1,112 @@ +/* Teach Screen Reader + * + * From: https://github.com/PokemonAutomation/ + * + * Reads the screens a TM is taught through, from the bag to the move it + * replaces, as the game shows them (console B, 2026-11-03): the bag's + * pockets, TM CASE in KEY ITEMS and its OPEN/REGISTER/CANCEL menu, the TM + * CASE's list and a TM's USE/GIVE/EXIT menu, the party menu's "Teach which + * POKEMON?", its messages and YES/NO, and the KNOWN MOVES page picking the + * move to forget (read with PokemonFRLG_SummaryMovesReader.h). + * + * Which screen shows is told from the colors of its background, its boxes + * and the dark arrow of its cursor, measured on those captures in the + * game's 240x160 pixels: the bag's frame is orange, the TM CASE's mint, + * the party menu's dark teal. Names and messages are read by OCR, in + * English only so far: in another language nothing is read. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_TeachScreenReader_H +#define PokemonAutomation_PokemonFRLG_TeachScreenReader_H + +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/Language.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +enum class TeachScreen{ + OTHER, + // A pocket's list, its cursor live. + BAG, + // OPEN/REGISTER/CANCEL over the bag, or USE/GIVE/EXIT over the TM CASE. + ITEM_MENU, + // The TM CASE's list, its cursor live. + TM_CASE, + // "Teach which POKEMON?" + PARTY_PROMPT, + // A message over the party menu. + PARTY_TEXT, + // A message over the party menu with YES/NO. + PARTY_YES_NO, + // The KNOWN MOVES page with its red cursor: the move to forget. + KNOWN_MOVES, +}; +const char* teach_screen_name(TeachScreen screen); + +TeachScreen read_teach_screen(const ImageViewRGB32& screen); + + +// Rows a list or menu shows at once: the bag's, the TM CASE's (its info +// box is below), the item menu's. +constexpr size_t TEACH_LIST_ROWS = 6; +constexpr size_t TM_CASE_ROWS = 5; +constexpr size_t ITEM_MENU_ROWS = 3; + +// The row the cursor is on: the dark arrow in the bag's list, the TM +// CASE's list, the item menu (0: OPEN or USE) or the party menu's YES/NO +// (0: YES); the red box on KNOWN MOVES. Nothing if no row or more than one +// has it. +std::optional read_teach_cursor(TeachScreen screen, const ImageViewRGB32& image); + +// The open pocket's name: "items", "key-items" or "poke-balls"; empty if +// not read. +std::string read_pocket_name(Logger& logger, Language language, const ImageViewRGB32& screen); + +// The key item in the bag's row `row`: "tm-case", "teachy-tv", ... or +// "cancel"; empty if not read. +std::string read_key_item(Logger& logger, Language language, const ImageViewRGB32& screen, size_t row); + +// The move of the TM or HM in the TM CASE's row `row`, as a slug such as +// "water-pulse"; empty for CLOSE or a move not read. +std::string read_tm_case_move(Logger& logger, Language language, const ImageViewRGB32& screen, size_t row); + +// The rows of KEY ITEMS (`screen` BAG) or the TM CASE from the top, up to +// the first not read; KEY ITEMS' end with its "cancel". +std::vector read_teach_rows(Logger& logger, Language language, const ImageViewRGB32& image, TeachScreen screen); + +// The messages of the party menu while a TM is taught. +enum class TeachText{ + UNKNOWN, + TEACH_WHICH, // "Teach which POKEMON?" + WANTS_TO_LEARN, // " wants to learn the move ." + KNOWS_FOUR, // "However, already knows four moves." + DELETE_QUESTION, // "Should a move be deleted and replaced with ?" + WHICH_MOVE, // "Which move should be forgotten?" + STOP_QUESTION, // "Stop trying to teach ?" + DID_NOT_LEARN, // " did not learn the move ." + NOT_COMPATIBLE, // " and are not compatible." +}; +const char* teach_text_name(TeachText text); + +// The message over the party menu, from the prompt's or the message box's +// first two lines. +TeachText read_teach_text(Logger& logger, Language language, const ImageViewRGB32& screen); + + +void add_tests_TeachScreenReader(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.cpp new file mode 100644 index 0000000000..6e066afdc9 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.cpp @@ -0,0 +1,90 @@ +/* Battle Text Box Detector + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonTools/Images/SolidColorTest.h" +#include "Tests/ImageUnitTest.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" +#include "PokemonFRLG_BattleTextBoxDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// Measured on the console's captures (1920x1080): blue (34, 88, 121), +// white (236, 229, 235), gold (222, 176, 52). +const FloatPixel BOX_BLUE{0.176, 0.357, 0.467}; +const FloatPixel FRAME_GOLD{0.493, 0.391, 0.116}; + +} + + +BattleTextBoxDetector::BattleTextBoxDetector(Color color) + : m_color(color) + // As BattleDialogDetector's. + , m_blue_top(0.0372308, 0.750154, 0.925538, 0.00934615) + , m_blue_right(0.956615, 0.7595, 0.00615385, 0.185885) + , m_white_line(0.0385, 0.7337, 0.923, 0.0058) + , m_gold_band(0.0385, 0.7163, 0.923, 0.0087) +{} + +void BattleTextBoxDetector::make_overlays(VideoOverlaySet& items) const{ + const BoxOption& GAME_BOX = GameSettings::instance().GAME_BOX; + items.add(m_color, GAME_BOX.inner_to_outer(m_blue_top)); + items.add(m_color, GAME_BOX.inner_to_outer(m_blue_right)); + items.add(m_color, GAME_BOX.inner_to_outer(m_white_line)); + items.add(m_color, GAME_BOX.inner_to_outer(m_gold_band)); +} + +bool BattleTextBoxDetector::detect(const ImageViewRGB32& screen){ + const ImageViewRGB32 game_screen = extract_box_reference(screen, GameSettings::instance().GAME_BOX); + return is_solid(extract_box_reference(game_screen, m_blue_top), BOX_BLUE, 0.25, 20) + && is_solid(extract_box_reference(game_screen, m_blue_right), BOX_BLUE, 0.25, 20) + && is_white(extract_box_reference(game_screen, m_white_line), 500, 20) + && is_solid(extract_box_reference(game_screen, m_gold_band), FRAME_GOLD, 0.10, 40); +} + + + +class Test_BattleTextBoxDetector : public ImageUnitTest{ +public: + Test_BattleTextBoxDetector(const std::string& image, bool expected) + : ImageUnitTest("PokemonFRLG::BattleTextBoxDetector - " + image, {image}) + , m_expected(expected) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + BattleTextBoxDetector detector(COLOR_RED); + return detector.detect(images[0]) == m_expected; + } + +private: + bool m_expected; +}; + +void add_tests_BattleTextBoxDetector(UnitTestDatabase& database){ + // Console B's captures: Mt. Moon B1F and Route 1, a battle's opening + // with the box still empty, after a leg's watch saw nothing; Viridian + // Forest, "Wild CATERPIE" with its HP box. + database.add("PokemonFRLG/BattleTextBoxDetector/MtMoonB1F_Opening_True.png", true); + database.add("PokemonFRLG/BattleTextBoxDetector/Route1_Opening_True.png", true); + database.add("PokemonFRLG/BattleTextBoxDetector/ViridianForest_WildCaterpie_True.png", true); + // The cave just before that battle, and black below Pewter's Pokemon + // Center, which BattleDialogDetector reads as the box. + database.add("PokemonFRLG/BattleTextBoxDetector/MtMoonB1F_Overworld_False.png", false); + database.add("PokemonFRLG/BattleTextBoxDetector/PewterPokemonCenter_BlackBelow_False.png", false); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.h new file mode 100644 index 0000000000..be72b867ea --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.h @@ -0,0 +1,55 @@ +/* Battle Text Box Detector + * + * From: https://github.com/PokemonAutomation/ + * + * A battle's text box, by its frame: blue inside a white line and a gold + * band. It shows from the moment a battle's opening ends, before the HP + * boxes and the "Wild appeared!" that BattleScreenDetector needs: + * in Mt. Moon the HP boxes came over 5s after the step that set the battle + * off (console B, 2026-11-03). Black below a small room, which passes for + * the box's blue (BattleDialogDetector), has neither line. + * + * English layout only: the Japanese box sits a little lower and narrower + * (BattleDialogDetector) and wasn't checked. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_BattleTextBoxDetector_H +#define PokemonAutomation_PokemonFRLG_BattleTextBoxDetector_H + +#include "Common/Cpp/Color.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" +#include "CommonTools/VisualDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +class BattleTextBoxDetector : public StaticScreenDetector{ +public: + BattleTextBoxDetector(Color color = COLOR_RED); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + +private: + Color m_color; + ImageFloatBox m_blue_top; + ImageFloatBox m_blue_right; + ImageFloatBox m_white_line; + ImageFloatBox m_gold_band; +}; + + +void add_tests_BattleTextBoxDetector(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_1F.json index a6cb236993..e1539f229f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_1F.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_1F.json @@ -7,27 +7,27 @@ }, "tiles": [ "########################################", - "#######...##.#########wwwwwwwww#..##..##", - "#######......#########wwwwwwwww#......##", - "#######..##..#wwwwwwwwwwwwwwwww#......##", - "##.####..##..#wwwwwwwwww#...#ww##.######", - "##.#.........#wwwwwwwwww#...#wwwwwwwwww#", - "#..#....##...#www#..........#wwwwwwwwww#", - "#..#....##...#www#..........#wwwwwwwwww#", - "#..#....##...#www#.................#www#", - "####....##.###www#...######...#....#www#", - "##.#....#www##www#...##.####.......#www#", - "##......#www##www##.###...##.......#www#", - "##......#wwwwwwww#....#...##...#####www#", - "##......#wwwwwwww#..#.##.###...###.....#", - "##..##..#wwwwwwww#.........#...#.......#", - "##..#...#www#....##......###...#.....###", - "##......#...#....####...####...#...#####", - "##......#...#..................#...#####", - "#####.###...##.#...............#...#####", - "#####..........#...............#...#####", - "#####..........#...............#...#####", - "#####..........#################...#####", + "#######~~~##~#########wwwwwwwww#~~##~~##", + "#######~~~.~~#########wwwwwwwww#~~.~~~##", + "#######~~##~~#wwwwwwwwwwwwwwwww#~~~~~~##", + "##.####~~##~~#wwwwwwwwww#~~~#ww##.######", + "##~#~~~~~~~~~#wwwwwwwwww#~~~#wwwwwwwwww#", + "#~~#~~~~##~~~#www#~~~~~~~~~~#wwwwwwwwww#", + "#.~#~~~~##~~~#www#~~~~~~~~~~#wwwwwwwwww#", + "#~~#~~~~##~~~#www#~~~~~~~~~~~~~~~~~#www#", + "####~~~~##.###www#~~~######~~~#~~~~#www#", + "##~#~~~~#www##www#~~~##~####~~.~~~~#www#", + "##~~~~~~#www##www##.###~.~##~~~~~~~#www#", + "##~~~~~~#wwwwwwww#~~~~#~~~##~~~#####www#", + "##~~~~~~#wwwwwwww#~~#~##.###~~~###~~~~~#", + "##~~##~~#wwwwwwww#~~~~~~~~~#~~~#~~~~~~~#", + "##~~#.~~#www#~~~~##~~~~~~###~~~#~~~~~###", + "##~~~~~~#~~~#~~~~####~~~####~~~#~~~#####", + "##~~~~~~#~~~#~~~~~~~~~~~~~~~~~~#~~~#####", + "#####.###~~~##.#~~~~~~~~~~~~~~~#~~~#####", + "#####~~~~~~~~~~#~~~~~~~~~~~~~~~#~~~#####", + "#####~~~~~~~~~~#~~~~~~~~~~~~~~~#~~~#####", + "#####~~~~~~~~~~#################~.~#####", "########################################" ], "elevation": [ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_2F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_2F.json index 185103d4c8..fa72ccc628 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_2F.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_2F.json @@ -9,24 +9,24 @@ "########################################", "########################################", "########################################", - "####....####..##.....#............######", - "#######.......#..###.#.#########..######", - "####..####.####.##.#.#.........#########", - "####.....#.........###########....######", - "#######.############.........####.######", - "####.#...............#######....#.######", - "####.#################..#..###.##.######", - "####.#....#.............#...#...#.######", - "####.#.##.#.#####.#######.#####.#.######", - "####.#..#.#.....#.......#.....#.#.######", - "####.#..#.#####.#######.#####.#.########", - "####.#..#.#.....#.....#.....#.#.#.######", - "####.####.#.#####.###.#.#.#.#.#.#.######", - "####.#....#.........#.#.#.#.#.#.#.######", - "####.#.####.#########.#.#.#.###.#.######", - "####.#....#.....#.....#.#.#.....#.######", - "####.############.#####.#.#######.######", - "####..............#.....#.........######", + "####~~~~####~~##~~~~~#~~~~~~~~~~~~######", + "#######~~~~~~.#~~###~#~#########~.######", + "####~~####~####~##~#~#~~~~~~~~~#########", + "####~.~~~#~~~~~~~~~###########~~~~######", + "#######~############~~~~~~~~~####~######", + "####~#~~~~~~~~~~~~~~~#######~~~~#~######", + "####~#################~~#~.###~##~######", + "####~#~~~~#~~~~~~~~~~~~.#~~~#~~~#~######", + "####~#~##~#~#####~#######~#####~#~######", + "####~#~~#~#~~~~~#~~~~~~~#~~~~~#~#~######", + "####~#~~#~#####~#######~#####~#~########", + "####~#~.#~#~~~~~#~~~~~#~~~~~#~#~#~######", + "####~####~#~#####~###~#~#~#~#~#~#~######", + "####~#~~~~#~~~~~~~~~#~#~#~#~#~#~#~######", + "####~#~####~#########~#~#~#~###~#~######", + "####~#~~~~#~~~~~#~~~~~#~#~#~~~~~#~######", + "####~############~#####~#~#######~######", + "####~~~~~~~~~~~~~~#~~~~~#~~~~~~~~~######", "########################################", "########################################" ], diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_B1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_B1F.json index 683929ff37..b06ee61a5f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_B1F.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCave_B1F.json @@ -7,27 +7,27 @@ }, "tiles": [ "########################################", - "##..............##.....############....#", - "##..............#........#######.......#", - "##..............#..####.....#######....#", - "##......#####.###..####.....#######....#", - "##......#..........########.#######.#..#", - "##..##..##........#########............#", - "##..#...###......##########............#", - "##......#wwwww##................#...####", - "#########wwwww##................#...####", - "#www############................#...####", - "#www##############.########.###.#...####", - "#www#.#.#.########........#.###.#...####", - "#www#.....########........#.....#...####", - "#www#.....########........#.....#...####", - "#www###.###wwww#......#...#.###.#...####", - "#wwwwwwwwwwwwww#......#...#.###.#...####", - "#wwwwwwwwwwwwww#......#...#.....#...####", - "#wwwwwwwwwwwwww#......#...#.....#...####", - "######wwwwwwwww##.##.##...###.###...####", - "######wwwwwwwwwwww#.......###.......####", - "######wwwwwwwwwwww##.....####.......####", + "##~~~~~~~~~~~~~~##~~~~~############~~~~#", + "##~~~~~~~~~~~~~~#~~~~~~~~#######~~~~~~~#", + "##~~~~~~~~~~~~~~#~~####~~~~~#######~~~~#", + "##~~~~~~#####.###~~####~~~~~#######~~~~#", + "##~~~~~~#~~~~~~~~~~########~#######~#~~#", + "##~~##~~##~~~~~~~~#########~~~~~~~~~~~~#", + "##~~#.~~###~~~~~~##########~~~~~~~~~~~~#", + "##~~~~~~#wwwww##~~~~~~~~~~~~~~~~#~~~####", + "#########wwwww##~~~~~~~~~~~~~~~~#~~~####", + "#www############~~~~~~~~~~~~~~~~#~~~####", + "#www##############.########~###~#~~~####", + "#www#~#~#~########~~~~~~~~#~###~#~~~####", + "#www#~~~~~########~~~~~~~~#~~~~~#~~~####", + "#www#~~~~~########~~~~~~~~#~~~~~#~~~####", + "#www###.###wwww#~~~~~~#~~~#~###~#~~~####", + "#wwwwwwwwwwwwww#~~~~~~#~~~#~###~#~~~####", + "#wwwwwwwwwwwwww#~~~~~~#~~~#~~~~~#~~~####", + "#wwwwwwwwwwwwww#~~~~~~#~~~#~~~~~#~~~####", + "######wwwwwwwww##.##.##~~~###.###~~~####", + "######wwwwwwwwwwww#~~~~~~~###~~~~~~~####", + "######wwwwwwwwwwww##~~~~~####~~~~~~~####", "########################################" ], "elevation": [ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json index 0c54fa2d7d..10e47a673a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json @@ -265,6 +265,11 @@ "side": "north", "to_map": "Route24", "offset": 12 + }, + { + "side": "west", + "to_map": "Route4", + "offset": 10 } ] } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_PokemonCenter_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_PokemonCenter_1F.json index c538b6e3b1..28d628941f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_PokemonCenter_1F.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_PokemonCenter_1F.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "#.#############", "###############", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/MtMoon_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/MtMoon_1F.json new file mode 100644 index 0000000000..cf3836faa8 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/MtMoon_1F.json @@ -0,0 +1,229 @@ +{ + "name": "MtMoon_1F", + "source": { + "decomp_file": "data/maps/MtMoon_1F/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "################################################", + "################################################", + "##~~~~~~~~~#########~~~~~~~~~~~~~~~~~~~~~~~~~~##", + "##~~~~~~~~~~#######~~~~~~~~~~~~~~~~~~~~~~~~~~~##", + "##~~~~~~~~~~~#####~~~~~~~~~~~~~~~~~~~~~~~~~~~~##", + "##~~~~~~~~~~~#####~~~~~~~~~~~~~~~~~~~~~~~~~~~~##", + "##~~~.~~~~~~~####~~~~~~~~~~~~~~~~~~~~~~~~~~~~~##", + "##~~~~~~~~~~~####~~~~~~~~~~~~~~~~~~~~~~~~#~~~~##", + "##~~~~~~~~~~~###~~~~~~~~~~~~~~~~~~~~~~~~####~~##", + "##~~~~~~~~~~~###~~~~~~~~~~~~~~~~~~~~~~~~~##~~~##", + "##~~~~~~~~~~~###~~~~###############~~~~~~~~~~~##", + "##~~~~~~~~~~~###~~~~###############~~~~~~~~~~~##", + "##~~~~~~~~~~~###~~~~###############~~~~~~~~~~~##", + "##~~~~~~~~~~~###~~~~#####~~~~~####~~~~~~~~~~~~##", + "##~~~~~~~~~~~###~~~.####~~~~~~~~~~~~~~~###~~~~##", + "##~~~~~~~~~~~###~~~~####~~~~~~~~~~~~~~~###~~~~##", + "##~~~~~~~~~~~###~~~~###~~~~~~~~.~~~~~~~###~~~~##", + "##~~~~~~~~~~~~~~~~~~###~~~~###~~~~~~~~~###~~~~##", + "##~~~~~~~~~~~~~~~~~~###~~~~###~~~~~~~~~###~~~~##", + "###########~~~~~~~~~###~~~~###~~~~~~~~~###~~~~##", + "###########~~~~~~~~~###~~~~###~~~~~~~~~###~~~~##", + "###########~~~~~~~~~###~~~~###~~~~~~~~~###~~~~##", + "##~~~~#################~~~~###~~~~~~~~~###~~~~##", + "##~~~~~################~~~~###~~~~~~~~~###~~~~##", + "##~~~~~~###############~~~~###~~~~~~~~~###~~~~##", + "##~~~~~~###~~~~~~~~~~~~~~~~###~~~~~~~~~###~~~~##", + "##~~~~~~###~~~~~~~~S~~~~~~~###~~~~~~~~~###~~~~##", + "##~~~~~~###~~~~~~~~~~~~~~~~###~~~~~~~~~###~~~~##", + "##~~~~~~~~~~~~~~~~~~~~~~~~~###~~~~~~~~~###~~~~##", + "##~~~~~~~~~~~~~~~~~~##########~~~###~~~###~~~~##", + "##~~~~~~~~~~~~~~~~~~##########~~~###~~~###~~~~##", + "##~~~~~~~~~~~~##~~~~##########~~~###~~~###~~~~##", + "##~~~~~~~~~~~####~~~#####~~~~~~~~###~~~~~~~~~~##", + "##~~~~~~~~~~~####~~~######~~~~~~~###~~~~~~~~~~##", + "##~~~~~~~~~~~####~~~######~~~~~~~###~~~~~~~~~~##", + "##~~~~~~~~~~~####~~~#####~~~~~~~~~~~~~~~~~~~~~##", + "##~~~~~~~~~~~####~~~####~~~~~~~~~~~~~~~~~~~~~~##", + "##~~~~~~~~~~~####~.~####~~~~~~~~~~~~~~~~~~~~~~##", + "################################################", + "################################################" + ], + "points": { + "warp_0": [5, 6], + "warp_1": [19, 14], + "warp_2": [31, 16], + "warp_3": [18, 37] + }, + "warps": [ + { + "x": 5, + "y": 6, + "enter": "step_on", + "to_map": "MtMoon_B1F", + "to_point": "warp_0" + }, + { + "x": 19, + "y": 14, + "enter": "step_on", + "to_map": "MtMoon_B1F", + "to_point": "warp_1" + }, + { + "x": 31, + "y": 16, + "enter": "step_on", + "to_map": "MtMoon_B1F", + "to_point": "warp_2" + }, + { + "x": 18, + "y": 37, + "enter": "walk_south", + "to_map": "Route4", + "to_point": "warp_0" + } + ], + "objects": [ + { + "name": "lass", + "kind": "npc", + "x": 20, + "y": 26, + "facing": "south", + "trainer": { + "sight": 2 + } + }, + { + "name": "bug_catcher", + "kind": "npc", + "x": 36, + "y": 30, + "facing": "east", + "trainer": { + "sight": 2 + } + }, + { + "name": "scientist", + "kind": "npc", + "x": 30, + "y": 35, + "trainer": { + "sight": 4, + "looks": [ + "north", + "east", + "south", + "west" + ] + } + }, + { + "name": "lass", + "kind": "npc", + "x": 33, + "y": 4, + "trainer": { + "sight": 2, + "looks": [ + "north", + "east", + "south", + "west" + ] + } + }, + { + "name": "bug_catcher", + "kind": "npc", + "x": 7, + "y": 26, + "facing": "south", + "trainer": { + "sight": 4 + } + }, + { + "name": "youngster", + "kind": "npc", + "x": 13, + "y": 17, + "facing": "east", + "trainer": { + "sight": 4 + } + }, + { + "name": "hiker", + "kind": "npc", + "x": 7, + "y": 10, + "wander": { + "x0": 5, + "y0": 8, + "x1": 9, + "y1": 12 + }, + "trainer": { + "sight": 1, + "looks": [ + "north", + "east", + "south", + "west" + ] + } + }, + { + "name": "item_ball", + "kind": "item", + "x": 2, + "y": 22, + "hide_flag": "FLAG_HIDE_MT_MOON_1F_PARALYZE_HEAL" + }, + { + "name": "item_ball", + "kind": "item", + "x": 11, + "y": 35, + "hide_flag": "FLAG_HIDE_MT_MOON_1F_TM09" + }, + { + "name": "item_ball", + "kind": "item", + "x": 26, + "y": 32, + "hide_flag": "FLAG_HIDE_MT_MOON_1F_POTION" + }, + { + "name": "item_ball", + "kind": "item", + "x": 42, + "y": 35, + "hide_flag": "FLAG_HIDE_MT_MOON_1F_RARE_CANDY" + }, + { + "name": "item_ball", + "kind": "item", + "x": 44, + "y": 21, + "hide_flag": "FLAG_HIDE_MT_MOON_1F_ESCAPE_ROPE" + }, + { + "name": "item_ball", + "kind": "item", + "x": 3, + "y": 2, + "hide_flag": "FLAG_HIDE_MT_MOON_1F_MOON_STONE" + }, + { + "name": "balding_man", + "kind": "npc", + "x": 42, + "y": 7 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/MtMoon_B1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/MtMoon_B1F.json new file mode 100644 index 0000000000..448aa61f4d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/MtMoon_B1F.json @@ -0,0 +1,121 @@ +{ + "name": "MtMoon_B1F", + "source": { + "decomp_file": "data/maps/MtMoon_B1F/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "#################################################", + "#################################################", + "##################~~~~~~~~###############~~~~~###", + "##~.~~~##########~~~~~~~~#############~~~~~~~####", + "##~~~~~#########~~~~~~~~~.~##########~~.~~~~~.~##", + "##~~~~~#########~.~~~~~~~~~#########~~~~~~~~~~~##", + "##~~~~~##########################################", + "##~~~~~##########################################", + "##~~~~~##########################################", + "##~~~~###########################################", + "##~~~~~##########################################", + "##~~~#~##########################################", + "##~~~~###########################################", + "##~~~~~##########################################", + "##~~~~~##########################################", + "##~~~~~##########################################", + "##~~~~~~~~~~~~~~~~~~~~~~#########################", + "##~~~~~~~~~~~~~~~~~~~~~~#########################", + "##~~~~~~~~~~~~~~~~~~~~.~#########################", + "#################################################", + "#################################################", + "#########################################~~.~####", + "#########################################~~~~####", + "#########################################~~~~####", + "#########################################~~~~####", + "#########################################~~~~####", + "#########################################~~~~####", + "#########################################~~~~####", + "#########################################~~~~####", + "#########################################~~~~####", + "#########################################~~~~####", + "##########################################~~~####", + "##########################################~~~####", + "##########################################~~~####", + "##########################~~~~~~~~~~~####~~~~####", + "#########################~~~~~~~~~~~~~~~~~~~~####", + "########################~~.~~~~~~~~~~~~~~~~~~####", + "#################################################", + "#################################################", + "#################################################" + ], + "points": { + "warp_0": [3, 3], + "warp_1": [25, 4], + "warp_2": [43, 21], + "warp_3": [22, 18], + "warp_4": [17, 5], + "warp_5": [26, 36], + "warp_6": [39, 4], + "warp_7": [45, 4] + }, + "warps": [ + { + "x": 3, + "y": 3, + "enter": "step_on", + "to_map": "MtMoon_1F", + "to_point": "warp_0" + }, + { + "x": 25, + "y": 4, + "enter": "step_on", + "to_map": "MtMoon_1F", + "to_point": "warp_1" + }, + { + "x": 43, + "y": 21, + "enter": "step_on", + "to_map": "MtMoon_1F", + "to_point": "warp_2" + }, + { + "x": 22, + "y": 18, + "enter": "step_on", + "to_map": "MtMoon_B2F", + "to_point": "warp_0" + }, + { + "x": 17, + "y": 5, + "enter": "step_on", + "to_map": "MtMoon_B2F", + "to_point": "warp_1" + }, + { + "x": 26, + "y": 36, + "enter": "step_on", + "to_map": "MtMoon_B2F", + "to_point": "warp_2" + }, + { + "x": 39, + "y": 4, + "enter": "step_on", + "to_map": "MtMoon_B2F", + "to_point": "warp_3" + }, + { + "x": 45, + "y": 4, + "enter": "step_on", + "to_map": "Route4", + "to_point": "warp_1" + } + ], + "objects": [], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/MtMoon_B2F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/MtMoon_B2F.json new file mode 100644 index 0000000000..33c98997eb --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/MtMoon_B2F.json @@ -0,0 +1,218 @@ +{ + "name": "MtMoon_B2F", + "source": { + "decomp_file": "data/maps/MtMoon_B2F/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "################################################", + "################################################", + "################################################", + "###~~~~~~~~~~~~~~~~~############################", + "###~~~~~~~~~~~~~~~~~############################", + "###~~~~~~~~~~~~~~~~~###~~~~######~~~~###########", + "###~~~~~~~~~~~~~~~~~###~~~~#~~~~#~~~~#~~~~######", + "###~~########~~########~~~~#~~~~##~~##~~~~######", + "##~~~~~~#8888~~88888###~~~~#~~~~~~~~~~~~~~######", + "##~~~#~~#~~~~~~~~~~~###~~~~#~~~~~~~~~~~~~~######", + "##~~~.#~#~~~~~~~~~~~###~~~~#~~##~~~~~~~~~~######", + "##~~~~~~#~~~~~~~~~~~###~~~~#~~#.~~~~~~~#~~######", + "##~~~~~######~~###~~###~~~~#~~~~~~~~~~~~~~######", + "#########~~~~~~~~#~~###~~~~#~~~~~~~~~~~~~~######", + "#vvvvvv#~~~~~~~~~#~~###~~~~88888888##8888888888#", + "########~~~~~~~~~######~~~~~~~~~~~~##~~~~~~~~~~#", + "########~~~~~~~~~~~~###~~~~~~~~~~~~##~~~~~~~~~~#", + "############~~~########~~~~~~~~~~~~##~~~~~~~~~~#", + "#######vvvv#~~~#vvvvvv#~~~~~~##~~#####~~##~~~~~#", + "############~~~########~~~~~~#~~~~~~~~~~~#~~~~~#", + "############~~~########~~#~~~#~~~~~~~~~~~#~~~~~#", + "########~~~~~~~~~~~~~##~~.~#~#~~~~~~~~~~~#~~~~~#", + "########~~~~~~~~~~~~~##~~~#~~#~~~~~~~~~~~#~~~~~#", + "########~~~~~##############################~~~~#", + "########~~~~~#vvvvvvvvvvvvvvvvvvvvvvvvvvvv#~~~~#", + "########~~~~~##############################~~~~#", + "########~~~~~###############~~~~###########~~~~#", + "########~~~~~##~~~~~~~~~~~~#~~~~#~~##~~~~~~~~~~#", + "########~~~~~###~~~~~~~~~~~##~~##~~##~~~~~~~~~~#", + "########~~~~~##~~~~~~~~~~~~~~~~~~~~##~~~~~######", + "########~~~~~##~~#~~~~~~~~~~~~~~~~~##~~~~~######", + "########~~~~~##~#.~~~~~~~~~~~~~~~~~##~~~~~######", + "########~~~~~####~~~~~~~~~~~~~~~~~~##~~~~~######", + "########~~~~~########################~~~~~######", + "########~~~~~#vvvvvvvvvvvvvvvvvvvvvv#~~~~~######", + "########~~~~~########################~~~~~######", + "########~~~~~########################~~~~~######", + "########~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~######", + "########~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~######", + "################################################" + ], + "points": { + "warp_0": [25, 21], + "warp_1": [31, 11], + "warp_2": [17, 31], + "warp_3": [5, 10] + }, + "regions": { + "wild": [ + [9, 8], + [10, 8], + [11, 8], + [12, 8], + [15, 8], + [16, 8], + [17, 8], + [18, 8], + [19, 8], + [27, 14], + [28, 14], + [29, 14], + [30, 14], + [31, 14], + [32, 14], + [33, 14], + [34, 14], + [37, 14], + [38, 14], + [39, 14], + [40, 14], + [41, 14], + [42, 14], + [43, 14], + [44, 14], + [45, 14], + [46, 14] + ] + }, + "warps": [ + { + "x": 25, + "y": 21, + "enter": "step_on", + "to_map": "MtMoon_B1F", + "to_point": "warp_3" + }, + { + "x": 31, + "y": 11, + "enter": "step_on", + "to_map": "MtMoon_B1F", + "to_point": "warp_4" + }, + { + "x": 17, + "y": 31, + "enter": "step_on", + "to_map": "MtMoon_B1F", + "to_point": "warp_5" + }, + { + "x": 5, + "y": 10, + "enter": "step_on", + "to_map": "MtMoon_B1F", + "to_point": "warp_6" + } + ], + "objects": [ + { + "name": "fossil", + "kind": "npc", + "x": 13, + "y": 7, + "hide_flag": "FLAG_HIDE_DOME_FOSSIL" + }, + { + "name": "fossil", + "kind": "npc", + "x": 14, + "y": 7, + "hide_flag": "FLAG_HIDE_HELIX_FOSSIL" + }, + { + "name": "scientist", + "kind": "npc", + "x": 13, + "y": 11 + }, + { + "name": "rocket_m", + "kind": "npc", + "x": 37, + "y": 21, + "trainer": { + "sight": 3, + "looks": [ + "north", + "east", + "south", + "west" + ] + }, + "hide_flag": "FLAG_HIDE_MISC_KANTO_ROCKETS" + }, + { + "name": "rocket_m", + "kind": "npc", + "x": 12, + "y": 20, + "facing": "south", + "trainer": { + "sight": 2 + }, + "hide_flag": "FLAG_HIDE_MISC_KANTO_ROCKETS" + }, + { + "name": "rocket_m", + "kind": "npc", + "x": 35, + "y": 12, + "facing": "north", + "trainer": { + "sight": 4 + }, + "hide_flag": "FLAG_HIDE_MISC_KANTO_ROCKETS" + }, + { + "name": "rocket_m", + "kind": "npc", + "x": 18, + "y": 27, + "facing": "south", + "trainer": { + "sight": 4 + }, + "hide_flag": "FLAG_HIDE_MISC_KANTO_ROCKETS" + }, + { + "name": "item_ball", + "kind": "item", + "x": 30, + "y": 26, + "hide_flag": "FLAG_HIDE_MT_MOON_B2F_STAR_PIECE" + }, + { + "name": "item_ball", + "kind": "item", + "x": 35, + "y": 5, + "hide_flag": "FLAG_HIDE_MT_MOON_B2F_TM46" + }, + { + "name": "item_ball", + "kind": "item", + "x": 24, + "y": 6, + "hide_flag": "FLAG_HIDE_MT_MOON_B2F_REVIVE" + }, + { + "name": "item_ball", + "kind": "item", + "x": 3, + "y": 11, + "hide_flag": "FLAG_HIDE_MT_MOON_B2F_ANTIDOTE" + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_PlayersHouse_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_PlayersHouse_1F.json index ee57a2a78a..bc171fed17 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_PlayersHouse_1F.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_PlayersHouse_1F.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "#############", "#############", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_PlayersHouse_2F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_PlayersHouse_2F.json index eb5cbf8d32..7ca500d896 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_PlayersHouse_2F.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_PlayersHouse_2F.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "############", "###########S", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_ProfessorOaksLab.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_ProfessorOaksLab.json index 158ea7c73d..724be58581 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_ProfessorOaksLab.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_ProfessorOaksLab.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "#############", "######SS#####", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_RivalsHouse.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_RivalsHouse.json index bfd7bdf2e0..641924ae26 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_RivalsHouse.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PalletTown_RivalsHouse.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "#############", "#############", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity.json index da09eb8aa1..845d8ae7dc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity.json @@ -162,6 +162,11 @@ "side": "south", "to_map": "Route2", "offset": 12 + }, + { + "side": "east", + "to_map": "Route3", + "offset": 10 } ] } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Gym.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Gym.json index 75afb1541c..5f5f46c50b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Gym.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Gym.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "#############", "####.....####", @@ -61,7 +62,11 @@ "name": "camper", "kind": "npc", "x": 3, - "y": 8 + "y": 8, + "facing": "east", + "trainer": { + "sight": 4 + } }, { "name": "gym_guy", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Mart.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Mart.json index 7b4e49dea9..29f7f0d2cd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Mart.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Mart.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "###########", "###########", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Museum_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Museum_1F.json index 1ea83f0faf..ee0947b080 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Museum_1F.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_Museum_1F.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "############################", "############################", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_PokemonCenter_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_PokemonCenter_1F.json index 90267e027f..87ce82cab4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_PokemonCenter_1F.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/PewterCity_PokemonCenter_1F.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "#.#############", "###############", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route24.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route24.json index 59d1789c3c..2c3bd7ee03 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route24.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route24.json @@ -103,37 +103,61 @@ "name": "camper", "kind": "npc", "x": 12, - "y": 19 + "y": 19, + "facing": "west", + "trainer": { + "sight": 2 + } }, { "name": "lass", "kind": "npc", "x": 10, - "y": 22 + "y": 22, + "facing": "east", + "trainer": { + "sight": 2 + } }, { "name": "youngster", "kind": "npc", "x": 12, - "y": 25 + "y": 25, + "facing": "west", + "trainer": { + "sight": 2 + } }, { "name": "lass", "kind": "npc", "x": 10, - "y": 28 + "y": 28, + "facing": "east", + "trainer": { + "sight": 2 + } }, { "name": "bug_catcher", "kind": "npc", "x": 12, - "y": 31 + "y": 31, + "facing": "west", + "trainer": { + "sight": 2 + } }, { "name": "camper", "kind": "npc", "x": 5, - "y": 21 + "y": 21, + "facing": "north", + "trainer": { + "sight": 5 + } }, { "name": "item_ball", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route3.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route3.json new file mode 100644 index 0000000000..91a4f7fc9c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route3.json @@ -0,0 +1,142 @@ +{ + "name": "Route3", + "source": { + "decomp_file": "data/maps/Route3/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "####################################################################........########", + "####################################################################........########", + "####################################################################........########", + "#############......................#################################.......#########", + "###########..........................###############################.....###########", + "###########..........................######................#########.....###########", + "###########.........###.......##.....#####...................#######.....###########", + "###########vvv..vvvv###vvvvvvv##vvv..#####...................#######.....###########", + "#######.............###.......##.....#####vvvv..#######vvvvv.#######vvv..###########", + "....................###.......##................#######......#######.....###########", + "....................###.......###...............#######......#####.......~~~~#######", + ".........###........###.......###~~~~~~~........#######...............##S~~~~~~#####", + "..##########vvvv..vv###vvvvvvv###~~~~~~~~~~~~~~########................~~~~~~~~#####", + "############........###.......###~~~~~~~~~~~~~~########................~~~~~~~~#####", + "############.................######~~~~~~~~~~~~########...............~~~~~~~~~#####", + "##############...............######~~~~~~~~~~~~##########.............~~~~~~~~~#####", + "#######################################~~~~~~~~##########.............~~~~~~########", + "####################################################################################", + "####################################################################################", + "####################################################################################" + ], + "points": {}, + "warps": [], + "objects": [ + { + "name": "youngster", + "kind": "npc", + "x": 70, + "y": 13 + }, + { + "name": "lass", + "kind": "npc", + "x": 40, + "y": 11, + "wander": { + "x0": 40, + "y0": 10, + "x1": 40, + "y1": 12 + }, + "trainer": { + "sight": 3, + "looks": [ + "north", + "south" + ] + } + }, + { + "name": "bug_catcher", + "kind": "npc", + "x": 32, + "y": 6, + "facing": "east", + "trainer": { + "sight": 4 + } + }, + { + "name": "lass", + "kind": "npc", + "x": 30, + "y": 3, + "facing": "west", + "trainer": { + "sight": 5 + } + }, + { + "name": "bug_catcher", + "kind": "npc", + "x": 25, + "y": 4, + "facing": "south", + "trainer": { + "sight": 2 + } + }, + { + "name": "youngster", + "kind": "npc", + "x": 29, + "y": 10, + "facing": "west", + "trainer": { + "sight": 5 + } + }, + { + "name": "lass", + "kind": "npc", + "x": 19, + "y": 9, + "facing": "west", + "trainer": { + "sight": 5 + } + }, + { + "name": "bug_catcher", + "kind": "npc", + "x": 12, + "y": 6, + "facing": "east", + "trainer": { + "sight": 3 + } + }, + { + "name": "youngster", + "kind": "npc", + "x": 17, + "y": 4, + "facing": "south", + "trainer": { + "sight": 2 + } + } + ], + "flag_sets": {}, + "connections": [ + { + "side": "north", + "to_map": "Route4", + "offset": 60 + }, + { + "side": "west", + "to_map": "PewterCity", + "offset": -10 + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route4.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route4.json new file mode 100644 index 0000000000..5c8b219633 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route4.json @@ -0,0 +1,148 @@ +{ + "name": "Route4", + "source": { + "decomp_file": "data/maps/Route4/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "############################################################################################################", + "##########.....#############################################################################################", + "###########################################..<......>.#########.<.....<###############...........###########", + "###########################################..<......>..>.....<..<.....<.....................................", + "####...#..#################################..<......>..>.....<..<.....<.....................................", + "###..#....SSD##.###.############.########....#vv..vv#..>.....<..<.....#vvvvvvvvvvvvvvvvvvvvvvvvvv###########", + "###..................##########........................>.....<..<................................#wwwwwwwwww", + "###vvvvv..........S..##########...S....................>.....<..#v..vvvvvvvvvvvvvvvvvvvvvvvvvvvvv#wwwwwwwwww", + "###..................#########.........................>.....<...................................#wwwwwwwwww", + "###............vvvvvvv########vvvvvvvvvvv...vvvvvvvvvvv#.....#v#......................####vvvvvvv###########", + "###.#.................########.................................#vvvvv..####vvvvvvvvvvv####..................", + "###vvvvvvvvv........#.########.........................................####~~~~~~~~~~~####..................", + "###............vvvvvvv###################..............................####~~~~~~~~~~~####..................", + "######.#..............###########################......................####~~~~~~~~~~~~~##.......###########", + "########.............############################......................####~~~~~~~~~~~~~##vv...vv###########", + "########vvv.......###############################vv..vvvvvvvvv..vvvvv######~~~~~~~~~~~~~##.......###########", + "########........#################################....................######~~~~~~~~~~~~.##.......###########", + "########........#################################....................######~~~~~~~~~~~...........###########", + "########.....vvv########################################.............#################.........#############", + "########........############################################################################################" + ], + "elevation": [ + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "000000000033333000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "000000000000000000000000000000000000000000033033333303000000000303333300000000000000003333333333300000000000", + "000000000000000000000000000000000000000000033033333303303333303303333303333333333333333333333333333333333333", + "000033303300000000000000000000000000000000033033333303303333303303333303333333333333333333333333333333333333", + "000330333300000300030000000000003000000003333000330003303333303303333300000000000000000000000000000000000000", + "000333333333333333333000000000033333333333333333333333303333303303333333333333333333333333333333301111111111", + "000000003333333333033000000000033303333333333333333333303333303300330000000000000000000000000000001111111111", + "000333333333333333333000000000333333333333333333333333303333303333333333333333333333333333333333301111111111", + "000333333333333000000000000000000000000003330000000000003333300033333333333333333333330000000000000000000000", + "000303333333333333333300000000333333333333333333333333333333333000000330000000000000000000333333333333333333", + "000000000000333333330300000000333333333333333333333333333333333333333330000333333333330000333333333333333333", + "000333333333333000000000000000000000000003333333333333333333333333333330000333333333330000333333333333333333", + "000000303333333333333300000000000000000000000000033333333333333333333330000333333333333300333333300000000000", + "000000003333333333333000000000000000000000000000033333333333333333333330000333333333333300003330000000000000", + "000000000003333333000000000000000000000000000000000330000000003300000000000333333333333300333333300000000000", + "000000003333333300000000000000000000000000000000033333333333333333333000000333333333333300333333300000000000", + "000000003333333300000000000000000000000000000000033333333333333333333000000333333333333333333333300000000000", + "000000003333300000000000000000000000000000000000000000003333333333333000000000000000003333333330000000000000", + "000000003333333300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ], + "points": { + "warp_0": [19, 5], + "warp_1": [32, 5], + "warp_2": [12, 5] + }, + "warps": [ + { + "x": 19, + "y": 5, + "enter": "cave_entrance", + "to_map": "MtMoon_1F", + "to_point": "warp_3" + }, + { + "x": 32, + "y": 5, + "enter": "cave_entrance", + "to_map": "MtMoon_B1F", + "to_point": "warp_7" + }, + { + "x": 12, + "y": 5, + "enter": "walk_into", + "to_map": "Route4_PokemonCenter_1F", + "to_point": "warp_1" + } + ], + "objects": [ + { + "name": "woman_1", + "kind": "npc", + "x": 9, + "y": 8, + "wander": { + "x0": 8, + "y0": 7, + "x1": 10, + "y1": 9 + } + }, + { + "name": "lass", + "kind": "npc", + "x": 75, + "y": 3, + "facing": "east", + "trainer": { + "sight": 4 + } + }, + { + "name": "item_ball", + "kind": "item", + "x": 67, + "y": 5, + "hide_flag": "FLAG_HIDE_ROUTE4_TM05" + }, + { + "name": "boy", + "kind": "npc", + "x": 15, + "y": 14, + "wander": { + "x0": 14, + "y0": 14, + "x1": 16, + "y1": 14 + } + }, + { + "name": "black_belt", + "kind": "npc", + "x": 47, + "y": 3 + }, + { + "name": "black_belt", + "kind": "npc", + "x": 50, + "y": 3 + } + ], + "flag_sets": {}, + "connections": [ + { + "side": "south", + "to_map": "Route3", + "offset": -60 + }, + { + "side": "east", + "to_map": "CeruleanCity", + "offset": -10 + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route4_PokemonCenter_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route4_PokemonCenter_1F.json new file mode 100644 index 0000000000..c3fe2d586c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route4_PokemonCenter_1F.json @@ -0,0 +1,112 @@ +{ + "name": "Route4_PokemonCenter_1F", + "source": { + "decomp_file": "data/maps/Route4_PokemonCenter_1F/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "#.#############", + "###############", + "....###..##....", + "....#######....", + "...............", + "...............", + "#..........##..", + "...........##..", + "...............", + "###############" + ], + "elevation": [ + "030000000000000", + "000000000000000", + "333300033003333", + "333300000003333", + "333333333333333", + "330333333333333", + "044033333330033", + "330333333330033", + "333333333333333", + "000000000000000" + ], + "points": { + "nurse_front": [7, 4], + "salesman_front": [2, 3], + "warp_1": [7, 8] + }, + "warps": [ + { + "x": 6, + "y": 8, + "enter": "walk_into", + "to_map": "Route4", + "to_point": "warp_2" + }, + { + "x": 7, + "y": 8, + "enter": "walk_south", + "to_map": "Route4", + "to_point": "warp_2" + }, + { + "x": 8, + "y": 8, + "enter": "walk_into", + "to_map": "Route4", + "to_point": "warp_2" + }, + { + "x": 1, + "y": 6, + "enter": "step_on" + } + ], + "objects": [ + { + "name": "nurse", + "kind": "npc", + "x": 7, + "y": 2 + }, + { + "name": "balding_man", + "kind": "npc", + "x": 1, + "y": 3 + }, + { + "name": "gentleman", + "kind": "npc", + "x": 12, + "y": 5 + }, + { + "name": "boy", + "kind": "npc", + "x": 5, + "y": 4, + "wander": { + "x0": 4, + "y0": 3, + "x1": 6, + "y1": 5 + } + }, + { + "name": "youngster", + "kind": "npc", + "x": 14, + "y": 4 + }, + { + "name": "clipboard", + "kind": "npc", + "x": 12, + "y": 6 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianCity_Mart.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianCity_Mart.json index 1078ec3405..9b3e8509a4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianCity_Mart.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianCity_Mart.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "###########", "###########", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianCity_PokemonCenter_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianCity_PokemonCenter_1F.json index 63a2707d8f..e0341f1440 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianCity_PokemonCenter_1F.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianCity_PokemonCenter_1F.json @@ -5,6 +5,7 @@ "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-10-31" }, + "allow_running": false, "tiles": [ "#.#############", "###############", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianForest.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianForest.json index 93352339c5..a6e51104b3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianForest.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/ViridianForest.json @@ -141,19 +141,31 @@ "name": "bug_catcher", "kind": "npc", "x": 47, - "y": 45 + "y": 45, + "facing": "west", + "trainer": { + "sight": 5 + } }, { "name": "bug_catcher", "kind": "npc", "x": 47, - "y": 29 + "y": 29, + "facing": "west", + "trainer": { + "sight": 4 + } }, { "name": "bug_catcher", "kind": "npc", "x": 7, - "y": 22 + "y": 22, + "facing": "west", + "trainer": { + "sight": 4 + } }, { "name": "item_ball", @@ -180,13 +192,24 @@ "name": "bug_catcher", "kind": "npc", "x": 43, - "y": 6 + "y": 6, + "trainer": { + "sight": 1, + "looks": [ + "south", + "north" + ] + } }, { "name": "bug_catcher", "kind": "npc", "x": 16, - "y": 5 + "y": 5, + "facing": "north", + "trainer": { + "sight": 1 + } }, { "name": "item_ball", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp index e0fc1a339e..632480ca81 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp @@ -4,6 +4,7 @@ * */ +#include #include "Common/Cpp/Exceptions.h" #include "PokemonFRLG_OverworldMap.h" @@ -43,8 +44,14 @@ bool is_ledge(const MapData& map, const TilePos& point){ bool is_water(const MapData& map, const TilePos& point){ return tile_at(map, point) == TileKind::WATER; } +const char WILD_REGION[] = "wild"; + bool is_tall_grass(const MapData& map, const TilePos& point){ - return tile_at(map, point) == TileKind::TALL_GRASS; + if (tile_at(map, point) == TileKind::TALL_GRASS){ + return true; + } + const auto wild = map.regions.find(WILD_REGION); + return wild != map.regions.end() && std::find(wild->second.begin(), wild->second.end(), point) != wild->second.end(); } namespace{ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h index 399d5f0537..42bcc590fe 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h @@ -58,7 +58,9 @@ constexpr uint8_t ELEVATION_GROUND = 3; // pokefirered TrySetUpWalkIntoSignpostScript()). Ledges are hopped the // way they face, which isn't modelled, so the planner avoids them. // - WATER is entered only by starting to Surf. -// - CLOSED_ can't be entered or left through that side. +// - CLOSED_ can't be entered or left through that side. One with wild +// encounters (a cave's) is listed in the map's region WILD_REGION, as its +// glyph can't say so too. // - A warp entered by step_on (ladders, escalators) or cave_entrance warps // the player who steps onto its tile; walk_ (exit mats, stairs // at the side of a room) warps the player standing on it who walks that @@ -72,6 +74,11 @@ uint8_t elevation_at(const MapData& map, const TilePos& point); bool is_wall(const MapData& map, const TilePos& point); bool is_water(const MapData& map, const TilePos& point); +// The map region of the tiles with wild encounters whose glyph isn't tall +// grass. +extern const char WILD_REGION[]; +// Whether a step onto the tile may start a wild battle: tall grass, or a +// tile in WILD_REGION. bool is_tall_grass(const MapData& map, const TilePos& point); bool is_ledge(const MapData& map, const TilePos& point); // Whether the tile itself refuses a step in `direction`: entering it, or diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp index 0f7514fe28..96954b03cc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -37,6 +37,14 @@ const char* const MAP_NAMES[] = { "PewterCity_Mart", "PewterCity_Museum_1F", "PewterCity_Gym", + // Route 3 to the Magikarp salesman in Route 4's Pokemon Center. + "Route3", + "Route4", + "Route4_PokemonCenter_1F", + // Mt. Moon, from Route 4 west of it to Route 4 east of it. + "MtMoon_1F", + "MtMoon_B1F", + "MtMoon_B2F", // Cerulean Cave, for Mewtwo. "CeruleanCity_PokemonCenter_1F", "CeruleanCity", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp index b9c82f093f..d8083fe067 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp @@ -4,13 +4,17 @@ * */ +#include +#include #include #include #include #include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMap.h" #include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldReanchor.h" #include "PokemonFRLG_OverworldRoute.h" namespace PokemonAutomation{ @@ -208,6 +212,159 @@ UnitTestResult test_not_json(){ return true; } +const MapData* built_in(const std::vector& maps, const std::string& name){ + for (const MapData& map : maps){ + if (map.name == name){ + return ↦ + } + } + return nullptr; +} + +// Route 3, Route 4 and its Pokemon Center load, joined to Pewter City; the +// Center doesn't let the player run, the routes do. +UnitTestResult test_route3_route4_maps(){ + const std::vector maps = overworld_maps(); + for (const char* name : {"Route3", "Route4", "Route4_PokemonCenter_1F"}){ + if (built_in(maps, name) == nullptr){ + return std::string("Missing map ") + name + "."; + } + } + if (!built_in(maps, "Route3")->allow_running || !built_in(maps, "PewterCity")->allow_running + || built_in(maps, "Route4_PokemonCenter_1F")->allow_running || built_in(maps, "PewterCity_PokemonCenter_1F")->allow_running + ){ + return "Expected running on the routes and Pewter City, not in the Pokemon Centers."; + } + const MapPosition nurse{"Route4_PokemonCenter_1F", map_point(maps, "Route4_PokemonCenter_1F", "nurse_front")}; + const MapPosition salesman{"Route4_PokemonCenter_1F", map_point(maps, "Route4_PokemonCenter_1F", "salesman_front")}; + if (nurse.point != TilePos{7, 4} || salesman.point != TilePos{2, 3}){ + return "Unexpected nurse_front or salesman_front."; + } + const MapPosition pewter{"PewterCity", {45, 21}}; + if (!plan_overworld_route(maps, pewter, RouteGoal{nurse, Direction::NORTH})){ + return "No route from Pewter City over Route 3 to Route 4's Pokemon Center."; + } + return true; +} + +// Mt. Moon's three floors load, every floor tile of them with wild +// encounters, and join Route 4's two cave mouths: the way from the west +// mouth (19, 5) to the east one (32, 5) goes down to B2F and back up, +// through the gap the fossils leave once taken, and comes out walked a +// tile south, facing south. +UnitTestResult test_mt_moon_maps(){ + const std::vector maps = overworld_maps({"FLAG_HIDE_DOME_FOSSIL", "FLAG_HIDE_HELIX_FOSSIL"}); + for (const char* name : {"MtMoon_1F", "MtMoon_B1F", "MtMoon_B2F"}){ + const MapData* map = built_in(maps, name); + if (map == nullptr){ + return std::string("Missing map ") + name + "."; + } + for (int y = 0; y < map->tiles.height; y++){ + for (int x = 0; x < map->tiles.width; x++){ + const TilePos tile{x, y}; + if (map->tiles.at(tile) == TileKind::OPEN && warp_at(*map, tile) == nullptr){ + return std::format("{}: open floor without wild encounters at {}.", name, Pokemon::to_string(tile)); + } + } + } + } + const std::optional> route = plan_overworld_route( + maps, MapPosition{"Route4", {19, 6}}, RouteGoal{MapPosition{"Route4", {32, 6}}, Direction::SOUTH} + ); + if (!route || route->empty()){ + return "No route through Mt. Moon from Route 4's west mouth to its east one."; + } + std::set floors; + for (const RouteStep& step : *route){ + if (step.warps){ + floors.insert(step.end.map); + } + } + if (floors != std::set{"MtMoon_1F", "MtMoon_B1F", "MtMoon_B2F", "Route4"} || route->back().facing_after != Direction::SOUTH){ + return "Expected the way through to warp onto every floor and out to Route 4 facing south."; + } + return true; +} + +// Mt. Moon B2F's floor closed on its north side, along row 14, has wild +// encounters like the cave floor south of it: a leg along it is wild, so the +// walker watches for a battle there. +UnitTestResult test_mt_moon_closed_floor_wild(){ + const std::vector maps = overworld_maps({"FLAG_HIDE_DOME_FOSSIL", "FLAG_HIDE_HELIX_FOSSIL"}); + const MapData* map = built_in(maps, "MtMoon_B2F"); + if (map == nullptr){ + return "Missing map MtMoon_B2F."; + } + if (map->tiles.at({40, 14}) != TileKind::CLOSED_NORTH || !is_tall_grass(*map, {40, 14})){ + return "Expected MtMoon_B2F (40, 14) to be floor closed on the north, with wild encounters."; + } + const std::optional> route = plan_overworld_route( + maps, MapPosition{"MtMoon_B2F", {37, 14}}, RouteGoal{MapPosition{"MtMoon_B2F", {46, 14}}, Direction::EAST} + ); + if (!route || route->size() != 1){ + return "Expected one leg along MtMoon_B2F's row 14."; + } + const RouteStep& step = route->front(); + if (step.direction != Direction::EAST || step.tiles != 9 || !step.wild){ + return "Expected a wild leg 9 tiles east along row 14, got " + to_string(step) + "."; + } + return true; +} + +// A planned step walks where its map doesn't let the player run, and only +// there: out of Pewter's Pokemon Center to the Gym. +UnitTestResult test_walk_only_indoors(){ + const std::vector maps = overworld_maps(); + MapPosition from{"PewterCity_PokemonCenter_1F", map_point(maps, "PewterCity_PokemonCenter_1F", "nurse_front")}; + const std::optional> route = plan_overworld_route( + maps, from, RouteGoal{MapPosition{"PewterCity", map_point(maps, "PewterCity", "gym_front")}, Direction::NORTH} + ); + if (!route){ + return "No route from Pewter's Pokemon Center to the Gym."; + } + bool indoors = false; + bool outdoors = false; + for (const RouteStep& step : *route){ + const bool in_center = from.map == "PewterCity_PokemonCenter_1F"; + if (step.action == RouteAction::WALK && step.walk_only != in_center){ + return "Wrong walk_only on " + to_string(step) + ", from " + to_string(from) + "."; + } + indoors = indoors || in_center; + outdoors = outdoors || !in_center; + from = step.end; + } + if (!indoors || !outdoors){ + return "Expected steps inside the Center and outside."; + } + const std::optional hold = hold_to_wall(maps, {"PewterCity_PokemonCenter_1F", {7, 4}}, false, Direction::WEST); + if (!hold || !hold->walk_only){ + return "Expected a hold to a wall in the Center to walk."; + } + return true; +} + +// From (7, 6) in Route 4's Pokemon Center to the salesman, no leg holds +// west along row 6: the escalator at (1, 6) rides up to 2F, which isn't +// loaded (console B, 2026-11-02, "west 5 (to wall)"). +UnitTestResult test_route4_center_escalator(){ + const std::vector maps = overworld_maps(); + const MapPosition start{"Route4_PokemonCenter_1F", {7, 6}}; + const MapPosition salesman{"Route4_PokemonCenter_1F", {2, 3}}; + if (hold_to_wall(maps, start, false, Direction::WEST)){ + return "Expected no hold west from (7, 6) along the escalator's row."; + } + const std::optional> route = plan_overworld_route(maps, start, RouteGoal{salesman, Direction::WEST}); + if (!route || !(follow_overworld_route(maps, start, *route) == salesman)){ + return "Expected a route from (7, 6) to the salesman."; + } + for (const RouteStep& step : *route){ + if (step.to_wall && step.direction == Direction::WEST && step.end.point == TilePos{2, 6}){ + return "Expected no hold west next to the escalator: " + to_string(step); + } + } + return true; +} + // A hall with an escalator at (1, 2) to a floor that isn't loaded, like // Route 4's Pokemon Center. const char* const ESCALATOR_HALL = R"({ @@ -236,6 +393,111 @@ UnitTestResult test_no_hold_onto_escalator(){ return true; } + +// Whether the player, `walked` tiles into `step` from `at` when stopped, +// is found again and walked back to the leg's end. +UnitTestResult check_found_again( + const std::vector& maps, const MapPosition& at, const RouteStep& step, WalkedTiles walked +){ + const std::vector candidates = stop_candidates(maps, at, step, walked); + const std::optional anchor = plan_reanchor(maps, candidates, step); + if (!anchor || !(follow_overworld_route(maps, anchor->found, anchor->back) == step.end)){ + return std::format( + "Not found again on {} from {}, stopped {} to {} tiles in ({} candidates).", + to_string(step), to_string(at), walked.min, walked.max, candidates.size() + ); + } + return true; +} + +// Every open leg from `start` to `goal`, stopped wherever the timing may +// put it, walking or running, or anywhere on it: the player is found again. +// A leg to a warp off the grass is held on to its warp instead. +UnitTestResult check_found_again(const std::vector& maps, const MapPosition& start, const MapPosition& goal){ + const std::optional> route = plan_overworld_route(maps, start, RouteGoal{goal, std::nullopt}); + if (!route){ + return "No route from " + to_string(start) + "."; + } + MapPosition at = start; + for (const RouteStep& step : *route){ + std::vector stops; + if (step.action == RouteAction::WALK && !step.to_wall && !(step.warps && !step.wild)){ + stops.push_back(whole_leg(step)); + for (int estimate = 0; estimate <= step.tiles; estimate++){ + stops.push_back(walked_near(step, estimate, std::chrono::milliseconds(267))); + stops.push_back(walked_near(step, estimate, std::chrono::milliseconds(133))); + } + } + for (const WalkedTiles& walked : stops){ + const UnitTestResult result = check_found_again(maps, at, step, walked); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + at = step.end; + } + return true; +} + +// 00.11's way from Pewter into Route 3 and 00.12's over Route 3 and 4. +UnitTestResult test_found_again_route3_route4(){ + const std::vector maps = overworld_maps(); + const UnitTestResult into_route3 = check_found_again(maps, {"PewterCity", {17, 26}}, {"Route3", {13, 8}}); + if (into_route3.result != UnitTestResult::PASSED){ + return into_route3; + } + return check_found_again(maps, {"Route3", {36, 8}}, {"Route4_PokemonCenter_1F", {7, 4}}); +} + +// A leg off the grass that crosses one trainer's sight, on the maps the +// story walks: a stop on it is that trainer's, on the tile they see, and +// they stand next to the player afterwards. +UnitTestResult test_trainer_stops(){ + const std::vector maps = overworld_maps(); + struct Case{ + const char* name; + MapPosition start; + Direction direction; + int tiles; + bool to_wall; + MapPosition player; + MapPosition trainer; + }; + const Case cases[] = { + { + .name = "Route 3, Lass Robin, east along row 9 (00.12)", + .start = {"Route3", {36, 9}}, .direction = Direction::EAST, .tiles = 11, .to_wall = true, + .player = {"Route3", {40, 9}}, .trainer = {"Route3", {40, 10}}, + }, + { + .name = "Viridian Forest, the Bug Catcher at (47, 45), north up column 42", + .start = {"ViridianForest", {42, 61}}, .direction = Direction::NORTH, .tiles = 17, .to_wall = false, + .player = {"ViridianForest", {42, 45}}, .trainer = {"ViridianForest", {43, 45}}, + }, + }; + for (const Case& c : cases){ + RouteStep step; + step.direction = c.direction; + step.tiles = c.tiles; + step.to_wall = c.to_wall; + step.end = c.start; + step.end.point = TilePos{ + c.start.point.x + (c.direction == Direction::EAST ? c.tiles : 0), + c.start.point.y - (c.direction == Direction::NORTH ? c.tiles : 0), + }; + const std::vector candidates = stop_candidates(maps, c.start, step, whole_leg(step)); + if (candidates.size() != 1 || !(candidates[0].player == c.player) || !candidates[0].trainer || !(candidates[0].trainer->at == c.trainer)){ + return std::format("{}: expected one stop, at {} with the trainer at {}; got {} candidates.", + c.name, to_string(c.player), to_string(c.trainer), candidates.size()); + } + const std::optional anchor = plan_reanchor(maps, candidates, step); + if (!anchor || !anchor->holds.empty() || !(follow_overworld_route(maps, c.player, anchor->back) == step.end)){ + return std::string(c.name) + ": expected a way on from the stop, with no holds."; + } + } + return true; +} + } @@ -247,8 +509,15 @@ void add_tests_OverworldMaps(UnitTestDatabase& database){ database.add("row of the wrong length", test_bad_row_length); database.add("unknown tile", test_unknown_tile); database.add("not JSON", test_not_json); + database.add("Route 3, Route 4 and its Pokemon Center", test_route3_route4_maps); + database.add("Mt. Moon joins Route 4's cave mouths", test_mt_moon_maps); + database.add("Mt. Moon's floor closed on one side is wild", test_mt_moon_closed_floor_wild); + database.add("walks where running isn't allowed", test_walk_only_indoors); + database.add("Route 4's Pokemon Center escalator", test_route4_center_escalator); database.add("trainer sight", test_trainer_sight); database.add("no hold onto an escalator", test_no_hold_onto_escalator); + database.add("found again on Route 3 and 4", test_found_again_route3_route4); + database.add("trainer stops on the story's maps", test_trainer_stops); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index 27b28bb54d..4c162db4bf 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -177,6 +177,7 @@ std::optional, RouteStep>> hold_all( add_unique(ends, Hypothesis{position.world, wall->end}); hold.tiles = std::max(hold.tiles, wall->tiles); hold.wild = hold.wild || wall->wild; + hold.walk_only = hold.walk_only || wall->walk_only; hold.end = wall->end; } return std::make_pair(std::move(ends), hold); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index 1a907f7a06..c91375a8a6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -58,6 +58,46 @@ int sight_onto(const std::vector& maps, const MapPosition& tile){ return sight; } +// The tiles an open `step` from `start` may have ended on with nothing +// seen: its end, and up to OPEN_LEG_SHORTFALL tiles before it. +std::vector short_candidates(const std::vector& maps, const MapPosition& start, const RouteStep& step){ + const std::vector tiles = leg_tiles(maps, start, step); + std::vector candidates; + for (size_t c = 0; c <= (size_t)OPEN_LEG_SHORTFALL && c < tiles.size(); c++){ + candidates.push_back(StopCandidate{tiles[tiles.size() - 1 - c], std::nullopt}); + } + return candidates; +} + +// Whether `next` is a hold to a wall. +bool is_wall_hold(const RouteStep& next){ + return next.action == RouteAction::WALK && next.to_wall && !next.warps; +} + +// Whether `next`, a hold to a wall, ends on its planned tile from each of +// `candidates`: then it finds the player itself. +bool hold_finds(const std::vector& maps, const std::vector& candidates, const RouteStep& next){ + for (const StopCandidate& candidate : candidates){ + const std::optional hold = hold_to_wall(maps, candidate.player, false, next.direction); + if (!hold || !(hold->end == next.end)){ + return false; + } + } + return true; +} + +// How many tiles `anchor`'s holds and way back walk, as far as planned. +int anchor_tiles(const Reanchor& anchor){ + int tiles = 0; + for (const RouteStep& step : anchor.holds){ + tiles += step.tiles; + } + for (const RouteStep& step : anchor.back){ + tiles += step.tiles; + } + return tiles; +} + // The route's lines in the route log the games share, as BDSP's walker // writes them, under the caller's label. class RouteLogLines{ @@ -118,6 +158,9 @@ class CheckedSteps{ const std::string leg = to_string(step); m_reholds = m_last_late && leg == m_last ? m_reholds + 1 : 0; StepOutcome outcome = m_calls.walk(step, facing); + if (outcome.end == StepEnd::BATTLE_BEFORE){ + return outcome; + } if (outcome.end == StepEnd::DONE && crossing && m_calls.watch_for_trainer){ outcome = StepOutcome{.end = m_calls.watch_for_trainer(step, crossing->approach_tiles), .walked = std::nullopt}; } @@ -182,9 +225,12 @@ class RouteRun{ const std::optional crossing = m_next >= m_holds ? crossing_here(step) : std::nullopt; const StepOutcome outcome = m_steps.walk(step, m_facing, crossing); if (outcome.end == StepEnd::DONE){ + const std::optional start = m_next >= m_unchecked ? m_at : std::nullopt; + remember_walked(step); m_at = step.end; m_facing = step.facing_after; m_lines.done(step, ++m_next, m_holds); + check_open_leg(step, start); continue; } const std::optional start = m_next >= m_holds ? m_at : std::nullopt; @@ -193,6 +239,13 @@ class RouteRun{ m_calls.fail(std::format("walk_overworld_route(): stopped part-way {} times.", m_stops)); return; } + if (outcome.end == StepEnd::BATTLE_BEFORE){ + if (!after_battle_before(step)){ + return; + } + continue; + } + m_walked.reset(); if (!after_stop(step, outcome, start, crossing)){ return; } @@ -211,6 +264,44 @@ class RouteRun{ return trainer_crossing(*maps(), *m_at, step); } + // A walk the next step's battle check may find stopped part-way: + // where in the plan it is, and where it started from, if known. + struct Walked{ + RouteStep step; + size_t index = 0; + std::optional start; + }; + + // `step`, m_plan[m_next], walked to its end. Only a walk not onto a + // warp can have stopped part-way unseen. + void remember_walked(const RouteStep& step){ + m_walked.reset(); + if (step.action == RouteAction::WALK && !step.warps){ + m_walked = Walked{step, m_next, m_next >= m_holds ? m_at : std::nullopt}; + } + } + + // A wild battle set off before `step`, m_plan[m_next], showed before + // it started. The step walked before it may have been stopped by the + // battle anywhere: the player is found again on it, as after a battle + // seen after its hold, and never taken to be on `step`'s line (the + // next leg was held from where the battle left the player, and the + // route went on 6 tiles off: console B, 2026-11-03, Mt. Moon B2F). + // With nothing walked since the plan was made, the player is where + // `step` starts. False once the route has failed. + bool after_battle_before(const RouteStep& step){ + if (!m_walked){ + m_calls.log("Route: a wild battle before " + to_string(step) + ", with nothing walked since the plan: walking it from its start."); + return true; + } + const Walked walked = std::move(*m_walked); + m_walked.reset(); + m_calls.log("Route: a wild battle before " + to_string(step) + " was set off on " + to_string(walked.step) + ", which may have stopped part-way."); + m_next = walked.index; + m_at = walked.start; + return after_stop(walked.step, StepOutcome{.end = StepEnd::LATE, .walked = std::nullopt}, walked.start, std::nullopt); + } + // After a stop on `step`, from `start` if known. False once the route // has failed. bool after_stop( @@ -262,6 +353,56 @@ class RouteRun{ return true; } + // After `step`, an open leg from `start` (not known: not checked) that + // ended with nothing seen, while m_plan[m_next] is the step after it. + // A hold to a wall after a leg that ended short ends somewhere else + // with nothing to show it (r16), unless it ends on its tile from + // wherever the leg left the player. If it doesn't, finds the player at + // walls first; without such holds, or if they and the way back walk + // farther than the leg there and back, trusts the leg. Only there, and + // only that far, to keep the check cheap: in Viridian Forest (r16) it + // walks 10 tiles after "west 6", while after Route 1's "west 4" the + // only holds walk 30. An open leg after a short one walks on off the + // route, which isn't checked. + void check_open_leg(const RouteStep& step, const std::optional& start){ + if (maps() == nullptr || m_planning->open_leg_check != OpenLegCheck::REANCHOR || !start + || step.action != RouteAction::WALK || step.to_wall || step.warps + || m_next >= m_plan.size() || !is_wall_hold(m_plan[m_next]) + ){ + return; + } + const std::vector candidates = short_candidates(*maps(), *start, step); + if (candidates.size() < 2 || hold_finds(*maps(), candidates, m_plan[m_next])){ + return; + } + const std::optional anchor = plan_reanchor(*maps(), candidates, step, m_planning->costs); + if (!anchor || anchor->holds.empty() || anchor_tiles(*anchor) > 2 * step.tiles){ + m_calls.log("Route: no holds to walls within " + std::to_string(2 * step.tiles) + " tiles find the player if " + to_string(step) + " ended short; taken as walked."); + return; + } + m_calls.log(std::format( + "Route: position check: {} may have ended up to {} tiles short with nothing seen; finding the player at walls.", + to_string(step), OPEN_LEG_SHORTFALL + )); + replan(*anchor, m_next); + m_at = std::nullopt; + m_lines.plan(std::nullopt, m_plan); + } + + // The plan from here: `anchor`'s holds and way back, then the old + // plan's steps from `rest` on. Neither the holds nor the way back are + // checked as open legs again. + void replan(const Reanchor& anchor, size_t rest){ + std::vector plan = anchor.holds; + plan.insert(plan.end(), anchor.back.begin(), anchor.back.end()); + plan.insert(plan.end(), m_plan.begin() + rest, m_plan.end()); + m_plan = std::move(plan); + m_walked.reset(); + m_holds = anchor.holds.size(); + m_unchecked = m_holds + anchor.back.size(); + m_next = 0; + } + // Finds the player on one of `candidates`, plans the way back to the // end of `step` (m_plan[m_next]), and the rest of the route after it. bool rejoin(const RouteStep& step, const std::vector& candidates, WalkedTiles walked){ @@ -283,12 +424,7 @@ class RouteRun{ ); return false; } - std::vector plan = anchor->holds; - plan.insert(plan.end(), anchor->back.begin(), anchor->back.end()); - plan.insert(plan.end(), m_plan.begin() + m_next + 1, m_plan.end()); - m_plan = std::move(plan); - m_holds = anchor->holds.size(); - m_next = 0; + replan(*anchor, m_next + 1); if (m_holds > 0){ m_at = std::nullopt; m_lines.plan(std::nullopt, m_plan); @@ -318,11 +454,17 @@ class RouteRun{ size_t m_next = 0; size_t m_stops = 0; size_t m_holds = 0; // of the plan, holds to walls that find the player + size_t m_unchecked = 0; // of the plan, the holds and the way back after them + std::optional m_walked; // the step walked last, if it may have stopped part-way }; } +WalkedTiles walked_before_warp(const RouteStep& step){ + return WalkedTiles{.min = 1, .max = step.tiles - 1}; +} + std::optional trainer_crossing( const std::vector& maps, const MapPosition& start, const RouteStep& step ){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h index 3e15603f4d..475b2bc69a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h @@ -17,7 +17,14 @@ * the route is planned again from it. More: holds to walls that end on * one tile from all of them find the player (plan_reanchor()), and a * way back to the stopped leg's end rejoins the route. The rest of the - * route is then walked as planned. + * route is then walked as planned. A leg onto a warp whose fade was + * a wild battle's counts as stopped before its warp + * (walked_before_warp()), and the way back goes on through the warp; + * - an open leg that ended with nothing seen may still have ended up to + * OPEN_LEG_SHORTFALL tiles short. When the next step is a hold to a + * wall that doesn't end on its tile from each of those, the player is + * found at walls the same way and walked back to the leg's end first. + * When no holds find them, the leg is taken as walked, as before. * * Where the player is is never guessed: when no holds find them from every * tile they may be on, the route fails, and the caller stops without @@ -49,6 +56,10 @@ enum class StepEnd{ STOPPED, // stopped part-way: where the player is isn't known LATE, // something was seen after the hold, which may have // stopped the player before the end + // Not walked: a wild battle set off before the step showed before it + // started. The step before it may have stopped part-way: its + // battle was seen too late (console B, 2026-11-03, Mt. Moon B2F). + BATTLE_BEFORE, }; // How a step ended, and for an open leg STOPPED part-way, how many of its @@ -66,6 +77,25 @@ enum class WallCheck{ SKIP, }; +// The most tiles an open leg has been seen to end short with nothing on +// screen: "west 6" in Viridian Forest ended 2 short (r16, console B, +// 2026-11-01); on Route 1 a column was lost (cp0006-b). The cause isn't +// known (the forest drift), so this is what was seen, not a bound. +constexpr int OPEN_LEG_SHORTFALL = 2; + +// Where a leg onto a warp stopped when its fade was a wild battle's: the +// battle's wipe passes for the fade (console B, 2026-11-03, Mt. Moon 1F), +// so the stop isn't timed. The battle started on one of the leg's steps +// before the warp. The walker reports the leg STOPPED there. +WalkedTiles walked_before_warp(const RouteStep& step); + +// Whether, after an open leg that ended with nothing seen, the player is +// found again at walls when where they stand matters (see run_route()). +enum class OpenLegCheck{ + REANCHOR, + TRUST, // taken to have ended where planned +}; + // What the route was planned with, to plan the way back onto it: the maps // (with the event flags the caller set) and the costs; and, for the route // log (Pokemon_RouteLog.h), where it starts and what the caller calls it. @@ -75,6 +105,7 @@ struct RoutePlanning{ std::optional start; std::string label = "Route"; WallCheck wall_check = WallCheck::TAP; + OpenLegCheck open_leg_check = OpenLegCheck::REANCHOR; }; // The console side. `walk` walks one step from `facing` (unknown after a diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index 9100f08d3f..4dba0fbe26 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -68,7 +68,10 @@ std::optional walk_step( if (step.warps){ wait_out_warp(console, context, step); static_assert(AFTER_WARP_BATTLE_WATCH >= AFTER_WARP); - check_warp_was_no_battle(console, context, step); + if (run_from_battle_after_warp(console, context)){ + console.log("Route: a wild battle, not the warp to " + step.end.map + "; the player is on " + to_string(step) + " before the warp.", COLOR_ORANGE); + return walked_before_warp(step); + } } break; } @@ -85,12 +88,21 @@ std::optional walk_step( // walk_step(), then a watch for what its last step set off. Not DONE if // the player may have stopped short of the step's end: an open leg // stopped, or something seen after the hold, which may have stopped it -// before the end (after a warp, it was checked for already). +// before the end (after a warp, it was checked for already). A battle +// on its way that the watch missed counts as seen (clear_unseen_battle()), +// also after a leg the maps see no wild tiles on. One on its way before +// the step starts was set off before it: the step isn't walked, and is +// BATTLE_BEFORE (clear_battle_before()). StepOutcome walk_and_watch( ConsoleHandle& console, ProControllerContext& context, const RouteStep& step, std::optional facing, const OverworldInterruptSet& interrupts, MoveSpeed speed ){ + if (clear_battle_before(console, context, interrupts.wild, step) > 0){ + return StepOutcome{.end = StepEnd::BATTLE_BEFORE, .walked = std::nullopt}; + } + clear_open_message(console, context, step.wild ? interrupts.wild : interrupts.other, step); + check_overworld(console, context, step); if (const std::optional walked = walk_step(console, context, step, facing, interrupts, speed)){ return StepOutcome{.end = StepEnd::STOPPED, .walked = walked}; } @@ -98,7 +110,11 @@ StepOutcome walk_and_watch( return StepOutcome{}; } const Milliseconds watch = step.wild ? AFTER_WILD_LEG_WATCH : AFTER_LEG_WATCH; - const size_t late = Pokemon::clear_late_interrupts(console, context, step.wild ? interrupts.wild : interrupts.other, watch, MAX_INTERRUPTIONS_PER_LEG); + const OverworldInterrupts& watched = step.wild ? interrupts.wild : interrupts.other; + size_t late = Pokemon::clear_late_interrupts(console, context, watched, watch, MAX_INTERRUPTIONS_PER_LEG); + if (late == 0){ + late = clear_unseen_battle(console, context, interrupts.wild, step); + } // After a warp it was checked for already. const bool done = late == 0 || step.warps || step.action != RouteAction::WALK; return StepOutcome{.end = done ? StepEnd::DONE : StepEnd::LATE, .walked = std::nullopt}; @@ -218,8 +234,6 @@ void walk_overworld_route_with_recovery( } RouteCalls calls; calls.walk = [&](const RouteStep& step, std::optional from_facing){ - clear_open_message(console, context, step.wild ? interrupts.wild : interrupts.other, step); - check_overworld(console, context, step); return walk_and_watch(console, context, step, from_facing, interrupts, speed); }; calls.hold_to_warp = [&](const RouteStep& step){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h index aedadc9332..cad763c5b1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h @@ -35,6 +35,8 @@ namespace PokemonFRLG{ // is isn't guessed: the stop's timing narrows it to a few tiles of the leg // (walked_near()), from which run_route() finds them again with // `planning`'s maps and costs, and walks the rest of the route as planned. +// A warp's fade that turns out to be a wild battle's stops its leg the +// same way, somewhere before the warp (walked_before_warp()). // // After a leg that crosses a trainer's sight it watches for the trainer, // long enough for them to walk up and speak, and hands what shows to @@ -48,10 +50,9 @@ namespace PokemonFRLG{ // A message open before a step is handed to `interrupts` once // (clear_open_message()). Throws OperationFailedException if a menu or // message is still open before a step, a warp's fade or the Surf prompt -// doesn't show, a warp's fade turns out to be a wild battle's, a leg is -// interrupted too many times, or the player can't be found again from -// every tile they may be on. A wild battle that starts as the last leg -// ends is cleared before it returns. +// doesn't show, a leg is interrupted too many times, or the player can't +// be found again from every tile they may be on. A wild battle that +// starts as the last leg ends is cleared before it returns. void walk_overworld_route_with_recovery( ConsoleHandle& console, ProControllerContext& context, const std::vector& route, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 6e63bc8ed4..89262939fb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -350,6 +350,7 @@ RouteStep to_route_step(const std::vector& maps, const Pokemon::Leg hold_to_wall( ){ std::optional player = locate(maps, from, surfing); WallHold hold; + hold.walk_only = player && !maps[player->map].allow_running; for (int c = 0; player && c <= MAX_HOLD_TILES; c++){ if (stops_at(maps, *player, direction) || ledge_stops_north(maps, *player, direction)){ hold.end = MapPosition{maps[player->map].name, player->state.position}; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index ce5d6f872b..900862e44e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -49,6 +49,9 @@ struct RouteStep{ bool warps = false; // WALK: on the water, where the player moves twice as fast. bool surfing = false; + // WALK: on a map where the game doesn't let the player run (indoors, + // MapData::allow_running): B held there still walks. + bool walk_only = false; // WALK: a wall stops the player at the end, so the leg can be held for // longer than it takes and still ends on its tile. bool to_wall = false; @@ -110,12 +113,13 @@ std::optional> plan_overworld_route( ); // Holding `direction` from `from`, on the water if `surfing`: where a wall -// stops the player, how many tiles on, and whether any tile on the way is -// tall grass or water. +// stops the player, how many tiles on, whether any tile on the way is tall +// grass or water, and whether the map lets the player run. struct WallHold{ MapPosition end; int tiles = 0; bool wild = false; + bool walk_only = false; }; // Nothing unless every step on the way is a plain walk: a warp, Surf, a // ledge, a person, a sign that would speak, or the edge of the loaded maps diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp new file mode 100644 index 0000000000..f350abf558 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -0,0 +1,741 @@ +/* Overworld Sim + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "Pokemon/Navigation/Pokemon_LegWalker.h" +#include "PokemonFRLG_OverworldReanchor.h" +#include "PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_OverworldSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace std::chrono_literals; + + +const char* sim_screen_name(SimScreen screen){ + switch (screen){ + case SimScreen::OVERWORLD: + return "the overworld"; + case SimScreen::MESSAGE: + return "a message"; + case SimScreen::START_MENU: + return "the START menu"; + case SimScreen::BATTLE_FLASH: + return "a battle's flashes"; + case SimScreen::BATTLE_WIPE: + return "a battle's wipe"; + case SimScreen::BATTLE_OPENING: + return "a battle's opening"; + case SimScreen::BATTLE: + return "a battle"; + case SimScreen::FADE: + return "a warp's fade"; + } + return "?"; +} + + +namespace{ + +// A short press: TURN_TAP and TURN_SETTLE. +constexpr Milliseconds TAP_TIME(340); +// The story's battle loop takes a battle that doesn't show in this long +// for none. +constexpr Milliseconds NO_BATTLE_WAIT(15000); +constexpr Milliseconds BATTLE_TIME(10000); +constexpr Milliseconds MESSAGE_CLOSE(1000); +// Rows of black below a small room, seen from this near its bottom. +constexpr int BLACK_BELOW_ROWS = 5; +constexpr int SMALL_ROOM_ROWS = 20; + +int find_map(const std::vector& maps, const std::string& name){ + for (size_t c = 0; c < maps.size(); c++){ + if (maps[c].name == name){ + return (int)c; + } + } + return -1; +} + +// The way a ledge on `tile` hops the player, if it is one. +std::optional ledge_direction(TileKind tile){ + switch (tile){ + case TileKind::LEDGE_NORTH: + return Direction::NORTH; + case TileKind::LEDGE_SOUTH: + return Direction::SOUTH; + case TileKind::LEDGE_WEST: + return Direction::WEST; + case TileKind::LEDGE_EAST: + return Direction::EAST; + default: + return std::nullopt; + } +} + +// The tile next to `player` a trainer standing on `box` walks up to. +TilePos trainer_stop(const Pokemon::TileBox& box, const TilePos& player){ + if (box.x0 <= player.x && player.x <= box.x1){ + return TilePos{player.x, box.y0 > player.y ? player.y + 1 : player.y - 1}; + } + return TilePos{box.x0 > player.x ? player.x + 1 : player.x - 1, player.y}; +} + +} + + +OverworldSim::OverworldSim(std::vector maps, MapPosition start, Direction facing, SimScript script) + : m_maps(std::move(maps)) + , m_script(std::move(script)) + , m_map(find_map(m_maps, start.map)) +{ + if (m_map < 0){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Sim: start on a map that isn't loaded: " + start.map); + } + m_player = PlayerState{start.point, elevation_at(map(), start.point), false}; + m_state.facing = facing; + m_state.screen = m_script.start_menu_open ? SimScreen::START_MENU : SimScreen::OVERWORLD; + update_view(); +} + + +void OverworldSim::note(const std::string& line){ + m_state.log.push_back(std::format("[Sim {} ms] {}", m_now.count(), line)); +} +void OverworldSim::violation(const std::string& line){ + note("violation: " + line); + m_state.violations.push_back(line); +} + +void OverworldSim::update_view(){ + m_state.position = MapPosition{map().name, m_player.position}; + m_state.black_below = map().connections.empty() && map().tiles.height <= SMALL_ROOM_ROWS + && m_player.position.y >= map().tiles.height - BLACK_BELOW_ROWS; +} + +void OverworldSim::schedule(Milliseconds at, SimScreen screen, bool unfreeze){ + const Pending pending{at, screen, unfreeze}; + const auto iter = std::upper_bound( + m_pending.begin(), m_pending.end(), pending, + [](const Pending& a, const Pending& b){ return a.at < b.at; } + ); + m_pending.insert(iter, pending); +} + +void OverworldSim::advance_to(Milliseconds time){ + while (!m_pending.empty() && m_pending.front().at <= time){ + m_state.screen = m_pending.front().screen; + m_frozen = m_frozen && !m_pending.front().unfreeze; + m_pending.erase(m_pending.begin()); + } + m_now = std::max(m_now, time); +} + + +bool OverworldSim::sees(SimWatcher watcher, SimScreen screen) const{ + switch (watcher){ + case SimWatcher::FLASH: + return screen == SimScreen::BATTLE_FLASH || screen == SimScreen::FADE; + case SimWatcher::BATTLE_SCREEN: + return screen == SimScreen::BATTLE; + case SimWatcher::BATTLE_TEXT_BOX: + return screen == SimScreen::BATTLE || screen == SimScreen::BATTLE_OPENING || (screen == SimScreen::OVERWORLD && m_state.black_below); + case SimWatcher::MESSAGE: + return screen == SimScreen::MESSAGE; + case SimWatcher::FADE: + return screen == SimScreen::FADE || screen == SimScreen::BATTLE_WIPE; + } + return false; +} + +std::optional OverworldSim::first_seen(SimWatcher watcher, Milliseconds from, Milliseconds to) const{ + if (sees(watcher, m_state.screen)){ + return from; + } + for (const Pending& pending : m_pending){ + if (pending.at > to){ + break; + } + if (pending.at > from && sees(watcher, pending.screen)){ + return pending.at; + } + } + return std::nullopt; +} + +std::optional OverworldSim::watch_until(const std::vector& watchers, Milliseconds from, Milliseconds to){ + advance_to(from); + std::optional seen; + std::optional when; + for (size_t c = 0; c < watchers.size(); c++){ + const std::optional at = first_seen(watchers[c], from, to); + if (at && (!when || *at < *when)){ + when = at; + seen = SimSeen{c, *at - from}; + } + } + advance_to(when.value_or(to)); + return seen; +} + + +std::optional OverworldSim::take_warp(int map_index, const TilePos& tile) const{ + const MapWarp* warp = warp_at(m_maps[map_index], tile); + if (warp == nullptr || !warp->to){ + return std::nullopt; + } + const int destination = find_map(m_maps, warp->to->map); + if (destination < 0){ + return std::nullopt; + } + const MapData& to = m_maps[destination]; + const auto arrival = to.points.find(warp->to->point); + if (arrival == to.points.end()){ + return std::nullopt; + } + TilePos position = arrival->second; + const bool walked_out = walks_out_on_arrival(to, position); + if (walked_out){ + position = position.next(Direction::SOUTH); + } + return Step{destination, PlayerState{position, elevation_at(to, position), false}, true, walked_out, false}; +} + +std::optional OverworldSim::cross_edge(Direction direction) const{ + for (const MapConnection& connection : map().connections){ + const int destination = connection.side == direction ? find_map(m_maps, connection.to_map) : -1; + if (destination < 0){ + continue; + } + const MapData& to = m_maps[destination]; + const TilePos here = m_player.position; + TilePos point{here.x - connection.offset, here.y - connection.offset}; + switch (direction){ + case Direction::NORTH: point.y = to.tiles.height - 1; break; + case Direction::SOUTH: point.y = 0; break; + case Direction::WEST: point.x = to.tiles.width - 1; break; + case Direction::EAST: point.x = 0; break; + } + const StepKind kind = classify_step_onto(map(), m_player, direction, to, point); + if (kind != StepKind::WALK){ + return std::nullopt; + } + return Step{destination, after_step_onto(map(), m_player, to, point, kind), false, false, false}; + } + return std::nullopt; +} + +// Pressing `direction` once, as the planner's press() reads the game, and +// also hopping a ledge the way it faces. +std::optional OverworldSim::press(Direction direction) const{ + const TilePos here = m_player.position; + if (exit_mat(map(), here) == direction){ + return take_warp(m_map, here); + } + const TilePos ahead = here.next(direction); + if (direction == Direction::NORTH && is_door(map(), ahead)){ + return take_warp(m_map, ahead); + } + if (!in_bounds(map(), ahead)){ + return cross_edge(direction); + } + if (ledge_direction(tile_at(map(), ahead)) == direction){ + const TilePos landed = ahead.next(direction); + return Step{m_map, PlayerState{landed, elevation_at(map(), landed), false}, false, false, true}; + } + const StepKind kind = classify_step(map(), m_player, direction); + if (kind != StepKind::WALK){ + return std::nullopt; + } + const Step step{m_map, after_step_onto(map(), m_player, map(), ahead, kind), false, false, false}; + if (warps_on_step(map(), ahead)){ + return take_warp(m_map, ahead); + } + return step; +} + + +// A trainer on the player's map who sees them now walks up and speaks. +bool OverworldSim::trainer_sees(Milliseconds pressed){ + std::vector& objects = m_maps[m_map].objects; + for (size_t c = 0; c < objects.size(); c++){ + MapObject& trainer = objects[c]; + if (!trainer.trainer || !trainer_may_see(map(), trainer, m_player.position)){ + continue; + } + const Pokemon::TileBox box = trainer.wander.value_or(Pokemon::TileBox{trainer.x, trainer.y, trainer.x, trainer.y}); + const TilePos stop = trainer_stop(box, m_player.position); + const int approach = std::abs(stop.x - trainer.x) + std::abs(stop.y - trainer.y); + trainer.x = stop.x; + trainer.y = stop.y; + trainer.wander.reset(); + // The player turns to face the trainer. + for (Direction direction : {Direction::NORTH, Direction::EAST, Direction::SOUTH, Direction::WEST}){ + m_state.facing = m_player.position.next(direction) == stop ? direction : m_state.facing; + } + m_trainer_speaking = c; + m_frozen = true; + schedule(pressed + m_script.timing.tile * (1 + approach) + m_script.timing.trainer_notice, SimScreen::MESSAGE, false); + note(std::format("{} on {} sees the player, walks up {} tile(s) to {}.", trainer.name, map().name, approach, to_string(stop))); + return true; + } + return false; +} + +// The player has taken `step`, pressed at `pressed`. Returns whether the +// game stopped them there: a warp, a wild battle, a trainer. +bool OverworldSim::after_step(const Step& step, Milliseconds pressed){ + const SimTiming& timing = m_script.timing; + m_map = step.map; + m_player = step.player; + m_state.steps++; + update_view(); + if (step.warped){ + m_state.warps++; + m_frozen = true; + schedule(pressed + timing.tile, SimScreen::FADE, false); + schedule(pressed + timing.tile + timing.fade, SimScreen::OVERWORLD, true); + note("warped to " + to_string(m_state.position) + "."); + return true; + } + return wild_battle(pressed) || trainer_sees(pressed); +} + +// A wild battle the script starts on this step onto tall grass, or onto a +// wild floor. +bool OverworldSim::wild_battle(Milliseconds pressed){ + if (!is_tall_grass(map(), m_player.position) && !m_script.wild_floor_maps.contains(map().name)){ + return false; + } + m_grass_steps++; + for (const SimEncounter& encounter : m_script.encounters){ + if (encounter.grass_step != m_grass_steps){ + continue; + } + m_frozen = true; + m_battle_pending = true; + if (!encounter.seen_late){ + schedule(pressed + m_script.timing.flash, SimScreen::BATTLE_FLASH, false); + } + if (m_script.timing.battle_wipe){ + schedule(pressed + *m_script.timing.battle_wipe, SimScreen::BATTLE_WIPE, false); + } + if (m_script.timing.battle_opening){ + schedule(pressed + *m_script.timing.battle_opening, SimScreen::BATTLE_OPENING, false); + } + schedule(pressed + m_script.timing.battle_screen, SimScreen::BATTLE, false); + note(std::format("a wild battle on {}{}.", to_string(m_state.position), encounter.seen_late ? " (flashes missed)" : "")); + return true; + } + return false; +} + +// Blocked holding `direction` at `pressed`: a sign walked into moving north +// or south shows its message. +void OverworldSim::bump(Direction direction, Milliseconds pressed){ + const TilePos ahead = m_player.position.next(direction); + const bool sign = tile_at(map(), ahead) == TileKind::SIGN + && (direction == Direction::NORTH || direction == Direction::SOUTH); + if (!sign){ + return; + } + m_state.signs_read++; + m_frozen = true; + schedule(pressed + m_script.timing.message, SimScreen::MESSAGE, false); + note("reads the sign on " + to_string(ahead) + "."); +} + +// The steps a hold of `duration` takes, `short_by` fewer. +void OverworldSim::walk_hold(Direction direction, Milliseconds duration, int short_by){ + const Milliseconds tile = m_script.timing.tile; + const Milliseconds end = m_now + duration; + Milliseconds pressed = m_now; + if (m_state.facing != direction){ + // Holding through a turn lost a tile on the console. + m_state.facing = direction; + pressed += tile; + } + int left = (int)((end - pressed + tile - 1ms) / tile) - short_by; + for (; left > 0 && pressed < end; left--){ + const std::optional step = press(direction); + if (!step){ + bump(direction, pressed); + return; + } + if (step->walked_out){ + m_state.facing = Direction::SOUTH; + } + if (after_step(*step, pressed)){ + return; + } + pressed += step->ledge ? tile * 2 : tile; + } +} + +// A hold the game doesn't move the player for. +void OverworldSim::stuck_hold(Direction direction){ + if (m_state.screen == SimScreen::START_MENU){ + note(std::string("the D-pad moves the START menu's cursor (") + direction_name(direction) + ")."); + }else if (m_battle_pending){ + violation(std::string("the D-pad held ") + direction_name(direction) + " into a battle."); + }else{ + note(std::string("the D-pad held ") + direction_name(direction) + " under " + sim_screen_name(m_state.screen) + "."); + } +} + + +std::optional OverworldSim::hold( + Direction direction, Milliseconds duration, Milliseconds release, Milliseconds watch, + const std::vector& watchers +){ + const Milliseconds start = m_now; + m_holds++; + if (m_state.screen != SimScreen::OVERWORLD || m_frozen){ + stuck_hold(direction); + }else{ + const auto short_hold = m_script.short_holds.find(m_holds); + walk_hold(direction, duration, short_hold == m_script.short_holds.end() ? 0 : short_hold->second); + } + return watch_until(watchers, start, start + std::max(duration + release, watch)); +} + +void OverworldSim::tap(Direction direction){ + if (m_state.screen != SimScreen::OVERWORLD || m_frozen){ + stuck_hold(direction); + }else if (m_state.facing != direction){ + m_state.facing = direction; + }else{ + walk_hold(direction, m_script.timing.tile / 2, 0); + } + advance_to(m_now + TAP_TIME); +} + +std::optional OverworldSim::watch(const std::vector& watchers, Milliseconds duration){ + return watch_until(watchers, m_now, m_now + duration); +} + +SimBattleEnd OverworldSim::handle_battle(SimBattleChoice choice){ + if (!m_battle_pending){ + violation("fought a battle that isn't there, on " + std::string(sim_screen_name(m_state.screen)) + "."); + advance_to(m_now + NO_BATTLE_WAIT); + return SimBattleEnd::NO_BATTLE; + } + while (m_state.screen != SimScreen::BATTLE && !m_pending.empty()){ + advance_to(m_pending.front().at); + } + if (m_trainer && choice == SimBattleChoice::RUN){ + violation("ran from a trainer's battle."); + advance_to(m_now + BATTLE_TIME); + return SimBattleEnd::CANT_RUN; + } + advance_to(m_now + BATTLE_TIME); + m_state.screen = SimScreen::OVERWORLD; + m_frozen = false; + m_battle_pending = false; + if (m_trainer){ + MapObject& trainer = m_maps[m_map].objects[*m_trainer]; + trainer.trainer.reset(); + m_state.trainers_beaten.push_back(trainer.name); + m_trainer.reset(); + } + note(std::string(choice == SimBattleChoice::RUN ? "ran from" : "won") + " the battle."); + return SimBattleEnd::ENDED; +} + +bool OverworldSim::close_message(){ + if (m_state.screen != SimScreen::MESSAGE){ + return false; + } + advance_to(m_now + MESSAGE_CLOSE); + m_state.screen = SimScreen::OVERWORLD; + if (!m_trainer_speaking){ + m_frozen = false; + return false; + } + m_trainer = m_trainer_speaking; + m_trainer_speaking.reset(); + m_battle_pending = true; + schedule(m_now + m_script.timing.trainer_battle, SimScreen::BATTLE, false); + note("the trainer's battle starts."); + return true; +} + + + +// +// OverworldSimWalker +// + +namespace{ + +using namespace OverworldWalkSteps; + +// As the console's watch for a trainer (PokemonFRLG_OverworldRecoveryWalk.cpp). +constexpr Milliseconds TRAINER_NOTICE(1500); +constexpr Milliseconds TRAINER_SPEAKS(1000); +// The console's wait for a warp's fade (wait_out_warp()). +constexpr Milliseconds WARP_TIMEOUT(8000); + +// The detection delay of the walker's interrupt `index` in `interrupts`. +Milliseconds delay_of(const OverworldInterrupts& interrupts, size_t index){ + return interrupts[index]->detection_delay(); +} + +} + + +OverworldSimWalker::OverworldSimWalker(OverworldSim& sim, SimWalkerRules rules, const OverworldInterruptSet& walker) + : m_sim(sim) + , m_rules(rules) +{ + // In the walker's order: battles first. + m_wild = { + {"a wild battle", SimWatcher::FLASH, delay_of(walker.wild, 0), true}, + {"a wild battle (seen late)", SimWatcher::BATTLE_SCREEN, delay_of(walker.wild, 1), true}, + {"a message", SimWatcher::MESSAGE, delay_of(walker.wild, 2), false}, + }; + m_other = { + {"a message", SimWatcher::MESSAGE, delay_of(walker.other, 0), false}, + }; +} + +void OverworldSimWalker::fail(const std::string& message){ + m_log.push_back("FAILED: " + message); + throw OperationFailedException(ErrorReportMode::NO_ERROR_REPORT, message); +} + +const std::vector& OverworldSimWalker::interrupts(const RouteStep& step) const{ + return step.wild ? m_wild : m_other; +} + +std::vector OverworldSimWalker::watchers(const std::vector& interrupts) const{ + std::vector ret; + for (const Interrupt& interrupt : interrupts){ + const bool old_battle_screen = interrupt.watcher == SimWatcher::BATTLE_SCREEN && !m_rules.battle_needs_hp_box; + ret.push_back(old_battle_screen ? SimWatcher::BATTLE_TEXT_BOX : interrupt.watcher); + } + return ret; +} + +// The story's interrupts: a battle is run from (a trainer's can't be), a +// message closed and a trainer's battle after it fought. +void OverworldSimWalker::clear(const Interrupt& interrupt){ + if (interrupt.battle){ + if (m_sim.handle_battle(SimBattleChoice::RUN) == SimBattleEnd::CANT_RUN){ + fail("Failed to flee the battle: it is a trainer's."); + } + return; + } + if (m_sim.close_message() && m_rules.fight_trainer_after_message){ + m_sim.handle_battle(SimBattleChoice::FIGHT); + } +} + +// Pokemon::clear_late_interrupts(). +size_t OverworldSimWalker::clear_late(const std::vector& interrupts, Milliseconds watch, size_t max){ + for (size_t count = 0; ; count++){ + const std::optional seen = m_sim.watch(watchers(interrupts), watch); + if (!seen){ + return count; + } + if (count >= max){ + fail(std::format("Interrupted {} times in a row after a leg.", count)); + } + m_log.push_back("Leg: " + interrupts[seen->watcher].name + " at the end of the leg."); + clear(interrupts[seen->watcher]); + } +} + +void OverworldSimWalker::check_overworld(const RouteStep& step){ + const SimScreen screen = m_sim.state().screen; + if (m_rules.check_overworld && (screen == SimScreen::START_MENU || screen == SimScreen::MESSAGE)){ + fail(std::string("walk_overworld_route(): ") + sim_screen_name(screen) + " is open, before: " + to_string(step)); + } +} + +std::optional OverworldSimWalker::hold_watching(const RouteStep& step, Milliseconds hold, const std::vector& interrupts){ + return m_sim.hold(step.direction, hold, STEP_RELEASE, hold + STEP_RELEASE + m_rules.watch_past_hold, watchers(interrupts)); +} + +// wait_out_warp(), then run_from_battle_after_warp(): false if the fade +// was a wild battle's, run from. wait_out_warp()'s watcher takes a wild +// battle's wipe for the fade (run 7; console B, 2026-11-03, Mt. Moon 1F): +// the battle screen stands in for the wipe. +bool OverworldSimWalker::after_warp(const RouteStep& step, int warps_before){ + if (m_sim.state().warps == warps_before){ + if (!m_sim.watch({SimWatcher::BATTLE_SCREEN}, WARP_TIMEOUT)){ + fail("walk_overworld_route(): no fade in and out after walking onto the warp to " + step.end.map + "."); + } + }else{ + m_sim.watch({SimWatcher::FADE}, WARP_TIMEOUT); + } + m_sim.watch({}, AFTER_WARP); + if (!m_sim.watch(watchers({m_wild[1]}), AFTER_WARP_BATTLE_WATCH - AFTER_WARP)){ + return true; + } + m_sim.handle_battle(SimBattleChoice::RUN); + return false; +} + +void OverworldSimWalker::fail_off_route(const RouteStep& step){ + fail("walk_overworld_route(): a wild battle, not the warp to " + step.end.map + ": the player is off the route."); +} + +std::optional OverworldSimWalker::walk_step(const RouteStep& step, std::optional facing){ + if (step.action == RouteAction::FACE){ + m_sim.tap(step.direction); + return std::nullopt; + } + if (step.action != RouteAction::WALK){ + fail("Sim: Surf isn't modelled."); + } + if (facing != step.direction){ + m_sim.tap(step.direction); + } + const int warps_before = m_sim.state().warps; + const std::vector& ints = interrupts(step); + Pokemon::LegSpec leg; + leg.direction = step.direction; + leg.tiles = step.tiles; + leg.to_wall = step.to_wall; + leg.tile_time = TILE_TIME; + leg.stop_on_interrupt = !step.to_wall; + leg.stop_if_blocked = step.to_wall && m_rules.stop_if_blocked; + Pokemon::LegCalls calls; + calls.hold_watching = [&](Milliseconds hold) -> std::optional{ + const std::optional seen = hold_watching(step, hold, ints); + return seen ? std::optional(Pokemon::HoldOutcome{seen->watcher, seen->elapsed}) : std::nullopt; + }; + calls.clear = [&](size_t index){ clear(ints[index]); }; + calls.detection_delay = [&](size_t index){ return ints[index].delay; }; + calls.name = [&](size_t index){ return ints[index].name; }; + calls.log = [&](const std::string& message){ m_log.push_back("Leg: " + message); }; + calls.fail = [&](const std::string& message){ fail(message); }; + const Pokemon::LegResult result = Pokemon::walk_leg_with(leg, calls, MAX_INTERRUPTIONS_PER_LEG); + if (result.stopped){ + return walked_near(step, result.tiles, TILE_TIME); + } + if (step.warps && !after_warp(step, warps_before)){ + if (!m_rules.find_player_after_warp_battle){ + fail_off_route(step); + } + m_log.push_back("Route: a wild battle, not the warp to " + step.end.map + "; the player is on " + to_string(step) + " before the warp."); + return walked_before_warp(step); + } + return std::nullopt; +} + +// walk_and_watch(). +StepOutcome OverworldSimWalker::walk(const RouteStep& step, std::optional facing){ + if (m_rules.battle_before_step && battle_on_its_way()){ + m_log.push_back("Route: " + std::string(sim_screen_name(m_sim.state().screen)) + " before " + to_string(step) + ": watching on for a wild battle set off before it."); + if (clear_late(m_wild, UNSEEN_BATTLE_WATCH, MAX_INTERRUPTIONS_PER_LEG) > 0){ + return StepOutcome{.end = StepEnd::BATTLE_BEFORE, .walked = std::nullopt}; + } + } + if (m_sim.state().screen == SimScreen::MESSAGE){ + m_log.push_back("Route: a message is open before " + to_string(step) + "; handing it to the leg's interrupts."); + clear_late(interrupts(step), OPEN_MESSAGE_WATCH, 1); + } + check_overworld(step); + if (const std::optional walked = walk_step(step, facing)){ + return StepOutcome{.end = StepEnd::STOPPED, .walked = walked}; + } + if (step.action == RouteAction::FACE){ + return StepOutcome{}; + } + size_t late = clear_late(interrupts(step), step.wild ? m_rules.after_wild_leg_watch : AFTER_LEG_WATCH, MAX_INTERRUPTIONS_PER_LEG); + if (late == 0){ + late = clear_unseen_battle(step); + } + const bool done = late == 0 || step.warps || step.action != RouteAction::WALK; + return StepOutcome{.end = done ? StepEnd::DONE : StepEnd::LATE, .walked = std::nullopt}; +} + +// Whether the screen shows a battle on its way: its wipe's black, or its +// text box before the HP boxes. +bool OverworldSimWalker::battle_on_its_way() const{ + const SimScreen screen = m_sim.state().screen; + return screen == SimScreen::BATTLE_WIPE || screen == SimScreen::BATTLE_OPENING; +} + +// clear_unseen_battle(): the battle's text box or wipe seen after the +// leg's watch. +size_t OverworldSimWalker::clear_unseen_battle(const RouteStep& step){ + const bool checked = step.wild ? m_rules.watch_on_for_unseen_battle : m_rules.watch_on_after_unmarked_leg; + if (!checked || step.action != RouteAction::WALK || step.warps || !battle_on_its_way()){ + return 0; + } + m_log.push_back("Route: " + std::string(sim_screen_name(m_sim.state().screen)) + " after " + to_string(step) + ", which nothing stopped: watching on for a wild battle."); + return clear_late(m_wild, UNSEEN_BATTLE_WATCH, MAX_INTERRUPTIONS_PER_LEG); +} + +void OverworldSimWalker::hold_to_warp(const RouteStep& step){ + std::vector fade_first{SimWatcher::FADE}; + for (const SimWatcher watcher : watchers(m_other)){ + fade_first.push_back(watcher); + } + const int warps_before = m_sim.state().warps; + for (size_t count = 0; count <= MAX_INTERRUPTIONS_PER_LEG; count++){ + m_sim.tap(step.direction); + const Milliseconds hold = TILE_TIME * (step.tiles + 2); + const std::optional seen = m_sim.hold(step.direction, hold, STEP_RELEASE, hold + STEP_RELEASE, fade_first); + if (seen && seen->watcher == 0){ + if (!after_warp(step, warps_before)){ + fail_off_route(step); + } + return; + } + if (!seen){ + break; + } + clear(m_other[seen->watcher - 1]); + } + fail("walk_overworld_route(): no fade holding on toward the warp to " + step.end.map + "."); +} + +StepEnd OverworldSimWalker::watch_for_trainer(const RouteStep& step, int approach_tiles){ + const Milliseconds watch = TRAINER_NOTICE + TILE_TIME * approach_tiles + TRAINER_SPEAKS; + return clear_late(interrupts(step), watch, MAX_INTERRUPTIONS_PER_LEG) == 0 ? StepEnd::DONE : StepEnd::LATE; +} + +bool OverworldSimWalker::moved_past_wall(const RouteStep& step){ + const MapPosition before = m_sim.state().position; + m_sim.tap(step.direction); + return !(m_sim.state().position == before); +} + +RouteCalls OverworldSimWalker::calls(){ + RouteCalls calls; + calls.walk = [this](const RouteStep& step, std::optional facing){ return walk(step, facing); }; + calls.hold_to_warp = [this](const RouteStep& step){ hold_to_warp(step); }; + calls.log = [this](const std::string& message){ m_log.push_back(message); }; + calls.fail = [this](const std::string& message){ fail(message); }; + calls.moved_past_wall = [this](const RouteStep& step){ return moved_past_wall(step); }; + calls.watch_for_trainer = [this](const RouteStep& step, int approach_tiles){ return watch_for_trainer(step, approach_tiles); }; + return calls; +} + +std::string OverworldSimWalker::run(const std::vector& route, const RoutePlanning& planning){ + try{ + run_route(route, m_sim.state().facing, &planning, calls()); + }catch (OperationFailedException& e){ + return e.message(); + } + return ""; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h new file mode 100644 index 0000000000..f7ff383c24 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -0,0 +1,347 @@ +/* Overworld Sim + * + * From: https://github.com/PokemonAutomation/ + * + * A whole-tile model of FireRed's overworld, for running the walker + * (run_route(), Pokemon::walk_leg_with()) without a console. The player + * moves a tile per 16 frames while the D-pad is held, by the maps' step + * rules (classify_step()), and also hops ledges the way they face, takes + * warps with a fade, reads signs walked into moving north or south, and is + * seen by trainers along their sight. Wild battles start on the steps a + * test chooses. Each of these shows a screen at a time after the step's + * press, which the walker's watchers see or miss as the console's do. + * + * Every console failure of the walker becomes a test here (red first). + * OverworldSimWalker is the console side of run_route() (RouteCalls) on + * the sim, step for step as walk_overworld_route_with_recovery() does it, + * with the walker's rules as switches, so a test can show a failure with + * the rule that was missing and its absence with the rule. + * + * Not modelled: Surf, bikes, running, HP and status. Wandering people + * stand on their tile. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldSim_H +#define PokemonAutomation_PokemonFRLG_OverworldSim_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_OverworldRecovery.h" +#include "PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_OverworldWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// What the screen shows, as the walker's detectors see it. +enum class SimScreen{ + OVERWORLD, + MESSAGE, // a white message box: a sign, a trainer + START_MENU, + BATTLE_FLASH, // a wild battle's opening flashes + BATTLE_WIPE, // black, as a wild battle's wipe ends, before its text box + BATTLE_OPENING, // a battle's text box, empty, before the HP boxes + BATTLE, // a battle's text box with the HP boxes + FADE, // a warp's fade out and in +}; +const char* sim_screen_name(SimScreen screen); + +// What the walker watches the screen with. +enum class SimWatcher{ + FLASH, // BattleFlashWatcher: a battle's flashes, and a warp's fade + BATTLE_SCREEN, // BattleScreenWatcher: the battle menu or an HP box + // BattleDialogDetector, which the walker used before BattleScreenWatcher: + // the battle text box alone, which black below a small room passes for. + BATTLE_TEXT_BOX, + MESSAGE, // WhiteDialogWatcher + FADE, // BlackScreenWatcher: a warp's fade +}; + +// A wild battle on the player's `grass_step`th step onto tall grass (from +// 1), or onto any tile of a map in SimScript::wild_floor_maps. Seen late: +// its flashes are missed, and only the battle screen shows. +struct SimEncounter{ + int grass_step = 1; + bool seen_late = false; +}; + +// How long after a step's press each thing shows. The walker's own +// detection delays (OverworldInterrupt::detection_delay()) are its guesses +// at these. +struct SimTiming{ + Milliseconds tile{267}; + Milliseconds flash{300}; + Milliseconds battle_screen{3700}; + // A wild battle's text box, before the HP boxes (battle_screen). Not + // set: not modelled, the battle screen shows at once. + std::optional battle_opening; + // Black, from the end of a wild battle's wipe until its text box + // (battle_opening) or HP boxes. Not set: not modelled. + std::optional battle_wipe; + Milliseconds message{400}; + Milliseconds fade{1000}; // from the step's end to the new map + Milliseconds trainer_notice{1000}; // "!" over the trainer, before they walk up + Milliseconds trainer_battle{2000}; // after the trainer's message closes +}; + +// What a test sets up beyond the maps. +struct SimScript{ + std::vector encounters; + // The START menu is open at the start: the D-pad moves its cursor. + bool start_menu_open = false; + // The hold numbered by the key (from 1) stops this many tiles short, + // with nothing on screen (the forest drift, console B, 2026-11-01). + std::map short_holds; + // Maps where every step can start a wild battle, also on tiles the maps + // don't mark as grass: Mt. Moon B2F's tiles closed on one side ('8') + // are cave floor too (console B, 2026-11-03). + std::set wild_floor_maps; + SimTiming timing; +}; + +// The player and the screen, and what went wrong that the game would show. +struct SimState{ + MapPosition position; + Direction facing = Direction::SOUTH; + SimScreen screen = SimScreen::OVERWORLD; + // Whether black shows below the map: the player stands near the bottom + // of a map with nothing joined to it (a small room or a gate). + bool black_below = false; + int steps = 0; + int warps = 0; + int signs_read = 0; + std::vector trainers_beaten; + // Things the walker did that the game wouldn't let pass: D-pad holds + // under a battle, a trainer's battle fled, a battle fought that wasn't + // there. + std::vector violations; + std::vector log; +}; + +// A watcher that fired: which (an index into the watchers asked for), and +// how long after the hold or watch started. +struct SimSeen{ + size_t watcher = 0; + Milliseconds elapsed{0}; +}; + +// How the walker handles the battle on screen. +enum class SimBattleChoice{ + RUN, + FIGHT, +}; +enum class SimBattleEnd{ + ENDED, + CANT_RUN, // a trainer's battle: "No! There's no running from a TRAINER battle!" + NO_BATTLE, // there was none, on screen or about to show +}; + +class OverworldSim{ +public: + OverworldSim(std::vector maps, MapPosition start, Direction facing, SimScript script = {}); + + const SimState& state() const{ return m_state; } + + // Holds `direction` for `duration` then lets go for `release`, while + // `watchers` watch until `watch` (at least the hold and release) from + // the start. Returns the first that saw its screen, as run_until(). + std::optional hold( + Direction direction, Milliseconds duration, Milliseconds release, Milliseconds watch, + const std::vector& watchers + ); + + // A short press of `direction`: turns the player, or steps if they + // already face that way. + void tap(Direction direction); + + // Watches `watchers` for `duration`, as wait_until(). + std::optional watch(const std::vector& watchers, Milliseconds duration); + + // Runs from or fights the battle on screen, back to the overworld. A + // trainer's battle can't be run from; a battle that isn't there is + // waited out (the story's battle loop gives up after 15 s). + SimBattleEnd handle_battle(SimBattleChoice choice); + + // Closes the message on screen with B. Returns whether a trainer's + // battle follows it. + bool close_message(); + +private: + // A screen that shows at `at`; `unfreeze`: the player can move again. + struct Pending{ + Milliseconds at; + SimScreen screen; + bool unfreeze; + }; + // Where one press of the D-pad takes the player. + struct Step{ + int map = 0; + PlayerState player; + bool warped = false; + bool walked_out = false; // the warp ended with the game walking the player a tile south + bool ledge = false; + }; + + void advance_to(Milliseconds time); + void schedule(Milliseconds at, SimScreen screen, bool unfreeze); + bool sees(SimWatcher watcher, SimScreen screen) const; + std::optional first_seen(SimWatcher watcher, Milliseconds from, Milliseconds to) const; + std::optional watch_until(const std::vector& watchers, Milliseconds from, Milliseconds to); + void walk_hold(Direction direction, Milliseconds duration, int short_by); + void stuck_hold(Direction direction); + void bump(Direction direction, Milliseconds pressed); + std::optional press(Direction direction) const; + std::optional take_warp(int map_index, const TilePos& tile) const; + std::optional cross_edge(Direction direction) const; + bool after_step(const Step& step, Milliseconds pressed); + bool wild_battle(Milliseconds pressed); + bool trainer_sees(Milliseconds pressed); + void update_view(); + void note(const std::string& line); + void violation(const std::string& line); + const MapData& map() const{ return m_maps[m_map]; } + + std::vector m_maps; + SimScript m_script; + int m_map = 0; + PlayerState m_player; + Milliseconds m_now{0}; + std::vector m_pending; // in time order + // The game has stopped the player: a battle, a message, a warp. + bool m_frozen = false; + // A battle is on screen or about to show. + bool m_battle_pending = false; + // The trainer (an object of the player's map) whose message is up or + // about to show, and whose battle follows it. + std::optional m_trainer_speaking; + // The trainer whose battle is on screen or about to show. + std::optional m_trainer; + int m_grass_steps = 0; + int m_holds = 0; + SimState m_state; +}; + + +// The walker's rules that console failures added, each on unless a test +// turns it off to show the failure it fixed. +struct SimWalkerRules{ + // Fail before a step unless the overworld is up (START menu left open, + // console B, 2026-11-01, Viridian Forest). + bool check_overworld = true; + // A battle needs an HP box or the battle menu: black below a small room + // isn't one (console B, 2026-11-01, Viridian Forest's north gate). + bool battle_needs_hp_box = true; + // A trainer's battle after their message is fought, not taken for a + // wild one and run from (console B, 2026-11-01, Viridian Forest). + bool fight_trainer_after_message = true; + // How long the walker watches after a leg through grass for a battle + // whose flashes it missed. It watched 500 ms, and walked the next leg + // into the battle (console B, 2026-11-01, Route 1, run 7 and r14). + Milliseconds after_wild_leg_watch = OverworldWalkSteps::AFTER_WILD_LEG_WATCH; + // A leg to a wall held again after an interrupt stops when it is + // interrupted again right away: by a sign it is held into. Without it + // the walker read the sign 6 times (console B, 2026-11-01, r16). + bool stop_if_blocked = true; + // How long a hold's watch goes on past the hold and its release. 0 as + // now; the tile-at-a-time build watched each step for 900 ms, and saw + // the warp's fade on the step onto the forest's north gate as a battle. + Milliseconds watch_past_hold{0}; + // A leg onto a warp whose fade was a wild battle's (its wipe passes for + // the fade) stops before the warp, and the player is found again on it. + // Without it the walker stopped as off the route (console B, + // 2026-11-03, Mt. Moon 1F, row 00.13). + bool find_player_after_warp_battle = true; + // After a leg's watch saw nothing, a battle's text box on screen (its + // HP boxes still to come) is watched on for, and the leg taken as + // stopped part-way. Without it the walker took "south 15 (to wall)" + // as walked and the next leg into the battle (console B, 2026-11-03, + // Mt. Moon B1F, row 00.13). + bool watch_on_for_unseen_battle = true; + // The same after a leg the maps see no wild tiles on (RouteStep::wild), + // which the leg and its watch don't watch for battles: on Mt. Moon + // B2F's tiles closed on one side, which the maps don't mark as wild, a + // battle on "east 7 (to wall)" left black after the leg, the leg was + // taken as walked and the next one held from where the battle was + // (console B, 2026-11-03, row 00.13). + bool watch_on_after_unmarked_leg = true; + // Before a step, a battle on its way (its text box or wipe) is watched + // on for, and the step before it taken as stopped part-way, not the + // step held from where the battle left the player. + bool battle_before_step = true; +}; + +// The console side of run_route() on an OverworldSim, as +// walk_overworld_route_with_recovery() does it, with the story's +// interrupts: a wild battle (flashes, or seen late) is fought or run from, +// a message is closed and a trainer's battle after it fought. +class OverworldSimWalker{ +public: + // `interrupts`: whose detection delays place a stopped leg, the + // walker's own or a caller's (story_interrupts()). Each set lists a + // wild battle, one seen late and a message for `wild`, and a message + // for `other`, in that order. + OverworldSimWalker( + OverworldSim& sim, SimWalkerRules rules = {}, + const OverworldInterruptSet& interrupts = default_overworld_interrupts() + ); + + // RouteCalls whose `fail` throws OperationFailedException, as the + // console's does. + RouteCalls calls(); + + // run_route() from the sim's facing, planned with `planning`. Returns + // the failure's message, or empty if the route was walked. + std::string run(const std::vector& route, const RoutePlanning& planning); + + const std::vector& log() const{ return m_log; } + +private: + // One of the story's interrupts (story_interrupts()), with the + // walker's detection delay for it. + struct Interrupt{ + std::string name; + SimWatcher watcher; + Milliseconds delay; + bool battle; + }; + StepOutcome walk(const RouteStep& step, std::optional facing); + std::optional walk_step(const RouteStep& step, std::optional facing); + bool after_warp(const RouteStep& step, int warps_before); + [[noreturn]] void fail_off_route(const RouteStep& step); + std::optional hold_watching(const RouteStep& step, Milliseconds hold, const std::vector& interrupts); + size_t clear_late(const std::vector& interrupts, Milliseconds watch, size_t max); + size_t clear_unseen_battle(const RouteStep& step); + bool battle_on_its_way() const; + void clear(const Interrupt& interrupt); + void hold_to_warp(const RouteStep& step); + StepEnd watch_for_trainer(const RouteStep& step, int approach_tiles); + bool moved_past_wall(const RouteStep& step); + void check_overworld(const RouteStep& step); + const std::vector& interrupts(const RouteStep& step) const; + std::vector watchers(const std::vector& interrupts) const; + [[noreturn]] void fail(const std::string& message); + + OverworldSim& m_sim; + SimWalkerRules m_rules; + std::vector m_wild; + std::vector m_other; + std::vector m_log; +}; + + +void add_tests_OverworldSim(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp new file mode 100644 index 0000000000..b1a6852e36 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -0,0 +1,807 @@ +/* Overworld Sim Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The walker's console failures (console B, 2026-11-01 and 02; + * ~/FRLGLab/notes/newgame-firered-console-B.md) replayed on the sim: each + * test walks the route that failed, the way it failed, and checks the + * walker now gets the player there, or stops without claiming it did. + * Where a walker rule fixed the failure, the test also turns the rule off + * and checks the sim fails the old way, so the sim is known to reproduce + * it. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class Test_OverworldSim : public UnitTest{ +public: + Test_OverworldSim(std::string name, std::function body) + : UnitTest("PokemonFRLG::OverworldSim - " + 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 std::vector& maps(){ + static const std::vector MAPS = overworld_maps(); + return MAPS; +} + +MapPosition at(const char* map, int x, int y){ + return MapPosition{map, {x, y}}; +} + +// Where a warp to point `point` of `map` puts the player: there, or a tile +// south when the game walks them out of a door. +MapPosition arrival(const char* map, const char* point){ + const TilePos tile = map_point(maps(), map, point); + for (const MapData& data : maps()){ + if (data.name == map && walks_out_on_arrival(data, tile)){ + return MapPosition{map, tile.next(Direction::SOUTH)}; + } + } + return MapPosition{map, tile}; +} + +RouteStep leg(Direction direction, int tiles, MapPosition end, bool to_wall){ + RouteStep step; + step.direction = direction; + step.tiles = tiles; + step.to_wall = to_wall; + step.facing_after = direction; + step.end = std::move(end); + return step; +} + +// A walk on the sim: the route from `start` (planned to `goal` if not +// given), what the sim starts with, and the walker's rules. +struct SimCase{ + MapPosition start = {}; + Direction facing = Direction::NORTH; + MapPosition goal = {}; + SimScript script = {}; + SimWalkerRules rules = {}; + std::optional> route = std::nullopt; + WallCheck wall_check = WallCheck::TAP; + OpenLegCheck open_leg_check = OpenLegCheck::REANCHOR; +}; + +struct SimRun{ + std::vector route; + std::string failure; + SimState state; + std::vector log; +}; + +std::vector planned(const SimCase& c){ + if (c.route){ + return *c.route; + } + const std::optional> route = plan_overworld_route( + maps(), c.start, RouteGoal{c.goal, std::nullopt}, default_route_costs(), c.facing + ); + if (!route){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No route from " + to_string(c.start) + " to " + to_string(c.goal) + "."); + } + return *route; +} + +SimRun run_case(const SimCase& c){ + SimRun run; + run.route = planned(c); + OverworldSim sim(maps(), c.start, c.facing, c.script); + OverworldSimWalker walker(sim, c.rules); + const RoutePlanning planning{.maps = &maps(), .costs = default_route_costs(), .start = c.start, .label = "Sim", .wall_check = c.wall_check, .open_leg_check = c.open_leg_check}; + run.failure = walker.run(run.route, planning); + run.state = sim.state(); + run.log = walker.log(); + run.log.insert(run.log.end(), sim.state().log.begin(), sim.state().log.end()); + return run; +} + +std::string describe(const std::vector& route){ + std::string text; + for (const RouteStep& step : route){ + text += "\n " + to_string(step); + } + return text; +} + +std::string describe(const SimRun& run){ + std::string text = std::format( + "\n ended at {}, facing {}, on {}; failure: \"{}\"", + to_string(run.state.position), direction_name(run.state.facing), sim_screen_name(run.state.screen), run.failure + ); + for (const std::string& violation : run.state.violations){ + text += "\n violation: " + violation; + } + text += "\n route:" + describe(run.route) + "\n log:"; + for (const std::string& line : run.log){ + text += "\n " + line; + } + return text; +} + +// The walk returned with the player at `goal`, and the game let it all pass. +bool arrived(const SimRun& run, const MapPosition& goal){ + return run.failure.empty() && run.state.position == goal && run.state.violations.empty(); +} + +// The walk stopped with `failure`, without claiming to be anywhere. +bool stopped_with(const SimRun& run, const std::string& failure){ + return run.failure.find(failure) != std::string::npos; +} + +bool on_grass(const MapPosition& tile){ + for (const MapData& map : maps()){ + if (map.name == tile.map){ + return is_tall_grass(map, tile.point); + } + } + return false; +} + +// The grass steps a walk of `route` from `start` takes before step `index` +// (from 0), and on it. +struct GrassSteps{ + int before = 0; + int on = 0; +}; +GrassSteps grass_steps(const MapPosition& start, const std::vector& route, size_t index){ + GrassSteps steps; + MapPosition from = start; + for (size_t c = 0; c <= index && c < route.size(); c++){ + const std::vector tiles = leg_tiles(maps(), from, route[c]); + for (size_t t = 1; t < tiles.size(); t++){ + (c < index ? steps.before : steps.on) += on_grass(tiles[t]) ? 1 : 0; + } + from = route[c].end; + } + return steps; +} + +bool open_wild_walk(const RouteStep& step){ + return step.action == RouteAction::WALK && step.wild && !step.warps && !step.to_wall; +} + +// The first open WALK of `route` through grass that ends on grass. +std::optional leg_ending_on_grass(const std::vector& route){ + for (size_t c = 0; c < route.size(); c++){ + if (open_wild_walk(route[c]) && on_grass(route[c].end)){ + return c; + } + } + return std::nullopt; +} + +// The longest open WALK of `route` through grass. +std::optional longest_wild_leg(const std::vector& route){ + std::optional found; + for (size_t c = 0; c < route.size(); c++){ + if (open_wild_walk(route[c]) && (!found || route[c].tiles > route[*found].tiles)){ + found = c; + } + } + return found; +} + + +// The first WALK of `route` onto a warp through grass. +std::optional wild_warp_leg(const std::vector& route){ + for (size_t c = 0; c < route.size(); c++){ + if (route[c].action == RouteAction::WALK && route[c].wild && route[c].warps){ + return c; + } + } + return std::nullopt; +} + +// A walk of `c` with a wild battle, its flashes missed, on step `tile` +// (from 1) of the route's first leg onto a warp through grass, which must +// be a grass step. Nothing if there is no such leg or step. +std::optional run_with_battle_on_warp_leg(SimCase c, size_t tile){ + const std::vector route = planned(c); + const std::optional warp = wild_warp_leg(route); + if (!warp){ + return std::nullopt; + } + const MapPosition from = *warp == 0 ? c.start : route[*warp - 1].end; + const std::vector tiles = leg_tiles(maps(), from, route[*warp]); + if (tile >= tiles.size() || !on_grass(tiles[tile])){ + return std::nullopt; + } + int grass_step = grass_steps(c.start, route, *warp).before; + for (size_t t = 1; t <= tile; t++){ + grass_step += on_grass(tiles[t]) ? 1 : 0; + } + c.script.encounters = {{.grass_step = grass_step, .seen_late = true}}; + return run_case(c); +} + + +// cp0007 m6f and r15 (console B, 2026-11-01): out of Viridian Forest +// through its north gate. The leg through the grass ends on the gate's +// warp. Two rules keep it from failing on screens that aren't battles: +// the hold isn't watched past its release, which the warp's fade comes +// after (the tile-at-a-time build watched each step 900 ms, took the fade +// for a battle's flashes, and tried to flee in the gate); and a battle +// needs an HP box (black below the gate's mat read as a battle's text +// box, and the story fought it for 10 minutes). +UnitTestResult test_forest_north_gate(){ + SimCase c{.start = at("ViridianForest", 5, 20), .facing = Direction::NORTH, .goal = at("Route2_ViridianForest_NorthEntrance", 7, 9)}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.warps != 1){ + return "Expected the gate reached:" + describe(now); + } + SimCase watched_past = c; + watched_past.rules.watch_past_hold = Milliseconds(900); + const SimRun fade = run_case(watched_past); + if (arrived(fade, c.goal)){ + return "Expected the fade taken for a battle when the hold is watched 900 ms past its end:" + describe(fade); + } + SimCase text_box = c; + text_box.rules.battle_needs_hp_box = false; + const SimRun black = run_case(text_box); + if (arrived(black, c.goal)){ + return "Expected the black below the gate's mat taken for a battle without an HP box:" + describe(black); + } + return true; +} + +// Console B, 2026-11-03, Mt. Moon 1F (row 00.13): a wild battle whose +// flashes were missed started on the leg south onto the ladder; its wipe +// passed for the ladder's fade, and the walker stopped as off the route, +// on the leg a tile short of the ladder. Here on the leg through the +// forest's grass onto its north gate: a battle on the leg's first or +// second step is run from, the player found again on the leg, and the +// gate reached through its warp. If the walk back onto the warp then +// ends short of it (hold 4: the leg, the hold that finds the player and +// its re-hold after the Bug Catcher), the route still stops on no fade. +// Without the rule it stops as off the route, as on the console. +UnitTestResult test_battle_on_warp_leg(){ + const SimCase c{.start = at("ViridianForest", 5, 20), .facing = Direction::NORTH, .goal = at("Route2_ViridianForest_NorthEntrance", 7, 9)}; + for (size_t tile : {1, 2}){ + const std::optional now = run_with_battle_on_warp_leg(c, tile); + if (!now){ + return std::format("Expected a leg onto the gate's warp with grass on its step {}:", tile) + describe(planned(c)); + } + if (!arrived(*now, c.goal) || now->state.warps != 1){ + return std::format("Expected the battle on step {} run from, then the gate reached:", tile) + describe(*now); + } + } + SimCase no_warp = c; + no_warp.script.short_holds[4] = 2; + const std::optional short_back = run_with_battle_on_warp_leg(no_warp, 1); + if (!short_back || !stopped_with(*short_back, "no fade in and out") || short_back->state.warps != 0){ + return "Expected a stop on no fade when the walk back ends short of the warp:" + (short_back ? describe(*short_back) : ""); + } + SimCase off = c; + off.rules.find_player_after_warp_battle = false; + const std::optional old = run_with_battle_on_warp_leg(off, 1); + if (!old || !stopped_with(*old, "a wild battle, not the warp to Route2_ViridianForest_NorthEntrance: the player is off the route.")){ + return "Expected a stop as off the route without the rule:" + (old ? describe(*old) : ""); + } + return true; +} + +// cp0007-zy (console B, 2026-11-01): with the START menu still open, three +// legs to walls in Viridian Forest "walked" without the player moving. +// The walker now stops before the first. +UnitTestResult test_start_menu_open(){ + SimCase c{.start = at("ViridianForest", 42, 44), .facing = Direction::NORTH, .goal = at("ViridianForest", 39, 25)}; + c.script.start_menu_open = true; + const SimRun now = run_case(c); + if (!stopped_with(now, "the START menu is open") || now.state.steps != 0){ + return "Expected a stop before the first leg:" + describe(now); + } + SimCase unchecked = c; + unchecked.rules.check_overworld = false; + const SimRun old = run_case(unchecked); + if (!old.failure.empty() || !(old.state.position == c.start)){ + return "Expected the walk to claim the route done without moving, with the check off:" + describe(old); + } + return true; +} + +// Route 1 (console B, 2026-11-01, run 7 and r14): a battle on a leg's last +// grass step whose flashes are missed shows only after the leg. The walker +// watches long enough after a leg through grass to see it and run before +// the next leg; watching 500 ms, it walked the next leg into the battle. +UnitTestResult test_battle_at_end_of_leg(){ + SimCase c{.start = at("PalletTown", 12, 1), .facing = Direction::NORTH, .goal = at("ViridianCity", 26, 27)}; + const std::vector route = planned(c); + const std::optional wild = leg_ending_on_grass(route); + if (!wild){ + return "Expected an open leg ending on Route 1's grass:" + describe(route); + } + const GrassSteps steps = grass_steps(c.start, route, *wild); + c.script.encounters = {{.grass_step = steps.before + steps.on, .seen_late = true}}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal)){ + return "Expected the battle run from, then the route walked:" + describe(now); + } + SimCase short_watch = c; + short_watch.rules.after_wild_leg_watch = Milliseconds(500); + const SimRun old = run_case(short_watch); + if (old.state.violations.empty()){ + return "Expected the next leg held into the battle when watching 500 ms:" + describe(old); + } + return true; +} + +// r16 and r17 (console B, 2026-11-01), Route 1 to Oak's Lab: a battle seen +// only late stops an open leg through grass part-way. The walker works out +// from the timing which tiles the player may be on, finds them again at +// walls, and walks on to the lab's door. +UnitTestResult test_late_battle_mid_leg(){ + SimCase c{.start = at("ViridianCity_Mart", 4, 3), .facing = Direction::WEST, .goal = at("PalletTown_ProfessorOaksLab", 6, 11)}; + const std::vector route = planned(c); + const std::optional open = longest_wild_leg(route); + if (!open || route[*open].tiles < 16){ + return "Expected an open leg through Route 1's grass of 16 tiles or more:" + describe(route); + } + const GrassSteps steps = grass_steps(c.start, route, *open); + c.script.encounters = {{.grass_step = steps.before + 1, .seen_late = true}}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal)){ + return "Expected the player found again and the lab reached:" + describe(now); + } + return true; +} + +// cp0007 m6f (console B, 2026-11-01), Viridian Forest: a Bug Catcher sees +// the player, speaks, and their battle starts. It is fought; the walker +// tried to flee it, 5 times, when it closed the message as any other. +UnitTestResult test_trainer_not_fled(){ + SimCase c{.start = at("ViridianForest", 42, 49), .facing = Direction::NORTH, .goal = at("ViridianForest", 41, 40)}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.trainers_beaten.empty()){ + return "Expected the Bug Catcher fought and the route walked:" + describe(now); + } + SimCase old_message = c; + old_message.rules.fight_trainer_after_message = false; + const SimRun old = run_case(old_message); + if (old.state.violations.empty()){ + return "Expected the trainer's battle run from, or walked into, without the rule:" + describe(old); + } + return true; +} + +// cp0007-r..t (console B, 2026-11-01), Viridian City: "south 2 (to wall)" +// from the Pokemon Center held into a ledge and hopped it, to (26, 31). +// The planner no longer holds into ledges; the old leg still hops one. +UnitTestResult test_no_hold_over_ledge(){ + const SimCase c{.start = at("ViridianCity", 26, 27), .facing = Direction::SOUTH, .goal = at("ViridianCity", 26, 29)}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal)){ + return "Expected the route to stop above the ledge:" + describe(now); + } + SimCase old_leg = c; + old_leg.route = std::vector{leg(Direction::SOUTH, 2, c.goal, true)}; + old_leg.wall_check = WallCheck::SKIP; // the tap after it came later + const SimRun old = run_case(old_leg); + if (!(old.state.position == at("ViridianCity", 26, 31))){ + return "Expected the old leg to hop the ledge to (26, 31):" + describe(old); + } + return true; +} + +// r16 (console B, 2026-11-01), Viridian Forest: an open leg west ended 2 +// tiles short with nothing on screen, and the next leg, south to a wall, +// was held into the TRAINER TIPS sign on (9, 29) six times. The walker now +// finds the player at walls after the open leg, since the hold south +// doesn't end on its tile from where a short leg leaves them. Without that +// check it stops at the sign's second read, as off the route; without the +// stop either, it reads the sign 6 times. +UnitTestResult test_short_leg_then_sign(){ + SimCase c{.start = at("ViridianForest", 13, 28), .facing = Direction::WEST, .goal = at("ViridianForest", 7, 33)}; + c.route = std::vector{ + leg(Direction::WEST, 6, at("ViridianForest", 7, 28), false), + leg(Direction::SOUTH, 5, c.goal, true), + }; + c.script.short_holds[1] = 2; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.signs_read != 0){ + return "Expected the player found at walls after the short leg, and the route walked:" + describe(now); + } + SimCase trusted = c; + trusted.open_leg_check = OpenLegCheck::TRUST; + const SimRun blocked = run_case(trusted); + if (!stopped_with(blocked, "interrupted right away, again") || blocked.state.signs_read > 2){ + return "Expected a stop at the sign's second read without the check:" + describe(blocked); + } + SimCase unbounded = trusted; + unbounded.rules.stop_if_blocked = false; + const SimRun old = run_case(unbounded); + if (!stopped_with(old, "Interrupted 6 times") || old.state.signs_read != 6){ + return "Expected the sign read 6 times without the rule:" + describe(old); + } + return true; +} + +// r16 (console B, 2026-11-01), Viridian Forest, a tile short instead of 2: +// the hold south from (8, 28) misses the sign and stops at the wall a +// column east of the plan's, with nothing on screen, so the walk was +// claimed done off the route. The walker now finds the player at walls +// after the open leg; without that check it ends on (8, 33). +UnitTestResult test_short_leg_unseen(){ + SimCase c{.start = at("ViridianForest", 13, 28), .facing = Direction::WEST, .goal = at("ViridianForest", 7, 33)}; + c.route = std::vector{ + leg(Direction::WEST, 6, at("ViridianForest", 7, 28), false), + leg(Direction::SOUTH, 5, c.goal, true), + }; + c.script.short_holds[1] = 1; + const SimRun now = run_case(c); + if (!arrived(now, c.goal)){ + return "Expected the player found at walls after the short leg, and the route walked:" + describe(now); + } + SimCase trusted = c; + trusted.open_leg_check = OpenLegCheck::TRUST; + const SimRun old = run_case(trusted); + if (!old.failure.empty() || !(old.state.position == at("ViridianForest", 8, 33))){ + return "Expected the walk claimed done on (8, 33) without the check:" + describe(old); + } + return true; +} + +// r18 (console B, 2026-11-01), Pewter Gym: after Brock, Camper Liam sees +// the player on the walk south to the door, walks up and battles. The +// route holds on south to the door's mat; finding the player again at +// walls failed there, since the gym's trainers block every hold. +UnitTestResult test_trainer_on_way_out(){ + const SimCase c{.start = at("PewterCity_Gym", 6, 6), .facing = Direction::NORTH, .goal = arrival("PewterCity", "warp_2")}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.trainers_beaten != std::vector{"camper"}){ + return "Expected Liam fought and the gym left:" + describe(now); + } + return true; +} + +// cp0006-a (console B, 2026-11-01), Viridian Mart: the clerk's scene ends +// with the player on (4, 3), the story's start for the walk out. From the +// tile beside it the walk misses the only exit mat, (4, 7), and stops on +// no fade rather than going on as if outside. +UnitTestResult test_mart_exit(){ + const SimCase c{.start = at("ViridianCity_Mart", 4, 3), .facing = Direction::WEST, .goal = arrival("ViridianCity", "warp_4")}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal)){ + return "Expected the Mart left by its mat:" + describe(now); + } + SimCase wrong = c; + wrong.route = planned(c); + wrong.start = at("ViridianCity_Mart", 5, 3); + const SimRun off = run_case(wrong); + if (!stopped_with(off, "no fade") || off.state.warps != 0){ + return "Expected a stop on no fade from (5, 3):" + describe(off); + } + return true; +} + +// Mt. Moon (story row 00.13): every floor tile has wild encounters, and +// the floors join by ladders taken on stepping onto them. From the Moon +// Stone (3, 2) on 1F down to the foot of B2F's ladder: two ladders, each +// a fade. +UnitTestResult test_mt_moon_ladders(){ + const SimCase c{.start = at("MtMoon_1F", 3, 3), .facing = Direction::NORTH, .goal = at("MtMoon_B2F", 26, 21)}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.warps != 2){ + return "Expected B2F reached by two ladders:" + describe(now); + } + return true; +} + +// The Moon Stone's ball (3, 2) on Mt. Moon 1F is a short way off the way +// down: from (11, 3), where 00.13's walk up column 11 ends, to face it +// and then on to B1F costs a few tiles more than going straight, and +// both walks end where they were planned to. +UnitTestResult test_moon_stone_detour(){ + const MapPosition from = at("MtMoon_1F", 11, 3); + const MapPosition ball_front = at("MtMoon_1F", 3, 3); + const MapPosition b1f = arrival("MtMoon_B1F", "warp_0"); + const auto tiles = [](const std::vector& route){ + int count = 0; + for (const RouteStep& step : route){ + count += step.tiles; + } + return count; + }; + const SimCase straight{.start = from, .facing = Direction::NORTH, .goal = b1f}; + const SimCase to_ball{.start = from, .facing = Direction::NORTH, .goal = ball_front}; + const SimCase on{.start = ball_front, .facing = Direction::NORTH, .goal = b1f}; + const int extra = tiles(planned(to_ball)) + tiles(planned(on)) - tiles(planned(straight)); + if (extra > 8){ + return std::format("Expected the detour to cost 8 tiles or fewer, not {}:", extra) + describe(planned(to_ball)) + describe(planned(on)); + } + for (const SimCase& c : {to_ball, on}){ + const SimRun run = run_case(c); + if (!arrived(run, c.goal)){ + return "Expected the detour walked:" + describe(run); + } + } + return true; +} + +// Mt. Moon B2F: the Grunt at (37, 21) looks around (sight 3) over the +// only way east. 00.13 crosses his room on legs to walls; the sim's +// trainers see every way they may look, so he sees the player on +// (37, 22), is fought, and the leg is held on to its wall. +UnitTestResult test_mt_moon_grunt_mid_leg(){ + SimCase c{.start = at("MtMoon_B2F", 32, 22), .facing = Direction::SOUTH, .goal = at("MtMoon_B2F", 39, 14)}; + c.route = std::vector{ + leg(Direction::EAST, 8, at("MtMoon_B2F", 40, 22), true), + leg(Direction::NORTH, 3, at("MtMoon_B2F", 40, 19), true), + leg(Direction::WEST, 1, at("MtMoon_B2F", 39, 19), false), + leg(Direction::NORTH, 5, c.goal, true), + }; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.trainers_beaten != std::vector{"rocket_m"}){ + return "Expected the Grunt fought once and the legs walked:" + describe(now); + } + return true; +} + +// Mt. Moon B2F: 00.13's open leg east along the bottom row, (8, 38) to +// (11, 38), ends a tile short with nothing on screen. The hold north +// after it would end on (10, 21), a column west of the Grunt's line the +// table steps into from (11, 21): the walker finds the player at walls +// and walks back first. Taken as walked, the route ends on (10, 21). +UnitTestResult test_mt_moon_short_leg(){ + SimCase c{.start = at("MtMoon_B2F", 8, 38), .facing = Direction::WEST, .goal = at("MtMoon_B2F", 11, 21)}; + c.route = std::vector{ + leg(Direction::EAST, 3, at("MtMoon_B2F", 11, 38), false), + leg(Direction::NORTH, 17, c.goal, true), + }; + c.script.short_holds[1] = 1; + const SimRun now = run_case(c); + if (!arrived(now, c.goal)){ + return "Expected the player found at walls after the short leg, and the route walked:" + describe(now); + } + SimCase trusted = c; + trusted.open_leg_check = OpenLegCheck::TRUST; + const SimRun old = run_case(trusted); + if (!old.failure.empty() || !(old.state.position == at("MtMoon_B2F", 10, 21))){ + return "Expected the walk claimed done on (10, 21) without the check:" + describe(old); + } + return true; +} + +// Mt. Moon: a wild battle whose flashes are missed stops a leg to a wall +// part-way (every cave tile can start one); it is run from and the leg +// walked again, and the route goes on down the ladders. +UnitTestResult test_mt_moon_late_battle(){ + SimCase c{.start = at("MtMoon_1F", 3, 3), .facing = Direction::NORTH, .goal = at("MtMoon_B2F", 26, 21)}; + c.script.encounters = {{.grass_step = 9, .seen_late = true}}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal)){ + return "Expected the battle run from, then B2F reached:" + describe(now); + } + return true; +} + +// Console B, 2026-11-03 (row 00.13, b2f_ladder_foot): a wild battle whose +// flashes were missed started on "south 3 (warp)" from (5, 3) to 1F's +// ladder; its wipe passed for the ladder's fade, and the walker stopped as +// off the route, with the player on (5, 5). A battle on the leg's first or +// second step is now run from, the player found again on the leg, and the +// route goes on down both ladders. Without the rule it stops as it did. +UnitTestResult test_mt_moon_battle_on_ladder_leg(){ + const SimCase c{.start = at("MtMoon_1F", 3, 3), .facing = Direction::NORTH, .goal = at("MtMoon_B2F", 26, 21)}; + for (size_t tile : {1, 2}){ + const std::optional now = run_with_battle_on_warp_leg(c, tile); + if (!now){ + return std::format("Expected a leg onto 1F's ladder with a cave step {}:", tile) + describe(planned(c)); + } + if (!arrived(*now, c.goal) || now->state.warps != 2){ + return std::format("Expected the battle on step {} run from, then B1F and B2F reached:", tile) + describe(*now); + } + } + SimCase off = c; + off.rules.find_player_after_warp_battle = false; + const std::optional old = run_with_battle_on_warp_leg(off, 2); + if (!old || !stopped_with(*old, "a wild battle, not the warp to MtMoon_B1F: the player is off the route.") + || !(old->state.position == at("MtMoon_1F", 5, 5)) + ){ + return "Expected a stop as off the route on (5, 5) without the rule:" + (old ? describe(*old) : ""); + } + return true; +} + +// Console B, 2026-11-03 (row 00.13, b2f_ladder_foot): on B1F a wild +// battle started part-way down "south 15 (to wall)" from (3, 3), with its +// flashes missed. Its HP boxes came over 5 s after the step, after the +// leg's watch had ended on the battle's empty text box: the leg was taken +// as walked to (3, 18), and "east 19 (warp)" was walked into the battle +// from a tile it wasn't on. The console ran (133 ms a tile) and the sim +// walks, so the battle starts a few steps later here, for the same +// timing. The text box after the watch is now watched on for, the leg +// walked again to its wall, and the route reaches B2F's ladder. Without +// the rule the leg is taken as walked, and the run stops on no fade onto +// the ladder down to B2F, as it did. +UnitTestResult test_mt_moon_battle_seen_after_wall_leg(){ + SimCase c{.start = at("MtMoon_B1F", 3, 3), .facing = Direction::SOUTH, .goal = at("MtMoon_B2F", 26, 21)}; + c.script.encounters = {{.grass_step = 13, .seen_late = true}}; + c.script.timing.battle_opening = Milliseconds(4500); + c.script.timing.battle_screen = Milliseconds(5300); + const std::vector route = planned(c); + if (route.empty() || to_string(route[0]) != "walk south 15 (to wall) -> MtMoon_B1F (3, 18)"){ + return "Expected the route to start with \"south 15 (to wall)\":" + describe(route); + } + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.warps != 1){ + return "Expected the battle run from, the leg walked again and B2F reached:" + describe(now); + } + SimCase off = c; + off.rules.watch_on_for_unseen_battle = false; + off.rules.battle_before_step = false; + const SimRun old = run_case(off); + const bool taken_as_walked = std::any_of(old.log.begin(), old.log.end(), [](const std::string& line){ + return line.find("south 15 (to wall): ended at MtMoon_B1F (3, 18) (walked)") != std::string::npos; + }); + if (!taken_as_walked || !stopped_with(old, "no fade in and out after walking onto the warp to MtMoon_B2F.")){ + return "Expected the leg taken as walked and the run stopped on no fade, as on the console, without the rule:" + describe(old); + } + return true; +} + +// Console B, 2026-11-03 (row 00.13, b2f_south): on Mt. Moon B2F a wild +// battle started on the first step of "east 7 (to wall)" from (39, 14), +// along tiles closed on their north side, which the maps didn't mark as +// wild then: the leg watched only for messages, and the screen was black +// after it. The leg was taken as walked to (46, 14); "south 14 (to +// wall)" saw the battle, ran, and was held again from (40, 14), to +// (40, 17), and every leg after it went on about 6 tiles off. The sim +// walks the route from (39, 14) on to below Miguel's trigger (14, 12), +// with the battle's wipe black from 1 s after its step and its HP boxes +// at 5.3 s, as on the console. +SimCase b2f_east_battle_case(){ + SimCase c{.start = at("MtMoon_B2F", 39, 14), .facing = Direction::NORTH, .goal = at("MtMoon_B2F", 14, 12)}; + c.route = std::vector{ + leg(Direction::EAST, 7, at("MtMoon_B2F", 46, 14), true), + leg(Direction::SOUTH, 14, at("MtMoon_B2F", 46, 28), true), + leg(Direction::WEST, 9, at("MtMoon_B2F", 37, 28), true), + leg(Direction::SOUTH, 10, at("MtMoon_B2F", 37, 38), true), + leg(Direction::WEST, 29, at("MtMoon_B2F", 8, 38), true), + leg(Direction::EAST, 3, at("MtMoon_B2F", 11, 38), false), + leg(Direction::NORTH, 17, at("MtMoon_B2F", 11, 21), true), + leg(Direction::EAST, 3, at("MtMoon_B2F", 14, 21), false), + leg(Direction::NORTH, 9, c.goal, false), + }; + // As the planner marked them on the console: all but "east 7", whose + // floor closed on the north the maps didn't yet mark wild. + for (RouteStep& step : *c.route){ + step.wild = to_string(step) != "walk east 7 (to wall) -> MtMoon_B2F (46, 14)"; + } + c.script.wild_floor_maps = {"MtMoon_B2F"}; + c.script.encounters = {{.grass_step = 1, .seen_late = true}}; + c.script.timing.battle_wipe = Milliseconds(1000); + c.script.timing.battle_opening = Milliseconds(4500); + c.script.timing.battle_screen = Milliseconds(5300); + return c; +} + +bool logged(const SimRun& run, const std::string& text){ + return std::any_of(run.log.begin(), run.log.end(), [&](const std::string& line){ + return line.find(text) != std::string::npos; + }); +} + +// The black after "east 7" is now a battle on its way: it is run from, +// the leg walked again to its wall, and the route reaches (14, 12). +// Without the rule, and without the check before the next step, the sim +// goes the console's way: "east 7" is taken as walked, and the route ends +// far from (14, 12). +UnitTestResult test_mt_moon_black_after_unmarked_leg(){ + const SimCase c = b2f_east_battle_case(); + const std::vector planned_route = plan_overworld_route(maps(), c.start, RouteGoal{at("MtMoon_B2F", 46, 14), std::nullopt}).value_or(std::vector{}); + if (planned_route.size() != 1 || to_string(planned_route[0]) != "walk east 7 (to wall) -> MtMoon_B2F (46, 14)"){ + return "Expected the planner's \"east 7 (to wall)\" from (39, 14):" + describe(planned_route); + } + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || !logged(now, "a battle's wipe after walk east 7 (to wall)")){ + return "Expected the black after \"east 7\" watched on, the battle run from and (14, 12) reached:" + describe(now); + } + SimCase off = c; + off.rules.watch_on_after_unmarked_leg = false; + off.rules.battle_before_step = false; + const SimRun old = run_case(off); + const int off_by = std::abs(old.state.position.point.x - c.goal.point.x) + std::abs(old.state.position.point.y - c.goal.point.y); + if (!logged(old, "east 7 (to wall): ended at MtMoon_B2F (46, 14) (walked)") || (old.failure.empty() && off_by < 6)){ + return "Expected \"east 7\" taken as walked and the route ended or stopped far from (14, 12), as on the console, without the rules:" + describe(old); + } + return true; +} + +// The same battle, its black seen only before the next step (on the +// console the check after the leg can come before the wipe): the step +// isn't walked, and the player is found on "east 7", walked again to its +// wall, not on "south 14"'s line. +UnitTestResult test_mt_moon_battle_before_next_leg(){ + SimCase c = b2f_east_battle_case(); + c.rules.watch_on_after_unmarked_leg = false; + c.wall_check = WallCheck::SKIP; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || !logged(now, "a battle's wipe before walk south 14 (to wall)")){ + return "Expected the battle seen before \"south 14\", \"east 7\" walked again and (14, 12) reached:" + describe(now); + } + SimCase off = c; + off.rules.battle_before_step = false; + const SimRun old = run_case(off); + if (old.state.position == c.goal){ + return "Expected the route to end off (14, 12) without the check before a step:" + describe(old); + } + return true; +} + +// Mt. Moon B2F, after the HELIX FOSSIL: the player steps onto (14, 7), +// where it lay, and the way out goes north through the gap, west to the +// ladder (5, 10), up to B1F and out of its east end onto Route 4, walked +// a tile south of the cave mouth. +UnitTestResult test_mt_moon_way_out(){ + const SimCase c{.start = at("MtMoon_B2F", 14, 7), .facing = Direction::NORTH, .goal = at("Route4", 32, 6)}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.warps != 2 || now.state.facing != Direction::SOUTH){ + return "Expected Route 4 east of the mountain reached by two warps, facing south:" + describe(now); + } + return true; +} + +} + + +void add_tests_OverworldSim(UnitTestDatabase& database){ + database.add("the forest's north gate: no fade or black read as a battle", test_forest_north_gate); + database.add("a battle on a leg onto a warp", test_battle_on_warp_leg); + database.add("the START menu left open", test_start_menu_open); + database.add("a battle at the end of a leg", test_battle_at_end_of_leg); + database.add("a late battle stops an open leg", test_late_battle_mid_leg); + database.add("a trainer's battle is fought, not fled", test_trainer_not_fled); + database.add("no hold over a ledge", test_no_hold_over_ledge); + database.add("a short leg, then a sign", test_short_leg_then_sign); + database.add("a short leg, nothing seen", test_short_leg_unseen); + database.add("a trainer on the way out of Pewter Gym", test_trainer_on_way_out); + database.add("out of Viridian Mart from the clerk's scene", test_mart_exit); + database.add("Mt. Moon: down the ladders to B2F", test_mt_moon_ladders); + database.add("Mt. Moon: the Moon Stone is a short detour", test_moon_stone_detour); + database.add("Mt. Moon: the Grunt who looks around stops a leg to a wall", test_mt_moon_grunt_mid_leg); + database.add("Mt. Moon: a short open leg before the Grunt's line", test_mt_moon_short_leg); + database.add("Mt. Moon: a late wild battle in the cave", test_mt_moon_late_battle); + database.add("Mt. Moon: after the HELIX, out to Route 4", test_mt_moon_way_out); + database.add("Mt. Moon: a battle on the leg onto 1F's ladder", test_mt_moon_battle_on_ladder_leg); + database.add("Mt. Moon: a battle seen only after a leg to a wall", test_mt_moon_battle_seen_after_wall_leg); + database.add("Mt. Moon: black after a leg not marked wild", test_mt_moon_black_after_unmarked_leg); + database.add("Mt. Moon: a battle seen only before the next leg", test_mt_moon_battle_before_next_leg); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 348b57bcf6..59563a1c16 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -8,6 +8,7 @@ #include #include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" #include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "CommonFramework/VideoPipeline/VideoFeed.h" #include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_BattleDialogs.h" @@ -15,6 +16,7 @@ #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_StartMenuDetector.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_OverworldWalk.h" @@ -68,11 +70,8 @@ class LateWildBattleInterrupt : public WildBattleInterrupt{ virtual std::unique_ptr make_watcher() const override{ return std::make_unique(COLOR_RED); } - // The flashes, the wipe and the opening animation: 3.7s after the step's - // press, twice on Route 1 (console B, 2026-11-01). Other maps open - // battles with other wipes, so elsewhere this is a guess. virtual Milliseconds detection_delay() const override{ - return 3700ms; + return OverworldWalkSteps::LATE_WILD_BATTLE_DELAY; } }; @@ -179,15 +178,19 @@ void hold_dpad_running(ProControllerContext& context, Direction direction, Milli pbf_wait(context, STEP_RELEASE); } +MoveSpeed step_speed(const RouteStep& step, MoveSpeed speed){ + return step.walk_only ? MoveSpeed::WALK : speed; +} + Milliseconds tile_time(const RouteStep& step, MoveSpeed speed){ if (step.surfing){ return SURF_TILE_TIME; } - return speed == MoveSpeed::RUN ? RUN_TILE_TIME : TILE_TIME; + return step_speed(step, speed) == MoveSpeed::RUN ? RUN_TILE_TIME : TILE_TIME; } Pokemon::LegMover leg_mover(const RouteStep& step, MoveSpeed speed){ - if (speed == MoveSpeed::RUN && !step.surfing){ + if (step_speed(step, speed) == MoveSpeed::RUN && !step.surfing){ return hold_dpad_running; } return hold_dpad; @@ -203,7 +206,7 @@ std::string speed_note(const RouteStep& step, MoveSpeed speed){ } return std::format( "[Speed] {} {}: {} tiles in {} ms, held {} ms", - move_speed_name(speed), Pokemon::direction_name(step.direction), step.tiles, + move_speed_name(step_speed(step, speed)), Pokemon::direction_name(step.direction), step.tiles, (tile_time(step, speed) * step.tiles).count(), leg_hold_time(step, speed).count() ); } @@ -262,18 +265,79 @@ void clear_open_message( Pokemon::clear_late_interrupts(console, context, interrupts, OPEN_MESSAGE_WATCH, 1); } +// A battle whose flashes were missed shows its HP boxes, which the leg's +// watch for a late battle needs, seconds after its text box: in Mt. Moon +// the watch after "south 15 (to wall)" ended on the box still empty, the +// leg was taken as walked to its wall, and the next leg was walked into +// the battle, 5 tiles short of where it was planned from (console B, +// 2026-11-03, row 00.13). +namespace{ + +// What on screen shows a wild battle on its way: its text box, or the +// black of its wipe. Nothing if neither shows. +const char* battle_on_its_way(ConsoleHandle& console, ProControllerContext& context){ + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + if (!screen){ + return nullptr; + } + if (BattleTextBoxDetector(COLOR_RED).detect(screen)){ + return "a battle's text box"; + } + if (BlackScreenDetector(COLOR_RED).detect(screen)){ + return "a black screen"; + } + return nullptr; +} + +} + +size_t clear_unseen_battle( + ConsoleHandle& console, ProControllerContext& context, + const OverworldInterrupts& wild, const RouteStep& step +){ + if (step.action != RouteAction::WALK || step.warps){ + return 0; + } + const char* seen = battle_on_its_way(console, context); + if (seen == nullptr){ + return 0; + } + console.log(std::format("Route: {} after {}, which nothing stopped: watching on for a wild battle.", seen, to_string(step)), COLOR_ORANGE); + return Pokemon::clear_late_interrupts(console, context, wild, UNSEEN_BATTLE_WATCH, MAX_INTERRUPTIONS_PER_LEG); +} + +size_t clear_battle_before( + ConsoleHandle& console, ProControllerContext& context, + const OverworldInterrupts& wild, const RouteStep& step +){ + const char* seen = battle_on_its_way(console, context); + if (seen == nullptr){ + return 0; + } + console.log(std::format("Route: {} before {}: watching on for a wild battle set off before it.", seen, to_string(step)), COLOR_ORANGE); + return Pokemon::clear_late_interrupts(console, context, wild, UNSEEN_BATTLE_WATCH, MAX_INTERRUPTIONS_PER_LEG); +} + // After a warp's fade: fails, once the battle is fled, if it was a wild // battle's. In run 7 (console B, 2026-11-01) the player had drifted onto // Route 1's grass, and the step meant for Oak's Lab's door set off a // battle; its flashes and wipe passed for the warp's fade, the route went // on as if inside, and the next screen was the battle's FIGHT menu. -void check_warp_was_no_battle(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ +bool run_from_battle_after_warp(ConsoleHandle& console, ProControllerContext& context){ BattleScreenWatcher battle(COLOR_RED); context.wait_for_all_requests(); if (wait_until(console, context, AFTER_WARP_BATTLE_WATCH, {battle}) < 0){ - return; + return false; } flee_battle(console, context); + return true; +} + +void check_warp_was_no_battle(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ + if (!run_from_battle_after_warp(console, context)){ + return; + } OperationFailedExceptionWithScreenshot::fire( ErrorReportMode::SEND_ERROR_REPORT, "walk_overworld_route(): a wild battle, not the warp to " + step.end.map + ": the player is off the route.", @@ -428,10 +492,10 @@ void walk_overworld_route( break; } if (step.action != RouteAction::FACE){ - Pokemon::clear_late_interrupts( - console, context, step.wild ? interrupts.wild : interrupts.other, - step.wild ? AFTER_WILD_LEG_WATCH : AFTER_LEG_WATCH, MAX_INTERRUPTIONS_PER_LEG - ); + const OverworldInterrupts& watched = step.wild ? interrupts.wild : interrupts.other; + if (Pokemon::clear_late_interrupts(console, context, watched, step.wild ? AFTER_WILD_LEG_WATCH : AFTER_LEG_WATCH, MAX_INTERRUPTIONS_PER_LEG) == 0){ + clear_unseen_battle(console, context, interrupts.wild, step); + } } facing = step.facing_after; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index 2c3b665033..5e914bb7fe 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -109,12 +109,21 @@ inline constexpr Milliseconds AFTER_WARP_BATTLE_WATCH(5000); // A message set off by a leg's last step shows after the hold ends. Caught // here, before the next leg's turn tap, which the message would swallow. inline constexpr Milliseconds AFTER_LEG_WATCH(500); -// The same after a leg through grass. A battle whose flashes are missed is -// seen only by its "Wild appeared!", 3.7s after its step's press -// (twice on Route 1, console B, 2026-11-01, run 7). Unless the walker waits -// that long, the next leg is walked into the battle and lost: there, "east -// 4" and "west 4". +// A wild battle whose flashes are missed is seen only by its "Wild +// appeared!": the flashes, the wipe and the opening animation +// take 3.7s from its step's press, twice on Route 1 (console B, 2026-11-01, +// run 7). Other maps open battles with other wipes, so elsewhere this is a +// guess. Every interrupt for such a battle places the stop with this. +inline constexpr Milliseconds LATE_WILD_BATTLE_DELAY(3700); +// The same after a leg through grass, for a battle seen that late. Unless +// the walker waits that long, the next leg is walked into the battle and +// lost: on Route 1, "east 4" and "west 4". inline constexpr Milliseconds AFTER_WILD_LEG_WATCH(3500); +// After a leg's watch saw nothing, but the screen shows a battle on its +// way: how long to watch on for its HP boxes. In Mt. Moon they came over +// 5s after the step that set the battle off, after the watch had ended on +// the battle's empty text box (console B, 2026-11-03, row 00.13). +inline constexpr Milliseconds UNSEEN_BATTLE_WATCH(5000); // How long clear_open_message() watches for the message it saw. inline constexpr Milliseconds OPEN_MESSAGE_WATCH(1000); inline constexpr size_t MAX_INTERRUPTIONS_PER_LEG = 5; @@ -128,12 +137,17 @@ void hold_dpad(ProControllerContext& context, Direction direction, Milliseconds // Turn taps go without B: a tap only turns the player. void hold_dpad_running(ProControllerContext& context, Direction direction, Milliseconds duration); +// The speed `step` moves at when the walker goes at `speed`: a walk where +// the map doesn't let the player run (RouteStep::walk_only). +MoveSpeed step_speed(const RouteStep& step, MoveSpeed speed); + // How long one tile of `step` takes at `speed`: on the water, the Surf -// pace whatever the speed. +// pace whatever the speed; where running isn't allowed, the walking pace. Milliseconds tile_time(const RouteStep& step, MoveSpeed speed); -// The mover for `step` at `speed`: hold_dpad_running() for a run on foot, -// hold_dpad() otherwise (B does nothing on the water). +// The mover for `step` at `speed`: hold_dpad_running() for a run on foot +// where the map allows it, hold_dpad() otherwise (B does nothing on the +// water). Pokemon::LegMover leg_mover(const RouteStep& step, MoveSpeed speed); // How long the walker holds the D-pad for a WALK `step` at `speed` @@ -160,6 +174,34 @@ void clear_open_message( const OverworldInterrupts& interrupts, const RouteStep& step ); +// After `step`'s hold and the watch after it, which saw nothing: if the +// screen shows a wild battle on its way anyway (its text box before the HP +// boxes, or black), watches on for it with `wild` (the interrupts for a +// step where a wild battle can start) and clears what shows. Returns how +// many it cleared: the leg may have stopped part-way. After any walk not +// onto a warp, whose fade check_warp_was_no_battle() covers, also where +// the maps see no wild tiles (RouteStep::wild): they miss some. Mt. Moon +// B2F's tiles closed on one side are cave floor too, and a battle on +// "east 7 (to wall)" there left the screen black after the leg, which +// was taken as walked (console B, 2026-11-03, row 00.13). +size_t clear_unseen_battle( + ConsoleHandle& console, ProControllerContext& context, + const OverworldInterrupts& wild, const RouteStep& step +); + +// Before `step`: if the screen shows a wild battle on its way (as +// clear_unseen_battle()), set off before the step started, watches on for +// it with `wild` and clears what shows. Returns how many it cleared: the +// step before may have stopped part-way. +size_t clear_battle_before( + ConsoleHandle& console, ProControllerContext& context, + const OverworldInterrupts& wild, const RouteStep& step +); + +// After a warp's fade: if it was a wild battle's, runs from the battle and +// returns true. +bool run_from_battle_after_warp(ConsoleHandle& console, ProControllerContext& context); + // After `step`'s warp: fails, once the battle is fled, if its fade was a // wild battle's. void check_warp_was_no_battle(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp index 8265cb6bd2..0e88570121 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp @@ -45,6 +45,10 @@ struct HoldCase{ Milliseconds hold; // speed_note()'s line, or empty. std::string note; + // On a map where the player can't run (a Pokemon Center). + bool walk_only = false; + // Whether the leg is held with B. + bool runs = false; }; // An open leg lets go half a tile before its last step would end; a leg @@ -57,9 +61,13 @@ const std::vector& hold_cases(){ {.name = "walk, to a wall", .speed = MoveSpeed::WALK, .tiles = 5, .to_wall = true, .surfing = false, .hold = Milliseconds(7 * 267), .note = "[Speed] walk west: 5 tiles in 1335 ms, held 1869 ms"}, {.name = "run, open", .speed = MoveSpeed::RUN, .tiles = 5, .to_wall = false, .surfing = false, - .hold = Milliseconds(4 * 133 + 66), .note = ""}, + .hold = Milliseconds(4 * 133 + 66), .note = "", .runs = true}, {.name = "run, to a wall", .speed = MoveSpeed::RUN, .tiles = 40, .to_wall = true, .surfing = false, - .hold = Milliseconds(42 * 133), .note = "[Speed] run west: 40 tiles in 5320 ms, held 5586 ms"}, + .hold = Milliseconds(42 * 133), .note = "[Speed] run west: 40 tiles in 5320 ms, held 5586 ms", .runs = true}, + {.name = "run, indoors: walks", .speed = MoveSpeed::RUN, .tiles = 5, .to_wall = true, .surfing = false, + .hold = Milliseconds(7 * 267), .note = "[Speed] walk west: 5 tiles in 1335 ms, held 1869 ms", .walk_only = true}, + {.name = "run, indoors, open: walks", .speed = MoveSpeed::RUN, .tiles = 5, .to_wall = false, .surfing = false, + .hold = Milliseconds(4 * 267 + 133), .note = "", .walk_only = true}, {.name = "run, surfing to a wall", .speed = MoveSpeed::RUN, .tiles = 3, .to_wall = true, .surfing = true, .hold = Milliseconds(5 * 133), .note = ""}, {.name = "walk, surfing", .speed = MoveSpeed::WALK, .tiles = 3, .to_wall = false, .surfing = true, @@ -68,12 +76,20 @@ const std::vector& hold_cases(){ return cases; } +// Whether `mover` is hold_dpad_running(), which holds B. +bool holds_b(const Pokemon::LegMover& mover){ + using Mover = void (*)(ProControllerContext&, Direction, Milliseconds); + const Mover* target = mover.target(); + return target != nullptr && *target == hold_dpad_running; +} + UnitTestResult check_hold(const HoldCase& test){ RouteStep step; step.direction = Direction::WEST; step.tiles = test.tiles; step.to_wall = test.to_wall; step.surfing = test.surfing; + step.walk_only = test.walk_only; const Milliseconds hold = leg_hold_time(step, test.speed); if (hold != test.hold){ return std::format("Expected a hold of {}ms, got {}ms.", test.hold.count(), hold.count()); @@ -82,6 +98,9 @@ UnitTestResult check_hold(const HoldCase& test){ if (note != test.note){ return "Expected the note \"" + test.note + "\", got \"" + note + "\"."; } + if (holds_b(leg_mover(step, test.speed)) != test.runs){ + return test.runs ? "Expected the leg held with B." : "Expected the leg held without B."; + } return true; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 4ccaa6a0d7..18dda426ef 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" @@ -25,8 +26,10 @@ #include "Inference/Menus/PokemonFRLG_PartyMenuDetector.h" #include "Inference/Menus/PokemonFRLG_SaveCardReader.h" #include "Inference/Menus/PokemonFRLG_SummaryMovesReader.h" +#include "Inference/Menus/PokemonFRLG_TeachScreenReader.h" #include "Inference/PokemonFRLG_BattleLevelUpReader.h" #include "Inference/PokemonFRLG_BattleSelectionArrowDetector.h" +#include "Inference/PokemonFRLG_BattleTextBoxDetector.h" #include "Inference/PokemonFRLG_PartyLevelUpReader.h" #include "Inference/PokemonFRLG_ShinySymbolDetector.h" #include "Inference/PokemonFRLG_StatsReader.h" @@ -36,13 +39,18 @@ #include "Overworld/PokemonFRLG_OverworldReanchor.h" #include "Overworld/PokemonFRLG_OverworldRecovery.h" #include "Overworld/PokemonFRLG_OverworldRoute.h" +#include "Overworld/PokemonFRLG_OverworldSim.h" #include "Overworld/PokemonFRLG_OverworldWalk.h" #include "Overworld/PokemonFRLG_PlayerFacingReader.h" #include "Programs/PokemonFRLG_NamingScreenKeyboard.h" #include "PokemonFRLG_Navigation.h" +#include "Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.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" #include "Programs/RngManipulation/PokemonFRLG_RngPlanInputs_Tests.h" #include "Programs/RngManipulation/PokemonFRLG_RngPlanLog_Tests.h" #include "Programs/RngManipulation/PokemonFRLG_RngPlanTracker_Tests.h" @@ -670,6 +678,18 @@ void add_tests_NamingText(UnitTestDatabase& database){ } } +// The RNG programs' searches, plans and loops. +void add_tests_RngManipulation(UnitTestDatabase& database){ + add_tests_RngTargetSearch(database); + add_tests_RngPlanInputs(database); + add_tests_RngPlan(database); + add_tests_RngPlanTracker(database); + add_tests_RngPlanLog(database); + add_tests_RngSteps(database); + add_tests_StarterRngLoop(database); + add_tests_GiftRngAttempts(database); +} + } @@ -680,6 +700,7 @@ static void add_tests_AutoStoryReaders(UnitTestDatabase& database){ add_tests_OptionMenuReader(database); add_tests_SaveCardReader(database); add_tests_SummaryMovesReader(database); + add_tests_TeachScreenReader(database); } void add_tests(UnitTestDatabase& database){ @@ -688,6 +709,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); @@ -705,6 +727,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_EliteFourRoomDetector(database); add_tests_HallOfFameSavingDetector(database); add_tests_BattleMoveSelectionArrow(database); + add_tests_BattleTextBoxDetector(database); add_tests_BagDetector(database); add_tests_AutoStoryReaders(database); add_tests_ExitWildBattle(database); @@ -713,22 +736,20 @@ void add_tests(UnitTestDatabase& database){ add_tests_OverworldWalk(database); add_tests_OverworldReanchor(database); add_tests_OverworldRecovery(database); + add_tests_OverworldSim(database); add_tests_PlayerFacingReader(database); add_tests_AutoStory(database); add_tests_AutoStorySimulation(database); add_tests_AutoStoryItems(database); add_tests_AutoStoryRngLoop(database); + add_tests_AutoStoryCapture(database); + add_tests_AutoStoryPcStorage(database); + add_tests_AutoStoryAbilities(database); add_tests_NamingCursor(database); add_tests_NamingPlan(database); add_tests_NamingKeys(database); add_tests_NamingText(database); - add_tests_RngTargetSearch(database); - add_tests_RngPlanInputs(database); - add_tests_RngPlan(database); - add_tests_RngPlanTracker(database); - add_tests_RngPlanLog(database); - add_tests_RngSteps(database); - add_tests_StarterRngLoop(database); + add_tests_RngManipulation(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp index a57983b637..b033fca258 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp @@ -19,6 +19,7 @@ #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_NamingScreenKeyboard.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "VideoGames/PokemonFRLG/Programs/PokemonFRLG_TrainerCard.h" +#include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.h" #include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_RngPlanLog.h" #include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_RngPlanTracker.h" #include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_SidSteps.h" @@ -55,7 +56,7 @@ std::unique_ptr AutoStory_Descriptor::make_stats() const{ namespace{ // The start and end dropdowns' entries: the table's checkpoints, 00.01 to -// 00.10. The options hold a reference to it. +// 00.12b. The options hold a reference to it. const StringSelectDatabase& checkpoint_database(){ static const StringSelectDatabase database = Pokemon::story_checkpoint_database(story_checkpoints()); return database; @@ -70,6 +71,15 @@ const char* const RUN_STARTER_RNG_TEXT = "goes on; otherwise the run stops with the save south of the table.
" "SID Helper's timing needs the text speed and button settings already saved, as with Stop for SID Helper."; +const char* const RUN_GIFT_RNG_TEXT = + "Run Gift RNG in AutoStory:
Off by default. After 00.12 saves beside the Magikarp salesman in Route 4's " + "Pokemon Center, run Gift RNG's attempts for the Magikarp from that save, as the RNG plan lists them for its TID and " + "SID (set by Run Starter RNG in AutoStory, or typed in). A shiny Magikarp is saved there (00.12b) and the run goes " + "on; otherwise the run stops with the save beside the salesman.
" + "To try again from that save, start at 00.12b with this on: it loads the save and runs the attempts first. " + "After Gift RNG run by hand, start at 00.12b with this off.
" + "English only. The Magikarp costs 500 and needs a free party slot; AutoStory's options leave the text fast."; + AutoStory::AutoStory() : RUN(checkpoint_database()) , NEW_GAME_FROM_TITLE( @@ -103,6 +113,7 @@ AutoStory::AutoStory() false ) , RUN_STARTER_RNG(RUN_STARTER_RNG_TEXT, LockMode::LOCK_WHILE_RUNNING, false) + , RUN_GIFT_RNG(RUN_GIFT_RNG_TEXT, LockMode::LOCK_WHILE_RUNNING, false) , NOTIFICATION_SHINY("Shiny found", true, true, ImageAttachmentMode::JPG, {"Notifs", "Showcase"}) , NOTIFICATION_STATUS_UPDATE("Status Update", true, false, std::chrono::seconds(3600)) , NOTIFICATIONS({&NOTIFICATION_SHINY, &NOTIFICATION_STATUS_UPDATE, &NOTIFICATION_PROGRAM_FINISH, &NOTIFICATION_ERROR_FATAL}) @@ -113,6 +124,7 @@ AutoStory::AutoStory() PA_ADD_OPTION(STOP_FOR_SID_HELPER); PA_ADD_OPTION(STOP_FOR_STARTER_RNG); PA_ADD_OPTION(RUN_STARTER_RNG); + PA_ADD_OPTION(RUN_GIFT_RNG); PA_ADD_OPTION(RNG_PLAN); PA_ADD_OPTION(NOTIFICATIONS); } @@ -270,16 +282,17 @@ static void throw_if_failed(const Pokemon::SegmentReport& report){ // Gets the game to where checkpoint `first` starts: a new game from the // title screen, the game as another program left it, or the checkpoint's -// save loaded. +// save loaded. `at_handoff`: the run starts with the RNG handoff before a +// live-start row, whose save is where that row starts, so it is loaded. static void prepare_start( SingleSwitchProgramEnvironment& env, ProControllerContext& context, - const std::vector& checkpoints, size_t first, StoryEnv& story + const std::vector& checkpoints, size_t first, StoryEnv& story, bool at_handoff ){ const StoryCheckpoint& checkpoint = checkpoints[first]; if (!checkpoint.start){ return; } - if (checkpoint.live_start){ + if (checkpoint.live_start && !at_handoff){ env.console.log(checkpoint.id + " starts from the game as it is: no save to load."); return; } @@ -320,12 +333,12 @@ static void stop_for_sid_helper( ); } -// The TID for the Starter RNG plan: the RNG plan's, or read off the -// Trainer Card (START, the player's entry), which is then closed again. -// Before the first Pokemon, as under the ball, the player's entry is second. -static uint16_t starter_rng_tid( +// The TID for the RNG plan: the RNG plan's, or read off the Trainer Card +// (START, the player's entry), which is then closed again. Run before +// `checkpoint` saves, which a failure tells the user to start at again. +static uint16_t rng_plan_tid( SingleSwitchProgramEnvironment& env, ProControllerContext& context, - const StarterRngPlanOptions& options, Language language + const StarterRngPlanOptions& options, Language language, const std::string& checkpoint ){ std::optional tid = options.tid_override(env.console); if (tid){ @@ -337,8 +350,8 @@ static uint16_t starter_rng_tid( if (!tid){ OperationFailedExceptionWithScreenshot::fire( ErrorReportMode::NO_ERROR_REPORT, - "Couldn't read the TID on the Trainer Card, so there is no Starter RNG plan. Nothing was saved: " - "type the TID into the RNG plan's TID option and start AutoStory at 00.04 again.", + "Couldn't read the TID on the Trainer Card, so there is no RNG plan. Nothing was saved: " + "type the TID into the RNG plan's TID option and start AutoStory at " + checkpoint + " again.", env.console ); } @@ -347,17 +360,21 @@ static uint16_t starter_rng_tid( return *tid; } -// The Starter RNG attempts. Run under the ball before 00.04 saves there, so -// a failure ends the run with 00.03's save kept. -static StarterRngPlanned plan_starter_rng_attempts( +// The RNG plan's attempts for the handoff `stop` (STARTER_RNG or +// GIFT_RNG) that ends `checkpoint`. Run where it ends, before it saves +// there, so a failure ends the run with the save it started from kept. +static StarterRngPlanned plan_handoff_attempts( SingleSwitchProgramEnvironment& env, ProControllerContext& context, - const StarterRngPlanOptions& options, Language language + const StarterRngPlanOptions& options, Language language, StoryHandoff stop, const std::string& checkpoint ){ - const uint16_t tid = starter_rng_tid(env, context, options, language); + const uint16_t tid = rng_plan_tid(env, context, options, language, checkpoint); try{ - return StarterRngPlanned{.tid = tid, .attempts = options.plan(env.console, language, tid)}; + std::vector attempts = stop == StoryHandoff::GIFT_RNG + ? options.gift_plan(env.console, language, tid) + : options.plan(env.console, language, tid); + return StarterRngPlanned{.tid = tid, .attempts = std::move(attempts)}; }catch (UserSetupError& e){ - throw UserSetupError(env.console, e.message() + " Nothing was saved: fix the RNG plan and start AutoStory at 00.04 again."); + throw UserSetupError(env.console, e.message() + " Nothing was saved: fix the RNG plan and start AutoStory at " + checkpoint + " again."); } } @@ -451,9 +468,9 @@ static void set_sid_in_program( namespace{ -// Run Starter RNG in AutoStory: the time caps, Starter RNG's default Max -// Resets per try, and an attempt's misses before the run stops. -const StarterRngLoopLimits STARTER_RNG_LIMITS{ +// Run Starter RNG and Gift RNG in AutoStory: the time caps, both programs' +// default Max Resets per try, and an attempt's misses before the run stops. +const StarterRngLoopLimits RNG_LOOP_LIMITS{ .attempt_time = std::chrono::hours(3), .total_time = std::chrono::hours(12), .max_resets = 50, @@ -462,28 +479,18 @@ const StarterRngLoopLimits STARTER_RNG_LIMITS{ } -// Starter RNG's attempts in the program, from the save south of the table. -// Returns true for a shiny Squirtle, on the target or not, the game on its -// summary; otherwise the run has stopped with what to do next. A shiny that -// confirms the SID writes it into the RNG plan's SID option. -static bool run_starter_rng_in_program( - SingleSwitchProgramEnvironment& env, ProControllerContext& context, - const StarterRngPlanned& planned, StarterRngPlanOptions& options, Language language, - const StarterRngNotifications& notifications, EventNotificationOption& finished +// The RNG plan's attempts for `planned`, each tried through `steps`. A +// shiny that confirms the SID writes it into the RNG plan's SID option. +static StarterRngLoopResult run_rng_plan_in_program( + SingleSwitchProgramEnvironment& env, StarterRngLoopSteps& steps, + const StarterRngPlanned& planned, StarterRngPlanOptions& options ){ RngPlanTracker tracker( - planned.attempts, options.plan_candidates(env.console, planned.tid), options.SHINY_REQUIRED, STARTER_RNG_LIMITS.max_retries + planned.attempts, options.plan_candidates(env.console, planned.tid), options.SHINY_REQUIRED, RNG_LOOP_LIMITS.max_retries ); - // The settings file gets the calibration saved after each reset when - // the program's panel is closed or the app quits. - RngStopCalibrationOptions& calibration = options.CALIBRATION; - ConsoleStarterRngSteps console_steps(env, context, StarterRngConsoleSettings{ - .language = language, .sound = options.SOUND, .calibration = calibration.calibration(calibration.STARTER_INGAME), - }, notifications); - CalibrationSavingSteps steps(console_steps, calibration, calibration.STARTER_INGAME); RngPlanLogger logger(env.console); const StarterRngLoopResult result = run_starter_rng_plan( - logger, tracker, steps, STARTER_RNG_LIMITS, planned.tid, options.helper_candidates(planned.tid) + logger, tracker, steps, RNG_LOOP_LIMITS, planned.tid, options.helper_candidates(planned.tid) ); const std::string confirmed_sid = result.sid ? std::to_string(*result.sid) : ""; if (result.end == StarterRngLoopEnd::SHINY && result.sid && static_cast(options.SID) != confirmed_sid){ @@ -497,7 +504,15 @@ static bool run_starter_rng_in_program( "when the program's panel is closed or the app quits.", COLOR_BLUE ); } - const std::string message = starter_rng_loop_message(result); + return result; +} + +// True for a shiny, on the target or not, with `message` logged; otherwise +// the run has stopped with it. +static bool shiny_or_stop( + SingleSwitchProgramEnvironment& env, EventNotificationOption& finished, + const StarterRngLoopResult& result, const std::string& message +){ if (result.end != StarterRngLoopEnd::SHINY && result.end != StarterRngLoopEnd::SHINY_OFF_TARGET){ stop_for_handoff(env, finished, message); return false; @@ -507,34 +522,84 @@ static bool run_starter_rng_in_program( return true; } -// After a shiny Squirtle: off its summary, then the rival's battle, which -// saves (00.04b), and the rows after it to `last`. Until that save, a -// reload would load the save under the ball and lose the shiny, so a -// failure stops the run instead. Returns the last row's id. -static std::string run_after_shiny_starter( +// Starter RNG's attempts in the program, from the save south of the table. +// Returns true for a shiny Squirtle, the game on its summary; otherwise +// the run has stopped with what to do next. +static bool run_starter_rng_in_program( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + const StarterRngPlanned& planned, StarterRngPlanOptions& options, Language language, + const StarterRngNotifications& notifications, EventNotificationOption& finished +){ + // The settings file gets the calibration saved after each reset when + // the program's panel is closed or the app quits. + RngStopCalibrationOptions& calibration = options.CALIBRATION; + ConsoleStarterRngSteps console_steps(env, context, StarterRngConsoleSettings{ + .language = language, .sound = options.SOUND, .calibration = calibration.calibration(calibration.STARTER_INGAME), + }, notifications); + CalibrationSavingSteps steps(console_steps, calibration, calibration.STARTER_INGAME); + const StarterRngLoopResult result = run_rng_plan_in_program(env, steps, planned, options); + return shiny_or_stop(env, finished, result, starter_rng_loop_message(result)); +} + +// Gift RNG's attempts in the program, from the save beside the Magikarp +// salesman. Returns true for a shiny Magikarp, the game on its summary; +// otherwise the run has stopped with what to do next. +static bool run_gift_rng_in_program( SingleSwitchProgramEnvironment& env, ProControllerContext& context, - StoryEnv& story, StoryExecutor& executor, const Pokemon::SegmentPolicy& policy, size_t ball_row, size_t last + const StarterRngPlanned& planned, StarterRngPlanOptions& options, Language language, + const GiftRngNotifications& notifications, EventNotificationOption& finished ){ - const std::vector rows = rows_after_starter_rng(story_checkpoints(), ball_row, last); + // The console's seed and CSF offsets, from Starter RNG too, and Gift + // RNG's own in-game offset, saved as Starter RNG's are. + RngStopCalibrationOptions& calibration = options.CALIBRATION; + ConsoleGiftRngSteps console_steps(env, context, GiftRngConsoleSettings{ + .language = language, .sound = options.SOUND, .calibration = calibration.calibration(calibration.GIFT_INGAME), + }, notifications); + CalibrationSavingSteps steps(console_steps, calibration, calibration.GIFT_INGAME); + const StarterRngLoopResult result = run_rng_plan_in_program(env, steps, planned, options); + return shiny_or_stop(env, finished, result, gift_rng_loop_message(result)); +} + +// After a shiny Squirtle: off its summary, and the rows to run next: the +// rival's battle, which saves (00.04b), and the rows after it to `last`. +// Until that save, a reload would load the save under the ball and lose +// the shiny, so a failure stops the run instead. +static StoryRunPlan after_shiny_starter( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, StoryEnv& story, + size_t ball_row, size_t last, const StoryHandoffs& handoffs +){ + StoryRunPlan plan = rows_after_starter_rng(story_checkpoints(), ball_row, last, handoffs); // As Starter RNG leaves the summary for the overworld. pbf_mash_button(context, BUTTON_B, 5000ms); context.wait_for_all_requests(); - env.console.log("The shiny Squirtle isn't saved until " + rows.front().id + " saves after the rival's battle. " + env.console.log("The shiny Squirtle isn't saved until " + plan.rows.front().id + " saves after the rival's battle. " + "If it fails before then, the run stops without reloading: save by hand.", COLOR_BLUE); + story.has_save = false; + return plan; +} + +// After a shiny Magikarp: the rows to run next, from 00.12b, which leaves +// its summary and saves, to `last`. Until that save, a failure stops the +// run, as after a shiny Squirtle. +static StoryRunPlan after_shiny_magikarp( + SingleSwitchProgramEnvironment& env, StoryEnv& story, size_t salesman_row, size_t last, const StoryHandoffs& handoffs +){ + StoryRunPlan plan = rows_after_gift_rng(story_checkpoints(), salesman_row, last, handoffs); + env.console.log("The shiny Magikarp isn't saved until " + plan.rows.front().id + " saves. " "If it fails before then, the run stops without reloading: save by hand.", COLOR_BLUE); story.has_save = false; - run_story_rows(env, story, executor, rows, policy, nullptr); - return rows.back().id; + return plan; } // The run is done through `done_through`. The finish message also gives -// the SID when Starter RNG set it, from `sid_before` to `sid_after`. +// the SID when an RNG loop set it, from `sid_before` to `sid_after`. static void send_story_finished( SingleSwitchProgramEnvironment& env, EventNotificationOption& finished, const std::string& done_through, const std::string& sid_before, const std::string& sid_after ){ std::string message = "AutoStory: done through " + done_through + "."; if (sid_after != sid_before){ - message += " Starter RNG confirmed the SID, " + sid_after + ", and saved it as the RNG plan's SID."; + message += " The RNG plan's attempts confirmed the SID, " + sid_after + ", and saved it as the RNG plan's SID."; } env.console.log(message); send_program_finished_notification(env, finished, message); @@ -546,6 +611,7 @@ StoryHandoffs AutoStory::story_handoffs() const{ return StoryHandoffs{ .sid_helper = RUN_STARTER_RNG ? static_cast(RNG_PLAN.SID).empty() : bool(STOP_FOR_SID_HELPER), .starter_rng = STOP_FOR_STARTER_RNG || RUN_STARTER_RNG, + .gift_rng = RUN_GIFT_RNG, }; } @@ -577,32 +643,73 @@ void AutoStory::program(SingleSwitchProgramEnvironment& env, ProControllerContex } StoryEnv story{env.console, context, checkpoints[first].supplies, checkpoints[first].supplies, false, RUN.checks()}; story.player_name = player_name; - prepare_start(env, context, checkpoints, first, story); + const bool at_gift_rng = plan.stop == StoryHandoff::GIFT_RNG && plan.rows.empty(); + prepare_start(env, context, checkpoints, first, story, at_gift_rng); StoryExecutor executor(story); executor.set_checks(RUN.checks()); executor.set_language(RUN.language()); - StarterRngPlanned planned; - std::function before; - if (plan.stop == StoryHandoff::STARTER_RNG){ - before = [&]{ planned = plan_starter_rng_attempts(env, context, RNG_PLAN, RUN.language()); }; - } - run_story_rows(env, story, executor, plan.rows, RUN.policy(), std::move(before)); - std::string done_through = plan.rows.back().id; - if (plan.stop == StoryHandoff::STARTER_RNG){ - std::optional done = after_starter_ball( - env, context, story, executor, planned, checkpoint_index(checkpoints, done_through) - ); - if (!done){ - return; + + const std::optional done_through = at_gift_rng + ? run_from_gift_rng_save(env, context, story, executor, first) + : run_story_plan(env, context, story, executor, std::move(plan)); + if (done_through){ + send_story_finished(env, NOTIFICATION_PROGRAM_FINISH, *done_through, sid_before, RNG_PLAN.SID); + } +} + +std::optional AutoStory::run_story_plan( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + StoryEnv& story, StoryExecutor& executor, StoryRunPlan plan +){ + while (true){ + const std::string last_id = plan.rows.back().id; + const StoryHandoff stop = plan.stop; + StarterRngPlanned planned; + std::function before; + if (stop == StoryHandoff::STARTER_RNG || stop == StoryHandoff::GIFT_RNG){ + before = [&, stop, last_id]{ + planned = plan_handoff_attempts(env, context, RNG_PLAN, RUN.language(), stop, last_id); + }; + } + run_story_rows(env, story, executor, plan.rows, RUN.policy(), std::move(before)); + const size_t row = checkpoint_index(story_checkpoints(), last_id); + std::optional next; + switch (stop){ + case StoryHandoff::NONE: + return last_id; + case StoryHandoff::SID_HELPER: + throw InternalProgramError(&env.logger(), PA_CURRENT_FUNCTION, "SID Helper's stop has no rows to run."); + case StoryHandoff::STARTER_RNG: + next = after_starter_ball(env, context, story, planned, row); + break; + case StoryHandoff::GIFT_RNG: + next = after_magikarp_salesman(env, context, story, planned, row); + break; } - done_through = *done; + if (!next){ + return std::nullopt; + } + plan = std::move(*next); + } +} + +std::optional AutoStory::run_from_gift_rng_save( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + StoryEnv& story, StoryExecutor& executor, size_t first +){ + const StarterRngPlanned planned = plan_handoff_attempts( + env, context, RNG_PLAN, RUN.language(), StoryHandoff::GIFT_RNG, story_checkpoints()[first].id + ); + std::optional next = after_magikarp_salesman(env, context, story, planned, first - 1); + if (!next){ + return std::nullopt; } - send_story_finished(env, NOTIFICATION_PROGRAM_FINISH, done_through, sid_before, RNG_PLAN.SID); + return run_story_plan(env, context, story, executor, std::move(*next)); } -std::optional AutoStory::after_starter_ball( +std::optional AutoStory::after_starter_ball( SingleSwitchProgramEnvironment& env, ProControllerContext& context, - StoryEnv& story, StoryExecutor& executor, const StarterRngPlanned& planned, size_t ball_row + StoryEnv& story, const StarterRngPlanned& planned, size_t ball_row ){ if (!RUN_STARTER_RNG){ stop_for_starter_rng(env, NOTIFICATION_PROGRAM_FINISH, RNG_PLAN.sound_name(), rng_attempt_lines(planned.attempts)); @@ -614,7 +721,20 @@ std::optional AutoStory::after_starter_ball( if (!run_starter_rng_in_program(env, context, planned, RNG_PLAN, RUN.language(), notifications, NOTIFICATION_PROGRAM_FINISH)){ return std::nullopt; } - return run_after_shiny_starter(env, context, story, executor, RUN.policy(), ball_row, RUN.end_index()); + return after_shiny_starter(env, context, story, ball_row, RUN.end_index(), story_handoffs()); +} + +std::optional AutoStory::after_magikarp_salesman( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + StoryEnv& story, const StarterRngPlanned& planned, size_t salesman_row +){ + const GiftRngNotifications notifications{ + .shiny = NOTIFICATION_SHINY, .status_update = NOTIFICATION_STATUS_UPDATE, .error_recoverable = NOTIFICATION_ERROR_RECOVERABLE, + }; + if (!run_gift_rng_in_program(env, context, planned, RNG_PLAN, RUN.language(), notifications, NOTIFICATION_PROGRAM_FINISH)){ + return std::nullopt; + } + return after_shiny_magikarp(env, story, salesman_row, RUN.end_index(), story_handoffs()); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.h index c25edad269..a8dd3b56dd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.h @@ -49,13 +49,33 @@ class AutoStory : public SingleSwitchProgramInstance{ private: // The RNG programs' handoffs the run takes, from the options. StoryHandoffs story_handoffs() const; + // Runs `plan`'s rows, then the RNG handoff they stop at and, for a + // shiny, the rows after it, to the end checkpoint. Returns the last + // checkpoint run, or nothing once the run has stopped. + std::optional run_story_plan( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + StoryEnv& story, StoryExecutor& executor, StoryRunPlan plan + ); // After 00.04 saved under Squirtle's ball (row `ball_row`): the stop - // for Starter RNG, or its attempts run here and, for a shiny, the run - // on to the end checkpoint. Returns the last checkpoint run, or nothing - // once the run has stopped. - std::optional after_starter_ball( + // for Starter RNG, or its attempts run here. Returns the rows to run + // after a shiny, or nothing once the run has stopped. + std::optional after_starter_ball( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + StoryEnv& story, const StarterRngPlanned& planned, size_t ball_row + ); + // Started at 00.12b (row `first`) with Run Gift RNG in AutoStory on, + // the save beside the salesman loaded: Gift RNG's attempts, then for a + // shiny the rows after them. As run_story_plan() returns. + std::optional run_from_gift_rng_save( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + StoryEnv& story, StoryExecutor& executor, size_t first + ); + // After 00.12 saved beside the Magikarp salesman (row `salesman_row`): + // Gift RNG's attempts run here. Returns the rows to run after a shiny, + // or nothing once the run has stopped. + std::optional after_magikarp_salesman( SingleSwitchProgramEnvironment& env, ProControllerContext& context, - StoryEnv& story, StoryExecutor& executor, const StarterRngPlanned& planned, size_t ball_row + StoryEnv& story, const StarterRngPlanned& planned, size_t salesman_row ); // The start and end checkpoints, attempts, checks and language, as in @@ -69,6 +89,8 @@ class AutoStory : public SingleSwitchProgramInstance{ // Runs SID Helper's steps and Starter RNG's attempts in the program // instead of stopping for them. BooleanCheckBoxOption RUN_STARTER_RNG; + // Runs Gift RNG's attempts for the Magikarp in the program after 00.12. + BooleanCheckBoxOption RUN_GIFT_RNG; StarterRngPlanOptions RNG_PLAN; EventNotificationOption NOTIFICATION_SHINY; EventNotificationOption NOTIFICATION_STATUS_UPDATE; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.cpp new file mode 100644 index 0000000000..d02816e771 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.cpp @@ -0,0 +1,111 @@ +/* AutoStory Abilities + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/Exceptions/ProgramFinishedException.h" +#include "PokemonFRLG_AutoStoryAbilities.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using Pokemon::Outcome; + + +namespace{ + +Outcome mart(Logger& logger, const MartPlan& plan, StoryAbilities& abilities){ + const MartResult result = run_mart(logger, abilities.mart, plan); + const std::string message = mart_message(plan, result); + const bool ok = result.end == MartEnd::DONE || result.end == MartEnd::NOTHING_TO_BUY; + logger.log(message, result.end == MartEnd::DONE ? COLOR_BLACK : COLOR_RED); + return ok ? Pokemon::outcome_ok() : Pokemon::outcome_failed(std::string("mart_") + mart_end_name(result.end), message); +} + +Outcome teach(Logger& logger, const TeachPlan& plan, StoryAbilities& abilities){ + const TeachResult result = run_teach(logger, abilities.teach, abilities.members, plan); + // NO_ITEM: skipped, with run_teach()'s log line. + if (result.end == TeachEnd::LEARNED || result.end == TeachEnd::ALREADY_KNOWN || result.end == TeachEnd::NO_ITEM){ + return Pokemon::outcome_ok(); + } + return Pokemon::outcome_failed(std::string("teach_") + teach_end_name(result.end), teach_message(plan, result)); +} + +Outcome level(Logger& logger, const LevelCheck& check, StoryAbilities& abilities){ + const LevelResult result = run_level_check(logger, abilities.members, check); + switch (result.end){ + case LevelEnd::REACHED: + return Pokemon::outcome_ok(); + case LevelEnd::BELOW: + return Pokemon::outcome_failed("level_below", std::to_string(*result.level) + " < " + std::to_string(check.level)); + case LevelEnd::UNREAD: + break; + } + return Pokemon::outcome_failed("level_unread"); +} + +Outcome grind(Logger& logger, VideoStream* stream, const GrindPlan& plan, StoryAbilities& abilities){ + const GrindResult result = run_grind(logger, abilities.grind, abilities.members, plan); + const std::string message = grind_message(plan, result); + if (result.end == GrindEnd::SHINY){ + // Not a failed Outcome, which would reload the save. + if (stream != nullptr){ + throw ProgramFinishedException(message, *stream); + } + throw ProgramFinishedException(message); + } + if (result.end == GrindEnd::REACHED){ + return Pokemon::outcome_ok(); + } + return Pokemon::outcome_failed(std::string("grind_") + grind_end_name(result.end), message); +} + +Outcome fossil(Logger& logger, Fossil fossil, StoryAbilities& abilities){ + const ChoiceResult result = run_fossil_choice(logger, abilities.choice, fossil); + if (result.end == ChoiceEnd::TAKEN){ + return Pokemon::outcome_ok(); + } + logger.log("Choice: " + result.detail, COLOR_RED); + return Pokemon::outcome_failed(std::string("fossil_") + choice_end_name(result.end), result.detail); +} + +} + + +std::optional run_ability_game_step( + Logger& logger, VideoStream* stream, const Pokemon::StoryStep& step, StoryAbilities& abilities +){ + const std::string_view variant = step.variant; + if (variant.starts_with(MART_VARIANT_PREFIX)){ + const std::optional plan = parse_mart_variant(variant); + return plan ? mart(logger, *plan, abilities) : Pokemon::outcome_failed("bad_variant", step.variant); + } + if (variant.starts_with(TEACH_VARIANT_PREFIX)){ + const std::optional plan = parse_teach_variant(variant); + return plan ? teach(logger, *plan, abilities) : Pokemon::outcome_failed("bad_variant", step.variant); + } + if (variant.starts_with(LEVEL_VARIANT_PREFIX)){ + const std::optional check = parse_level_variant(variant); + return check ? level(logger, *check, abilities) : Pokemon::outcome_failed("bad_variant", step.variant); + } + if (variant.starts_with(GRIND_VARIANT_PREFIX)){ + const std::optional plan = parse_grind_variant(variant); + if (!plan || !step.target){ + return Pokemon::outcome_failed("bad_variant", step.variant + (step.target ? "" : " (no nurse)")); + } + return grind(logger, stream, *plan, abilities); + } + if (variant.starts_with(FOSSIL_VARIANT_PREFIX)){ + const std::optional choice = parse_fossil_variant(variant); + return choice ? fossil(logger, *choice, abilities) : Pokemon::outcome_failed("bad_variant", step.variant); + } + return std::nullopt; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.h new file mode 100644 index 0000000000..2a61bfb467 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.h @@ -0,0 +1,61 @@ +/* AutoStory Abilities + * + * From: https://github.com/PokemonAutomation/ + * + * The GAME steps beyond walking and talking that later checkpoints name + * in the table: MART (a budgeted buy list), TEACH (a TM or HM), LEVEL (a + * member's level checked), GRIND (wild battles to a level) and + * CHOICE(fossil). Each is a variant of a GAME step; this runs the one a + * step names with the game steps it is given. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryAbilities_H +#define PokemonAutomation_PokemonFRLG_AutoStoryAbilities_H + +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "PokemonFRLG_AutoStoryChoice.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryMart.h" +#include "PokemonFRLG_AutoStoryMember.h" +#include "PokemonFRLG_AutoStoryTeach.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The game steps the abilities take. +struct StoryAbilities{ + MemberReader members; + MartActions& mart; + TeachActions& teach; + GrindActions& grind; + ChoiceActions& choice; +}; + +// A MART, TEACH, LEVEL, GRIND or fossil step with `abilities`: nothing for +// another variant. A GRIND that meets a shiny throws +// ProgramFinishedException, with a screenshot from `stream` if given, so +// the segment stops without reloading. Any other end but success is a +// failed Outcome; a MART that buys nothing because the money doesn't +// cover it, and a TEACH whose TM isn't in the TM CASE, succeed, with the +// reason logged. +std::optional run_ability_game_step( + Logger& logger, VideoStream* stream, const Pokemon::StoryStep& step, StoryAbilities& abilities +); + + +// These steps' tests, with the Mart's, TEACH's, GRIND's and the fossil's. +void add_tests_AutoStoryAbilities(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities_Tests.cpp new file mode 100644 index 0000000000..a0f06a450f --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities_Tests.cpp @@ -0,0 +1,216 @@ +/* AutoStory Abilities Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The LEVEL step, and which ends of the MART, TEACH, GRIND and fossil + * steps the story takes as success, failure or a stop. + * + */ + +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/Exceptions/ProgramFinishedException.h" +#include "PokemonFRLG_AutoStoryAbilities.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +// A game where nothing happens: a lead whose level is set, a Mart whose +// money is set, a patch whose one battle is shiny if set. +class StubMart : public MartActions{ +public: + size_t money = 3000; + + virtual bool open_buy_list() override{ return true; } + virtual std::optional read_money() override{ return money; } + virtual void move_cursor(size_t, size_t) override{} + virtual std::string read_row(size_t) override{ return ""; } + virtual PurchaseEnd buy(const MartBuy&) override{ return PurchaseEnd::FAILED; } + virtual bool leave() override{ return true; } +}; + +class StubTeach : public TeachActions{ +public: + // Its TM Case is empty. + virtual ItemOpen open_item(std::string_view, std::string_view) override{ return ItemOpen::NOT_IN_CASE; } + virtual TeachPrompt choose_member(size_t) override{ return TeachPrompt::FAILED; } + virtual ForgetEnd forget(size_t) override{ return ForgetEnd::FAILED; } + virtual void stop_teaching() override{} + virtual bool leave() override{ return true; } + virtual void dump_screen(std::string_view) override{} +}; + +class StubPatch : public GrindActions{ +public: + bool shiny = false; + + virtual bool find_encounter(Pokemon::Direction) override{ return true; } + virtual EncounterSeen read_encounter() override{ return {.species = "pidgey", .shiny = shiny}; } + virtual GrindFight fight() override{ return GrindFight::FAILED; } + virtual bool needs_heal() override{ return false; } + virtual bool heal_trip() override{ return true; } + virtual std::optional win_chance(const MemberInfo&, std::string_view) override{ return std::nullopt; } +}; + +class StubFossils : public ChoiceActions{ +public: + virtual bool talk() override{ return false; } + virtual std::optional read_offer() override{ return std::nullopt; } + virtual void answer(bool) override{} + virtual bool finish() override{ return true; } + virtual std::optional fossil_in_bag() override{ return std::nullopt; } +}; + +struct StubGame{ + std::optional level = 16; + StubMart mart; + StubTeach teach; + StubPatch patch; + StubFossils fossils; +}; + + +class Test_AutoStoryAbilities : public UnitTest{ +public: + Test_AutoStoryAbilities(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStoryAbilities - " + 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; +}; + + +Pokemon::StoryStep game_step(std::string variant){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = "test"; + step.variant = std::move(variant); + return step; +} + +// The failure name the step ends with ("" for success); "none" if it +// isn't one of these steps. +std::string run_step(StubGame& game, const Pokemon::StoryStep& step){ + QuietLogger logger; + const MemberReader members = [&game](size_t slot) -> std::optional{ + if (slot != 0 || !game.level){ + return std::nullopt; + } + return MemberInfo{.species = "squirtle", .level = game.level, .moves = {"water-gun", "surf"}}; + }; + StoryAbilities abilities{members, game.mart, game.teach, game.patch, game.fossils}; + const std::optional outcome = run_ability_game_step(logger, nullptr, step, abilities); + return outcome ? outcome->failure : "none"; +} + +struct StepCase{ + std::string name; + std::function setup; + Pokemon::StoryStep step; + std::string failure; +}; + +std::vector step_cases(){ + const auto none = [](StubGame&){}; + const Pokemon::StoryStep grind = grind_step("grind", "", {.member = 0, .level = 20, .max_encounters = 5, .pace = Pokemon::Direction::EAST, .heal_every = 0}, {}); + return { + {"level reached", none, level_step("check", "", {.slot = 0, .level = 16}), ""}, + {"level below", none, level_step("check", "", {.slot = 0, .level = 17}), "level_below"}, + {"level unread", [](StubGame& game){ game.level.reset(); }, level_step("check", "", {.slot = 0, .level = 16}), "level_unread"}, + {"a Mart with too little money succeeds", [](StubGame& game){ game.mart.money = 100; }, game_step("mart PewterCity_Mart budget=2000 potion=5"), ""}, + {"a Mart purchase that fails", none, game_step("mart PewterCity_Mart budget=2000 potion=5"), "mart_failed"}, + {"a move it knows already succeeds", none, game_step("teach hm03 surf slot=1"), ""}, + {"a TM not in the TM Case is skipped", none, game_step("teach tm03 water-pulse slot=1 forget=water-gun"), ""}, + {"a level that can't be read stops a GRIND", [](StubGame& game){ game.level.reset(); }, grind, "grind_unread"}, + {"a GRIND without a nurse", none, game_step("grind slot=1 level=20"), "bad_variant"}, + {"no YES/NO at the fossil", none, fossil_step("fossil", "", Fossil::HELIX), "fossil_failed"}, + {"a bad variant", none, game_step("mart PewterCity_Mart"), "bad_variant"}, + {"another game step", none, game_step("capture rattata"), "none"}, + }; +} + +UnitTestResult test_ability_steps(){ + for (const StepCase& item : step_cases()){ + StubGame game; + item.setup(game); + const std::string failure = run_step(game, item.step); + if (failure != item.failure){ + return std::format("{}: \"{}\", expected \"{}\".", item.name, failure, item.failure); + } + } + return true; +} + + +// A shiny in a GRIND stops the program, not the checkpoint: a reload +// would lose it. +UnitTestResult test_grind_shiny_stops(){ + StubGame game; + game.patch.shiny = true; + Pokemon::StoryStep step = grind_step("grind", "", {.member = 0, .level = 20, .max_encounters = 5, .pace = Pokemon::Direction::EAST, .heal_every = 0}, {}); + try{ + run_step(game, step); + }catch (ProgramFinishedException&){ + return true; + } + return "Expected the program to stop on a shiny."; +} + + +UnitTestResult test_level_variant(){ + const std::string variant = level_variant({.slot = 1, .level = 23}); + const std::optional back = parse_level_variant(variant); + if (variant != "level slot=2 min=23" || !back || back->slot != 1 || back->level != 23){ + return "Unexpected level variant \"" + variant + "\"."; + } + for (std::string_view bad : {"level", "level slot=1", "level min=5", "level slot=0 min=5", "level slot=1 min=101"}){ + if (parse_level_variant(bad)){ + return "Expected \"" + std::string(bad) + "\" refused."; + } + } + return true; +} + +} + + +void add_tests_AutoStoryAbilities(UnitTestDatabase& database){ + const auto add = [&](std::string name, UnitTestResult (*body)()){ + database.add(std::move(name), body); + }; + add("the steps' ends as outcomes", test_ability_steps); + add("a shiny in a GRIND stops the program", test_grind_shiny_stops); + add("the LEVEL step's variant", test_level_variant); + add_tests_AutoStoryMart(database); + add_tests_AutoStoryTeach(database); + add_tests_AutoStoryGrind(database); + add_tests_AutoStoryChoice(database); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp index c3c5d3461c..6512c02174 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp @@ -891,17 +891,32 @@ void play_new_game_to_player_name(ConsoleHandle& console, ProControllerContext& NewGameIntro(console, context, player_name, NewGameIntro::End::PLAYER_NAME_TYPED).run(); } -void close_trainer_card(ConsoleHandle& console, ProControllerContext& context){ +namespace{ + +// After the RNG program `program`: from HOME, if it went there, back to +// the game, then B until the overworld. +void back_to_overworld_after(ConsoleHandle& console, ProControllerContext& context, const std::string& program){ context.wait_for_all_requests(); VideoSnapshot screen = console.video().snapshot(); if (screen && HomeMenuDetector(console, COLOR_RED).detect(screen)){ - console.log("After SID Helper: on HOME, back to the game."); + console.log("After " + program + ": on HOME, back to the game."); resume_game_from_home(console, context); } - // B closes the card, then the START menu. mash_until_overworld(console, context, 20000ms); } +} + +void close_trainer_card(ConsoleHandle& console, ProControllerContext& context){ + // B closes the card, then the START menu. + back_to_overworld_after(console, context, "SID Helper"); +} + +void leave_gift_summary(ConsoleHandle& console, ProControllerContext& context){ + // B closes the summary, the party, then the START menu. + back_to_overworld_after(console, context, "Gift RNG"); +} + } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h index 7065c8e1f7..5e07d1cd4c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h @@ -228,6 +228,11 @@ void play_new_game_to_player_name(ConsoleHandle& console, ProControllerContext& // Returns on the overworld. void close_trainer_card(ConsoleHandle& console, ProControllerContext& context); +// From the gift's summary Gift RNG leaves open, or from HOME (its "go home +// when done"): back to the game, the summary, the party and the START +// menu closed. Returns on the overworld. +void leave_gift_summary(ConsoleHandle& console, ProControllerContext& context); + } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 8428706822..43702b6b96 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -745,7 +745,7 @@ class StoryWildBattle : public OverworldInterrupt{ } // As the walker's own wild battle interrupts. virtual Milliseconds detection_delay() const override{ - return m_late ? 2000ms : 300ms; + return m_late ? OverworldWalkSteps::LATE_WILD_BATTLE_DELAY : 300ms; } // Run from or fought by the battle loop, which returns only once START // opens on the overworld, so nothing of the battle is left before the 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..d4ca584649 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp @@ -0,0 +1,641 @@ +/* AutoStory Capture + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/ProgramFinishedException.h" +#include "CommonFramework/Tools/DebugDumper.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_BattleScreenDetector.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; +} + + +bool pace_until_battle(Logger& logger, PatchSteps& steps, size_t max_steps){ + for (size_t c = 0; c < max_steps; c++){ + if (steps.step()){ + return true; + } + // The flash can be missed (console B, 2026-11-03): the battle's + // first text then waits for A, and the D-pad does nothing. Found a + // step late, the player stays where the battle started, but the + // step after it still counts as taken. + if (steps.battle_shows()){ + logger.log(std::format("Pacing: a battle shows after step {}; its flash was missed.", c + 1), COLOR_ORANGE); + return true; + } + } + if (steps.overworld_shows()){ + return false; + } + if (steps.battle_shows()){ + logger.log(std::format("Pacing: a battle shows after the last of {} steps; its flash was missed.", max_steps), COLOR_ORANGE); + return true; + } + logger.log(std::format("Pacing: no battle in {} steps, and the overworld doesn't show.", max_steps), COLOR_RED); + steps.dump_screen("no-battle-not-overworld"); + return false; +} + + +ConsolePatchSteps::ConsolePatchSteps(ConsoleHandle& console, ProControllerContext& context, Direction pace, PaceSpot& spot) + : m_console(console) + , m_context(context) + , m_pace(pace) + , m_spot(spot) +{} + +bool ConsolePatchSteps::step(){ + const Direction way = m_spot.displaced ? opposite(m_pace) : m_pace; + BattleFlashWatcher battle(COLOR_RED); + const int ret = run_until( + m_console, m_context, + [&](ProControllerContext& context){ + step_once(context, way, m_spot.facing); + }, + {battle} + ); + m_spot = {.displaced = !m_spot.displaced, .facing = way}; + return ret == 0; +} + +bool ConsolePatchSteps::battle_shows(){ + m_context.wait_for_all_requests(); + VideoSnapshot screen = m_console.video().snapshot(); + return screen && BattleScreenDetector(COLOR_RED).detect(screen); +} + +bool ConsolePatchSteps::overworld_shows(){ + return PokemonFRLG::overworld_shows(m_console, m_context); +} + +void ConsolePatchSteps::dump_screen(std::string_view label){ + m_context.wait_for_all_requests(); + dump_debug_image(m_console, "AutoStory/pace", std::string(label), m_console.video().snapshot()); +} + + +ConsoleCaptureActions::ConsoleCaptureActions(ConsoleHandle& console, ProControllerContext& context, Language language) + : m_console(console) + , m_context(context) + , m_language(language) +{} + +bool ConsoleCaptureActions::find_encounter(Direction pace){ + PaceSpot spot; + ConsolePatchSteps steps(m_console, m_context, pace, spot); + return pace_until_battle(m_console.logger(), steps, PACE_MAX_STEPS); +} + +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..17a8017b84 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h @@ -0,0 +1,285 @@ +/* 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 +); + + +// Where pacing has left the player: on the patch's tile, or one tile +// `pace` from it, as the last step taken left it. +struct PaceSpot{ + bool displaced = false; + // The way the player faces after its last step; nothing before one. + std::optional facing = std::nullopt; +}; + +// The game steps of pacing in a patch for a wild battle, injected so the +// search runs in tests. +class PatchSteps{ +public: + virtual ~PatchSteps() = default; + + // One step, `pace` or back as the last left the player: whether a + // battle's flash showed. + virtual bool step() = 0; + // Whether a battle shows: its text box, with an HP box or the menu. + virtual bool battle_shows() = 0; + // Whether the overworld is up, with no text or menu open. + virtual bool overworld_shows() = 0; + // Saves the screen as a debug image, named `label`. + virtual void dump_screen(std::string_view label) = 0; +}; + +// From the overworld, in the patch: steps until a wild battle starts, +// `max_steps` at most. True once one has. +bool pace_until_battle(Logger& logger, PatchSteps& steps, size_t max_steps); + +// Steps in a patch before pacing gives up: about 10 % of steps in grass +// start a battle at Route 4's rates. +constexpr size_t PACE_MAX_STEPS = 200; + +// The console's PatchSteps: `pace` and back from `spot`, which each step +// updates. +class ConsolePatchSteps : public PatchSteps{ +public: + ConsolePatchSteps(ConsoleHandle& console, ProControllerContext& context, Pokemon::Direction pace, PaceSpot& spot); + + virtual bool step() override; + virtual bool battle_shows() override; + virtual bool overworld_shows() override; + virtual void dump_screen(std::string_view label) override; + +private: + ConsoleHandle& m_console; + ProControllerContext& m_context; + Pokemon::Direction m_pace; + PaceSpot& m_spot; +}; + + +// 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..f052619db5 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp @@ -0,0 +1,615 @@ +/* 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 +#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; +} + + + +// A patch where the battle starts on step `battle_step` (from 1): the +// flash shows unless missed; its screen shows from `screen_after` steps +// later on (0: right after the step), or never. +struct FakePatch : public PatchSteps{ + size_t battle_step = 0; + bool flash_missed = false; + std::optional screen_after = 0; + // Whether the overworld shows when no battle does. + bool overworld = true; + + size_t steps = 0; + size_t moved = 0; + std::vector dumps = {}; + + bool battle_started() const{ + return battle_step != 0 && steps >= battle_step; + } + virtual bool step() override{ + if (battle_started()){ + // The D-pad does nothing in a battle. + steps++; + return false; + } + steps++; + moved++; + return steps == battle_step && !flash_missed; + } + virtual bool battle_shows() override{ + return battle_started() && screen_after && steps >= battle_step + *screen_after; + } + virtual bool overworld_shows() override{ + return !battle_started() && overworld; + } + virtual void dump_screen(std::string_view label) override{ + dumps.emplace_back(label); + } +}; + +struct PaceCase{ + std::string name; + FakePatch patch; + bool found; + // Steps the player took on the overworld: none in a battle. + size_t moved; + size_t dumps = 0; +}; + +UnitTestResult test_pace_until_battle(){ + const auto patch = [](size_t battle_step, bool flash_missed, std::optional screen_after, bool overworld){ + FakePatch fake; + fake.battle_step = battle_step; + fake.flash_missed = flash_missed; + fake.screen_after = screen_after; + fake.overworld = overworld; + return fake; + }; + std::vector cases{ + {"the flash on step 5", patch(5, false, 0, true), true, 5}, + // Console B, 2026-11-03: "Wild RATTATA appeared!" waited for A + // while 193 more steps paced in vain. + {"the flash missed, the text box right after", patch(5, true, 0, true), true, 5}, + {"the flash missed, the text box a step later", patch(5, true, 1, true), true, 5}, + {"the flash missed, the text box only after the last step", patch(PACE_MAX_STEPS, true, 0, true), true, PACE_MAX_STEPS}, + {"no battle: the overworld, no image", patch(0, false, 0, true), false, PACE_MAX_STEPS}, + {"no battle, not the overworld: an image", patch(0, false, 0, false), false, PACE_MAX_STEPS, 1}, + }; + for (PaceCase& item : cases){ + LineLogger logger; + const bool found = pace_until_battle(logger, item.patch, PACE_MAX_STEPS); + if (found != item.found || item.patch.moved != item.moved || item.patch.dumps.size() != item.dumps){ + return std::format( + "{}: expected found {}, {} steps moved, {} images; got {}, {}, {} ({}).", + item.name, item.found, item.moved, item.dumps, + found, item.patch.moved, item.patch.dumps.size(), joined(logger.lines) + ); + } + } + 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); + add("pacing finds a battle whose flash was missed", test_pace_until_battle); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice.cpp new file mode 100644 index 0000000000..c6e7603425 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice.cpp @@ -0,0 +1,165 @@ +/* AutoStory Choice + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Common/Cpp/Time.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryChoice.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace std::chrono_literals; + + +const char* fossil_name(Fossil fossil){ + switch (fossil){ + case Fossil::DOME: return "dome"; + case Fossil::HELIX: return "helix"; + } + return "?"; +} + +std::optional fossil_from_name(std::string_view name){ + for (Fossil fossil : {Fossil::DOME, Fossil::HELIX}){ + if (name == fossil_name(fossil)){ + return fossil; + } + } + return std::nullopt; +} + +Pokemon::TilePos fossil_tile(Fossil fossil){ + return fossil == Fossil::DOME ? Pokemon::TilePos{13, 7} : Pokemon::TilePos{14, 7}; +} + +const char* choice_end_name(ChoiceEnd end){ + switch (end){ + case ChoiceEnd::TAKEN: return "taken"; + case ChoiceEnd::WRONG_ONE: return "wrong_one"; + case ChoiceEnd::FAILED: return "failed"; + } + return "?"; +} + + +std::string fossil_variant(Fossil fossil){ + return std::string(FOSSIL_VARIANT_PREFIX) + fossil_name(fossil); +} + +std::optional parse_fossil_variant(std::string_view variant){ + if (!variant.starts_with(FOSSIL_VARIANT_PREFIX)){ + return std::nullopt; + } + return fossil_from_name(variant.substr(FOSSIL_VARIANT_PREFIX.size())); +} + +Pokemon::StoryStep fossil_step(std::string name, std::string why, Fossil fossil){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = std::move(name); + step.why = std::move(why); + step.variant = fossil_variant(fossil); + return step; +} + + +namespace{ + +ChoiceResult choice_end(ChoiceEnd end, std::string detail){ + return {.end = end, .confirmed = false, .detail = std::move(detail)}; +} + +// NO on the prompt, then the overworld; ends as `end`. +ChoiceResult decline(ChoiceActions& actions, ChoiceEnd end, std::string detail){ + actions.answer(false); + if (!actions.finish()){ + return choice_end(ChoiceEnd::FAILED, "the overworld didn't come back after NO"); + } + return choice_end(end, std::move(detail)); +} + +} + +ChoiceResult run_fossil_choice(Logger& logger, ChoiceActions& actions, Fossil fossil){ + if (!actions.talk()){ + return choice_end(ChoiceEnd::FAILED, "no YES/NO after talking to the fossil"); + } + // Unread, the offer is taken to be the fossil the table faced the + // player to. + const std::optional offer = actions.read_offer(); + if (offer && *offer != fossil){ + return decline(actions, ChoiceEnd::WRONG_ONE, std::format("facing the {} fossil, not the {}", fossil_name(*offer), fossil_name(fossil))); + } + actions.answer(true); + if (!actions.finish()){ + return choice_end(ChoiceEnd::FAILED, "the overworld didn't come back after the fossil"); + } + const std::optional held = actions.fossil_in_bag(); + if (held && *held != fossil){ + return choice_end(ChoiceEnd::FAILED, std::format("the bag holds the {} fossil", fossil_name(*held))); + } + logger.log(std::format( + "Choice: took the {} fossil{}{}.", fossil_name(fossil), offer ? "" : ", by where the player faces (the prompt isn't read)", + held ? ", seen in the bag" : " (the bag isn't read)" + )); + return {.end = ChoiceEnd::TAKEN, .confirmed = held.has_value(), .detail = ""}; +} + + +ConsoleChoiceActions::ConsoleChoiceActions(ConsoleHandle& console, ProControllerContext& context) + : m_console(console) + , m_context(context) +{} + +bool ConsoleChoiceActions::talk(){ + pbf_press_button(m_context, BUTTON_A, 160ms, 500ms); + const WallClock deadline = current_time() + 5000ms; + while (current_time() < deadline){ + m_context.wait_for_all_requests(); + VideoSnapshot screen = m_console.video().snapshot(); + if (screen && yes_no_cursor(screen)){ + return true; + } + pbf_wait(m_context, 250ms); + } + return false; +} + +std::optional ConsoleChoiceActions::read_offer(){ + m_console.log("Choice: no reader for the fossil prompt's text yet.", COLOR_RED); + return std::nullopt; +} + +void ConsoleChoiceActions::answer(bool yes){ + // Reads the cursor (it opens on YES) and answers; B answers NO. + answer_yes_no(m_console, m_context, yes, BUTTON_B, 5000ms); +} + +bool ConsoleChoiceActions::finish(){ + try{ + mash_until_overworld(m_console, m_context, 30000ms); + return true; + }catch (OperationFailedException& e){ + m_console.log("Choice: " + e.message(), COLOR_RED); + return false; + } +} + +std::optional ConsoleChoiceActions::fossil_in_bag(){ + // The item list reader has no names for the fossils yet. + return std::nullopt; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice.h new file mode 100644 index 0000000000..831ceaec6e --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice.h @@ -0,0 +1,130 @@ +/* AutoStory Choice + * + * From: https://github.com/PokemonAutomation/ + * + * CHOICE steps: a story pick between two things the player takes one of. + * The first is Mt. Moon's fossil (pokefirered data/maps/MtMoon_B2F): after + * Super Nerd Miguel is beaten (the battle step's job), the DOME FOSSIL + * lies at (13, 7) and the HELIX FOSSIL at (14, 7). A on one: "You want + * the DOME/HELIX FOSSIL?" YES/NO. YES: " obtained the ... FOSSIL!", + * then Miguel walks to the other and takes it; NO: nothing happens and + * both stay. + * + * The table walks the player to face the chosen fossil; the step reads + * which fossil the prompt offers before it answers YES, and answers NO + * (B) and stops if it offers the other. A prompt it can't read is taken + * to offer the fossil the player faces, and answered YES. After the + * scene the bag is read for the fossil, where a reader can. + * + * The decisions take their game steps from a ChoiceActions, so they run + * in tests against a fake Mt. Moon. On the console the prompt's text has + * no reader yet: the step waits for the YES/NO box, answers YES by the + * player's place, and clears the scene to the overworld. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryChoice_H +#define PokemonAutomation_PokemonFRLG_AutoStoryChoice_H + +#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 "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The `fossil` option: HELIX by default (the world record's). +enum class Fossil{ + DOME, + HELIX, +}; +const char* fossil_name(Fossil fossil); +std::optional fossil_from_name(std::string_view name); + +// Where `fossil` lies on MtMoon_B2F (map.json object_events). +Pokemon::TilePos fossil_tile(Fossil fossil); + +// The prefix of a GAME step's variant that names the fossil CHOICE. +inline constexpr std::string_view FOSSIL_VARIANT_PREFIX = "fossil "; + +// "fossil helix" or "fossil dome". +std::string fossil_variant(Fossil fossil); +std::optional parse_fossil_variant(std::string_view variant); + +// The table's CHOICE(fossil) step, the player facing the fossil. +Pokemon::StoryStep fossil_step(std::string name, std::string why, Fossil fossil); + + +// The game steps the fossil CHOICE takes, injected so its decisions run +// in tests. +class ChoiceActions{ +public: + virtual ~ChoiceActions() = default; + + // Facing the fossil: A, until its YES/NO shows. False if none did. + virtual bool talk() = 0; + // On the YES/NO: the fossil the prompt offers; nothing if unread. + virtual std::optional read_offer() = 0; + // YES (A) or NO (B) on the YES/NO. + virtual void answer(bool yes) = 0; + // The scene's text to the overworld. False if it didn't come back. + virtual bool finish() = 0; + // From the overworld: the fossil in the bag's Key Items; nothing if + // it can't be read. + virtual std::optional fossil_in_bag() = 0; +}; + +enum class ChoiceEnd{ + TAKEN, + // The prompt offered the other fossil: NO answered. + WRONG_ONE, + FAILED, +}; +const char* choice_end_name(ChoiceEnd end); + +struct ChoiceResult{ + ChoiceEnd end = ChoiceEnd::FAILED; + // TAKEN: whether the bag confirmed it. + bool confirmed = false; + std::string detail = {}; +}; + +// Facing the fossil: takes `fossil`. +ChoiceResult run_fossil_choice(Logger& logger, ChoiceActions& actions, Fossil fossil); + + +// The console's fossil CHOICE: talks until the YES/NO box shows, has no +// reader for the prompt's text yet, answers by the box's cursor, and +// clears the scene to the overworld. +class ConsoleChoiceActions : public ChoiceActions{ +public: + ConsoleChoiceActions(ConsoleHandle& console, ProControllerContext& context); + + virtual bool talk() override; + virtual std::optional read_offer() override; + virtual void answer(bool yes) override; + virtual bool finish() override; + virtual std::optional fossil_in_bag() override; + +private: + ConsoleHandle& m_console; + ProControllerContext& m_context; +}; + + +void add_tests_AutoStoryChoice(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice_Tests.cpp new file mode 100644 index 0000000000..d9fe28b9e6 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice_Tests.cpp @@ -0,0 +1,199 @@ +/* AutoStory Choice Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The fossil CHOICE against a fake MtMoon_B2F that runs its scripts.inc: + * YES on a fossil takes it and Miguel the other; NO leaves both. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryChoice.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +class FakeFossilRoom : public ChoiceActions{ +public: + explicit FakeFossilRoom(Fossil facing) + : facing(facing) + {} + + Fossil facing; + bool offer_readable = true; + bool bag_readable = true; + std::optional taken = std::nullopt; + size_t fossils_left = 2; + std::string error = {}; + + virtual bool talk() override{ + if (fossils_left < 2){ + return false; + } + m_prompt = true; + return true; + } + virtual std::optional read_offer() override{ + if (!m_prompt){ + error = "read the offer with no prompt"; + } + return offer_readable ? std::optional(facing) : std::nullopt; + } + virtual void answer(bool yes) override{ + if (!m_prompt){ + error = "answered with no prompt"; + } + m_prompt = false; + if (yes){ + // The player takes this one, Miguel the other. + taken = facing; + fossils_left = 0; + } + } + virtual bool finish() override{ + if (m_prompt){ + error = "left the prompt unanswered"; + } + return true; + } + virtual std::optional fossil_in_bag() override{ + return bag_readable ? taken : std::nullopt; + } + +private: + bool m_prompt = false; +}; + + +class Test_AutoStoryChoice : public UnitTest{ +public: + Test_AutoStoryChoice(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStoryChoice - " + 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; +}; + + +struct ChoiceCase{ + std::string name = {}; + Fossil wanted = Fossil::HELIX; + Fossil facing = Fossil::HELIX; + std::function setup = {}; + ChoiceEnd end = ChoiceEnd::TAKEN; + std::optional taken = std::nullopt; + bool confirmed = false; +}; + +std::vector choice_cases(){ + return { + {.name = "HELIX, the default", .taken = Fossil::HELIX, .confirmed = true}, + {.name = "DOME", .wanted = Fossil::DOME, .facing = Fossil::DOME, .taken = Fossil::DOME, .confirmed = true}, + { + .name = "facing the other fossil: NO, both left", + .facing = Fossil::DOME, + .end = ChoiceEnd::WRONG_ONE, + }, + // The console has no reader for the prompt's text: the table has + // walked the player to face the wanted fossil, so YES takes it + // (console B: answering NO and stopping left 00.13 stuck there). + { + .name = "an unread prompt: YES, facing the wanted fossil", + .setup = [](FakeFossilRoom& room){ room.offer_readable = false; }, + .taken = Fossil::HELIX, + .confirmed = true, + }, + { + .name = "an unread prompt and an unread bag: YES", + .setup = [](FakeFossilRoom& room){ room.offer_readable = false; room.bag_readable = false; }, + .taken = Fossil::HELIX, + }, + { + .name = "taken, the bag unread", + .setup = [](FakeFossilRoom& room){ room.bag_readable = false; }, + .taken = Fossil::HELIX, + }, + { + .name = "both already gone", + .setup = [](FakeFossilRoom& room){ room.fossils_left = 0; }, + .end = ChoiceEnd::FAILED, + }, + }; +} + +UnitTestResult test_fossil_choice(){ + for (const ChoiceCase& item : choice_cases()){ + QuietLogger logger; + FakeFossilRoom room(item.facing); + if (item.setup){ + item.setup(room); + } + const ChoiceResult result = run_fossil_choice(logger, room, item.wanted); + if (!room.error.empty()){ + return item.name + ": " + room.error; + } + if (result.end != item.end || room.taken != item.taken || result.confirmed != item.confirmed){ + return std::format("{}: ended {} ({}), expected {}.", item.name, + choice_end_name(result.end), result.detail, choice_end_name(item.end)); + } + } + return true; +} + + +UnitTestResult test_fossil_variant(){ + if (fossil_variant(Fossil::HELIX) != "fossil helix" || parse_fossil_variant("fossil dome") != Fossil::DOME){ + return "Expected \"fossil helix\" and \"fossil dome\"."; + } + for (std::string_view bad : {"fossil", "fossil old_amber", "fossil helix dome", "grind level=18"}){ + if (parse_fossil_variant(bad)){ + return "Expected \"" + std::string(bad) + "\" refused."; + } + } + // MtMoon_B2F map.json: DOME at (13, 7), HELIX at (14, 7). + if (!(fossil_tile(Fossil::DOME) == Pokemon::TilePos{13, 7}) || !(fossil_tile(Fossil::HELIX) == Pokemon::TilePos{14, 7})){ + return "The fossils' tiles aren't MtMoon_B2F's."; + } + return true; +} + +} + + +void add_tests_AutoStoryChoice(UnitTestDatabase& database){ + const auto add = [&](std::string name, UnitTestResult (*body)()){ + database.add(std::move(name), body); + }; + add("the fossil on a fake Mt. Moon", test_fossil_choice); + add("the table's variant", test_fossil_variant); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index a39e555328..a25dcbe4e2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -15,9 +15,12 @@ #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryAbilities.h" #include "PokemonFRLG_AutoStoryBattleData.h" +#include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStorySummary.h" +#include "PokemonFRLG_AutoStoryTeachScreens.h" #include "PokemonFRLG_AutoStoryWinChance.h" namespace PokemonAutomation{ @@ -312,15 +315,18 @@ std::optional story_pose(const Pokemon::Pose& pose){ } std::string save_card_place(const std::string& map){ - // The longer names first: "ViridianForest" before "ViridianCity". - static const std::pair PLACES[] = { + // A map of the place, or a building in it ("Route4_PokemonCenter_1F" + // is on Route 4, "Route24" isn't on Route 2). + static const std::pair PLACES[] = { {"PalletTown", "pallet-town"}, {"ViridianForest", "viridian-forest"}, {"ViridianCity", "viridian-city"}, {"PewterCity", "pewter-city"}, + {"Route3", "route-3"}, + {"Route4", "route-4"}, }; for (const auto& [prefix, place] : PLACES){ - if (map.rfind(prefix, 0) == 0){ + if (map == prefix || map.rfind(prefix + "_", 0) == 0){ return place; } } @@ -354,6 +360,7 @@ std::optional> fixed_route( step.direction = leg.direction; step.tiles = leg.tiles; step.facing_after = leg.direction; + step.walk_only = !map->allow_running; for (int c = 0; c < leg.tiles; c++){ const StepKind kind = classify_step(*map, player, leg.direction); const TilePos next = player.position.next(leg.direction); @@ -443,11 +450,84 @@ Outcome StoryExecutor::ready(RunState& state, const StoryStep& step){ return get_ready(m_env, state, step); } -Outcome StoryExecutor::game(RunState&, const StoryStep& step){ +namespace{ + +bool walked(StoryEnv& env, RunState& state, const StoryStep& step){ + const Outcome outcome = walk_route(env, state, step); + if (outcome.succeeded() && outcome.pose){ + state.pose = *outcome.pose; + } + return outcome.succeeded(); +} + +// A GRIND's trip from the patch to the nurse (`step`'s target) and back +// to `patch`, starting where pacing left the player (`spot`). +bool grind_heal_trip(StoryEnv& env, RunState& state, const StoryStep& step, const Pokemon::Pose& patch, const GrindSpot& spot){ + std::optional here = story_pose(patch); + if (!here){ + return false; + } + if (spot.displaced && spot.facing){ + here->tile = here->tile.next(*spot.facing); + } + if (spot.facing){ + here->facing = spot.facing; + } + state.pose = to_pose(*here); + StoryStep walk; + walk.kind = StoryStepKind::ROUTE; + walk.name = step.name + "_to_nurse"; + walk.target = step.target; + if (!walked(env, state, walk)){ + return false; + } + heal_at_center(env.console, env.context); + // PP too: a grind's battles spend more than its attacks have. + healed_by_nurse(env.supplies); + walk.name = step.name + "_back_to_patch"; + walk.target = patch; + return walked(env, state, walk); +} + +// A MART, TEACH, LEVEL, GRIND or fossil step on the console; nothing for +// another variant. +std::optional run_abilities(StoryEnv& env, RunState& state, const StoryStep& step){ + const Pokemon::Pose patch = state.pose; + ConsoleMartActions mart(env.console, env.context, env.language, env.supplies); + ConsoleTeachScreens teach_screens(env.console, env.context, env.language); + ScreenTeachActions teach(env.console.logger(), teach_screens); + ConsoleGrindActions grind( + env.console, env.context, env.language, env.supplies, env.checks, + [&](const GrindSpot& spot){ + return grind_heal_trip(env, state, step, patch, spot); + } + ); + ConsoleChoiceActions choice(env.console, env.context); + StoryAbilities abilities{console_member_reader(env.console, env.context, env.language), mart, teach, grind, choice}; + return run_ability_game_step(env.console.logger(), &env.console, step, abilities); +} + +} + +Outcome StoryExecutor::game(RunState& state, const StoryStep& step){ if (step.variant == CLOSE_TRAINER_CARD){ close_trainer_card(m_env.console, m_env.context); return Pokemon::outcome_ok(); } + if (step.variant == LEAVE_GIFT_SUMMARY){ + leave_gift_summary(m_env.console, m_env.context); + return Pokemon::outcome_ok(); + } + 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; + } + const std::optional ability = run_abilities(m_env, state, step); + if (ability){ + return *ability; + } return Pokemon::unsupported_step(step); } @@ -473,6 +553,13 @@ StoryRunPlan plan_story_run( const std::vector& checkpoints, size_t first, size_t last, const StoryHandoffs& handoffs ){ StoryRunPlan plan; + // Started at the way back in from Gift RNG with its handoff on: the run + // takes the handoff again, from the save the row before ends at. + if (first > 0 && first < checkpoints.size() && checkpoints[first].live_start + && checkpoints[first - 1].handoff == StoryHandoff::GIFT_RNG && handoffs.gift_rng){ + plan.stop = StoryHandoff::GIFT_RNG; + return plan; + } for (size_t c = first; c <= last && c < checkpoints.size(); c++){ const StoryCheckpoint& row = checkpoints[c]; if (row.live_start && c != first){ @@ -488,6 +575,11 @@ StoryRunPlan plan_story_run( plan.stop = StoryHandoff::STARTER_RNG; return plan; } + if (row.handoff == StoryHandoff::GIFT_RNG && handoffs.gift_rng){ + plan.rows.emplace_back(row); + plan.stop = StoryHandoff::GIFT_RNG; + return plan; + } if (row.handoff == StoryHandoff::STARTER_RNG && c + 1 < checkpoints.size()){ plan.rows.emplace_back(joined_rows(row, checkpoints[c + 1])); c++; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index b716973374..a0000f2d66 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -59,6 +59,10 @@ inline const std::string RUN_VARIANT = "run"; // The GAME step that closes the Trainer Card SID Helper leaves open. inline const std::string CLOSE_TRAINER_CARD = "close_trainer_card"; +// The GAME step that leaves the gift's summary Gift RNG leaves open for +// the overworld. +inline const std::string LEAVE_GIFT_SUMMARY = "leave_gift_summary"; + // How `step`, a ROUTE or LEGS, moves the player: RUN_VARIANT runs. MoveSpeed move_speed(const StoryStep& step); @@ -74,6 +78,9 @@ enum class StoryHandoff{ // which is where Starter RNG starts. With the handoff off, it runs on // into the next checkpoint as one, as the story did before the split. STARTER_RNG, + // The checkpoint saves beside the Magikarp salesman, facing him, which + // is where Gift RNG starts. + GIFT_RNG, }; // A shared checkpoint with what FRLG adds to it. @@ -94,6 +101,7 @@ struct StoryCheckpoint : public Pokemon::StoryCheckpoint{ struct StoryHandoffs{ bool sid_helper = false; bool starter_rng = false; + bool gift_rng = false; }; // What a run from table row `first` to `last` does. @@ -103,13 +111,16 @@ struct StoryRunPlan{ // NONE: the run ends after `rows`. SID_HELPER: `rows` is empty; the run // plays the new game to the player's keyboard and stops there. // STARTER_RNG: the run stops after `rows`, the last of which saves at - // the starter's ball. + // the starter's ball. GIFT_RNG: likewise, beside the Magikarp salesman; + // with `rows` empty, the run started at 00.12b and takes Gift RNG from + // the save beside the salesman, where the row before `first` ends. StoryHandoff stop = StoryHandoff::NONE; }; // The rows of `checkpoints` from `first` to `last`, both inclusive, as a // run with `handoffs` takes them: live-start rows only when the run -// starts there, a run that reaches a handoff that is on stops for it, and +// starts there (00.12b with Gift RNG's handoff on starts at the handoff +// instead), a run that reaches a handoff that is on stops for it, and // a STARTER_RNG row whose handoff is off is joined with the row after it, // under its own id. StoryRunPlan plan_story_run( @@ -117,8 +128,9 @@ StoryRunPlan plan_story_run( ); -// The maps the story plans on: every map to Brock, with the objects the -// game hides after the Hall of Fame removed, as the lab's frlg_route does. +// The maps the story plans on: every map to Route 4's Pokemon Center, with +// the objects the game hides after the Hall of Fame removed, as the lab's +// frlg_route does. const std::vector& story_maps(); Pokemon::Pose to_pose(const StoryPose& pose); @@ -224,6 +236,12 @@ class StoryExecutor : public Pokemon::StoryStepExecutor{ virtual Pokemon::Outcome shop(Pokemon::RunState& state, const StoryStep& step) override; 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. A MART, TEACH, LEVEL, GRIND or + // fossil CHOICE (PokemonFRLG_AutoStoryAbilities.h); a GRIND heals by + // routing to its target, the nurse, and back. virtual Pokemon::Outcome game(Pokemon::RunState& state, const StoryStep& step) override; private: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp new file mode 100644 index 0000000000..6393acf42b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp @@ -0,0 +1,373 @@ +/* AutoStory Grind + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryVariantWords.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using Pokemon::Direction; + + +const char* grind_end_name(GrindEnd end){ + switch (end){ + case GrindEnd::REACHED: return "reached"; + case GrindEnd::LEVEL_CAP: return "level_cap"; + case GrindEnd::NO_ESTIMATE: return "no_estimate"; + case GrindEnd::OUT_OF_ENCOUNTERS: return "out_of_encounters"; + case GrindEnd::NO_ENCOUNTER: return "no_encounter"; + case GrindEnd::SHINY: return "shiny"; + case GrindEnd::PLAYER_FAINTED: return "player_fainted"; + case GrindEnd::UNREAD: return "unread"; + case GrindEnd::REFUSED: return "refused"; + case GrindEnd::FAILED: return "failed"; + } + return "?"; +} + + +std::string grind_variant(const GrindPlan& plan){ + std::string variant = std::format( + "{}slot={} level={} encounters={} pace={} heal_every={}", + GRIND_VARIANT_PREFIX, plan.member + 1, plan.level, plan.max_encounters, + direction_name(plan.pace), plan.heal_every + ); + if (!plan.trainer.empty()){ + variant += std::format(" beat={} chance={:.0f}", plan.trainer, 100 * plan.min_chance); + } + return variant; +} + +namespace{ + +bool set_grind_word(GrindPlan& plan, std::string_view word){ + const std::optional pair = variant_pair(word); + if (!pair){ + return false; + } + if (pair->key == "beat"){ + plan.trainer = std::string(pair->value); + return !plan.trainer.empty(); + } + if (pair->key == "pace"){ + for (Direction direction : Pokemon::ALL_DIRECTIONS){ + if (pair->value == direction_name(direction)){ + plan.pace = direction; + return true; + } + } + return false; + } + const std::optional number = variant_number(pair->value, 4); + if (!number){ + return false; + } + if (pair->key == "slot" && *number >= 1 && *number <= PARTY_SLOTS){ + plan.member = *number - 1; + }else if (pair->key == "level" && *number >= 2 && *number <= 100){ + plan.level = (unsigned)*number; + }else if (pair->key == "encounters" && *number >= 1){ + plan.max_encounters = *number; + }else if (pair->key == "heal_every"){ + plan.heal_every = *number; + }else if (pair->key == "chance" && *number >= 1 && *number <= 100){ + plan.min_chance = static_cast(*number) / 100; + }else{ + return false; + } + return true; +} + +} + +std::optional parse_grind_variant(std::string_view variant){ + if (!variant.starts_with(GRIND_VARIANT_PREFIX)){ + return std::nullopt; + } + GrindPlan plan; + bool level_seen = false; + bool chance_seen = false; + for (std::string_view word : variant_words(variant.substr(GRIND_VARIANT_PREFIX.size()))){ + if (!set_grind_word(plan, word)){ + return std::nullopt; + } + level_seen |= word.starts_with("level="); + chance_seen |= word.starts_with("chance="); + } + // A chance needs a trainer to be against. + if (!level_seen || (chance_seen && plan.trainer.empty())){ + return std::nullopt; + } + return plan; +} + +Pokemon::StoryStep grind_step(std::string name, std::string why, const GrindPlan& plan, Pokemon::Pose nurse){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = std::move(name); + step.why = std::move(why); + step.variant = grind_variant(plan); + step.target = std::move(nurse); + return step; +} + + +namespace{ + +// One GRIND step under way. +class GrindRun{ +public: + GrindRun(Logger& logger, GrindActions& actions, const MemberReader& reader, const GrindPlan& plan) + : m_logger(logger) + , m_actions(actions) + , m_reader(reader) + , m_plan(plan) + {} + + GrindResult run(){ + if (m_plan.member != 0){ + return end(GrindEnd::REFUSED, "only the lead grinds: the battle handler fights with it alone"); + } + if (!read_level()){ + return end(GrindEnd::UNREAD, "couldn't read the member's level"); + } + if (over()){ + return m_result; + } + while (true){ + if (m_result.encounters >= m_plan.max_encounters){ + return end(GrindEnd::OUT_OF_ENCOUNTERS, ""); + } + if (heal_due() && !heal()){ + return end(GrindEnd::FAILED, "the trip to the nurse and back failed"); + } + if (!battle()){ + return m_result; + } + if (over()){ + if (m_result.end != GrindEnd::REACHED || heal()){ + return m_result; + } + return end(GrindEnd::FAILED, "the last trip to the nurse failed"); + } + } + } + +private: + GrindResult end(GrindEnd end, std::string detail){ + m_result.end = end; + m_result.detail = std::move(detail); + return m_result; + } + bool read_level(){ + std::optional member = m_reader(m_plan.member); + if (!member || !member->level){ + return false; + } + m_result.level = member->level; + m_member = std::move(*member); + return true; + } + bool reached() const{ + return m_result.level && *m_result.level >= m_plan.level; + } + // Whether the step ends at the member just read, its end set if so: + // the level reached; or with a trainer, the win chance reached, else + // the level cap. + bool over(){ + if (!m_plan.trainer.empty()){ + return chance_over(); + } + if (!reached()){ + return false; + } + end(GrindEnd::REACHED, ""); + return true; + } + bool chance_over(){ + const std::optional estimate = m_actions.win_chance(m_member, m_plan.trainer); + if (!estimate){ + end(GrindEnd::NO_ESTIMATE, std::format("the sim has no win chance against {}", m_plan.trainer)); + return true; + } + m_result.chance = estimate->chance; + m_logger.log(std::format( + "Grind: level {} against {}: the sim wins {:.0f}% with {}.", + *m_result.level, m_plan.trainer, 100 * estimate->chance, estimate->inputs + )); + if (estimate->chance >= m_plan.min_chance){ + end(GrindEnd::REACHED, ""); + return true; + } + if (reached()){ + end(GrindEnd::LEVEL_CAP, std::format("Lv{} is the cap, short of the win chance", m_plan.level)); + return true; + } + return false; + } + bool heal_due(){ + return m_actions.needs_heal() || (m_plan.heal_every > 0 && m_since_heal >= m_plan.heal_every); + } + bool heal(){ + m_logger.log(std::format("Grind: to the nurse after {} battles.", m_since_heal)); + m_since_heal = 0; + m_result.heals++; + return m_actions.heal_trip(); + } + + // One wild battle, from the patch back to it. False if the step ends + // (the result is set). + bool battle(){ + if (!m_actions.find_encounter(m_plan.pace)){ + end(GrindEnd::NO_ENCOUNTER, ""); + return false; + } + m_result.encounters++; + const EncounterSeen seen = m_actions.read_encounter(); + if (seen.shiny){ + m_logger.log("Grind: a shiny! Stopping in its battle.", COLOR_BLUE); + end(GrindEnd::SHINY, seen.species); + return false; + } + switch (m_actions.fight()){ + case GrindFight::OVER: + break; + case GrindFight::LOST: + end(GrindEnd::PLAYER_FAINTED, ""); + return false; + case GrindFight::FAILED: + end(GrindEnd::FAILED, "the battle didn't end on the overworld"); + return false; + } + m_since_heal++; + if (!read_level()){ + end(GrindEnd::FAILED, "couldn't read the level after a battle"); + return false; + } + m_logger.log(std::format("Grind: battle {} ({}), level {}.", m_result.encounters, seen.species, *m_result.level)); + return true; + } + + Logger& m_logger; + GrindActions& m_actions; + const MemberReader& m_reader; + const GrindPlan& m_plan; + size_t m_since_heal = 0; + MemberInfo m_member; + GrindResult m_result; +}; + +} + +GrindResult run_grind(Logger& logger, GrindActions& actions, const MemberReader& reader, const GrindPlan& plan){ + GrindResult result = GrindRun(logger, actions, reader, plan).run(); + logger.log(grind_message(plan, result), result.end == GrindEnd::REACHED ? COLOR_BLACK : COLOR_RED); + return result; +} + +std::string grind_message(const GrindPlan& plan, const GrindResult& result){ + const std::string level = result.level ? std::to_string(*result.level) : "?"; + const std::string chance = plan.trainer.empty() ? "" : std::format( + ", win chance {} of {:.0f}% against {}", + result.chance ? std::format("{:.0f}%", 100 * *result.chance) : "?", 100 * plan.min_chance, plan.trainer + ); + const std::string tally = std::format( + "level {} of {}{}, {} encounters, {} trips to the nurse", level, plan.level, chance, result.encounters, result.heals + ); + if (result.end == GrindEnd::SHINY){ + return std::format( + "Grind: a shiny {} in encounter {}. Stopped in its battle without running, for you to decide.", + result.detail.empty() ? "Pokemon" : result.detail, result.encounters + ); + } + if (result.detail.empty()){ + return std::format("Grind slot {}: {}: {}.", plan.member + 1, grind_end_name(result.end), tally); + } + return std::format("Grind slot {}: {}: {}: {}.", plan.member + 1, grind_end_name(result.end), result.detail, tally); +} + +PartyMember lead_as_read(const MemberInfo& read, const PartyMember& tracked){ + std::vector moves; + for (const std::string& move : read.moves){ + const auto known = std::find_if(tracked.moves.begin(), tracked.moves.end(), [&](const KnownMove& item){ + return item.move == move; + }); + const int full = max_pp(move); + moves.emplace_back(SummaryMove{.move = move, .pp = known == tracked.moves.end() ? full : known->pp, .max_pp = full}); + } + const uint8_t level = read.level ? static_cast(std::min(*read.level, 100u)) : battle_level(tracked); + return member_from_summary(read.species.empty() ? tracked.species : read.species, level, moves, read.stats); +} + +std::optional healed_win_chance(PartyMember lead, int potions, std::string_view trainer){ + restore_pp(lead); + return story_win_chance(lead, potions, 1.0, trainer); +} + + +ConsoleGrindActions::ConsoleGrindActions( + ConsoleHandle& console, ProControllerContext& context, Language language, + Supplies& supplies, StoryChecks checks, HealTrip heal_trip +) + : m_console(console) + , m_context(context) + , m_language(language) + , m_supplies(supplies) + , m_checks(checks) + , m_encounters(console, context, language) + , m_heal_trip(std::move(heal_trip)) +{} + +bool ConsoleGrindActions::find_encounter(Direction pace){ + // As ConsoleCaptureActions::find_encounter(), keeping track of the + // tile: a battle starts on the tile just stepped onto. + ConsolePatchSteps steps(m_console, m_context, pace, m_spot); + return pace_until_battle(m_console.logger(), steps, PACE_MAX_STEPS); +} + +EncounterSeen ConsoleGrindActions::read_encounter(){ + return m_encounters.read_encounter(); +} + +GrindFight ConsoleGrindActions::fight(){ + try{ + const BattleEnd end = fight_battle(m_console, m_context, m_supplies, BattleKind::WILD_FIGHT, m_checks, m_language); + return end == BattleEnd::WON ? GrindFight::OVER : GrindFight::LOST; + }catch (OperationFailedException& e){ + m_console.log("Grind: " + e.message(), COLOR_RED); + return GrindFight::FAILED; + } +} + +bool ConsoleGrindActions::needs_heal(){ + return m_supplies.hurt || m_supplies.status != StatusCondition::NONE || attacks_low_on_pp(m_supplies); +} + +std::optional ConsoleGrindActions::win_chance(const MemberInfo& member, std::string_view trainer){ + m_supplies.lead = lead_as_read(member, m_supplies.lead); + return healed_win_chance(m_supplies.lead, m_supplies.potions, trainer); +} + +bool ConsoleGrindActions::heal_trip(){ + if (!m_heal_trip || !m_heal_trip(m_spot)){ + return false; + } + // Back on the patch's tile, facing as the route left it. + m_spot = {}; + return true; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h new file mode 100644 index 0000000000..408278cfae --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h @@ -0,0 +1,209 @@ +/* AutoStory Grind + * + * From: https://github.com/PokemonAutomation/ + * + * GRIND(who, where, level): fight wild battles in a patch until a party + * member reaches a level, then heal. Or GRIND(who, where, trainer): until + * the battle sim (PokemonFRLG_AutoStoryWinChance.h) says the member beats + * the next boss often enough, the level then a cap: between battles, with + * the level, moves and stats just read, healed, and the bag's Potions. The table stands the player in the + * patch (as for a capture) and names the Pokemon Center's nurse as the + * step's target: between rounds, and at the end, the step walks there, + * heals and walks back to where it started. + * + * Each battle is the story's battle handler as it is (fight_battle(), + * WILD_FIGHT: it fights while the lead is healthy and runs otherwise); + * move-learn prompts, evolutions and switching are its business. So the + * member that grinds must lead the party: the step refuses another slot. + * It heals when the battle handler says the lead is hurt or has a status, + * when its attacks are low on PP, or after `heal_every` battles; the + * nurse restores PP as well. It is bounded by encounters, and a + * shiny stops it as it stops a capture: nothing pressed. + * + * The member is read with a MemberReader before the first battle and + * after each win. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryGrind_H +#define PokemonAutomation_PokemonFRLG_AutoStoryGrind_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Language.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "PokemonFRLG_AutoStoryBattle.h" +#include "PokemonFRLG_AutoStoryBattleData.h" +#include "PokemonFRLG_AutoStoryCapture.h" +#include "PokemonFRLG_AutoStoryMember.h" +#include "PokemonFRLG_AutoStoryParty.h" +#include "PokemonFRLG_AutoStoryWinChance.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +struct GrindPlan{ + // The party slot that grinds: the lead (0) only. + size_t member = 0; + // Without a trainer, the level to reach; with one, the cap: the step + // stops there if the chance is still short. + unsigned level = 2; + // Wild battles, of any species, before the step gives up. + size_t max_encounters = 60; + // The way the player steps to look for a battle, then back. + Pokemon::Direction pace = Pokemon::Direction::EAST; + // Battles between visits to the nurse; 0: only when hurt. + size_t heal_every = 0; + // The next boss step's trainer, e.g. "TRAINER_RIVAL_CERULEAN_BULBASAUR": + // grind until the sim's win chance against it is `min_chance` or + // more. Empty: grind to `level`. + std::string trainer = {}; + double min_chance = MIN_WIN_CHANCE; +}; + +// The prefix of a GAME step's variant that names a GRIND step. +inline constexpr std::string_view GRIND_VARIANT_PREFIX = "grind "; + +// `plan` as a variant: "grind slot=1 level=18 encounters=60 pace=east +// heal_every=8", the slot counted from 1, and with a trainer +// " beat=TRAINER_RIVAL_CERULEAN_BULBASAUR chance=70", the chance in +// percent. The step's target is the nurse. +std::string grind_variant(const GrindPlan& plan); +std::optional parse_grind_variant(std::string_view variant); + +// The table's GRIND step: `nurse` is where the player faces the nurse. +Pokemon::StoryStep grind_step(std::string name, std::string why, const GrindPlan& plan, Pokemon::Pose nurse); + + +enum class GrindFight{ + // The battle ended with the overworld back and the lead standing, + // won or run from. + OVER, + // The lead fainted: a blackout. + LOST, + FAILED, +}; + +// The game steps a GRIND step takes, injected so its decisions run in +// tests. +class GrindActions{ +public: + virtual ~GrindActions() = default; + + // From the overworld, in the patch: steps `pace` and back until a wild + // battle starts. False if none did. + virtual bool find_encounter(Pokemon::Direction pace) = 0; + // From a wild battle's start: whether it is shiny, and its species. + virtual EncounterSeen read_encounter() = 0; + // From the battle menu: the battle handler, to the overworld. + virtual GrindFight fight() = 0; + // Whether the lead is hurt, has a status or is low on PP in its + // attacks, as the battles left it. + virtual bool needs_heal() = 0; + // From the patch: to the nurse, heal, back to the patch. False if it + // didn't get there and back. + virtual bool heal_trip() = 0; + // The sim's chance for `member`, as just read, healed, against + // `trainer`; none if the sim lacks the species or the trainer. + virtual std::optional win_chance(const MemberInfo& member, std::string_view trainer) = 0; +}; + +enum class GrindEnd{ + // The level, or the win chance, reached. + REACHED, + // The level cap reached with the win chance still short. + LEVEL_CAP, + // The sim has no chance for the member against the trainer. + NO_ESTIMATE, + OUT_OF_ENCOUNTERS, + NO_ENCOUNTER, + // A shiny showed: stopped in its battle, nothing pressed. + SHINY, + PLAYER_FAINTED, + // The member's level couldn't be read: no battle fought. + UNREAD, + REFUSED, + FAILED, +}; +const char* grind_end_name(GrindEnd end); + +struct GrindResult{ + GrindEnd end = GrindEnd::FAILED; + size_t encounters = 0; + size_t heals = 0; + std::optional level = std::nullopt; + // The last win chance against the plan's trainer. + std::optional chance = std::nullopt; + std::string detail = {}; +}; + +// From the overworld in the patch: grinds as `plan` says. +GrindResult run_grind(Logger& logger, GrindActions& actions, const MemberReader& reader, const GrindPlan& plan); + +std::string grind_message(const GrindPlan& plan, const GrindResult& result); + +// The story's lead as `read` shows it (species, level, moves, stats), +// each move's PP as `tracked` counts it for a move of that name, else +// full. +PartyMember lead_as_read(const MemberInfo& read, const PartyMember& tracked); + +// The sim's chance for `lead` with `potions` against `trainer`, once the +// nurse has healed it (full HP and PP). +std::optional healed_win_chance(PartyMember lead, int potions, std::string_view trainer); + + +// Where pacing left the player, for the trip to the nurse. +using GrindSpot = PaceSpot; + +// The console's GRIND steps: encounters found by pacing as a capture +// does, the shiny heard as a capture hears it, battles by fight_battle(), +// the trip to the nurse and back by the caller (the story's routes and +// nurse), told where pacing left the player. +class ConsoleGrindActions : public GrindActions{ +public: + using HealTrip = std::function; + + ConsoleGrindActions( + ConsoleHandle& console, ProControllerContext& context, Language language, + Supplies& supplies, StoryChecks checks, HealTrip heal_trip + ); + + virtual bool find_encounter(Pokemon::Direction pace) override; + virtual EncounterSeen read_encounter() override; + virtual GrindFight fight() override; + virtual bool needs_heal() override; + virtual bool heal_trip() override; + // With the bag's Potions. The member read becomes the story's lead, + // so the boss's own check before its battle plays the same one. + virtual std::optional win_chance(const MemberInfo& member, std::string_view trainer) override; + +private: + ConsoleHandle& m_console; + ProControllerContext& m_context; + Language m_language; + Supplies& m_supplies; + StoryChecks m_checks; + ConsoleCaptureActions m_encounters; + HealTrip m_heal_trip; + GrindSpot m_spot; +}; + + +void add_tests_AutoStoryGrind(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp new file mode 100644 index 0000000000..16e871eb9f --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp @@ -0,0 +1,475 @@ +/* AutoStory Grind Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The GRIND step against a fake patch: wild battles that each give EXP, + * a lead that gets hurt after a few, a nurse to walk to, and the bounds + * and stops (a shiny, a blackout, a level that can't be read); and + * grinding until the sim's win chance against the next boss is enough, + * with a level cap. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryBattleSim.h" +#include "PokemonFRLG_AutoStoryGrind.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +constexpr std::string_view RIVAL = "TRAINER_RIVAL_CERULEAN_BULBASAUR"; + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +class FakePatch : public GrindActions{ +public: + unsigned level = 16; + bool level_readable = true; + // Wins for each level gained. + size_t wins_per_level = 2; + size_t wins = 0; + // The lead is hurt after this many battles since the nurse. + size_t hurt_after = 3; + // Encounters the patch has; then none start. + size_t encounters_available = 100; + // The encounter (from 1) that is shiny, or that is lost; 0: none. + size_t shiny_at = 0; + size_t lost_at = 0; + bool heal_fails = false; + // The sim's chance at each level; none: no estimate. + std::function(unsigned level)> chance_at = {}; + + size_t encounters = 0; + size_t fights = 0; + size_t heals = 0; + size_t chances = 0; + std::string error = {}; + + std::optional read_member(size_t slot){ + if (m_in_battle){ + error = "read the level in a battle"; + } + if (!level_readable || slot != 0){ + return std::nullopt; + } + return MemberInfo{.species = "squirtle", .level = level, .moves = {}}; + } + virtual bool find_encounter(Pokemon::Direction) override{ + if (m_in_battle){ + error = "looked for a battle in a battle"; + } + if (encounters >= encounters_available){ + return false; + } + encounters++; + m_in_battle = true; + return true; + } + virtual EncounterSeen read_encounter() override{ + return {.species = "pidgey", .shiny = encounters == shiny_at}; + } + virtual GrindFight fight() override{ + if (!m_in_battle){ + error = "fought off a battle"; + } + m_in_battle = false; + fights++; + m_since_heal++; + if (encounters == lost_at){ + return GrindFight::LOST; + } + wins++; + if (wins % wins_per_level == 0){ + level++; + } + return GrindFight::OVER; + } + virtual bool needs_heal() override{ + return m_since_heal >= hurt_after; + } + virtual bool heal_trip() override{ + if (m_in_battle){ + error = "walked to the nurse in a battle"; + } + heals++; + m_since_heal = 0; + return !heal_fails; + } + virtual std::optional win_chance(const MemberInfo& member, std::string_view trainer) override{ + if (m_in_battle){ + error = "asked the sim in a battle"; + } + if (trainer != RIVAL){ + error = "asked the sim about " + std::string(trainer); + } + chances++; + const std::optional chance = chance_at && member.level ? chance_at(*member.level) : std::nullopt; + if (!chance){ + return std::nullopt; + } + return WinEstimate{.battles = 100, .wins = size_t(*chance * 100), .chance = *chance, .inputs = "a fake lead"}; + } + +private: + bool m_in_battle = false; + size_t m_since_heal = 0; +}; + + +class Test_AutoStoryGrind : public UnitTest{ +public: + Test_AutoStoryGrind(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStoryGrind - " + 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; +}; + + +struct GrindCase{ + std::string name = {}; + GrindPlan plan = {}; + std::function setup = {}; + GrindEnd end = GrindEnd::REACHED; + size_t encounters = 0; + size_t fights = 0; + size_t heals = 0; + // The level it ended at, if it matters. + std::optional level = std::nullopt; +}; + +GrindPlan squirtle_to(unsigned level){ + return {.member = 0, .level = level, .max_encounters = 20, .pace = Pokemon::Direction::EAST, .heal_every = 0}; +} + +// Until the rival is 70%, Lv`cap` at most. +GrindPlan beat_rival(unsigned cap){ + GrindPlan plan = squirtle_to(cap); + plan.trainer = std::string(RIVAL); + return plan; +} + +// 40% at Lv16, 5% more a level, 72% from Lv19. +void winnable_from_19(FakePatch& patch){ + patch.chance_at = [](unsigned level) -> std::optional{ + return level >= 19 ? 0.72 : 0.40 + 0.05 * (level - 16); + }; +} +void never_winnable(FakePatch& patch){ + patch.chance_at = [](unsigned) -> std::optional{ return 0.50; }; +} + +std::vector grind_cases_1(){ + return { + { + .name = "16 to 18: a heal when hurt, and one at the end", + .plan = squirtle_to(18), + .encounters = 4, .fights = 4, .heals = 2, + }, + { + .name = "already there: nothing fought", + .plan = squirtle_to(16), + }, + { + .name = "a heal every two battles", + .plan = [](){ GrindPlan plan = squirtle_to(18); plan.heal_every = 2; return plan; }(), + .encounters = 4, .fights = 4, .heals = 2, + }, + { + .name = "not the lead", + .plan = [](){ GrindPlan plan = squirtle_to(18); plan.member = 1; return plan; }(), + .end = GrindEnd::REFUSED, + }, + { + .name = "a level that can't be read: nothing fought", + .plan = squirtle_to(18), + .setup = [](FakePatch& patch){ patch.level_readable = false; }, + .end = GrindEnd::UNREAD, + }, + }; +} + +std::vector grind_cases_2(){ + return { + { + .name = "out of encounters", + .plan = [](){ GrindPlan plan = squirtle_to(30); plan.max_encounters = 3; return plan; }(), + .end = GrindEnd::OUT_OF_ENCOUNTERS, + .encounters = 3, .fights = 3, .heals = 0, + }, + { + .name = "a shiny stops it before its battle", + .plan = squirtle_to(18), + .setup = [](FakePatch& patch){ patch.shiny_at = 2; }, + .end = GrindEnd::SHINY, + .encounters = 2, .fights = 1, .heals = 0, + }, + { + .name = "a blackout", + .plan = squirtle_to(18), + .setup = [](FakePatch& patch){ patch.lost_at = 2; }, + .end = GrindEnd::PLAYER_FAINTED, + .encounters = 2, .fights = 2, .heals = 0, + }, + { + .name = "the trip to the nurse fails", + .plan = squirtle_to(18), + .setup = [](FakePatch& patch){ patch.heal_fails = true; }, + .end = GrindEnd::FAILED, + .encounters = 3, .fights = 3, .heals = 1, + }, + { + .name = "no battle starts", + .plan = squirtle_to(18), + .setup = [](FakePatch& patch){ patch.encounters_available = 1; }, + .end = GrindEnd::NO_ENCOUNTER, + .encounters = 1, .fights = 1, .heals = 0, + }, + }; +} + +// Grinding until the rival is winnable: the chance is asked before the +// first battle and after each. +std::vector grind_cases_3(){ + return { + { + .name = "until 70% against the rival: the first level that is", + .plan = beat_rival(30), + .setup = winnable_from_19, + .encounters = 6, .fights = 6, .heals = 2, + .level = 19, + }, + { + .name = "winnable already: nothing fought", + .plan = beat_rival(30), + .setup = [](FakePatch& patch){ patch.chance_at = [](unsigned){ return std::optional(0.80); }; }, + .level = 16, + }, + { + .name = "winnable past the level cap: the chance decides", + .plan = beat_rival(16), + .setup = [](FakePatch& patch){ patch.chance_at = [](unsigned){ return std::optional(0.80); }; }, + .level = 16, + }, + { + .name = "the level cap before the chance: stopped, no last heal", + .plan = beat_rival(18), + .setup = never_winnable, + .end = GrindEnd::LEVEL_CAP, + .encounters = 4, .fights = 4, .heals = 1, + .level = 18, + }, + { + .name = "out of encounters before the chance", + .plan = [](){ GrindPlan plan = beat_rival(30); plan.max_encounters = 3; return plan; }(), + .setup = never_winnable, + .end = GrindEnd::OUT_OF_ENCOUNTERS, + .encounters = 3, .fights = 3, .heals = 0, + }, + { + .name = "no chance from the sim: nothing fought", + .plan = beat_rival(30), + .end = GrindEnd::NO_ESTIMATE, + }, + }; +} + +std::vector grind_cases(){ + std::vector cases; + for (std::vector part : {grind_cases_1(), grind_cases_2(), grind_cases_3()}){ + cases.insert(cases.end(), part.begin(), part.end()); + } + return cases; +} + +UnitTestResult check_grind_case(const GrindCase& item){ + QuietLogger logger; + FakePatch patch; + if (item.setup){ + item.setup(patch); + } + const GrindResult result = run_grind(logger, patch, [&patch](size_t slot){ + return patch.read_member(slot); + }, item.plan); + if (!patch.error.empty()){ + return item.name + ": " + patch.error; + } + if (result.end != item.end){ + return std::format("{}: ended {}, expected {} ({}).", item.name, + grind_end_name(result.end), grind_end_name(item.end), result.detail); + } + if (patch.encounters != item.encounters || patch.fights != item.fights || patch.heals != item.heals){ + return std::format("{}: {} encounters, {} fights, {} heals; expected {}, {}, {}.", item.name, + patch.encounters, patch.fights, patch.heals, item.encounters, item.fights, item.heals); + } + if (item.level && result.level != item.level){ + return std::format("{}: ended at level {}, expected {}.", item.name, result.level.value_or(0), *item.level); + } + const size_t chances = item.plan.trainer.empty() ? 0 : patch.fights + 1; + if (patch.chances != chances){ + return std::format("{}: the sim asked {} times, expected {}.", item.name, patch.chances, chances); + } + return true; +} + +UnitTestResult test_grind(){ + for (const GrindCase& item : grind_cases()){ + const UnitTestResult result = check_grind_case(item); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + return true; +} + + +// The cap's stop says what was short: the level, the chance and the +// trainer. +UnitTestResult test_grind_cap_message(){ + QuietLogger logger; + FakePatch patch; + never_winnable(patch); + const GrindPlan plan = beat_rival(18); + const GrindResult result = run_grind(logger, patch, [&patch](size_t slot){ + return patch.read_member(slot); + }, plan); + const std::string message = grind_message(plan, result); + const std::string expected = + "Grind slot 1: level_cap: Lv18 is the cap, short of the win chance: level 18 of 18, win chance 50% of 70%" + " against TRAINER_RIVAL_CERULEAN_BULBASAUR, 4 encounters, 1 trips to the nurse."; + if (message != expected){ + return "Expected \"" + expected + "\", got \"" + message + "\"."; + } + return true; +} + + +// The lead the sim plays is the one read: its species, level, moves and +// stats; the PP the battles left in a move it still knows, and a new +// one's full. +UnitTestResult test_lead_as_read(){ + PartyMember tracked = new_member("wartortle", 22); + tracked.moves = {{.move = "bite", .pp = 10}, {.move = "tail-whip", .pp = 30}, {.move = "bubble", .pp = 5}, {.move = "withdraw", .pp = 40}}; + const Stats stats{.hp = 68, .attack = 40, .defense = 50, .sp_attack = 41, .sp_defense = 49, .speed = 38}; + const MemberInfo read{.species = "wartortle", .level = 23, .moves = {"bite", "water-pulse", "bubble", "withdraw"}, .stats = stats}; + const PartyMember lead = lead_as_read(read, tracked); + std::string moves; + for (const KnownMove& move : lead.moves){ + moves += std::format("{} {}, ", move.move, move.pp); + } + if (moves != "bite 10, water-pulse 20, bubble 5, withdraw 40, "){ + return "Unexpected moves: " + moves; + } + if (lead.species != "wartortle" || lead.level != 23 || battle_level(lead) != 23 || !lead.moves_read + || !lead.stats || lead.stats->hp != 68 || lead.stats->speed != 38 + ){ + return "Expected the species, level and stats read."; + } + return true; +} + + +// Console B at the end of 00.15 (2026-11-03): Wartortle Lv22, 65 HP, +// with Water Pulse taught over Tail Whip, and 10 Potions. The sim, the +// levels since the summary added to its stats, wins against the +// Cerulean rival about 42% at Lv22, 62% at Lv23, 66% at Lv24 and 76% at +// Lv25: a GRIND to 70% stops at Lv25. +UnitTestResult test_console_party(){ + Stats stats = combatant("wartortle", 22, OWN_GENES)->stats; + stats.hp = 65; + const MemberInfo read{.species = "wartortle", .level = 22, .moves = {"bite", "water-pulse", "bubble", "withdraw"}, .stats = stats}; + const PartyMember at22 = lead_as_read(read, PartyMember{}); + for (uint8_t level = 22; level <= 25; level++){ + PartyMember lead = at22; + lead.moves_through_level = level; + const std::optional estimate = healed_win_chance(lead, 10, RIVAL); + if (!estimate || (estimate->chance >= MIN_WIN_CHANCE) != (level == 25)){ + return std::format( + "Lv{}: {:.1f}%; expected the first at {:.0f}% or more at Lv25.", + level, estimate ? 100 * estimate->chance : 0.0, 100 * MIN_WIN_CHANCE + ); + } + } + return true; +} + + +UnitTestResult test_grind_variant(){ + GrindPlan plan = squirtle_to(18); + plan.max_encounters = 60; + plan.pace = Pokemon::Direction::NORTH; + plan.heal_every = 8; + const std::string variant = grind_variant(plan); + if (variant != "grind slot=1 level=18 encounters=60 pace=north heal_every=8"){ + return "Unexpected variant \"" + variant + "\"."; + } + const std::optional back = parse_grind_variant(variant); + if (!back || back->level != 18 || back->max_encounters != 60 || back->pace != Pokemon::Direction::NORTH + || back->heal_every != 8 || back->member != 0 + ){ + return "\"" + variant + "\" didn't read back as its plan."; + } + GrindPlan rival = beat_rival(30); + rival.max_encounters = 100; + rival.pace = Pokemon::Direction::WEST; + const std::string rival_variant = grind_variant(rival); + if (rival_variant != "grind slot=1 level=30 encounters=100 pace=west heal_every=0 beat=TRAINER_RIVAL_CERULEAN_BULBASAUR chance=70"){ + return "Unexpected variant \"" + rival_variant + "\"."; + } + const std::optional rival_back = parse_grind_variant(rival_variant + " chance=75"); + if (!rival_back || rival_back->trainer != RIVAL || rival_back->level != 30 || rival_back->min_chance != 0.75){ + return "\"" + rival_variant + " chance=75\" didn't read back as its plan."; + } + for (std::string_view bad : { + "grind", "grind slot=1", "grind slot=1 level=101", "grind slot=7 level=18", "grind level=18 pace=up", + "grind level=18 encounters=0", "capture rattata", "grind level=30 beat=", "grind level=30 chance=70", + "grind level=30 beat=TRAINER_X chance=0", "grind level=30 beat=TRAINER_X chance=101", + }){ + if (parse_grind_variant(bad)){ + return "Expected \"" + std::string(bad) + "\" refused."; + } + } + return true; +} + +} + + +void add_tests_AutoStoryGrind(UnitTestDatabase& database){ + const auto add = [&](std::string name, UnitTestResult (*body)()){ + database.add(std::move(name), body); + }; + add("grinding on a fake patch", test_grind); + add("the table's variant", test_grind_variant); + add("the level cap's message", test_grind_cap_message); + add("the lead the sim plays is the one read", test_lead_as_read); + add("console B's party stops at Lv25 against the Cerulean rival", test_console_party); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp index 107e9fc670..0aea6c90a0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp @@ -2,14 +2,17 @@ * * From: https://github.com/PokemonAutomation/ * - * The Mart cursor's moves, and the bag's rows as the story counts them. + * The Mart cursor's moves, the bag's rows as the story counts them, and + * where the story's wild battle seen late places a stopped leg. * */ #include #include #include +#include #include +#include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/Logging/AbstractLogger.h" #include "Common/Cpp/TestRunners/UnitTest.h" @@ -17,6 +20,8 @@ #include "CommonFramework/Language.h" #include "Tests/ImageUnitTest.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryBattle.h" #include "PokemonFRLG_AutoStoryBattleDecisions.h" @@ -215,6 +220,69 @@ UnitTestResult test_unread_language(Logger& logger){ return true; } +// The open leg through Route 1's grass on the way from Viridian Mart to +// Oak's Lab ("south 20"), and the tile it starts from. +struct GrassLeg{ + MapPosition start; + RouteStep step; +}; +std::optional route1_grass_leg(const std::vector& maps){ + const MapPosition mart{"ViridianCity_Mart", {4, 3}}; + const RouteGoal lab{MapPosition{"PalletTown_ProfessorOaksLab", {6, 11}}, std::nullopt}; + const std::optional> route = plan_overworld_route(maps, mart, lab, default_route_costs(), Direction::WEST); + if (!route){ + return std::nullopt; + } + MapPosition start = mart; + for (const RouteStep& step : *route){ + if (step.action == RouteAction::WALK && step.wild && !step.to_wall && !step.warps && step.tiles >= 16){ + return GrassLeg{start, step}; + } + start = step.end; + } + return std::nullopt; +} + +// How many tiles of `leg` the player walked to `at`, or -1 if `at` is off it. +int tiles_walked(const std::vector& maps, const GrassLeg& leg, const MapPosition& at){ + const std::vector tiles = leg_tiles(maps, leg.start, leg.step); + for (size_t c = 0; c < tiles.size(); c++){ + if (tiles[c] == at){ + return (int)c; + } + } + return -1; +} + +// r16 and r17 (console B, 2026-11-01), Route 1: a wild battle on the first +// grass step of "south 20" whose flashes were missed shows "Wild +// appeared!" 3.7 s after the step's press. The story's interrupt for it +// must place the stop where the battle stopped the player, as the walker's +// does: with 2 s it put them about 6 tiles further on. +UnitTestResult test_late_battle_placed(Logger&){ + const std::vector maps = overworld_maps(); + const std::optional leg = route1_grass_leg(maps); + if (!leg){ + return "Expected an open leg of 16 tiles or more through Route 1's grass from Viridian Mart to Oak's Lab."; + } + SimScript script; + script.encounters = {{.grass_step = 1, .seen_late = true}}; + OverworldSim sim(maps, leg->start, leg->step.direction, script); + Supplies supplies; + const OverworldInterruptSet story = story_interrupts(supplies, false, StoryChecks::FAST, Language::English); + OverworldSimWalker walker(sim, {}, story); + const StepOutcome outcome = walker.calls().walk(leg->step, leg->step.direction); + const int walked = tiles_walked(maps, *leg, sim.state().position); + if (outcome.end != StepEnd::STOPPED || !outcome.walked || walked < outcome.walked->min || outcome.walked->max < walked){ + return std::format( + "Expected \"{}\" from {} stopped and placed on tile {} ({}), where the battle stopped the player; placed on tiles {} to {}.", + to_string(leg->step), to_string(leg->start), walked, to_string(sim.state().position), + outcome.walked ? outcome.walked->min : -1, outcome.walked ? outcome.walked->max : -1 + ); + } + return true; +} + // battle_shows(), the battle loop's look at whether the battle is still on // screen, on console B's captures. @@ -280,6 +348,7 @@ void add_tests_AutoStoryItems(UnitTestDatabase& database){ database.add("an unread item name keeps its row", test_pocket_rows_unread); database.add("unread bag counts count as some", test_unread_counts); database.add("no item names read in a language without them", test_unread_language); + database.add("a wild battle seen late is placed where it stopped the leg", test_late_battle_placed); // "Wild CHANSEY appeared!" with its HP box, and Mt. Moon B2F's corridor // after "Got away safely!", whose black lower half reads as the battle's // text box (console B, 2026-11-03: 9 minutes of A and B there). diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.cpp new file mode 100644 index 0000000000..39cab8c78f --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.cpp @@ -0,0 +1,432 @@ +/* AutoStory Mart + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryMart.h" +#include "PokemonFRLG_AutoStoryVariantWords.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace std::chrono_literals; + + +namespace{ + +// The quantity box's most (shop.c Task_BuyHowManyDialogueInit). +constexpr size_t MAX_QUANTITY = 99; + +// Prices from pokefirered src/data/items.json. +constexpr MartStockItem POKE_BALL{"poke-ball", 200}; +constexpr MartStockItem GREAT_BALL{"great-ball", 600}; +constexpr MartStockItem ULTRA_BALL{"ultra-ball", 1200}; +constexpr MartStockItem POTION{"potion", 300}; +constexpr MartStockItem SUPER_POTION{"super-potion", 700}; +constexpr MartStockItem HYPER_POTION{"hyper-potion", 1200}; +constexpr MartStockItem MAX_POTION{"max-potion", 2500}; +constexpr MartStockItem FULL_RESTORE{"full-restore", 3000}; +constexpr MartStockItem REVIVE{"revive", 1500}; +constexpr MartStockItem ANTIDOTE{"antidote", 100}; +constexpr MartStockItem PARLYZ_HEAL{"parlyz-heal", 200}; +constexpr MartStockItem AWAKENING{"awakening", 250}; +constexpr MartStockItem BURN_HEAL{"burn-heal", 250}; +constexpr MartStockItem ICE_HEAL{"ice-heal", 250}; +constexpr MartStockItem FULL_HEAL{"full-heal", 600}; +constexpr MartStockItem ESCAPE_ROPE{"escape-rope", 550}; +constexpr MartStockItem REPEL{"repel", 350}; +constexpr MartStockItem SUPER_REPEL{"super-repel", 500}; +constexpr MartStockItem MAX_REPEL{"max-repel", 700}; + +// Each Mart's `_Items` list in pokefirered data/maps//scripts.inc. +const std::map, std::less<>>& mart_stocks(){ + static const std::map, std::less<>> stocks = { + {"ViridianCity_Mart", {POKE_BALL, POTION, ANTIDOTE, PARLYZ_HEAL}}, + {"PewterCity_Mart", {POKE_BALL, POTION, ANTIDOTE, PARLYZ_HEAL, AWAKENING, BURN_HEAL, ESCAPE_ROPE, REPEL}}, + {"CeruleanCity_Mart", { + POKE_BALL, SUPER_POTION, POTION, ANTIDOTE, PARLYZ_HEAL, AWAKENING, BURN_HEAL, ESCAPE_ROPE, REPEL, + }}, + {"VermilionCity_Mart", {POKE_BALL, SUPER_POTION, ANTIDOTE, PARLYZ_HEAL, AWAKENING, ICE_HEAL, REPEL}}, + {"LavenderTown_Mart", { + GREAT_BALL, SUPER_POTION, REVIVE, ANTIDOTE, PARLYZ_HEAL, BURN_HEAL, ICE_HEAL, ESCAPE_ROPE, SUPER_REPEL, + }}, + {"FuchsiaCity_Mart", {ULTRA_BALL, GREAT_BALL, SUPER_POTION, REVIVE, FULL_HEAL, MAX_REPEL}}, + {"SaffronCity_Mart", {GREAT_BALL, HYPER_POTION, REVIVE, FULL_HEAL, ESCAPE_ROPE, MAX_REPEL}}, + {"CinnabarIsland_Mart", {ULTRA_BALL, GREAT_BALL, HYPER_POTION, REVIVE, FULL_HEAL, ESCAPE_ROPE, MAX_REPEL}}, + {"IndigoPlateau_PokemonCenter_1F", { + ULTRA_BALL, GREAT_BALL, FULL_RESTORE, MAX_POTION, REVIVE, FULL_HEAL, MAX_REPEL, + }}, + }; + return stocks; +} + +std::optional stock_row(const std::vector& stock, std::string_view item){ + for (size_t row = 0; row < stock.size(); row++){ + if (stock[row].item == item){ + return row; + } + } + return std::nullopt; +} + +// Sets one "key=value" of a Mart variant into `plan`. False if it can't. +bool set_mart_word(MartPlan& plan, std::string_view word){ + const std::optional pair = variant_pair(word); + const std::optional number = pair ? variant_number(pair->value) : std::nullopt; + if (!number){ + return false; + } + if (pair->key == "budget"){ + plan.budget = *number; + }else if (pair->key == "floor"){ + plan.money_floor = *number; + }else if (*number >= 1 && *number <= MAX_QUANTITY && stock_row(mart_stock(plan.map), pair->key)){ + plan.lines.push_back({std::string(pair->key), *number}); + }else{ + return false; + } + return true; +} + +} + + +const std::vector& mart_stock(std::string_view map){ + static const std::vector NONE; + const auto found = mart_stocks().find(map); + return found == mart_stocks().end() ? NONE : found->second; +} + +const char* mart_end_name(MartEnd end){ + switch (end){ + case MartEnd::DONE: return "done"; + case MartEnd::NOTHING_TO_BUY: return "nothing_to_buy"; + case MartEnd::NO_MONEY_READ: return "no_money_read"; + case MartEnd::FAILED: return "failed"; + } + return "?"; +} + + +std::string mart_variant(const MartPlan& plan){ + std::string variant = std::format("{}{} budget={}", MART_VARIANT_PREFIX, plan.map, plan.budget); + if (plan.money_floor){ + variant += std::format(" floor={}", *plan.money_floor); + } + for (const MartLine& line : plan.lines){ + variant += std::format(" {}={}", line.item, line.quantity); + } + return variant; +} + +std::optional parse_mart_variant(std::string_view variant){ + if (!variant.starts_with(MART_VARIANT_PREFIX)){ + return std::nullopt; + } + const std::vector words = variant_words(variant.substr(MART_VARIANT_PREFIX.size())); + if (words.empty() || mart_stock(words[0]).empty()){ + return std::nullopt; + } + MartPlan plan; + plan.map = std::string(words[0]); + for (size_t c = 1; c < words.size(); c++){ + if (!set_mart_word(plan, words[c])){ + return std::nullopt; + } + } + if (plan.lines.empty()){ + return std::nullopt; + } + return plan; +} + +Pokemon::StoryStep mart_step(std::string name, std::string why, const MartPlan& plan){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = std::move(name); + step.why = std::move(why); + step.variant = mart_variant(plan); + return step; +} + + +std::optional> plan_mart_buys( + const std::vector& stock, const MartPlan& plan, size_t money +){ + std::vector buys; + size_t left = std::min(plan.budget, money); + for (const MartLine& line : plan.lines){ + const std::optional row = stock_row(stock, line.item); + if (!row){ + return std::nullopt; + } + const size_t price = stock[*row].price; + const size_t quantity = std::min({line.quantity, left / price, MAX_QUANTITY}); + if (quantity == 0){ + continue; + } + buys.push_back({.row = *row, .item = line.item, .quantity = quantity, .cost = quantity * price}); + left -= quantity * price; + } + return buys; +} + +QuantityPresses quantity_presses(size_t quantity){ + if (quantity <= 1){ + return {}; + } + // RIGHT from 1 reaches 11, 21, ...: never past `quantity`, so never + // stopped at the most. + const size_t right = (quantity - 1) / 10; + return {.right = right, .up = quantity - 1 - right * 10}; +} + + +namespace{ + +// One Mart step under way. +class MartRun{ +public: + MartRun(Logger& logger, MartActions& actions, const MartPlan& plan) + : m_logger(logger) + , m_actions(actions) + , m_plan(plan) + {} + + MartResult run(){ + if (!m_actions.open_buy_list()){ + return end(MartEnd::FAILED, "the BUY list didn't show"); + } + const std::optional read = m_actions.read_money(); + const std::optional money = read ? read : m_plan.money_floor; + if (!money){ + return leave(MartEnd::NO_MONEY_READ, ""); + } + m_logger.log(std::format("Mart: ${} ({}), budget ${}.", *money, read ? "read" : "the table's floor", m_plan.budget)); + const std::optional> buys = plan_mart_buys(mart_stock(m_plan.map), m_plan, *money); + if (!buys){ + return leave(MartEnd::FAILED, "an item the Mart doesn't sell"); + } + if (buys->empty()){ + return leave(MartEnd::NOTHING_TO_BUY, ""); + } + for (const MartBuy& buy : *buys){ + if (!buy_one(buy)){ + return leave(MartEnd::FAILED, m_result.detail); + } + } + if (!money_checks(read)){ + return leave(MartEnd::FAILED, m_result.detail); + } + return leave(MartEnd::DONE, ""); + } + +private: + MartResult end(MartEnd end, std::string detail){ + m_result.end = end; + m_result.detail = std::move(detail); + return m_result; + } + // Out to the overworld, then ends as `end`, unless that fails. + MartResult leave(MartEnd end, std::string detail){ + if (!m_actions.leave()){ + return this->end(MartEnd::FAILED, "the overworld didn't come back after the Mart"); + } + return this->end(end, std::move(detail)); + } + + bool buy_one(const MartBuy& buy){ + m_actions.move_cursor(m_cursor, buy.row); + m_cursor = buy.row; + const std::string item = m_actions.read_row(buy.row); + if (!item.empty() && item != buy.item){ + m_result.detail = std::format("row {} is {}, not {}", buy.row + 1, item, buy.item); + return false; + } + if (item.empty()){ + m_logger.log(std::format("Mart: couldn't read row {}; buying it by position.", buy.row + 1), COLOR_ORANGE); + } + switch (m_actions.buy(buy)){ + case PurchaseEnd::BOUGHT: + m_logger.log(std::format("Mart: bought {} {} for ${}.", buy.quantity, buy.item, buy.cost)); + m_result.bought.push_back(buy); + m_result.spent += buy.cost; + return true; + case PurchaseEnd::NOT_ENOUGH_MONEY: + m_result.detail = std::format("not enough money for {} {}", buy.quantity, buy.item); + return false; + case PurchaseEnd::WRONG_QUANTITY: + m_result.detail = std::format("the quantity box didn't show {} for {}", buy.quantity, buy.item); + return false; + case PurchaseEnd::FAILED: + break; + } + m_result.detail = std::format("buying {} {} didn't come back to the list", buy.quantity, buy.item); + return false; + } + + // With the money read before, it is read again: the difference must + // be what was spent. + bool money_checks(const std::optional& before){ + if (!before){ + return true; + } + const std::optional after = m_actions.read_money(); + if (!after || *before - *after == m_result.spent){ + return true; + } + m_result.detail = std::format("${} before and ${} after, but ${} bought", *before, *after, m_result.spent); + return false; + } + + Logger& m_logger; + MartActions& m_actions; + const MartPlan& m_plan; + size_t m_cursor = 0; + MartResult m_result; +}; + +} + +MartResult run_mart(Logger& logger, MartActions& actions, const MartPlan& plan){ + return MartRun(logger, actions, plan).run(); +} + +std::string mart_message(const MartPlan& plan, const MartResult& result){ + std::string bought; + for (const MartBuy& buy : result.bought){ + bought += std::format("{}{} {}", bought.empty() ? "" : ", ", buy.quantity, buy.item); + } + if (bought.empty()){ + bought = "nothing"; + } + switch (result.end){ + case MartEnd::DONE: + return std::format("Mart ({}): bought {} for ${}.", plan.map, bought, result.spent); + case MartEnd::NOTHING_TO_BUY: + return std::format("Mart ({}): the money or budget covers none of the list; bought nothing.", plan.map); + case MartEnd::NO_MONEY_READ: + return std::format( + "Mart ({}): the money box can't be read and the table gives no floor; bought nothing.", plan.map + ); + case MartEnd::FAILED: + return std::format("Mart ({}): {}; bought {} for ${}.", plan.map, result.detail, bought, result.spent); + } + return "Mart: ?"; +} + + +ConsoleMartActions::ConsoleMartActions( + ConsoleHandle& console, ProControllerContext& context, Language language, Supplies& supplies +) + : m_console(console) + , m_context(context) + , m_language(language) + , m_supplies(supplies) +{} + +bool ConsoleMartActions::open_buy_list(){ + // "May I help you?" with BUY on top, then the list with the cursor on + // its top row, as buy_items() found them. + pbf_press_button(m_context, BUTTON_A, 160ms, 2000ms); + pbf_press_button(m_context, BUTTON_A, 160ms, 2000ms); + m_context.wait_for_all_requests(); + m_row = 0; + m_lowest_row = 0; + return true; +} + +std::optional ConsoleMartActions::read_money(){ + // No reader for the money box yet. + return std::nullopt; +} + +void ConsoleMartActions::move_cursor(size_t from, size_t to){ + for (size_t c = from; c < to; c++){ + pbf_press_dpad(m_context, DPAD_DOWN, 160ms, 300ms); + } + for (size_t c = to; c < from; c++){ + pbf_press_dpad(m_context, DPAD_UP, 160ms, 300ms); + } + m_context.wait_for_all_requests(); + m_row = to; + m_lowest_row = std::max(m_lowest_row, to); +} + +std::string ConsoleMartActions::read_row(size_t row){ + // Rows past the window's last but one may have scrolled the list. + if (m_lowest_row + 1 >= (size_t)ITEM_LIST_ROWS){ + return ""; + } + VideoSnapshot screen = m_console.video().snapshot(); + if (!screen){ + return ""; + } + return read_item_list_name(m_console.logger(), m_language, screen, (int)row); +} + +PurchaseEnd ConsoleMartActions::buy(const MartBuy& buy){ + // "How many?" drops presses while its text prints. + pbf_press_button(m_context, BUTTON_A, 160ms, 2500ms); + const QuantityPresses presses = quantity_presses(buy.quantity); + for (size_t c = 0; c < presses.right; c++){ + pbf_press_dpad(m_context, DPAD_RIGHT, 160ms, 340ms); + } + for (size_t c = 0; c < presses.up; c++){ + pbf_press_dpad(m_context, DPAD_UP, 160ms, 340ms); + } + // "That will be..." YES/NO, YES, "Here you are!"; back to the list. + pbf_press_button(m_context, BUTTON_A, 160ms, 3500ms); + pbf_press_button(m_context, BUTTON_A, 160ms, 2000ms); + pbf_press_button(m_context, BUTTON_A, 160ms, 1500ms); + m_context.wait_for_all_requests(); + count_supplies(m_supplies, buy.item, buy.quantity); + return PurchaseEnd::BOUGHT; +} + +bool ConsoleMartActions::leave(){ + pbf_press_button(m_context, BUTTON_B, 160ms, 1500ms); + pbf_press_button(m_context, BUTTON_B, 160ms, 1500ms); + try{ + mash_until_overworld(m_console, m_context, 20000ms); + return true; + }catch (OperationFailedException& e){ + m_console.log("Mart: " + e.message(), COLOR_RED); + return false; + } +} + + +void count_supplies(Supplies& supplies, std::string_view item, size_t quantity){ + int Supplies::* count = nullptr; + if (item == "potion"){ + count = &Supplies::potions; + }else if (item == "antidote"){ + count = &Supplies::antidotes; + }else if (item == "parlyz-heal"){ + count = &Supplies::parlyz_heals; + } + if (count == nullptr){ + return; + } + if (std::find(supplies.pocket.begin(), supplies.pocket.end(), item) == supplies.pocket.end()){ + supplies.pocket.emplace_back(item); + } + supplies.*count += (int)quantity; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.h new file mode 100644 index 0000000000..28de24cd5b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.h @@ -0,0 +1,225 @@ +/* AutoStory Mart + * + * From: https://github.com/PokemonAutomation/ + * + * A story step at any Poke Mart: a buy list in priority order, within a + * budget, as pokefirered's shop.c runs the BUY menu: + * + * - Talking to the clerk: BUY, SELL, SEE YA!, on BUY. A on BUY opens the + * Mart's list (its stock in the map's scripts.inc order, then CANCEL), + * the cursor on the top row, and the money box. + * - A on an item the player can't pay one of: "You don't have enough + * money." and back to the list. Otherwise "How many?" with the + * quantity box on 1. Its most is the money over the price, at most 99. + * UP adds one and wraps from the most to 1; DOWN takes one and wraps + * from 1 to the most; RIGHT adds ten and LEFT takes ten, both stopping + * at the ends (menu_helpers.c AdjustQuantityAccordingToDPadInput). + * - A on the quantity: "... And you wanted N? That will be $X." YES/NO on + * YES; YES: "Here you are! Thank you!", the money taken, back to the + * list with the cursor on the row bought. + * + * The step never opens SELL. Each line buys as many as its quantity, + * the budget left and the money left allow, in the order listed; a line + * that can't buy one is skipped. The money is read from the money box + * when a reader exists; without one the step counts on the table's + * floor (the least the ledger says the player has) and, with neither, + * buys nothing: a quantity typed past the most wraps, and buys the wrong + * number. + * + * The decisions take their game steps from a MartActions, so they run in + * tests against a fake Mart; ConsoleMartActions does them on the console + * with the item list reader, whose names cover the items a new game meets + * before the Boulder Badge (a row it can't read is bought by position). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryMart_H +#define PokemonAutomation_PokemonFRLG_AutoStoryMart_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Language.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "PokemonFRLG_AutoStoryBattle.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// One item a Mart sells: its slug (as read_item_list_name() names it, +// e.g. "parlyz-heal") and its price (pokefirered items.json). +struct MartStockItem{ + std::string_view item; + size_t price; +}; + +// The BUY list of the Mart on map `map` (e.g. "PewterCity_Mart"), in row +// order, CANCEL not included; empty for a map whose stock isn't known. +const std::vector& mart_stock(std::string_view map); + +// One line of a buy list: an item and how many. +struct MartLine{ + std::string item = {}; + size_t quantity = 1; +}; + +// What a Mart step buys. +struct MartPlan{ + // The Mart's map, for its stock. + std::string map = {}; + // In priority order. + std::vector lines = {}; + // The most the step spends. + size_t budget = 0; + // The least money the player has here, from the money plan: used + // when the money box can't be read. + std::optional money_floor = std::nullopt; +}; + +// The prefix of a GAME step's variant that names a Mart step. +inline constexpr std::string_view MART_VARIANT_PREFIX = "mart "; + +// `plan` as a variant: "mart PewterCity_Mart budget=2000 floor=2400 +// potion=5 repel=3", the lines in order. +std::string mart_variant(const MartPlan& plan); +// A variant from mart_variant(); nothing if it isn't one, has no line, or +// names an item its Mart doesn't sell. +std::optional parse_mart_variant(std::string_view variant); + +// The table's Mart step. +Pokemon::StoryStep mart_step(std::string name, std::string why, const MartPlan& plan); + + +// One purchase the step makes. +struct MartBuy{ + // The list's row, from 0. + size_t row = 0; + std::string item = {}; + size_t quantity = 0; + size_t cost = 0; +}; + +// What `plan` buys at a Mart with `stock` and `money`: each line as many +// as fit in the budget and the money left, in order, at most 99; lines +// that buy none are left out. Nothing if a line's item isn't stocked. +std::optional> plan_mart_buys( + const std::vector& stock, const MartPlan& plan, size_t money +); + +// The presses that turn the quantity box from 1 into `quantity` without +// passing its most: RIGHT adds ten, then UP one at a time. +struct QuantityPresses{ + size_t right = 0; + size_t up = 0; +}; +QuantityPresses quantity_presses(size_t quantity); + + +enum class PurchaseEnd{ + // "Here you are!": bought, back at the list. + BOUGHT, + // "You don't have enough money.": back at the list. + NOT_ENOUGH_MONEY, + // The quantity box showed another number: backed out with B. + WRONG_QUANTITY, + FAILED, +}; + +// The game steps a Mart step takes, injected so its decisions run in +// tests. +class MartActions{ +public: + virtual ~MartActions() = default; + + // Facing the clerk: talk, BUY, to the list with the cursor on its top + // row. False if the list didn't show. + virtual bool open_buy_list() = 0; + // On the list: the money box; nothing if it can't be read. + virtual std::optional read_money() = 0; + // On the list: the cursor from row `from` to row `to`. + virtual void move_cursor(size_t from, size_t to) = 0; + // On the list: row `row`'s item slug; empty if it can't be read. + virtual std::string read_row(size_t row) = 0; + // On the list, the cursor on `buy`'s row: A, its quantity typed as + // quantity_presses() says, A, YES. The quantity box is read before + // the A, if it can be: another number backs out. + virtual PurchaseEnd buy(const MartBuy& buy) = 0; + // From the list: CANCEL, SEE YA!, to the overworld. False if it didn't + // come back. + virtual bool leave() = 0; +}; + +enum class MartEnd{ + DONE, + // The plan bought nothing: the money (or its floor) doesn't cover one + // of anything. The Mart isn't touched. + NOTHING_TO_BUY, + // Neither the money box nor the table's floor gave the money. + NO_MONEY_READ, + FAILED, +}; +const char* mart_end_name(MartEnd end); + +struct MartResult{ + MartEnd end = MartEnd::FAILED; + std::vector bought = {}; + size_t spent = 0; + // FAILED: what failed. + std::string detail = {}; +}; + +// Facing the clerk: buys as `plan` says, then back to the overworld. +MartResult run_mart(Logger& logger, MartActions& actions, const MartPlan& plan); + +std::string mart_message(const MartPlan& plan, const MartResult& result); + + +// Counts `quantity` of `item` into `supplies`, if it is one the battles +// use (Potions, Antidotes, Parlyz Heals); a new one goes to the end of the +// pocket, where the bag adds it. +void count_supplies(Supplies& supplies, std::string_view item, size_t quantity); + + +// The console's Mart. The money box and the quantity box have no reader +// yet, so the money is the table's floor and the quantity is typed by +// presses. Potions, Antidotes and Parlyz Heals bought are counted into +// `supplies`. +class ConsoleMartActions : public MartActions{ +public: + ConsoleMartActions(ConsoleHandle& console, ProControllerContext& context, Language language, Supplies& supplies); + + virtual bool open_buy_list() override; + virtual std::optional read_money() override; + virtual void move_cursor(size_t from, size_t to) override; + virtual std::string read_row(size_t row) override; + virtual PurchaseEnd buy(const MartBuy& buy) override; + virtual bool leave() override; + +private: + ConsoleHandle& m_console; + ProControllerContext& m_context; + Language m_language; + Supplies& m_supplies; + size_t m_row = 0; + // The lowest row the cursor has reached: past the window's rows the + // list scrolls, and a screen row is no longer the list's. + size_t m_lowest_row = 0; +}; + + +void add_tests_AutoStoryMart(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart_Tests.cpp new file mode 100644 index 0000000000..78bcf52cb5 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart_Tests.cpp @@ -0,0 +1,401 @@ +/* AutoStory Mart Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The Mart step against a fake Mart that runs the BUY menu as + * pokefirered's shop.c does (see PokemonFRLG_AutoStoryMart.h): the list + * keeps its cursor on the row bought, the quantity box's most is the + * money over the price and UP wraps from it to 1, and an item the player + * can't pay one of is refused. + * + */ + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryMart.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +class FakeMart : public MartActions{ +public: + FakeMart(std::string_view map, size_t money) + : stock(mart_stock(map)) + , money(money) + {} + + std::vector stock; + size_t money; + bool money_readable = true; + bool rows_readable = true; + bool quantity_readable = true; + // Rows that read as another item. + std::map misread = {}; + // Each purchase takes this much more than its price: a bug to catch. + size_t overcharge = 0; + std::map bag = {}; + size_t list_opened = 0; + bool in_list = false; + std::string error = {}; + + virtual bool open_buy_list() override{ + in_list = true; + m_cursor = 0; + list_opened++; + return true; + } + virtual std::optional read_money() override{ + if (!in_list){ + error = "read the money off the list"; + } + return money_readable ? std::optional(money) : std::nullopt; + } + virtual void move_cursor(size_t from, size_t to) override{ + if (from != m_cursor){ + error = std::format("moved from row {}, but the cursor was on row {}", from, m_cursor); + } + // The list is the stock, then CANCEL. + m_cursor = std::min(to, stock.size()); + } + virtual std::string read_row(size_t row) override{ + if (row != m_cursor){ + error = std::format("read row {}, the cursor on row {}", row, m_cursor); + } + const auto found = misread.find(row); + if (found != misread.end()){ + return found->second; + } + return rows_readable && row < stock.size() ? std::string(stock[row].item) : ""; + } + virtual PurchaseEnd buy(const MartBuy& buy) override{ + if (m_cursor >= stock.size()){ + error = "A on CANCEL"; + in_list = false; + return PurchaseEnd::FAILED; + } + const size_t price = stock[m_cursor].price; + if (price > money){ + return PurchaseEnd::NOT_ENOUGH_MONEY; + } + const size_t quantity = typed_quantity(buy.quantity, std::min(99, money / price)); + if (quantity_readable && quantity != buy.quantity){ + return PurchaseEnd::WRONG_QUANTITY; + } + money -= quantity * price + overcharge; + bag[std::string(stock[m_cursor].item)] += quantity; + return PurchaseEnd::BOUGHT; + } + virtual bool leave() override{ + in_list = false; + return true; + } + +private: + // The box after quantity_presses(wanted), from 1, with this most. + static size_t typed_quantity(size_t wanted, size_t most){ + const QuantityPresses presses = quantity_presses(wanted); + size_t quantity = 1; + for (size_t c = 0; c < presses.right; c++){ + quantity = std::min(quantity + 10, most); + } + for (size_t c = 0; c < presses.up; c++){ + quantity = quantity + 1 > most ? 1 : quantity + 1; + } + return quantity; + } + + size_t m_cursor = 0; +}; + + +class Test_AutoStoryMart : public UnitTest{ +public: + Test_AutoStoryMart(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStoryMart - " + 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; +}; + + +MartPlan pewter_plan(size_t budget, std::vector lines, std::optional floor = std::nullopt){ + return {.map = "PewterCity_Mart", .lines = std::move(lines), .budget = budget, .money_floor = floor}; +} + +std::string bag_string(const std::map& bag){ + std::string text; + for (const auto& item : bag){ + text += std::format("{}{} {}", text.empty() ? "" : ", ", item.second, item.first); + } + return text.empty() ? "nothing" : text; +} + +struct MartCase{ + std::string name = {}; + MartPlan plan = {}; + size_t money = 0; + std::function setup = {}; + MartEnd end = MartEnd::DONE; + std::map bag = {}; + size_t money_after = 0; +}; + +std::vector mart_cases_1(){ + return { + { + .name = "priority order within the budget, a line cut to what fits", + .plan = pewter_plan(2000, {{"potion", 5}, {"repel", 3}, {"antidote", 3}}), + .money = 3000, + .bag = {{"potion", 5}, {"repel", 1}, {"antidote", 1}}, + .money_after = 1050, + }, + { + .name = "the money, below the budget, limits it", + .plan = pewter_plan(2000, {{"potion", 5}}), + .money = 700, + .bag = {{"potion", 2}}, + .money_after = 100, + }, + { + .name = "a line that can't buy one is skipped", + .plan = pewter_plan(400, {{"escape-rope", 1}, {"antidote", 2}}), + .money = 3000, + .bag = {{"antidote", 2}}, + .money_after = 2800, + }, + { + .name = "twenty-five by RIGHT and UP", + .plan = pewter_plan(10000, {{"poke-ball", 25}}), + .money = 6000, + .bag = {{"poke-ball", 25}}, + .money_after = 1000, + }, + }; +} + +std::vector mart_cases_2(){ + return { + { + .name = "unread money: the table's floor", + .plan = pewter_plan(2000, {{"potion", 5}}, 1000), + .money = 1200, + .setup = [](FakeMart& mart){ mart.money_readable = false; }, + .bag = {{"potion", 3}}, + .money_after = 300, + }, + { + .name = "unread money and no floor: nothing bought", + .plan = pewter_plan(2000, {{"potion", 5}}), + .money = 3000, + .setup = [](FakeMart& mart){ mart.money_readable = false; }, + .end = MartEnd::NO_MONEY_READ, + .money_after = 3000, + }, + { + .name = "a floor above the money: the quantity box wraps, and its read catches it", + .plan = pewter_plan(2000, {{"potion", 3}}, 1000), + .money = 500, + .setup = [](FakeMart& mart){ mart.money_readable = false; }, + .end = MartEnd::FAILED, + .money_after = 500, + }, + { + .name = "too poor for any of it", + .plan = pewter_plan(2000, {{"potion", 5}}), + .money = 250, + .end = MartEnd::NOTHING_TO_BUY, + .money_after = 250, + }, + }; +} + +std::vector mart_cases_3(){ + return { + { + .name = "a row that reads as another item stops it", + .plan = pewter_plan(2000, {{"potion", 1}, {"antidote", 1}}), + .money = 3000, + .setup = [](FakeMart& mart){ mart.misread[2] = "parlyz-heal"; }, + .end = MartEnd::FAILED, + .bag = {{"potion", 1}}, + .money_after = 2700, + }, + { + .name = "rows that can't be read are bought by position", + .plan = pewter_plan(2000, {{"repel", 2}, {"potion", 1}}), + .money = 3000, + .setup = [](FakeMart& mart){ mart.rows_readable = false; }, + .bag = {{"repel", 2}, {"potion", 1}}, + .money_after = 2000, + }, + { + .name = "more money gone than bought fails", + .plan = pewter_plan(2000, {{"potion", 1}}), + .money = 3000, + .setup = [](FakeMart& mart){ mart.overcharge = 10; }, + .end = MartEnd::FAILED, + .bag = {{"potion", 1}}, + .money_after = 2690, + }, + }; +} + +std::vector mart_cases(){ + std::vector cases; + for (std::vector part : {mart_cases_1(), mart_cases_2(), mart_cases_3()}){ + cases.insert(cases.end(), part.begin(), part.end()); + } + return cases; +} + +UnitTestResult check_mart_case(const MartCase& item){ + QuietLogger logger; + FakeMart mart(item.plan.map, item.money); + if (item.setup){ + item.setup(mart); + } + const MartResult result = run_mart(logger, mart, item.plan); + if (!mart.error.empty()){ + return item.name + ": " + mart.error; + } + if (result.end != item.end){ + return std::format("{}: ended {}, expected {} ({}).", item.name, + mart_end_name(result.end), mart_end_name(item.end), result.detail); + } + if (mart.bag != item.bag || mart.money != item.money_after){ + return std::format("{}: bag {}, ${}; expected {}, ${}.", item.name, + bag_string(mart.bag), mart.money, bag_string(item.bag), item.money_after); + } + if (mart.in_list){ + return item.name + ": left in the Mart's list."; + } + return true; +} + +UnitTestResult test_mart_buys(){ + for (const MartCase& item : mart_cases()){ + const UnitTestResult result = check_mart_case(item); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + return true; +} + + +// The presses for each quantity reach it from 1 in a box whose most is +// just that quantity, so RIGHT never stops at the most early. +UnitTestResult test_quantity_presses(){ + for (size_t quantity = 1; quantity <= 99; quantity++){ + const QuantityPresses presses = quantity_presses(quantity); + const size_t reached = 1 + presses.right * 10 + presses.up; + if (reached != quantity || presses.up > 9){ + return std::format("{}: {} RIGHT and {} UP reach {}.", quantity, presses.right, presses.up, reached); + } + } + return true; +} + + +UnitTestResult test_mart_variant(){ + const MartPlan plan = pewter_plan(2000, {{"potion", 5}, {"repel", 3}}, 2400); + const std::string variant = mart_variant(plan); + if (variant != "mart PewterCity_Mart budget=2000 floor=2400 potion=5 repel=3"){ + return "Unexpected variant \"" + variant + "\"."; + } + const std::optional back = parse_mart_variant(variant); + if (!back || back->map != plan.map || back->budget != 2000 || back->money_floor != plan.money_floor + || back->lines.size() != 2 || back->lines[1].item != "repel" || back->lines[1].quantity != 3 + ){ + return "\"" + variant + "\" didn't read back as its plan."; + } + for (std::string_view bad : { + "mart", "mart PewterCity_Mart budget=2000", "mart Nowhere_Mart potion=1", + "mart ViridianCity_Mart repel=1", "mart PewterCity_Mart potion=0", "mart PewterCity_Mart potion=100", + "mart PewterCity_Mart potion", "teach hm03 surf slot=1", + }){ + if (parse_mart_variant(bad)){ + return "Expected \"" + std::string(bad) + "\" refused."; + } + } + return true; +} + + +// Each Mart's stock is its scripts.inc list, with known prices. +UnitTestResult test_mart_stock(){ + const std::vector& viridian = mart_stock("ViridianCity_Mart"); + if (viridian.size() != 4 || viridian[3].item != "parlyz-heal" || viridian[1].price != 300){ + return "Viridian's stock isn't POKE BALL, POTION (300), ANTIDOTE, PARLYZ HEAL."; + } + const std::vector& cerulean = mart_stock("CeruleanCity_Mart"); + if (cerulean.size() != 9 || cerulean[1].item != "super-potion" || cerulean[2].item != "potion"){ + return "Cerulean sells SUPER POTION above POTION."; + } + if (!mart_stock("Route4_PokemonCenter_1F").empty()){ + return "Expected no stock for a map without a Mart."; + } + return true; +} + + +// Potions, Antidotes and Parlyz Heals bought go into the battles' supplies. +UnitTestResult test_count_supplies(){ + Supplies supplies; + supplies.pocket = {"potion"}; + supplies.potions = 1; + count_supplies(supplies, "antidote", 3); + count_supplies(supplies, "potion", 2); + count_supplies(supplies, "repel", 4); + count_supplies(supplies, "poke-ball", 5); + if (supplies.potions != 3 || supplies.antidotes != 3 || supplies.pocket != std::vector{"potion", "antidote"}){ + return std::format("Supplies {} potions, {} antidotes, {} pocket rows.", + supplies.potions, supplies.antidotes, supplies.pocket.size()); + } + return true; +} + +} + + +void add_tests_AutoStoryMart(UnitTestDatabase& database){ + const auto add = [&](std::string name, UnitTestResult (*body)()){ + database.add(std::move(name), body); + }; + add("buy lists on a fake Mart", test_mart_buys); + add("quantity presses", test_quantity_presses); + add("the table's variant", test_mart_variant); + add("Mart stocks", test_mart_stock); + add("bought supplies counted", test_count_supplies); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.cpp new file mode 100644 index 0000000000..5500e5e21f --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.cpp @@ -0,0 +1,94 @@ +/* AutoStory Member + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "PokemonFRLG_AutoStoryMember.h" +#include "PokemonFRLG_AutoStorySummary.h" +#include "PokemonFRLG_AutoStoryVariantWords.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +MemberReader console_member_reader(ConsoleHandle& console, ProControllerContext& context, Language language){ + return [&console, &context, language](size_t slot) -> std::optional{ + const std::optional read = read_party_summary(console, context, StartMenu::FULL, language, slot); + if (!read || read->species.empty() || !read->level || read->moves.empty()){ + console.log(std::format("Party: couldn't read the member in slot {}.", slot + 1), COLOR_RED); + return std::nullopt; + } + MemberInfo info{.species = read->species, .level = read->level, .moves = {}, .stats = read->stats}; + for (const SummaryMove& move : read->moves){ + info.moves.push_back(move.move); + } + return info; + }; +} + + +std::string level_variant(const LevelCheck& check){ + return std::format("{}slot={} min={}", LEVEL_VARIANT_PREFIX, check.slot + 1, check.level); +} + +std::optional parse_level_variant(std::string_view variant){ + if (!variant.starts_with(LEVEL_VARIANT_PREFIX)){ + return std::nullopt; + } + LevelCheck check; + bool slot_seen = false; + bool level_seen = false; + for (std::string_view word : variant_words(variant.substr(LEVEL_VARIANT_PREFIX.size()))){ + const std::optional pair = variant_pair(word); + const std::optional number = pair ? variant_number(pair->value, 3) : std::nullopt; + if (!number){ + return std::nullopt; + } + if (pair->key == "slot" && *number >= 1 && *number <= PARTY_SLOTS){ + check.slot = *number - 1; + slot_seen = true; + }else if (pair->key == "min" && *number >= 1 && *number <= 100){ + check.level = (unsigned)*number; + level_seen = true; + }else{ + return std::nullopt; + } + } + if (!slot_seen || !level_seen){ + return std::nullopt; + } + return check; +} + +Pokemon::StoryStep level_step(std::string name, std::string why, const LevelCheck& check){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = std::move(name); + step.why = std::move(why); + step.variant = level_variant(check); + return step; +} + +LevelResult run_level_check(Logger& logger, const MemberReader& reader, const LevelCheck& check){ + const std::optional member = reader(check.slot); + if (!member || !member->level){ + logger.log(std::format("Level: couldn't read slot {}'s level.", check.slot + 1), COLOR_RED); + return {}; + } + const bool reached = *member->level >= check.level; + logger.log(std::format( + "Level: slot {} ({}) is level {}, {} {}.", + check.slot + 1, member->species.empty() ? "?" : member->species, + *member->level, reached ? "at least" : "below", check.level + )); + return {.end = reached ? LevelEnd::REACHED : LevelEnd::BELOW, .level = member->level}; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.h new file mode 100644 index 0000000000..e2795c6c53 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.h @@ -0,0 +1,103 @@ +/* AutoStory Member + * + * From: https://github.com/PokemonAutomation/ + * + * What the story reads about one party member: its species, level, stats + * and moves, as the summary screen shows them (page 1: level; page 2: + * stats; page 3: the four moves). The story's steps take them from a MemberReader, so their + * decisions run in tests against a fake party; on the console they read + * the member's summary (read_party_summary(), PokemonFRLG_AutoStorySummary.h). + * A member that can't be read stops every step that needs its level or + * moves instead of guessing. + * + * Also the LEVEL step: a check, for the table, that a member has reached + * a level, before a fight that needs it. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryMember_H +#define PokemonAutomation_PokemonFRLG_AutoStoryMember_H + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "CommonFramework/Language.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "PokemonFRLG_AutoStoryDamage.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +constexpr size_t PARTY_SLOTS = 6; +constexpr size_t MOVE_SLOTS = 4; + +// A party member as its summary shows it. +struct MemberInfo{ + // The species' slug, e.g. "squirtle"; empty if not read. + std::string species = {}; + // 1-100; nothing if not read. + std::optional level = std::nullopt; + // Its moves' slugs in the summary's order (e.g. "water-gun"), one to + // four; empty if not read. + std::vector moves = {}; + // POKEMON SKILLS (HP as its maximum); none if not read. + std::optional stats = std::nullopt; +}; + +// Reads party slot `slot` (0-5), from the overworld back to the +// overworld: its summary, read; nothing if the slot is empty or the +// summary couldn't be read. +using MemberReader = std::function(size_t slot)>; + +// The console's reader: the member's summary, opened from the START +// menu's POKEMON, in a game in `language`. The steps that read members +// come after the Pokedex, so the START menu is the full one. A member +// whose species, level or moves aren't all read is nothing. +MemberReader console_member_reader(ConsoleHandle& console, ProControllerContext& context, Language language); + + +// What a LEVEL step checks: party slot `slot` (0-5) is at least `level`. +struct LevelCheck{ + size_t slot = 0; + unsigned level = 1; +}; + +// The prefix of a GAME step's variant that names a LEVEL step. +inline constexpr std::string_view LEVEL_VARIANT_PREFIX = "level "; + +// `check` as a variant: "level slot=1 min=16", the slot counted from 1. +std::string level_variant(const LevelCheck& check); +// A variant from level_variant(); nothing if it isn't one. +std::optional parse_level_variant(std::string_view variant); + +// The table's LEVEL step. +Pokemon::StoryStep level_step(std::string name, std::string why, const LevelCheck& check); + +enum class LevelEnd{ + REACHED, + BELOW, + // The member couldn't be read: nothing is known. + UNREAD, +}; +struct LevelResult{ + LevelEnd end = LevelEnd::UNREAD; + std::optional level = std::nullopt; +}; + +// Reads the member and compares. +LevelResult run_level_check(Logger& logger, const MemberReader& reader, const LevelCheck& check); + + + +} +} +} +#endif 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/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.cpp index f6c1f5d3c9..53289eff21 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.cpp @@ -64,6 +64,33 @@ std::string starter_rng_loop_message(const StarterRngLoopResult& result){ } +std::string gift_rng_loop_message(const StarterRngLoopResult& result){ + const std::string save = "The save is still beside the salesman, facing him: start AutoStory at 00.12b with Run Gift " + "RNG in AutoStory on to try again, or run Gift RNG (Magikarp) from it by hand with an attempt from the log, then " + "start AutoStory at 00.12b with that option off."; + switch (result.end){ + case StarterRngLoopEnd::SHINY: + return "Gift RNG: shiny Magikarp, on the planned target. 00.12b saves it."; + case StarterRngLoopEnd::SHINY_OFF_TARGET: + return "Gift RNG: shiny Magikarp, but not the planned target. 00.12b saves it."; + case StarterRngLoopEnd::TARGET: + return "Gift RNG hit the target (shiny not required). Nothing was saved after it: start AutoStory at 00.12b " + "to save the Magikarp where it stands."; + case StarterRngLoopEnd::OUT_OF_ATTEMPTS: + return std::format( + "Gift RNG: every planned attempt hit its target and none was shiny, so the RNG plan's SID doesn't fit. " + "SIDs left: {}. {}", join_sids(result.remaining_sids), save + ); + case StarterRngLoopEnd::MISSED_EVERY_ATTEMPT_LEFT: + return "Gift RNG: every attempt left missed its target more often than the retries allow, or Gift RNG refused " + "its timings (see the log). Check the RNG plan's sound setting. " + save; + case StarterRngLoopEnd::OUT_OF_TIME: + return std::format("Gift RNG: out of time after {} attempts. {}", result.attempts_run, save); + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown Gift RNG loop end."); +} + + CalibrationSavingSteps::CalibrationSavingSteps( StarterRngLoopSteps& steps, RngStopCalibrationOptions& calibration, FloatingPointOption& ingame ) @@ -88,8 +115,25 @@ StarterRngResult CalibrationSavingSteps::run_attempt(const RngAttempt& attempt, } -std::vector rows_after_starter_rng( - const std::vector& checkpoints, size_t ball_row, size_t last +namespace{ + +// `plan` with the rows from `first` to `last` as plan_story_run() takes +// them after it, and the handoff they stop at. +StoryRunPlan then_rows( + StoryRunPlan plan, const std::vector& checkpoints, size_t first, size_t last, const StoryHandoffs& handoffs +){ + if (last >= first){ + StoryRunPlan rest = plan_story_run(checkpoints, first, last, handoffs); + plan.rows.insert(plan.rows.end(), rest.rows.begin(), rest.rows.end()); + plan.stop = rest.stop; + } + return plan; +} + +} + +StoryRunPlan rows_after_starter_rng( + const std::vector& checkpoints, size_t ball_row, size_t last, const StoryHandoffs& handoffs ){ if (ball_row + 1 >= checkpoints.size()){ throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No checkpoint after the starter's ball."); @@ -104,12 +148,24 @@ std::vector rows_after_starter_rng( row.steps.erase(row.steps.begin(), taken + 1); row.title = "after Starter RNG in AutoStory: the rival's battle"; - std::vector rows{std::move(row)}; - if (last > ball_row + 1){ - StoryRunPlan rest = plan_story_run(checkpoints, ball_row + 2, last, StoryHandoffs{}); - rows.insert(rows.end(), rest.rows.begin(), rest.rows.end()); + StoryRunPlan plan; + plan.rows.emplace_back(std::move(row)); + return then_rows(std::move(plan), checkpoints, ball_row + 2, last, handoffs); +} + +StoryRunPlan rows_after_gift_rng( + const std::vector& checkpoints, size_t salesman_row, size_t last, const StoryHandoffs& handoffs +){ + if (salesman_row + 1 >= checkpoints.size()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No checkpoint after the Magikarp salesman."); + } + const StoryCheckpoint& row = checkpoints[salesman_row + 1]; + if (row.steps.empty() || row.steps.front().variant != LEAVE_GIFT_SUMMARY){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, row.id + " doesn't start by leaving the gift's summary."); } - return rows; + StoryRunPlan plan; + plan.rows.emplace_back(row); + return then_rows(std::move(plan), checkpoints, salesman_row + 2, last, handoffs); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h index a6c0f248af..5e66e78e39 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h @@ -2,9 +2,10 @@ * * From: https://github.com/PokemonAutomation/ * - * AutoStory's Starter RNG stop, run in the program with the Starter RNG - * loop (RngManipulation/PokemonFRLG_StarterRngLoop.h) from the save under - * Squirtle's ball: the plan it runs, what the run says when the loop + * AutoStory's RNG stops, run in the program with the RNG plan loop + * (RngManipulation/PokemonFRLG_StarterRngLoop.h): Starter RNG from the + * save under Squirtle's ball, and Gift RNG from the save beside Route 4's + * Magikarp salesman. The plan each runs, what the run says when the loop * ends, and the rows it takes after a shiny. * */ @@ -26,7 +27,7 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -// The Starter RNG plan, for the TID it was made for. +// An RNG plan (Starter RNG's or Gift RNG's), for the TID it was made for. struct StarterRngPlanned{ uint16_t tid = 0; std::vector attempts; @@ -34,6 +35,8 @@ struct StarterRngPlanned{ // What the run says when the loop ends with `result`. std::string starter_rng_loop_message(const StarterRngLoopResult& result); +// As starter_rng_loop_message(), for Gift RNG's Magikarp. +std::string gift_rng_loop_message(const StarterRngLoopResult& result); // `steps`, with the calibration of each reset also kept in the RNG plan's @@ -56,10 +59,21 @@ class CalibrationSavingSteps : public StarterRngLoopSteps{ // The rows a run takes after a shiny from the loop, which leaves the player // under Squirtle's ball with the rival's ball taken: the row after // `ball_row` (00.04b) from its step after "rival_takes_bulbasaur", then -// the rows after it to `last` as plan_story_run() takes them. Throws -// InternalProgramError for a table without that row or step. -std::vector rows_after_starter_rng( - const std::vector& checkpoints, size_t ball_row, size_t last +// the rows after it to `last` as plan_story_run() takes them with +// `handoffs`, with the handoff they stop at. Throws InternalProgramError +// for a table without that row or step. +StoryRunPlan rows_after_starter_rng( + const std::vector& checkpoints, size_t ball_row, size_t last, const StoryHandoffs& handoffs +); + +// The rows a run takes after a shiny Magikarp from the loop, which leaves +// its summary open beside the salesman: the row after `salesman_row` +// (00.12b), which leaves the summary and saves, then the rows after it to +// `last` as plan_story_run() takes them with `handoffs`. Throws +// InternalProgramError for a table whose next row doesn't leave the +// summary. +StoryRunPlan rows_after_gift_rng( + const std::vector& checkpoints, size_t salesman_row, size_t last, const StoryHandoffs& handoffs ); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp index f042a5349c..480cdd0726 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp @@ -2,7 +2,8 @@ * * From: https://github.com/PokemonAutomation/ * - * What the run says after a shiny, and the rows it takes then. + * What the run says when an RNG loop (Starter RNG's or Gift RNG's) ends, + * and the rows it takes after a shiny. * */ @@ -10,10 +11,12 @@ #include #include #include +#include #include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/Logging/AbstractLogger.h" #include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.h" #include "PokemonFRLG_AutoStoryTable.h" #include "PokemonFRLG_AutoStoryRngLoop.h" @@ -79,41 +82,153 @@ UnitTestResult test_shiny_messages(){ } -// After a shiny, 00.04b picks up at the rival's trigger and runs on. -UnitTestResult test_rows_after_shiny(){ +size_t row_index(std::string_view id){ const std::vector& checkpoints = story_checkpoints(); - size_t ball_row = 0; - size_t parcel_row = 0; for (size_t c = 0; c < checkpoints.size(); c++){ - if (checkpoints[c].id == "00.04"){ - ball_row = c; - } - if (checkpoints[c].id == "00.05"){ - parcel_row = c; + if (checkpoints[c].id == id){ + return c; } } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No checkpoint " + std::string(id)); +} + +std::string joined_ids(const StoryRunPlan& plan){ + std::string ids; + for (const StoryCheckpoint& row : plan.rows){ + ids += (ids.empty() ? "" : ",") + row.id; + } + return ids; +} + +std::string first_step(const StoryRunPlan& plan){ + return plan.rows.empty() || plan.rows.front().steps.empty() ? "" : plan.rows.front().steps.front().name; +} + +// After a shiny, 00.04b picks up at the rival's trigger and runs on, to +// Gift RNG's stop when it is on. +UnitTestResult test_rows_after_shiny(){ + const std::vector& checkpoints = story_checkpoints(); + const size_t ball_row = row_index("00.04"); struct Case{ - size_t last; + std::string last; + StoryHandoffs handoffs = {}; std::string ids; + StoryHandoff stop = StoryHandoff::NONE; }; const std::vector cases = { - {.last = ball_row, .ids = "00.04b"}, - {.last = ball_row + 1, .ids = "00.04b"}, - {.last = parcel_row, .ids = "00.04b,00.05"}, + {.last = "00.04", .ids = "00.04b"}, + {.last = "00.04b", .ids = "00.04b"}, + {.last = "00.05", .ids = "00.04b,00.05"}, + {.last = "00.12b", .ids = "00.04b,00.05,00.06,00.07,00.08,00.09,00.10,00.11,00.12"}, + { + .last = "00.12b", + .handoffs = {.starter_rng = true, .gift_rng = true}, + .ids = "00.04b,00.05,00.06,00.07,00.08,00.09,00.10,00.11,00.12", + .stop = StoryHandoff::GIFT_RNG, + }, }; for (const Case& item : cases){ - const std::vector rows = rows_after_starter_rng(checkpoints, ball_row, item.last); - std::string ids; - for (const StoryCheckpoint& row : rows){ - ids += (ids.empty() ? "" : ",") + row.id; + const StoryRunPlan plan = rows_after_starter_rng(checkpoints, ball_row, row_index(item.last), item.handoffs); + if (joined_ids(plan) != item.ids || first_step(plan) != "above_rival_trigger" || plan.stop != item.stop){ + return std::format( + "To {}: expected rows {} starting at above_rival_trigger, stop {}; got {} starting at {}, stop {}", + item.last, item.ids, int(item.stop), joined_ids(plan), first_step(plan), int(plan.stop) + ); + } + } + return true; +} + + +// What the run says when Gift RNG's loop ends: a shiny goes on to 00.12b; +// otherwise the save beside the salesman is where to go on from. +UnitTestResult test_gift_messages(){ + struct Case{ + StarterRngLoopResult result; + std::string expected; + }; + const std::string save = "The save is still beside the salesman, facing him: start AutoStory at 00.12b with Run Gift " + "RNG in AutoStory on to try again, or run Gift RNG (Magikarp) from it by hand with an attempt from the log, then " + "start AutoStory at 00.12b with that option off."; + const std::vector cases = { + { + .result = {.end = StarterRngLoopEnd::SHINY, .attempts_run = 1, .remaining_sids = {300}, .sid = uint16_t(300)}, + .expected = "Gift RNG: shiny Magikarp, on the planned target. 00.12b saves it.", + }, + { + .result = {.end = StarterRngLoopEnd::SHINY_OFF_TARGET, .attempts_run = 1, .remaining_sids = {300}, .sid = std::nullopt}, + .expected = "Gift RNG: shiny Magikarp, but not the planned target. 00.12b saves it.", + }, + { + .result = {.end = StarterRngLoopEnd::OUT_OF_ATTEMPTS, .attempts_run = 3, .remaining_sids = {}, .sid = std::nullopt}, + .expected = "Gift RNG: every planned attempt hit its target and none was shiny, so the RNG plan's SID doesn't fit. " + "SIDs left: -. " + save, + }, + { + .result = {.end = StarterRngLoopEnd::OUT_OF_TIME, .attempts_run = 4, .remaining_sids = {300}, .sid = std::nullopt}, + .expected = "Gift RNG: out of time after 4 attempts. " + save, + }, + }; + for (const Case& item : cases){ + const std::string got = gift_rng_loop_message(item.result); + if (got != item.expected){ + return "Expected \"" + item.expected + "\", got \"" + got + "\"."; } - const std::string first_step = rows.front().steps.empty() ? "" : rows.front().steps.front().name; - if (ids != item.ids || first_step != "above_rival_trigger"){ + } + return true; +} + +// 00.12 saves facing the salesman for Gift RNG; after a shiny Magikarp, +// 00.12b leaves its summary and saves there, then the run goes on. +UnitTestResult test_rows_after_magikarp(){ + const std::vector& checkpoints = story_checkpoints(); + const size_t salesman_row = row_index("00.12"); + const StoryCheckpoint& salesman = checkpoints[salesman_row]; + const StoryCheckpoint& after = checkpoints[salesman_row + 1]; + const std::optional start = after.start ? story_pose(*after.start) : std::nullopt; + if (salesman.handoff != StoryHandoff::GIFT_RNG || !after.live_start || !start + || start->map != "Route4_PokemonCenter_1F" || !(start->tile == TilePos{2, 3}) || start->facing != Direction::WEST + ){ + return "Expected 00.12 to be Gift RNG's handoff and 00.12b to start from the live game at (2, 3) facing west."; + } + for (std::string_view last : {"00.12", "00.12b"}){ + const StoryRunPlan plan = rows_after_gift_rng(checkpoints, salesman_row, row_index(last), StoryHandoffs{.gift_rng = true}); + if (joined_ids(plan) != "00.12b" || first_step(plan) != LEAVE_GIFT_SUMMARY || plan.stop != StoryHandoff::NONE){ + return std::format( + "To {}: expected 00.12b from {}; got {} from {}", last, LEAVE_GIFT_SUMMARY, joined_ids(plan), first_step(plan) + ); + } + } + return true; +} + +// Gift RNG's outcomes count in the RNG plan as Starter RNG's do: only a +// shiny on the target confirms anything. +UnitTestResult test_gift_outcomes(){ + struct Case{ + GiftRngOutcome outcome; + RngAttemptOutcome expected; + }; + const std::vector cases = { + {.outcome = GiftRngOutcome::shiny_on_target, .expected = RngAttemptOutcome::SHINY}, + {.outcome = GiftRngOutcome::shiny, .expected = RngAttemptOutcome::SHINY_OFF_TARGET}, + {.outcome = GiftRngOutcome::shiny_off_target, .expected = RngAttemptOutcome::SHINY_OFF_TARGET}, + {.outcome = GiftRngOutcome::target_hit, .expected = RngAttemptOutcome::HIT_NOT_SHINY}, + {.outcome = GiftRngOutcome::out_of_resets, .expected = RngAttemptOutcome::MISSED}, + {.outcome = GiftRngOutcome::no_matches, .expected = RngAttemptOutcome::MISSED}, + }; + for (const Case& item : cases){ + const RngAttemptOutcome got = plan_outcome(loop_outcome(item.outcome)); + if (got != item.expected){ return std::format( - "Expected rows {} starting at above_rival_trigger; got {} starting at {}", item.ids, ids, first_step + "Gift RNG outcome {}: expected plan outcome {}, got {}.", int(item.outcome), int(item.expected), int(got) ); } } + // The plan's targets are Starter RNG's (StarterRngPlanOptions::gift_plan()). + if (fixed_advances_offset(PokemonFRLG_RngTarget::magikarp) != fixed_advances_offset(PokemonFRLG_RngTarget::starters)){ + return "Expected the Magikarp's advances offset to be the starters'."; + } return true; } @@ -190,14 +305,40 @@ UnitTestResult test_starter_calibration_saved(){ return true; } +// Gift RNG starts from the console's seed and CSF offsets, which Starter +// RNG may have saved, and its own in-game offset: 0 until it saves one. +// Saving Gift RNG's leaves Starter RNG's in-game offset as it was. +UnitTestResult test_gift_calibration(){ + StarterRngPlanOptions options; + RngStopCalibrationOptions& calibration = options.CALIBRATION; + calibration.save({.seed_offset = 1.0, .csf_offset = 0.5, .ingame_offset = -1.2}, calibration.STARTER_INGAME); + const RngCalibrations gift = calibration.calibration(calibration.GIFT_INGAME); + const RngCalibrations expected{.seed_offset = 17 / FRLG_FRAME_DURATION, .csf_offset = 0.5, .ingame_offset = 0}; + if (gift != expected){ + return std::format("Expected Gift RNG to start from {}; got {}", calibration_text(expected), calibration_text(gift)); + } + calibration.save({.seed_offset = 1.0, .csf_offset = 0.5, .ingame_offset = 9.0}, calibration.GIFT_INGAME); + if (double(calibration.GIFT_INGAME) != 9.0 || double(calibration.STARTER_INGAME) != -1.2){ + return std::format( + "Expected in-game offsets Gift 9 and Starter -1.2; got {} and {}", + double(calibration.GIFT_INGAME), double(calibration.STARTER_INGAME) + ); + } + return true; +} + } void add_tests_AutoStoryRngLoop(UnitTestDatabase& database){ database.add("shiny messages", test_shiny_messages); database.add("rows after a shiny", test_rows_after_shiny); + database.add("Gift RNG messages", test_gift_messages); + database.add("00.12 and 00.12b: rows after a shiny Magikarp", test_rows_after_magikarp); + database.add("Gift RNG outcomes in the RNG plan", test_gift_outcomes); database.add("Starter RNG starts from the RNG plan's calibration", test_starter_calibration_from_options); database.add("Starter RNG saves its calibration after each reset", test_starter_calibration_saved); + database.add("Gift RNG keeps its own in-game calibration", test_gift_calibration); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.cpp index 57943c2dcd..36eb29dd0c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.cpp @@ -31,10 +31,15 @@ RngStopCalibrationOptions::RngStopCalibrationOptions() "for Squirtle only: each RNG stop keeps its own.", LockMode::LOCK_WHILE_RUNNING, 0.0 ) + , GIFT_INGAME( + "Gift RNG's In-Game Advances Calibration:
As in Gift RNG's RNG Calibration, for the Magikarp.", + LockMode::LOCK_WHILE_RUNNING, 0.0 + ) { PA_ADD_OPTION(SEED); PA_ADD_OPTION(CSF); PA_ADD_OPTION(STARTER_INGAME); + PA_ADD_OPTION(GIFT_INGAME); } RngCalibrations RngStopCalibrationOptions::calibration(const FloatingPointOption& ingame) const{ @@ -162,6 +167,24 @@ std::vector StarterRngPlanOptions::plan_candidates(Logger& logger, } std::vector StarterRngPlanOptions::plan(Logger& logger, Language language, uint16_t tid) const{ + return plan_for(logger, language, tid, "Starter RNG"); +} + +std::vector StarterRngPlanOptions::gift_plan(Logger& logger, Language language, uint16_t tid) const{ + // In English, Gift RNG's Magikarp is timed as Starter RNG's Squirtle is: + // the same title screen offset (-845 ms), 200 Continue Screen frames, an + // in-game wait of at least 7,500 ms (7,200 ms of presses and a 300 ms + // gap) and advances offset 160, so Starter RNG's plan suits it. The + // salesman's text takes 1.5 s less in French and 1.5 s more in Japanese. + if (language != Language::English){ + throw UserSetupError(logger, "RNG plan: Gift RNG in AutoStory plans for English games only."); + } + return plan_for(logger, language, tid, "Gift RNG"); +} + +std::vector StarterRngPlanOptions::plan_for( + Logger& logger, Language language, uint16_t tid, const std::string& program +) const{ // SID Helper's candidates are logged even with the SID known, to place // it among them (rng_log_confirmed()). const std::vector helper = helper_candidates(tid); @@ -183,7 +206,7 @@ std::vector StarterRngPlanOptions::plan(Logger& logger, Language lan }); if (attempts.empty()){ throw UserSetupError(logger, - "RNG plan: no Starter RNG target passes the natures, minimum IVs and shiny options for TID " + std::to_string(tid) + "." + "RNG plan: no " + program + " target passes the natures, minimum IVs and shiny options for TID " + std::to_string(tid) + "." ); } for (size_t c = 0; c < attempts.size(); c++){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.h index dc0a8e1343..47e8e7a8bf 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.h @@ -51,6 +51,8 @@ class RngStopCalibrationOptions : public GroupOption{ FloatingPointOption CSF; // Starter RNG's in-game advances offset. FloatingPointOption STARTER_INGAME; + // Gift RNG's, for the Magikarp. + FloatingPointOption GIFT_INGAME; }; @@ -68,6 +70,9 @@ class StarterRngPlanOptions : public GroupOption{ // in "language", logged as "RngPlan:" lines. Throws UserSetupError // when no target passes. std::vector plan(Logger& logger, Language language, uint16_t tid) const; + // As plan(), for Gift RNG's Magikarp from Route 4's salesman. Throws + // UserSetupError in a language other than English. + std::vector gift_plan(Logger& logger, Language language, uint16_t tid) const; // SID Helper's candidates for "tid", from the two SID Helper options. std::vector helper_candidates(uint16_t tid) const; // The SIDs plan() plans for: the SID if set, else helper_candidates(). @@ -90,6 +95,9 @@ class StarterRngPlanOptions : public GroupOption{ private: RngTargetFilters filters(Logger& logger) const; std::optional sid(Logger& logger) const; + // The attempts plan_starter_rng() finds, logged, for `program` + // ("Starter RNG") in the error when there are none. + std::vector plan_for(Logger& logger, Language language, uint16_t tid, const std::string& program) const; }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp new file mode 100644 index 0000000000..6036ac956f --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -0,0 +1,265 @@ +/* AutoStory Run Plan Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Which rows of the story table a run takes (plan_story_run()) with the + * RNG programs' handoffs on and off, and the rows the handoffs split or + * join. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryTable.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class Test_AutoStory : public UnitTest{ +public: + Test_AutoStory(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStory - " + 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; +}; + + +size_t row_index(std::string_view id){ + const std::vector& checkpoints = story_checkpoints(); + for (size_t c = 0; c < checkpoints.size(); c++){ + if (checkpoints[c].id == id){ + return c; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No checkpoint " + std::string(id)); +} + +std::string joined_ids(const std::vector& rows){ + std::string text; + for (const StoryCheckpoint& row : rows){ + text += (text.empty() ? "" : " ") + row.id; + } + return text; +} + +const char* handoff_name(StoryHandoff handoff){ + switch (handoff){ + case StoryHandoff::NONE: + return "none"; + case StoryHandoff::SID_HELPER: + return "SID Helper"; + case StoryHandoff::STARTER_RNG: + return "Starter RNG"; + case StoryHandoff::GIFT_RNG: + return "Gift RNG"; + } + return "?"; +} + +struct RunPlanScenario{ + std::string name = ""; + std::string first = ""; + // The table's last row if empty. + std::string last = ""; + StoryHandoffs handoffs = {}; + std::string expected_ids = ""; + StoryHandoff expected_stop = StoryHandoff::NONE; +}; + +class Test_RunPlan : public UnitTest{ +public: + Test_RunPlan(RunPlanScenario scenario) + : UnitTest("PokemonFRLG::AutoStory - run plan: " + scenario.name) + , m_scenario(std::move(scenario)) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + const size_t last = m_scenario.last.empty() ? story_checkpoints().size() - 1 : row_index(m_scenario.last); + const StoryRunPlan plan = plan_story_run(story_checkpoints(), row_index(m_scenario.first), last, m_scenario.handoffs); + const std::string ids = joined_ids(plan.rows); + if (ids != m_scenario.expected_ids || plan.stop != m_scenario.expected_stop){ + return "Expected rows \"" + m_scenario.expected_ids + "\", stop " + handoff_name(m_scenario.expected_stop) + + "; got \"" + ids + "\", stop " + handoff_name(plan.stop) + "."; + } + return true; + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + const RunPlanScenario m_scenario; +}; + +void add_tests_RunPlan(UnitTestDatabase& database){ + const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13"; + const RunPlanScenario scenarios[] = { + {.name = "handoffs off: the story as before", .first = "00.01", .expected_ids = TODAY}, + {.name = "handoffs off: a range", .first = "00.02", .last = "00.04", .expected_ids = "00.02 00.03 00.04"}, + { + .name = "SID Helper: 00.01 stops on the keyboard", + .first = "00.01", + .handoffs = {.sid_helper = true}, + .expected_ids = "", + .expected_stop = StoryHandoff::SID_HELPER, + }, + { + .name = "SID Helper: back in at 00.01b", + .first = "00.01b", + .last = "00.03", + .handoffs = {.sid_helper = true}, + .expected_ids = "00.01b 00.02 00.03", + }, + {.name = "00.01b runs when started at, option off", .first = "00.01b", .last = "00.02", .expected_ids = "00.01b 00.02"}, + {.name = "SID Helper: a later start passes over it", .first = "00.02", .last = "00.03", .handoffs = {.sid_helper = true}, .expected_ids = "00.02 00.03"}, + {.name = "handoffs off: an end at 00.04 still takes the rival's battle", .first = "00.03", .last = "00.04", .expected_ids = "00.03 00.04"}, + { + .name = "Starter RNG: stops after 00.04's save", + .first = "00.02", + .handoffs = {.starter_rng = true}, + .expected_ids = "00.02 00.03 00.04", + .expected_stop = StoryHandoff::STARTER_RNG, + }, + {.name = "00.04b runs from its own save", .first = "00.04b", .last = "00.05", .expected_ids = "00.04b 00.05"}, + {.name = "Starter RNG: a later start doesn't stop", .first = "00.05", .last = "00.06", .handoffs = {.starter_rng = true}, .expected_ids = "00.05 00.06"}, + { + .name = "both handoffs, back in after SID Helper", + .first = "00.01b", + .handoffs = {.sid_helper = true, .starter_rng = true}, + .expected_ids = "00.01b 00.02 00.03 00.04", + .expected_stop = StoryHandoff::STARTER_RNG, + }, + }; + for (const RunPlanScenario& scenario : scenarios){ + database.add(scenario); + } +} + +void add_tests_GiftRunPlan(UnitTestDatabase& database){ + const RunPlanScenario scenarios[] = { + { + .name = "Gift RNG: stops after 00.12's save", + .first = "00.11", + .handoffs = {.gift_rng = true}, + .expected_ids = "00.11 00.12", + .expected_stop = StoryHandoff::GIFT_RNG, + }, + {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11", .expected_ids = "00.11 00.12 00.12c 00.13"}, + {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b", .expected_ids = "00.12b 00.12c 00.13"}, + { + .name = "Gift RNG: started at 00.12b, from the save beside the salesman", + .first = "00.12b", + .handoffs = {.gift_rng = true}, + .expected_ids = "", + .expected_stop = StoryHandoff::GIFT_RNG, + }, + { + .name = "Gift RNG: started at 00.12b with Starter RNG on too", + .first = "00.12b", + .handoffs = {.sid_helper = true, .starter_rng = true, .gift_rng = true}, + .expected_ids = "", + .expected_stop = StoryHandoff::GIFT_RNG, + }, + {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}, .expected_ids = "00.12b 00.12c 00.13"}, + { + .name = "Starter RNG first, with both on", + .first = "00.03", + .handoffs = {.starter_rng = true, .gift_rng = true}, + .expected_ids = "00.03 00.04", + .expected_stop = StoryHandoff::STARTER_RNG, + }, + }; + for (const RunPlanScenario& scenario : scenarios){ + database.add(scenario); + } +} + +std::string step_names(const StoryCheckpoint& checkpoint){ + std::string text; + for (const StoryStep& step : checkpoint.steps){ + text += (text.empty() ? "" : " ") + step.name; + } + return text; +} + +// With Starter RNG's handoff off, 00.04 and 00.04b run as the one 00.04 +// the story had before the split: from the lab's table to the rival's +// battle, saving once with the starter's START menu. +UnitTestResult test_starter_rows_joined(){ + const std::vector& checkpoints = story_checkpoints(); + const StoryRunPlan plan = plan_story_run(checkpoints, row_index("00.04"), row_index("00.04b"), StoryHandoffs{}); + const std::string expected_steps = + "below_squirtle squirtle_ball choose_squirtle no_nickname rival_takes_bulbasaur above_rival_trigger rival_trigger rival_battle"; + if (plan.rows.size() != 1){ + return "Expected one checkpoint, got \"" + joined_ids(plan.rows) + "\"."; + } + const StoryCheckpoint& row = plan.rows[0]; + if (row.id != "00.04" || row.title != "Squirtle, then the rival's battle" || step_names(row) != expected_steps){ + return "Expected 00.04 \"Squirtle, then the rival's battle\": " + expected_steps + + "; got " + row.id + " \"" + row.title + "\": " + step_names(row); + } + const std::optional start = story_pose(*row.start); + if (!start || start->map != "PalletTown_ProfessorOaksLab" || !(start->tile == TilePos{6, 4}) || row.menu != StartMenu::BEFORE_POKEDEX){ + return "Expected the joined 00.04 to start at the table (6, 4) and save with the starter's START menu."; + } + // Split, 00.04 saves under the ball facing it, before the starter. + const StoryCheckpoint& to_ball = checkpoints[row_index("00.04")]; + const std::optional ball = story_pose(*checkpoints[row_index("00.04b")].start); + if (to_ball.handoff != StoryHandoff::STARTER_RNG || to_ball.menu != StartMenu::BEFORE_POKEMON + || !ball || !(ball->tile == TilePos{9, 5}) || ball->facing != Direction::NORTH + ){ + return "Expected 00.04 to end under Squirtle's ball (9, 5) facing north, saving before the starter."; + } + return true; +} + +// The ways back in after SID Helper and Gift RNG load no save and start +// where 00.01 and 00.12 leave the player, so the rows after them follow +// unchanged. +UnitTestResult test_after_sid_helper_row(){ + const StoryCheckpoint& sid = story_checkpoints()[row_index("00.01b")]; + if (!sid.live_start || story_checkpoints()[0].handoff != StoryHandoff::SID_HELPER){ + return "Expected 00.01 to be SID Helper's handoff and 00.01b to start from the live game."; + } + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.live_start != (checkpoint.id == "00.01b" || checkpoint.id == "00.12b")){ + return checkpoint.id + ": only 00.01b and 00.12b start from the live game."; + } + } + return true; +} + +} + + +void add_tests_AutoStoryRunPlan(UnitTestDatabase& database){ + add_tests_RunPlan(database); + add_tests_GiftRunPlan(database); + database.add("00.01b and 00.12b: back in after SID Helper and Gift RNG", test_after_sid_helper_row); + database.add("00.04 and 00.04b: one checkpoint with Starter RNG off", test_starter_rows_joined); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index eb29f6d463..91d4112d97 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -4,6 +4,9 @@ * */ +#include "PokemonFRLG_AutoStoryCapture.h" +#include "PokemonFRLG_AutoStoryChoice.h" +#include "PokemonFRLG_AutoStoryPcStorage.h" #include "PokemonFRLG_AutoStoryTable.h" namespace PokemonAutomation{ @@ -29,6 +32,11 @@ const char* const FOREST_GATE = "Route2_ViridianForest_NorthEntrance"; const char* const PEWTER = "PewterCity"; const char* const PEWTER_CENTER = "PewterCity_PokemonCenter_1F"; const char* const GYM = "PewterCity_Gym"; +const char* const ROUTE3 = "Route3"; +const char* const ROUTE4 = "Route4"; +const char* const ROUTE4_CENTER = "Route4_PokemonCenter_1F"; +const char* const MT_MOON_1F = "MtMoon_1F"; +const char* const MT_MOON_B2F = "MtMoon_B2F"; Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt){ return to_pose(StoryPose{map, {x, y}, facing}); @@ -112,9 +120,9 @@ StoryStep legs(std::string name, std::vector legs, std::string why, Wi return step; } -// `step`, a ROUTE or LEGS, run with B held: from the Running Shoes on. No -// checkpoint after 00.10 is in the table yet. -[[maybe_unused]] StoryStep running(StoryStep step){ +// `step`, a ROUTE or LEGS, run with B held: from the Running Shoes on. +// Indoors, where the game doesn't let the player run, it walks. +StoryStep running(StoryStep step){ step.variant = RUN_VARIANT; return step; } @@ -309,6 +317,154 @@ StoryCheckpoint running_shoes(){ // the player only turns up. He walks off and is removed; FLAG_SYS_B_DASH // is set (console B, 2026-11-02: about 20 B presses to the overworld). scene("shoes_from_aide", at(PEWTER, 46, 21, NORTH), "\"Oh, PLAYER!\" ... the Running Shoes; ends facing north"), + // Where the by-hand walk stopped to start 00.11 (console B, 2026-11-02). + route("below_pewter_center", at(PEWTER, 17, 26, SOUTH), "west along row 21 and down to below the Pokemon Center's door (17, 25)"), + }); +} + +// Route 3's trainers (Route3 map.json): the way east meets three of them. +// Bug Catcher Colton (12, 6) faces east, sight 3: the only way north past +// the row 7 ledges is the gap at x 14..15, below his line. Youngster Ben +// (17, 4) faces south, sight 2, so the way east keeps to row 3 past him. +// Bug Catcher Greg (25, 4) faces south, sight 2, over the way east along +// row 5. Bug Catcher James (32, 6) faces east, sight 4, over the way down +// at x 35..36. The others are kept out of: Lass Janice (19, 9) sees west +// along row 9 to x 14, Lass Sally (30, 3) west along row 3 to x 25, and +// Youngster Calvin (29, 10) west along row 10. Each line is stepped into on +// its own, so no leg is cut; the trainer walks up to the player, who stays +// put facing the way they stepped. +StoryCheckpoint route3(){ + return row("00.11", "Pewter Center, then Route 3's trainers", at(PEWTER, 17, 26, SOUTH), after_brock(), StartMenu::FULL, { + running(route("pewter_nurse", at(PEWTER_CENTER, 7, 4, NORTH), "in by the door below the player")), + heal("heal_pewter"), + // Up from row 9 west of Janice's line, which ends at x 14. + running(route("west_of_janice_line", at(ROUTE3, 13, 8), "out of Pewter by row 21, east into Route 3, short of Janice's line")), + running(legs("below_colton_line", {{EAST, 1}, {NORTH, 1}}, "to (14, 7), in the ledges' gap")), + step_into("colton_line", NORTH, "onto (14, 6), in Colton's line"), + battle("bug_catcher_colton", at(ROUTE3, 14, 6, NORTH), false, "his Bug Pokemon; Colton stops at (13, 6)"), + running(legs("past_ben", {{NORTH, 3}, {EAST, 4}, {SOUTH, 2}, {EAST, 6}}, "up to row 3 above Ben, down at x 18 east of his line, to (24, 5)")), + step_into("greg_line", EAST, "onto (25, 5), below Greg"), + battle("bug_catcher_greg", at(ROUTE3, 25, 5, EAST), false, "his Bug Pokemon; Greg is already next to the player"), + running(legs("above_james_line", {{EAST, 11}}, "along row 5 to the wall at (36, 5)")), + step_into("james_line", SOUTH, "onto (36, 6), at the end of James's line"), + battle("bug_catcher_james", at(ROUTE3, 36, 6, SOUTH), false, "his Bug Pokemon; James stops at (35, 6)"), + // Saved here, the trainers behind, so a failure on the way to Route 4 + // doesn't fight them again. + running(legs("save_spot", {{SOUTH, 2}}, "down to (36, 8), beside the wall east of it")), + }); +} + +// Route 3's north exit (x 68..75) joins Route 4 (offset 60) at x 8..15, up +// which column 12 leads to the Pokemon Center's door (12, 5). Lass Robin +// (40, 11) wanders up and down at x 40, looking north and south, sight 3. +// Rock above (y <= 8) and grass below (y >= 12) leave every way east +// across her column at y 9..11, so whether she sees the player depends on +// her facing. The walk watches for her after the leg along row 9, which +// crosses her sight on (40, 9) only; if she saw the player, her message +// goes to story_interrupts(), which fight her, and the route goes on from +// (40, 9) (run_route(); console B, 2026-11-02, she stopped the player +// there and spoke after the leg's short watch). +StoryCheckpoint magikarp_salesman(){ + StoryCheckpoint checkpoint = row("00.12", "Route 3 and 4 to the Magikarp salesman", at(ROUTE3, 36, 8, SOUTH), after_brock(), StartMenu::FULL, { + running(route("route4_nurse", at(ROUTE4_CENTER, 7, 4, NORTH), "east over Route 3, off the grass, north into Route 4 and its Pokemon Center")), + heal("heal_route4"), + // Gift RNG starts from a save facing the salesman (1, 3). + running(route("before_salesman", at(ROUTE4_CENTER, 2, 3, WEST), "beside the Magikarp salesman, facing him")), + }); + checkpoint.handoff = StoryHandoff::GIFT_RNG; + return checkpoint; +} + +// Gift RNG buys the Magikarp (500 of the money 00.12 brings) without +// moving the player and leaves its summary open, or HOME. This saves the +// shiny where 00.12 ended. A run started here with Gift RNG's handoff on +// loads the save beside the salesman and runs Gift RNG first. +StoryCheckpoint after_gift_rng(){ + StoryCheckpoint checkpoint = row("00.12b", "after Gift RNG: save with the Magikarp", at(ROUTE4_CENTER, 2, 3, WEST), after_brock(), StartMenu::FULL, { + game_step(LEAVE_GIFT_SUMMARY, at(ROUTE4_CENTER, 2, 3, WEST), "the Magikarp's summary Gift RNG left open, or HOME"), + }); + checkpoint.live_start = true; + return checkpoint; +} + +// The Magikarp goes to BOX 1 at the Center's PC (11, 1), so Mt. Moon is +// fought with the starter alone (console B, 2026-11-02: done by hand and +// saved at (11, 2), facing the PC, where this row ends). The wandering +// boy (4..6, 3..5) is walked around. The Magikarp comes from Gift RNG: +// with Gift RNG off the party is the starter alone, and the deposit is +// skipped. +StoryCheckpoint magikarp_to_box(){ + return row("00.12c", "Route 4 Center: the Magikarp into BOX 1", at(ROUTE4_CENTER, 2, 3, WEST), after_brock(), StartMenu::FULL, { + running(route("pc_front", at(ROUTE4_CENTER, 11, 2, NORTH), "below the PC (11, 1), facing it")), + pc_step("magikarp_to_box", "DEPOSIT the party's second slot into BOX 1, if it has one", PcTransfer{ + .action = PcAction::DEPOSIT, .party_slot = 1, .box = 0, .if_missing = PcMissing::SKIP, + }), + }); +} + +// Mt. Moon (pokefirered data/maps/MtMoon_1F, _B1F, _B2F). Every floor tile +// of the cave has wild encounters, which are run from, so its legs end on +// walls, ladders or someone standing still, which the walker holds again +// after a battle; the few open ones are short. The way through: up 1F's +// middle and west side, its north-west ladder, B1F's west ladder down to +// B2F's south-east, round B2F's south edge to its west, north past +// Miguel and the fossils, and the ladder up to B1F's east end, which +// leads out to Route 4 east of the mountain. +// - 1F's trainers are walked around: Youngster Josh (13, 17) sees east +// along row 17 to x 17, so the way west into the west room is down +// column 18 and up column 11; Lass Miriam walks round (32..33, 4..5), +// sight 2, so the way east keeps to row 9 and columns 38 and 40; Hiker +// Marcos wanders (5..9, 8..12) and sees one tile, so the way north is +// column 11 and row 3. +// - The Moon Stone ball (3, 2) on 1F (FLAG_HIDE_MT_MOON_1F_MOON_STONE) is +// a few tiles off the way to the ladder (5, 6). +// - The Grunt at (37, 21) on B2F looks around (sight 3) over the only way +// east round B2F. His room is crossed on legs to walls, at one tile of +// his sight each, (37, 22) and (40, 21); if he looks the player's way, +// he walks up beside the leg, story_interrupts() fights him, and the +// leg is held on to its wall. +// - The Grunt at (12, 20) faces south (sight 2) over (12, 21..22), across +// rows 21..22, the only way north to Miguel: stepped into from the +// west, he is next to the player and doesn't walk. +// - Super Nerd Miguel (13, 11) battles on the step onto (14, 11) +// (MtMoon_B2F_EventScript_MiguelTrigger, VAR_MAP_SCENE_MT_MOON_B2F 0), +// then "We'll each take one!" +// - The fossils: DOME (13, 7), HELIX (14, 7). After the HELIX, Miguel +// walks up to (13, 8) and takes the DOME, which opens the gap north. +// The save is on Route 4: the save card's places don't include Mt. Moon. +StoryCheckpoint mt_moon(){ + return row("00.13", "Mt. Moon: a Moon Stone, a Rocket, Miguel and the Helix Fossil", at(ROUTE4_CENTER, 11, 2, NORTH), after_brock(), StartMenu::FULL, { + running(route("route4_nurse", at(ROUTE4_CENTER, 7, 4, NORTH), "the nurse, below the counter")), + heal("heal_route4"), + running(route("mt_moon_entrance", at(MT_MOON_1F, 18, 37, NORTH), "out of the Center, west into Mt. Moon")), + running(legs("mt_moon_1f", { + {NORTH, 12}, {EAST, 8}, {NORTH, 12}, {EAST, 3}, {SOUTH, 1}, {EAST, 9}, + {NORTH, 1}, {EAST, 2}, {NORTH, 4}, {WEST, 24}, {EAST, 2}, {SOUTH, 12}, + {WEST, 7}, {NORTH, 18}, {WEST, 9}, {EAST, 1}, + }, "up the middle, east of Miriam, west along row 9, down column 18 past Josh, up column 11 past Marcos, to (3, 3)")), + running(route("below_moon_stone", at(MT_MOON_1F, 3, 3, NORTH), "facing the Moon Stone (3, 2)")), + talk("moon_stone_ball", "A on the item ball"), + // No reader for the text yet: the scene's end is the overworld. + scene("found_moon_stone", at(MT_MOON_1F, 3, 3, NORTH), "\" found a MOON STONE!\", then the pocket line; the ball is gone"), + running(route("b2f_ladder_foot", at(MT_MOON_B2F, 26, 21, EAST), "1F's ladder (5, 6), down B1F's west side to its ladder (22, 18), onto B2F")), + running(legs("b2f_south", { + {NORTH, 4}, {EAST, 8}, {WEST, 2}, {SOUTH, 5}, {EAST, 8}, {NORTH, 3}, {WEST, 1}, {NORTH, 5}, + {EAST, 7}, {SOUTH, 14}, {WEST, 9}, {SOUTH, 10}, {WEST, 29}, {EAST, 3}, {NORTH, 17}, + }, "through the Grunt's room at (37, 21), round the south edge, up column 11 to (11, 21)")), + step_into("grunt_line", EAST, "onto (12, 21), below the Grunt at (12, 20)"), + battle("rocket_grunt", at(MT_MOON_B2F, 12, 21, EAST), false, "Rattata and Zubat, Lv13"), + ready("ready_for_miguel", at(ROUTE4_CENTER, 7, 4, NORTH), "items for a status or low HP; the nurse, back through the cave, only if they can't"), + // Up column 13 to Miguel, who stops the leg on (13, 12), then east + // to the wall: the tile below his trigger. + running(legs("below_miguel_trigger", {{EAST, 1}, {NORTH, 9}, {EAST, 1}}, "to (14, 12), below Miguel's trigger (14, 11)")), + step_into("miguel_trigger", NORTH, "onto (14, 11), beside Miguel"), + battle("super_nerd_miguel", at(MT_MOON_B2F, 14, 11, NORTH), false, "Grimer, Voltorb and Koffing, Lv12; \"We'll each take one!\""), + running(legs("below_helix_fossil", {{NORTH, 3}}, "up column 14 to the HELIX FOSSIL (14, 7)")), + fossil_step("helix_fossil", "the HELIX FOSSIL; Miguel takes the DOME FOSSIL", Fossil::HELIX), + // The maps keep the fossils, which the game removes after the + // choice: the step onto the HELIX's tile is taken on its own. + step_into("helix_gap", NORTH, "onto (14, 7), where the HELIX FOSSIL lay"), + running(route("route4_east", at(ROUTE4, 32, 6, SOUTH), "west to B2F's ladder (5, 10), up to B1F, east to its exit onto Route 4")), }); } @@ -318,7 +474,7 @@ StoryCheckpoint running_shoes(){ const std::vector& story_checkpoints(){ static const std::vector checkpoints{ new_game(), after_sid_helper(), leave_house(), oaks_trigger(), to_starter_ball(), starter(), parcel(), pokedex(), shopping(), forest(), brock(), - running_shoes(), + running_shoes(), route3(), magikarp_salesman(), after_gift_rng(), magikarp_to_box(), mt_moon(), }; return checkpoints; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h index e93c6058f9..d7421214f1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h @@ -2,10 +2,11 @@ * * From: https://github.com/PokemonAutomation/ * - * FireRed from a new game to the Boulder Badge and the Running Shoes, as - * story checkpoints 00.01 to 00.10, each saved at its end. After 00.10 - * every route and leg runs. Taken from a by-hand run on - * console B (2026-11-01) and the pokefirered map scripts. + * FireRed from a new game to the Boulder Badge, the Running Shoes, Route + * 3 to the Magikarp salesman in Route 4's Pokemon Center, and through Mt. + * Moon to Route 4 east of it, as story checkpoints 00.01 to 00.13, each + * saved at its end. After 00.10 every route and leg runs. Taken from a + * by-hand run on console B (2026-11-01) and the pokefirered map scripts. * */ @@ -21,13 +22,16 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -// Checkpoints 00.01 to 00.10, in order, with 00.01b, the way back in after -// SID Helper, after 00.01, and 00.04 split in two at Squirtle's ball for -// Starter RNG: 00.04 and 00.04b. +// Checkpoints 00.01 to 00.13, in order, with 00.01b, the way back in after +// SID Helper, after 00.01, 00.04 split in two at Squirtle's ball for +// Starter RNG: 00.04 and 00.04b, 00.12b, the way back in after Gift RNG, +// after 00.12, and 00.12c, the Magikarp put in BOX 1, after it. const std::vector& story_checkpoints(); void add_tests_AutoStory(UnitTestDatabase& database); +// Which rows a run takes (PokemonFRLG_AutoStoryRunPlan_Tests.cpp). +void add_tests_AutoStoryRunPlan(UnitTestDatabase& database); // The table run end to end against a fake game (PokemonFRLG_AutoStorySimulation_Tests.cpp). void add_tests_AutoStorySimulation(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp new file mode 100644 index 0000000000..8e1e45b00a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp @@ -0,0 +1,292 @@ +/* AutoStory Teach + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "PokemonFRLG_AutoStoryTeach.h" +#include "PokemonFRLG_AutoStoryVariantWords.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +bool is_hm_move(std::string_view move){ + // HM01-HM07, FRLG's HMs. + for (std::string_view hm : {"cut", "fly", "surf", "strength", "flash", "rock-smash", "waterfall"}){ + if (move == hm){ + return true; + } + } + return false; +} + +const char* teach_end_name(TeachEnd end){ + switch (end){ + case TeachEnd::LEARNED: return "learned"; + case TeachEnd::ALREADY_KNOWN: return "already_known"; + case TeachEnd::REFUSED: return "refused"; + case TeachEnd::UNREAD: return "unread"; + case TeachEnd::NO_ITEM: return "no_item"; + case TeachEnd::FAILED: return "failed"; + } + return "?"; +} + + +std::string teach_variant(const TeachPlan& plan){ + std::string variant = std::format("{}{} {} slot={}", TEACH_VARIANT_PREFIX, plan.item, plan.move, plan.member + 1); + if (!plan.forget_move.empty()){ + variant += " forget=" + plan.forget_move; + }else if (plan.forget_slot){ + variant += std::format(" forget_slot={}", *plan.forget_slot + 1); + } + return variant; +} + +namespace{ + +bool is_tm_or_hm(std::string_view item){ + return item.size() == 4 && (item.starts_with("tm") || item.starts_with("hm")) + && variant_number(item.substr(2), 2).has_value(); +} + +bool set_teach_word(TeachPlan& plan, std::string_view word){ + const std::optional pair = variant_pair(word); + if (!pair){ + return false; + } + if (pair->key == "forget" && !pair->value.empty()){ + plan.forget_move = std::string(pair->value); + return true; + } + const std::optional number = variant_number(pair->value, 1); + if (pair->key == "slot" && number && *number >= 1 && *number <= PARTY_SLOTS){ + plan.member = *number - 1; + return true; + } + if (pair->key == "forget_slot" && number && *number >= 1 && *number <= MOVE_SLOTS){ + plan.forget_slot = *number - 1; + return true; + } + return false; +} + +} + +std::optional parse_teach_variant(std::string_view variant){ + if (!variant.starts_with(TEACH_VARIANT_PREFIX)){ + return std::nullopt; + } + const std::vector words = variant_words(variant.substr(TEACH_VARIANT_PREFIX.size())); + if (words.size() < 3 || !is_tm_or_hm(words[0]) || variant_pair(words[1])){ + return std::nullopt; + } + TeachPlan plan{.item = std::string(words[0]), .move = std::string(words[1])}; + bool slot_seen = false; + for (size_t c = 2; c < words.size(); c++){ + if (!set_teach_word(plan, words[c])){ + return std::nullopt; + } + slot_seen |= words[c].starts_with("slot="); + } + if (!slot_seen){ + return std::nullopt; + } + return plan; +} + +Pokemon::StoryStep teach_step(std::string name, std::string why, const TeachPlan& plan){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = std::move(name); + step.why = std::move(why); + step.variant = teach_variant(plan); + return step; +} + + +TeachDecision decide_teach(const TeachPlan& plan, const std::vector& moves){ + if (std::find(moves.begin(), moves.end(), plan.move) != moves.end()){ + return {.already_known = true}; + } + if (moves.size() < MOVE_SLOTS){ + return {}; + } + std::optional slot = plan.forget_slot; + if (!plan.forget_move.empty()){ + const auto found = std::find(moves.begin(), moves.end(), plan.forget_move); + if (found == moves.end()){ + return {.error = std::format("it doesn't know {}, the move to forget", plan.forget_move)}; + } + slot = size_t(found - moves.begin()); + } + if (!slot){ + return {.error = "it knows four moves and the table names none to forget"}; + } + if (is_hm_move(moves[*slot])){ + return {.error = std::format("{} is an HM move, which can't be forgotten", moves[*slot])}; + } + return {.forget = slot}; +} + + +namespace{ + +// One TEACH step under way. +class TeachRun{ +public: + TeachRun(TeachActions& actions, const MemberReader& reader, const TeachPlan& plan) + : m_actions(actions) + , m_reader(reader) + , m_plan(plan) + {} + + TeachResult run(){ + const std::optional member = m_reader(m_plan.member); + if (!member || member->moves.empty()){ + return end(TeachEnd::UNREAD, "couldn't read the member's moves"); + } + const TeachDecision decision = decide_teach(m_plan, member->moves); + if (decision.already_known){ + return end(TeachEnd::ALREADY_KNOWN, ""); + } + if (!decision.error.empty()){ + return end(TeachEnd::REFUSED, decision.error); + } + if (decision.forget){ + m_result.forgot = member->moves[*decision.forget]; + } + switch (m_actions.open_item(m_plan.item, m_plan.move)){ + case ItemOpen::OPENED: + break; + case ItemOpen::NOT_IN_CASE: + return leave(TeachEnd::NO_ITEM, std::format("no {} in the TM Case", m_plan.item)); + case ItemOpen::FAILED: + m_actions.dump_screen("open-failed"); + return leave(TeachEnd::FAILED, std::format("couldn't open {} from the TM Case", m_plan.item)); + } + if (!teach(decision.forget)){ + return leave(m_result.end, m_result.detail); + } + if (!m_actions.leave()){ + return failed("leave-failed", "the overworld didn't come back"); + } + return confirm(decision.forget); + } + +private: + TeachResult end(TeachEnd end, std::string detail){ + m_result.end = end; + m_result.detail = std::move(detail); + return m_result; + } + // FAILED, with the screen saved as `label`. + TeachResult failed(std::string_view label, std::string detail){ + m_actions.dump_screen(label); + return end(TeachEnd::FAILED, std::move(detail)); + } + TeachResult leave(TeachEnd end, std::string detail){ + if (!m_actions.leave()){ + return failed("leave-failed", "the overworld didn't come back"); + } + return this->end(end, std::move(detail)); + } + // Sets the result, saves the screen and gives up teaching. False. + bool give_up(TeachEnd end, std::string detail){ + m_actions.dump_screen(std::string("gave-up-") + teach_end_name(end)); + m_actions.stop_teaching(); + m_result.end = end; + m_result.detail = std::move(detail); + return false; + } + + // On "Teach which POKEMON?": the member and its prompts. False if + // it wasn't learned (the result is set). + bool teach(std::optional forget){ + switch (m_actions.choose_member(m_plan.member)){ + case TeachPrompt::LEARNED: + if (forget){ + return give_up(TeachEnd::FAILED, "it learned without forgetting: the moves read were wrong"); + } + return true; + case TeachPrompt::DELETE_PROMPT: + if (!forget){ + return give_up(TeachEnd::FAILED, "it asked to forget a move, but the moves read had room"); + } + return forget_move(*forget); + case TeachPrompt::NOT_COMPATIBLE: + return give_up(TeachEnd::REFUSED, std::format("it can't learn {}", m_plan.move)); + case TeachPrompt::ALREADY_KNOWS: + return give_up(TeachEnd::FAILED, std::format("the game says it knows {} already", m_plan.move)); + case TeachPrompt::FAILED: + break; + } + return give_up(TeachEnd::FAILED, "the party menu's answer wasn't read"); + } + + bool forget_move(size_t slot){ + switch (m_actions.forget(slot)){ + case ForgetEnd::LEARNED: + return true; + case ForgetEnd::HM_REFUSED: + return give_up(TeachEnd::REFUSED, "the game won't forget an HM move"); + case ForgetEnd::FAILED: + break; + } + return give_up(TeachEnd::FAILED, "the moves page didn't answer"); + } + + TeachResult confirm(std::optional forget){ + const std::optional after = m_reader(m_plan.member); + if (!after || after->moves.empty()){ + return failed("moves-unread", "couldn't read the moves after teaching"); + } + const std::vector& moves = after->moves; + const bool landed = forget + ? *forget < moves.size() && moves[*forget] == m_plan.move + : std::find(moves.begin(), moves.end(), m_plan.move) != moves.end(); + if (!landed){ + return failed("not-learned", std::format("{} isn't among its moves after teaching", m_plan.move)); + } + return end(TeachEnd::LEARNED, ""); + } + + TeachActions& m_actions; + const MemberReader& m_reader; + const TeachPlan& m_plan; + TeachResult m_result; +}; + +} + +TeachResult run_teach(Logger& logger, TeachActions& actions, const MemberReader& reader, const TeachPlan& plan){ + TeachResult result = TeachRun(actions, reader, plan).run(); + const Color color = result.end == TeachEnd::LEARNED ? COLOR_BLACK : result.end == TeachEnd::NO_ITEM ? COLOR_ORANGE : COLOR_RED; + logger.log(teach_message(plan, result), color); + return result; +} + +std::string teach_message(const TeachPlan& plan, const TeachResult& result){ + const std::string what = std::format("Teach {} ({}) to slot {}", plan.move, plan.item, plan.member + 1); + switch (result.end){ + case TeachEnd::LEARNED: + return result.forgot.empty() + ? std::format("{}: learned.", what) + : std::format("{}: learned, forgot {}.", what, result.forgot); + case TeachEnd::ALREADY_KNOWN: + return std::format("{}: it knows it already; nothing done.", what); + case TeachEnd::NO_ITEM: + return std::format("{}: {}; skipped.", what, result.detail); + default: + return std::format("{}: {}: {}.", what, teach_end_name(result.end), result.detail); + } +} + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h new file mode 100644 index 0000000000..3756cc3fc0 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h @@ -0,0 +1,177 @@ +/* AutoStory Teach + * + * From: https://github.com/PokemonAutomation/ + * + * A story step that teaches a TM or HM from the bag to a party member, + * forgetting a move the table names, as the game runs it (console B, + * 2026-11-03; the screens are in PokemonFRLG_AutoStoryTeachScreens.h): + * + * - BAG, KEY ITEMS, TM CASE, OPEN, the TM or HM, USE: the party menu, + * "Teach which POKEMON?", each member ABLE or NOT ABLE. A on a member + * that knows the move says so; one NOT ABLE: " and are not + * compatible.". + * - " wants to learn the move ."; with fewer than four moves + * it learns. + * - With four: "However, already knows four moves.", "Should a + * move be deleted and replaced with ?" YES/NO. YES opens the + * moves page; A on a move forgets it and it learns. An HM move can't be + * forgotten (pokefirered: "HM moves can't be forgotten now.") and the + * moves page shows again. B there, then "Stop trying to teach ?" + * YES, leaves it unlearned. + * - A TM is used up; an HM is not. + * + * A TM or HM the TM CASE hasn't got skips the step, with a log line: the + * table can teach a TM a run may not have won yet. + * + * The step reads the member's moves first (MemberReader) and decides + * everything before it opens the bag: the move to forget, by name or + * slot; a refusal if the member already knows the move, has four moves + * and no move to forget is named, or the move named is an HM move. After + * it, the moves are read again: the step succeeds only if the new move + * shows in the forgotten move's place. + * + * The decisions take their game steps from a TeachActions, so they run in + * tests against a fake party and TM Case; on the console they are + * ScreenTeachActions (PokemonFRLG_AutoStoryTeachScreens.h). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTeach_H +#define PokemonAutomation_PokemonFRLG_AutoStoryTeach_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "PokemonFRLG_AutoStoryMember.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// What a TEACH step teaches. +struct TeachPlan{ + // The TM or HM's slug, e.g. "hm03", "tm28". + std::string item = {}; + // The move it holds, e.g. "surf". + std::string move = {}; + // The party slot (0-5). + size_t member = 0; + // The move to forget when it knows four: by name, or else by its + // slot (0-3). Neither: it must have a free slot. + std::string forget_move = {}; + std::optional forget_slot = std::nullopt; +}; + +// Whether `move` is an HM's, which the game won't let a Pokemon forget. +bool is_hm_move(std::string_view move); + +// The prefix of a GAME step's variant that names a TEACH step. +inline constexpr std::string_view TEACH_VARIANT_PREFIX = "teach "; + +// `plan` as a variant: "teach hm03 surf slot=1 forget=tail-whip" or +// "... forget_slot=2", slots counted from 1, no forget when it has room. +std::string teach_variant(const TeachPlan& plan); +std::optional parse_teach_variant(std::string_view variant); + +Pokemon::StoryStep teach_step(std::string name, std::string why, const TeachPlan& plan); + + +// The move slot `plan` forgets for a member that knows `moves`: nothing +// when it has room. Empty `error` if it can be taught, else why not. +struct TeachDecision{ + std::optional forget = std::nullopt; + std::string error = {}; + // It knows the move already: nothing to do. + bool already_known = false; +}; +TeachDecision decide_teach(const TeachPlan& plan, const std::vector& moves); + + +// What A on the member showed. +enum class TeachPrompt{ + LEARNED, + // "Delete an older move to make room?" + DELETE_PROMPT, + NOT_COMPATIBLE, + ALREADY_KNOWS, + FAILED, +}; + +enum class ForgetEnd{ + LEARNED, + // "HM moves can't be forgotten now.": back on the moves page. + HM_REFUSED, + FAILED, +}; + +// What opening the TM or HM from the bag came to. +enum class ItemOpen{ + // On "Teach which POKEMON?". + OPENED, + // The TM CASE's list was read and hasn't got it. + NOT_IN_CASE, + FAILED, +}; + +// The game steps a TEACH step takes, injected so its decisions run in +// tests. +class TeachActions{ +public: + virtual ~TeachActions() = default; + + // From the overworld: the bag, the TM Case, `item` (which holds + // `move`), USE, to the party menu. + virtual ItemOpen open_item(std::string_view item, std::string_view move) = 0; + // On "Teach which POKEMON?": A on `slot`. + virtual TeachPrompt choose_member(size_t slot) = 0; + // On "Delete an older move?": YES, then A on move `slot` (0-3). + virtual ForgetEnd forget(size_t slot) = 0; + // From any of its prompts: give up teaching (NO, "Stop trying?" YES). + virtual void stop_teaching() = 0; + // Back to the overworld. False if it didn't come back. + virtual bool leave() = 0; + // Saves the screen as a debug image, named `label`: the step failed. + virtual void dump_screen(std::string_view label) = 0; +}; + +enum class TeachEnd{ + LEARNED, + ALREADY_KNOWN, + // decide_teach() or the game refused it. + REFUSED, + // The member's moves couldn't be read: nothing was pressed. + UNREAD, + // The TM or HM isn't in the TM CASE: skipped, nothing taught. + NO_ITEM, + FAILED, +}; +const char* teach_end_name(TeachEnd end); + +struct TeachResult{ + TeachEnd end = TeachEnd::FAILED; + // The move forgotten, if any. + std::string forgot = {}; + std::string detail = {}; +}; + +// From the overworld: reads the member, teaches as `plan` says, back to +// the overworld, and reads the member again to confirm. +TeachResult run_teach(Logger& logger, TeachActions& actions, const MemberReader& reader, const TeachPlan& plan); + +std::string teach_message(const TeachPlan& plan, const TeachResult& result); + + +void add_tests_AutoStoryTeach(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp new file mode 100644 index 0000000000..fdaa5b8d21 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp @@ -0,0 +1,416 @@ +/* AutoStory Teach Screens + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Tools/DebugDumper.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryTeachScreens.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace std::chrono_literals; + + +const char* teach_press_name(TeachPress press){ + switch (press){ + case TeachPress::A: return "A"; + case TeachPress::B: return "B"; + case TeachPress::UP: return "UP"; + case TeachPress::DOWN: return "DOWN"; + case TeachPress::LEFT: return "LEFT"; + case TeachPress::RIGHT: return "RIGHT"; + } + return "?"; +} + + +namespace{ + +// Looks a step makes before it gives up on a screen that doesn't change. +constexpr size_t MAX_LOOKS = 40; +// Looks at the same moves page after A on a move: the game refused it. +constexpr size_t MOVES_PAGE_STILL = 8; + +std::string rows_text(const std::vector& rows){ + std::string text; + for (const std::string& row : rows){ + text += (text.empty() ? "" : ", ") + row; + } + return text.empty() ? "none read" : text; +} + +} + + +ScreenTeachActions::ScreenTeachActions(Logger& logger, TeachScreens& screens) + : m_logger(logger) + , m_screens(screens) +{} + +void ScreenTeachActions::press(TeachPress press){ + m_screens.press(press); +} + +std::optional ScreenTeachActions::wait_for(TeachScreen screen, size_t looks){ + for (size_t c = 0; c < looks; c++){ + TeachView view = m_screens.look(); + if (view.screen == screen){ + return view; + } + } + m_logger.log(std::format("Teach: {} didn't show.", teach_screen_name(screen)), COLOR_RED); + return std::nullopt; +} + +bool ScreenTeachActions::move_cursor_to(TeachScreen screen, size_t row){ + for (size_t c = 0; c < TEACH_LIST_ROWS * 2; c++){ + const TeachView view = m_screens.look(); + if (view.screen != screen || !view.cursor){ + m_logger.log(std::format("Teach: lost the cursor on {}.", teach_screen_name(screen)), COLOR_RED); + return false; + } + if (*view.cursor == row){ + return true; + } + press(*view.cursor < row ? TeachPress::DOWN : TeachPress::UP); + } + return false; +} + +bool ScreenTeachActions::to_key_items(){ + for (size_t c = 0; c < 4; c++){ + const std::optional view = wait_for(TeachScreen::BAG); + if (!view){ + return false; + } + if (view->pocket == "key-items"){ + return true; + } + if (view->pocket == "items"){ + press(TeachPress::RIGHT); + }else if (view->pocket == "poke-balls"){ + press(TeachPress::LEFT); + }else{ + m_logger.log("Teach: the bag's pocket wasn't read.", COLOR_RED); + return false; + } + } + return false; +} + +ItemOpen ScreenTeachActions::open_row(TeachScreen screen, std::string_view name){ + const std::optional view = wait_for(screen); + if (!view){ + return ItemOpen::FAILED; + } + const auto found = std::find(view->rows.begin(), view->rows.end(), name); + if (found == view->rows.end()){ + m_logger.log(std::format("Teach: no {} in {} ({}).", name, teach_screen_name(screen), rows_text(view->rows)), COLOR_RED); + // No rows: nothing was read. + return view->rows.empty() ? ItemOpen::FAILED : ItemOpen::NOT_IN_CASE; + } + if (!move_cursor_to(screen, size_t(found - view->rows.begin()))){ + return ItemOpen::FAILED; + } + press(TeachPress::A); + if (!wait_for(TeachScreen::ITEM_MENU) || !move_cursor_to(TeachScreen::ITEM_MENU, 0)){ + return ItemOpen::FAILED; + } + press(TeachPress::A); + return ItemOpen::OPENED; +} + +ItemOpen ScreenTeachActions::open_item(std::string_view item, std::string_view move){ + if (!m_screens.open_bag()){ + m_logger.log("Teach: the bag didn't open.", COLOR_RED); + return ItemOpen::FAILED; + } + if (!to_key_items() || open_row(TeachScreen::BAG, "tm-case") != ItemOpen::OPENED){ + return ItemOpen::FAILED; + } + const ItemOpen opened = open_row(TeachScreen::TM_CASE, move); + if (opened != ItemOpen::OPENED){ + m_logger.log(std::format("Teach: couldn't use {} ({}) from the TM CASE.", item, move), COLOR_RED); + return opened; + } + return wait_for(TeachScreen::PARTY_PROMPT).has_value() ? ItemOpen::OPENED : ItemOpen::FAILED; +} + +TeachPrompt ScreenTeachActions::choose_member(size_t slot){ + if (!wait_for(TeachScreen::PARTY_PROMPT)){ + return TeachPrompt::FAILED; + } + // The menu opens on the lead; DOWN steps through the slots. + for (size_t c = 0; c < slot; c++){ + press(TeachPress::DOWN); + } + press(TeachPress::A); + return member_answer(); +} + +TeachPrompt ScreenTeachActions::member_answer(){ + bool incompatible = false; + size_t prompts = 0; + for (size_t c = 0; c < MAX_LOOKS; c++){ + const TeachView view = m_screens.look(); + switch (view.screen){ + case TeachScreen::PARTY_TEXT: + if (view.text == TeachText::WHICH_MOVE){ + return TeachPrompt::DELETE_PROMPT; + } + incompatible |= view.text == TeachText::NOT_COMPATIBLE; + press(TeachPress::A); + break; + case TeachScreen::PARTY_YES_NO: + return view.text == TeachText::STOP_QUESTION ? TeachPrompt::FAILED : TeachPrompt::DELETE_PROMPT; + case TeachScreen::KNOWN_MOVES: + return TeachPrompt::DELETE_PROMPT; + case TeachScreen::TM_CASE: + return TeachPrompt::LEARNED; + case TeachScreen::PARTY_PROMPT: + // Back on the prompt after a message, or not left yet. + if (incompatible){ + return TeachPrompt::NOT_COMPATIBLE; + } + if (++prompts > MOVES_PAGE_STILL){ + return TeachPrompt::FAILED; + } + break; + default: + break; + } + } + return TeachPrompt::FAILED; +} + +void ScreenTeachActions::answer_to_forget(const TeachView& view){ + // YES to delete a move; NO to stop trying, back to the moves. + if (view.text == TeachText::STOP_QUESTION){ + press(TeachPress::B); + }else{ + press(view.cursor == 1u ? TeachPress::UP : TeachPress::A); + } +} + +ForgetEnd ScreenTeachActions::forget(size_t slot){ + bool picked = false; + size_t still = 0; + // Looks at a screen not placed since the move was picked. + size_t unplaced = 0; + for (size_t c = 0; c < MAX_LOOKS; c++){ + const TeachView view = m_screens.look(); + switch (view.screen){ + case TeachScreen::PARTY_YES_NO: + answer_to_forget(view); + break; + case TeachScreen::PARTY_TEXT: + press(TeachPress::A); + break; + case TeachScreen::KNOWN_MOVES: + if (picked){ + if (++still > MOVES_PAGE_STILL){ + return ForgetEnd::HM_REFUSED; + } + }else if (view.cursor && *view.cursor != slot){ + press(*view.cursor < slot ? TeachPress::DOWN : TeachPress::UP); + }else if (view.cursor){ + press(TeachPress::A); + picked = true; + } + break; + case TeachScreen::TM_CASE: + case TeachScreen::BAG: + case TeachScreen::ITEM_MENU: + return picked ? ForgetEnd::LEARNED : ForgetEnd::FAILED; + default: + // After the pick, its messages: B pages through them, once the + // moves page has closed (the first look). + if (picked && ++unplaced > 1){ + press(TeachPress::B); + } + break; + } + } + if (picked && unplaced > 0){ + // The moves page closed on the pick: the moves read after decide. + m_logger.log("Teach: the TM CASE didn't show after the forget; reading the moves will tell.", COLOR_ORANGE); + return ForgetEnd::LEARNED; + } + return ForgetEnd::FAILED; +} + +void ScreenTeachActions::stop_teaching(){ + for (size_t c = 0; c < MAX_LOOKS; c++){ + const TeachView view = m_screens.look(); + switch (view.screen){ + case TeachScreen::KNOWN_MOVES: + case TeachScreen::PARTY_PROMPT: + press(TeachPress::B); + break; + case TeachScreen::PARTY_YES_NO: + // NO to deleting a move, then YES to stop trying. + if (view.text == TeachText::DELETE_QUESTION){ + press(TeachPress::B); + }else{ + press(view.cursor == 1u ? TeachPress::UP : TeachPress::A); + } + break; + case TeachScreen::PARTY_TEXT: + press(TeachPress::A); + break; + default: + return; + } + } +} + +void ScreenTeachActions::dump_screen(std::string_view label){ + m_screens.dump_screen(label); +} + +bool ScreenTeachActions::leave(){ + for (size_t c = 0; c < MAX_LOOKS; c++){ + const TeachView view = m_screens.look(); + switch (view.screen){ + case TeachScreen::BAG: + case TeachScreen::ITEM_MENU: + case TeachScreen::TM_CASE: + case TeachScreen::PARTY_PROMPT: + press(TeachPress::B); + break; + case TeachScreen::PARTY_TEXT: + case TeachScreen::PARTY_YES_NO: + case TeachScreen::KNOWN_MOVES: + stop_teaching(); + break; + case TeachScreen::OTHER: + return m_screens.to_overworld(); + } + } + return false; +} + + +ConsoleTeachScreens::ConsoleTeachScreens(ConsoleHandle& console, ProControllerContext& context, Language language) + : m_console(console) + , m_context(context) + , m_language(language) +{} + +bool ConsoleTeachScreens::open_bag(){ + try{ + open_bag_from_overworld(m_console, m_context); + m_context.wait_for_all_requests(); + return true; + }catch (OperationFailedException& e){ + m_console.log("Teach: " + e.message(), COLOR_RED); + return false; + } +} + +TeachView ConsoleTeachScreens::look(){ + // Two looks 150 ms apart that agree: the screen has stopped changing. + std::optional>> last; + for (size_t c = 0; c < 10; c++){ + m_context.wait_for_all_requests(); + VideoSnapshot snapshot = m_console.video().snapshot(); + if (snapshot){ + const TeachScreen screen = read_teach_screen(snapshot); + const std::optional cursor = read_teach_cursor(screen, snapshot); + if (last && last->first == screen && last->second == cursor){ + TeachView view = read_view(snapshot, screen, cursor); + log_view(view); + return view; + } + last.emplace(screen, cursor); + } + pbf_wait(m_context, 150ms); + } + return {}; +} + +TeachView ConsoleTeachScreens::read_view(const ImageViewRGB32& image, TeachScreen screen, std::optional cursor){ + Logger& logger = m_console.logger(); + TeachView view{.screen = screen, .cursor = cursor}; + switch (screen){ + case TeachScreen::BAG: + view.pocket = read_pocket_name(logger, m_language, image); + if (view.pocket == "key-items"){ + view.rows = read_teach_rows(logger, m_language, image, screen); + } + break; + case TeachScreen::TM_CASE: + view.rows = read_teach_rows(logger, m_language, image, screen); + break; + case TeachScreen::PARTY_PROMPT: + case TeachScreen::PARTY_TEXT: + case TeachScreen::PARTY_YES_NO: + view.text = read_teach_text(logger, m_language, image); + break; + default: + break; + } + return view; +} + +void ConsoleTeachScreens::log_view(const TeachView& view){ + std::string line = std::format( + "Teach: {}, cursor {}", teach_screen_name(view.screen), view.cursor ? std::to_string(*view.cursor + 1) : "none" + ); + if (!view.pocket.empty()){ + line += ", " + view.pocket; + } + if (!view.rows.empty()){ + line += " (" + rows_text(view.rows) + ")"; + } + if (view.text != TeachText::UNKNOWN){ + line += std::string(", \"") + teach_text_name(view.text) + "\""; + } + if (line != m_logged){ + m_console.log(line + "."); + m_logged = line; + } +} + +void ConsoleTeachScreens::press(TeachPress press){ + switch (press){ + case TeachPress::A: pbf_press_button(m_context, BUTTON_A, 160ms, 240ms); break; + case TeachPress::B: pbf_press_button(m_context, BUTTON_B, 160ms, 240ms); break; + case TeachPress::UP: pbf_press_dpad(m_context, DPAD_UP, 160ms, 240ms); break; + case TeachPress::DOWN: pbf_press_dpad(m_context, DPAD_DOWN, 160ms, 240ms); break; + case TeachPress::LEFT: pbf_press_dpad(m_context, DPAD_LEFT, 160ms, 240ms); break; + case TeachPress::RIGHT: pbf_press_dpad(m_context, DPAD_RIGHT, 160ms, 240ms); break; + } + m_context.wait_for_all_requests(); +} + +void ConsoleTeachScreens::dump_screen(std::string_view label){ + m_context.wait_for_all_requests(); + dump_debug_image(m_console, "AutoStory/teach", std::string(label), m_console.video().snapshot()); +} + +bool ConsoleTeachScreens::to_overworld(){ + try{ + mash_until_overworld(m_console, m_context, 20000ms); + return true; + }catch (OperationFailedException& e){ + m_console.log("Teach: " + e.message(), COLOR_RED); + return false; + } +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h new file mode 100644 index 0000000000..8fbb380d00 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h @@ -0,0 +1,161 @@ +/* AutoStory Teach Screens + * + * From: https://github.com/PokemonAutomation/ + * + * The TEACH step's game steps on the console, screen by screen as console + * B showed them (2026-11-03, FireRed, TM03 to a Wartortle with four + * moves): + * + * - START, BAG: the bag opens on the pocket it was left on (ITEMS); + * RIGHT goes to KEY ITEMS, LEFT back (POKE BALLS is right of KEY ITEMS). + * - KEY ITEMS: TEACHY TV, TM CASE, HELIX FOSSIL, CANCEL. A on TM CASE: + * OPEN/REGISTER/CANCEL, OPEN on top; A: the TM CASE's list ("No03 + * WATER PULSE x1", "No39 ROCK TOMB x1", CLOSE). + * - A on a TM: USE/GIVE/EXIT, USE on top; A: the party menu, "Teach which + * POKEMON?", each member ABLE or NOT ABLE, the cursor on the lead. + * - A on a member: " wants to learn the move ."; with room it + * learns. With four: "However, already knows four moves.", + * "Should a move be deleted and replaced with ?" YES/NO (YES on + * top), YES: "Which move should be forgotten?", then KNOWN MOVES with + * the four moves and the new one fifth, the red cursor on the first. A + * on a move forgets it: "1, 2 and... ... Poof!", " forgot .", + * "And... learned !", which the reader doesn't place (read + * "other"; B pages through them), then the TM CASE's list. B instead: "Stop trying to teach ?" + * YES/NO, YES: " did not learn the move ." and the TM CASE's + * list again, the TM kept. + * - From the TM CASE's list, B closes it to the bag, and B the bag. + * + * Each step looks at the screen before it presses, and stops at a screen + * it doesn't expect instead of pressing on. The screens come from a + * TeachScreens, so the steps run in tests against a fake bag, TM CASE and + * party menu that answer as the console did. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTeachScreens_H +#define PokemonAutomation_PokemonFRLG_AutoStoryTeachScreens_H + +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Language.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h" +#include "PokemonFRLG_AutoStoryTeach.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// A screen on the way, read. +struct TeachView{ + TeachScreen screen = TeachScreen::OTHER; + // The cursor's row, from the top. + std::optional cursor = std::nullopt; + // In the bag: the pocket's name ("items", "key-items", "poke-balls"). + std::string pocket = {}; + // In KEY ITEMS, its items ("tm-case", ...); in the TM CASE, its TMs' + // moves ("water-pulse", ...): from the top to the first not read. + std::vector rows = {}; + // Over the party menu: its message. + TeachText text = TeachText::UNKNOWN; +}; + +enum class TeachPress{ + A, + B, + UP, + DOWN, + LEFT, + RIGHT, +}; +const char* teach_press_name(TeachPress press); + +// The console's screens and buttons, as the TEACH step uses them. +class TeachScreens{ +public: + virtual ~TeachScreens() = default; + + // From the overworld: START, BAG. False if the bag didn't open. + virtual bool open_bag() = 0; + // The screen, once it has stopped changing. + virtual TeachView look() = 0; + virtual void press(TeachPress press) = 0; + // B until the overworld is back. False if it didn't come back. + virtual bool to_overworld() = 0; + // Saves the screen as a debug image, named `label`. + virtual void dump_screen(std::string_view label) = 0; +}; + + +// The TEACH step's game steps through `screens`. +class ScreenTeachActions : public TeachActions{ +public: + ScreenTeachActions(Logger& logger, TeachScreens& screens); + + virtual ItemOpen open_item(std::string_view item, std::string_view move) override; + virtual TeachPrompt choose_member(size_t slot) override; + virtual ForgetEnd forget(size_t slot) override; + virtual void stop_teaching() override; + virtual bool leave() override; + virtual void dump_screen(std::string_view label) override; + +private: + // Looks until `screen` shows, `looks` times at most. + std::optional wait_for(TeachScreen screen, size_t looks = 12); + // On `screen`: the cursor to `row`. False if it didn't get there. + bool move_cursor_to(TeachScreen screen, size_t row); + // In the bag: to KEY ITEMS. + bool to_key_items(); + // On the list `screen`: A on the row `name`, then A on the item menu's + // top row (OPEN or USE). NOT_IN_CASE: the rows read haven't got it. + ItemOpen open_row(TeachScreen screen, std::string_view name); + // After A on a member: what it showed. + TeachPrompt member_answer(); + // On a YES/NO while forgetting: the answer that goes on to the moves. + void answer_to_forget(const TeachView& view); + void press(TeachPress press); + + Logger& m_logger; + TeachScreens& m_screens; +}; + + +// The console's screens: read with PokemonFRLG_TeachScreenReader.h in a +// game in `language`. +class ConsoleTeachScreens : public TeachScreens{ +public: + ConsoleTeachScreens(ConsoleHandle& console, ProControllerContext& context, Language language); + + virtual bool open_bag() override; + virtual TeachView look() override; + virtual void press(TeachPress press) override; + virtual bool to_overworld() override; + virtual void dump_screen(std::string_view label) override; + +private: + // The screen in `image`, read. + TeachView read_view(const ImageViewRGB32& image, TeachScreen screen, std::optional cursor); + void log_view(const TeachView& view); + + ConsoleHandle& m_console; + ProControllerContext& m_context; + Language m_language; + // The last view logged. + std::string m_logged; +}; + + +void add_tests_AutoStoryTeachScreens(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp new file mode 100644 index 0000000000..d46eef5517 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp @@ -0,0 +1,485 @@ +/* AutoStory Teach Screens Tests + * + * The console's TEACH steps against a fake bag, TM CASE and party menu + * that answer as console B did on 2026-11-03 (FireRed): the bag left on + * ITEMS; KEY ITEMS with TEACHY TV, TM CASE, HELIX FOSSIL; TM03 WATER + * PULSE and TM39 ROCK TOMB in the TM CASE; a Wartortle Lv22 with BITE, + * TAIL WHIP, BUBBLE, WITHDRAW. That run's step failed with "couldn't open + * tm03 from the TM Case": the console's steps had no readers and stopped + * before the bag. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryTeachScreens.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +struct FakeTm{ + std::string move; + size_t count = 1; +}; + +// The game's state behind the screens. +enum class FakeScreen{ + OVERWORLD, + START_MENU, + BAG, + BAG_MENU, + TM_CASE, + TM_MENU, + PROMPT, + TEXT, + // Messages the reader doesn't place: "other". + UNREAD_TEXT, + DELETE_YES_NO, + STOP_YES_NO, + KNOWN_MOVES, +}; + +// The bag, TM CASE and party menu as console B showed them. +struct FakeTeachGame{ + std::vector pockets{"items", "key-items", "poke-balls"}; + size_t pocket = 0; + std::vector key_items{"teachy-tv", "tm-case", "helix-fossil", "cancel"}; + size_t bag_cursor = 0; + std::vector tms{{"water-pulse", 1}, {"rock-tomb", 1}}; + std::vector moves{"bite", "tail-whip", "bubble", "withdraw"}; + std::set compatible{"water-pulse", "rock-tomb"}; + + FakeScreen screen = FakeScreen::OVERWORLD; + size_t cursor = 0; + // The messages left to page through, and the screen after them. + std::deque texts = {}; + FakeScreen after_texts = FakeScreen::OVERWORLD; + // The messages after a move is forgotten ("1, 2 and... Poof!", "... + // forgot TAIL WHIP.", "And... learned WATER PULSE!") read as "other", + // as on console B (2026-11-03). + bool forget_texts_unread = false; + // The TM in use, by row. + size_t tm = 0; + std::vector presses = {}; + std::vector dumps = {}; + std::string error = {}; +}; + +std::vector tm_rows(const FakeTeachGame& game){ + std::vector rows; + for (const FakeTm& tm : game.tms){ + if (tm.count > 0){ + rows.push_back(tm.move); + } + } + return rows; +} + +void show_texts(FakeTeachGame& game, std::deque texts, FakeScreen after){ + game.texts = std::move(texts); + game.after_texts = after; + game.screen = FakeScreen::TEXT; +} + +void move_cursor(FakeTeachGame& game, TeachPress press, size_t rows){ + if (press == TeachPress::UP && game.cursor > 0){ + game.cursor--; + }else if (press == TeachPress::DOWN && game.cursor + 1 < rows){ + game.cursor++; + } +} + +std::string tm_move(const FakeTeachGame& game){ + const std::vector rows = tm_rows(game); + return game.tm < rows.size() ? rows[game.tm] : ""; +} + +void use_up(FakeTeachGame& game){ + const std::string move = tm_move(game); + for (FakeTm& tm : game.tms){ + if (tm.move == move && tm.count > 0){ + tm.count--; + return; + } + } +} + +void choose_member(FakeTeachGame& game){ + const std::string move = tm_move(game); + if (!game.compatible.contains(move)){ + show_texts(game, {TeachText::NOT_COMPATIBLE}, FakeScreen::PROMPT); + return; + } + if (game.moves.size() < MOVE_SLOTS){ + game.moves.push_back(move); + use_up(game); + game.cursor = 0; + show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::UNKNOWN}, FakeScreen::TM_CASE); + return; + } + show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::KNOWS_FOUR}, FakeScreen::DELETE_YES_NO); + game.cursor = 0; +} + +void press_bag(FakeTeachGame& game, TeachPress press){ + const bool key_items = game.pockets[game.pocket] == "key-items"; + switch (press){ + case TeachPress::LEFT: + case TeachPress::RIGHT: + if (press == TeachPress::LEFT && game.pocket > 0){ + game.pocket--; + game.cursor = 0; + }else if (press == TeachPress::RIGHT && game.pocket + 1 < game.pockets.size()){ + game.pocket++; + game.cursor = 0; + } + return; + case TeachPress::A: + if (key_items && game.key_items[game.cursor] == "tm-case"){ + game.screen = FakeScreen::BAG_MENU; + game.bag_cursor = game.cursor; + game.cursor = 0; + } + return; + case TeachPress::B: + game.screen = FakeScreen::START_MENU; + return; + default: + move_cursor(game, press, key_items ? game.key_items.size() : 4); + } +} + +void press_bag_menu(FakeTeachGame& game, TeachPress press){ + if (press == TeachPress::A && game.cursor == 0){ + game.screen = FakeScreen::TM_CASE; + game.cursor = 0; + }else if (press == TeachPress::A || press == TeachPress::B){ + game.screen = FakeScreen::BAG; + game.cursor = game.bag_cursor; + }else{ + move_cursor(game, press, 3); + } +} + +void press_tm_case(FakeTeachGame& game, TeachPress press){ + const size_t rows = tm_rows(game).size(); + if (press == TeachPress::B || (press == TeachPress::A && game.cursor == rows)){ + game.screen = FakeScreen::BAG; + game.cursor = game.bag_cursor; + }else if (press == TeachPress::A){ + game.tm = game.cursor; + game.screen = FakeScreen::TM_MENU; + game.cursor = 0; + }else{ + move_cursor(game, press, rows + 1); + } +} + +void press_tm_menu(FakeTeachGame& game, TeachPress press){ + if (press == TeachPress::A && game.cursor == 0){ + game.screen = FakeScreen::PROMPT; + }else if (press == TeachPress::A || press == TeachPress::B){ + game.screen = FakeScreen::TM_CASE; + game.cursor = game.tm; + }else{ + move_cursor(game, press, 3); + } +} + +void press_yes_no(FakeTeachGame& game, TeachPress press){ + const bool yes = press == TeachPress::A && game.cursor == 0; + const bool no = press == TeachPress::B || (press == TeachPress::A && game.cursor == 1); + if (game.screen == FakeScreen::DELETE_YES_NO && yes){ + show_texts(game, {TeachText::WHICH_MOVE}, FakeScreen::KNOWN_MOVES); + game.cursor = 0; + }else if (game.screen == FakeScreen::STOP_YES_NO && yes){ + show_texts(game, {TeachText::DID_NOT_LEARN}, FakeScreen::TM_CASE); + game.cursor = game.tm; + }else if (no){ + game.screen = game.screen == FakeScreen::DELETE_YES_NO ? FakeScreen::STOP_YES_NO : FakeScreen::DELETE_YES_NO; + game.cursor = 0; + }else{ + move_cursor(game, press, 2); + } +} + +void press_known_moves(FakeTeachGame& game, TeachPress press){ + if (press == TeachPress::B || (press == TeachPress::A && game.cursor == MOVE_SLOTS)){ + game.screen = FakeScreen::STOP_YES_NO; + game.cursor = 0; + }else if (press == TeachPress::A){ + if (is_hm_move(game.moves[game.cursor])){ + return; + } + game.moves[game.cursor] = tm_move(game); + use_up(game); + show_texts(game, {TeachText::UNKNOWN, TeachText::UNKNOWN, TeachText::UNKNOWN}, FakeScreen::TM_CASE); + if (game.forget_texts_unread){ + game.screen = FakeScreen::UNREAD_TEXT; + } + game.cursor = 0; + }else{ + move_cursor(game, press, MOVE_SLOTS + 1); + } +} + +void press_game(FakeTeachGame& game, TeachPress press){ + switch (game.screen){ + case FakeScreen::OVERWORLD: + case FakeScreen::START_MENU: + game.error = std::format("pressed {} off the menus", teach_press_name(press)); + return; + case FakeScreen::BAG: press_bag(game, press); return; + case FakeScreen::BAG_MENU: press_bag_menu(game, press); return; + case FakeScreen::TM_CASE: press_tm_case(game, press); return; + case FakeScreen::TM_MENU: press_tm_menu(game, press); return; + case FakeScreen::DELETE_YES_NO: + case FakeScreen::STOP_YES_NO: press_yes_no(game, press); return; + case FakeScreen::KNOWN_MOVES: press_known_moves(game, press); return; + case FakeScreen::PROMPT: + if (press == TeachPress::A){ + choose_member(game); + }else if (press == TeachPress::B){ + game.screen = FakeScreen::TM_CASE; + game.cursor = game.tm; + } + return; + case FakeScreen::TEXT: + case FakeScreen::UNREAD_TEXT: + game.texts.pop_front(); + if (game.texts.empty()){ + game.screen = game.after_texts; + } + return; + } +} + +TeachView view_of(const FakeTeachGame& game){ + switch (game.screen){ + case FakeScreen::OVERWORLD: + case FakeScreen::START_MENU: + case FakeScreen::UNREAD_TEXT: + return {}; + case FakeScreen::BAG:{ + const bool key_items = game.pockets[game.pocket] == "key-items"; + return {.screen = TeachScreen::BAG, .cursor = game.cursor, .pocket = game.pockets[game.pocket], + .rows = key_items ? game.key_items : std::vector{}}; + } + case FakeScreen::BAG_MENU: + case FakeScreen::TM_MENU: + return {.screen = TeachScreen::ITEM_MENU, .cursor = game.cursor}; + case FakeScreen::TM_CASE: + return {.screen = TeachScreen::TM_CASE, .cursor = game.cursor, .rows = tm_rows(game)}; + case FakeScreen::PROMPT: + return {.screen = TeachScreen::PARTY_PROMPT, .text = TeachText::TEACH_WHICH}; + case FakeScreen::TEXT: + return {.screen = TeachScreen::PARTY_TEXT, .text = game.texts.front()}; + case FakeScreen::DELETE_YES_NO: + return {.screen = TeachScreen::PARTY_YES_NO, .cursor = game.cursor, .text = TeachText::DELETE_QUESTION}; + case FakeScreen::STOP_YES_NO: + return {.screen = TeachScreen::PARTY_YES_NO, .cursor = game.cursor, .text = TeachText::STOP_QUESTION}; + case FakeScreen::KNOWN_MOVES: + return {.screen = TeachScreen::KNOWN_MOVES, .cursor = game.cursor}; + } + return {}; +} + +class FakeTeachScreens : public TeachScreens{ +public: + explicit FakeTeachScreens(FakeTeachGame& game) + : m_game(game) + {} + + virtual bool open_bag() override{ + if (m_game.screen != FakeScreen::OVERWORLD){ + m_game.error = "opened the bag off the overworld"; + return false; + } + m_game.screen = FakeScreen::BAG; + m_game.cursor = 0; + return true; + } + virtual TeachView look() override{ + return view_of(m_game); + } + virtual void press(TeachPress press) override{ + m_game.presses.emplace_back(teach_press_name(press)); + if (m_game.presses.size() > 200){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "more than 200 presses"); + } + press_game(m_game, press); + } + virtual bool to_overworld() override{ + if (m_game.screen == FakeScreen::START_MENU || m_game.screen == FakeScreen::OVERWORLD){ + m_game.screen = FakeScreen::OVERWORLD; + return true; + } + return false; + } + virtual void dump_screen(std::string_view label) override{ + m_game.dumps.emplace_back(label); + } + +private: + FakeTeachGame& m_game; +}; + + +struct ScreensCase{ + std::string name = {}; + std::function setup = {}; + TeachPlan plan = {.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; + TeachEnd end = TeachEnd::LEARNED; + std::vector moves = {"bite", "water-pulse", "bubble", "withdraw"}; + // TM03's count after. + size_t tm03 = 0; +}; + +void left_on_poke_balls(FakeTeachGame& game){ + game.pocket = 2; +} +void three_moves(FakeTeachGame& game){ + game.moves = {"bite", "bubble", "withdraw"}; +} +void no_tm03(FakeTeachGame& game){ + game.tms = {{"rock-tomb", 1}}; +} +void forget_texts_unread(FakeTeachGame& game){ + game.forget_texts_unread = true; +} +void nothing_compatible(FakeTeachGame& game){ + game.compatible.clear(); +} + +std::vector screens_cases(){ + return { + {.name = "console B: TM03 over TAIL WHIP"}, + { + // Console B, 2026-11-03: known_moves, then "other" until the + // step gave up; the move was learned. + .name = "the texts after the forget read as other", + .setup = forget_texts_unread, + }, + { + .name = "the bag left on POKE BALLS", + .setup = left_on_poke_balls, + }, + { + .name = "a free slot: no move forgotten", + .setup = three_moves, + .plan = {.item = "tm03", .move = "water-pulse", .member = 0}, + .moves = {"bite", "bubble", "withdraw", "water-pulse"}, + }, + { + .name = "no TM03 in the TM CASE: skipped, back to the overworld", + .setup = no_tm03, + .end = TeachEnd::NO_ITEM, + .moves = {"bite", "tail-whip", "bubble", "withdraw"}, + }, + { + .name = "not compatible: given up, back to the overworld", + .setup = nothing_compatible, + .end = TeachEnd::REFUSED, + .moves = {"bite", "tail-whip", "bubble", "withdraw"}, + .tm03 = 1, + }, + }; +} + +UnitTestResult check_screens_case(const ScreensCase& item){ + QuietLogger logger; + FakeTeachGame game; + if (item.setup){ + item.setup(game); + } + FakeTeachScreens screens(game); + ScreenTeachActions actions(logger, screens); + const MemberReader reader = [&game](size_t) -> std::optional{ + if (game.screen != FakeScreen::OVERWORLD){ + game.error = "read the summary off the overworld"; + } + return MemberInfo{.species = "wartortle", .level = 22, .moves = game.moves}; + }; + const TeachResult result = run_teach(logger, actions, reader, item.plan); + const size_t tm03 = game.tms.empty() || game.tms[0].move != "water-pulse" ? 0 : game.tms[0].count; + std::string presses; + for (const std::string& press : game.presses){ + presses += press + " "; + } + if (!game.error.empty()){ + return std::format("{}: {} (presses: {}).", item.name, game.error, presses); + } + if (result.end != item.end || game.moves != item.moves || tm03 != item.tm03){ + return std::format("{}: ended {} ({}), TM03 x{}; presses: {}.", + item.name, teach_end_name(result.end), result.detail, tm03, presses); + } + if (game.screen != FakeScreen::OVERWORLD){ + return std::format("{}: didn't end on the overworld; presses: {}.", item.name, presses); + } + // A debug image for each failure, none otherwise. + const bool failed = item.end == TeachEnd::FAILED || item.end == TeachEnd::REFUSED; + if (failed == game.dumps.empty()){ + return std::format("{}: {} debug images.", item.name, game.dumps.size()); + } + return true; +} + +UnitTestResult test_screens(){ + for (const ScreensCase& item : screens_cases()){ + const UnitTestResult result = check_screens_case(item); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + return true; +} + + +class Test_AutoStoryTeachScreens : public UnitTest{ +public: + Test_AutoStoryTeachScreens(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStoryTeachScreens - " + 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; +}; + +} + + +void add_tests_AutoStoryTeachScreens(UnitTestDatabase& database){ + database.add("TM03 through the console's screens", test_screens); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp new file mode 100644 index 0000000000..514c3c5540 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp @@ -0,0 +1,407 @@ +/* AutoStory Teach Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The TEACH step against a fake party and TM Case that answer as + * pokefirered's tm_case.c and party_menu.c do (see + * PokemonFRLG_AutoStoryTeach.h): a member with room learns, one with four + * moves asks to forget one, an HM move can't be forgotten, a TM is used up + * and an HM is not. The party is console B's (2026-11-02): Squirtle Lv16 + * with WATER GUN, TAIL WHIP, BUBBLE, WITHDRAW. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryTeach.h" +#include "PokemonFRLG_AutoStoryTeachScreens.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +struct FakeMon{ + std::string species; + std::vector moves; + std::set compatible; +}; + +struct FakeDisc{ + std::string move; + size_t count = 1; +}; + +class FakeTeachGame : public TeachActions{ +public: + std::vector party; + std::map tm_case = {}; + bool moves_readable = true; + // What the reader says before teaching, if not the party's moves. + std::optional> stale_moves = std::nullopt; + // The moves page forgets nothing: a bug the confirmation must catch. + bool forget_ignored = false; + // The bag or the TM Case didn't open. + bool open_fails = false; + size_t items_opened = 0; + size_t stopped = 0; + std::string error = {}; + + std::optional read_member(size_t slot){ + if (m_screen != Screen::OVERWORLD){ + error = "read the summary off the overworld"; + } + if (slot >= party.size() || !moves_readable){ + return std::nullopt; + } + MemberInfo info{.species = party[slot].species, .level = 16, .moves = party[slot].moves}; + if (stale_moves && items_opened == 0){ + info.moves = *stale_moves; + } + return info; + } + virtual ItemOpen open_item(std::string_view item, std::string_view) override{ + items_opened++; + if (open_fails){ + m_screen = Screen::BAG; + return ItemOpen::FAILED; + } + const auto found = tm_case.find(std::string(item)); + if (found == tm_case.end() || found->second.count == 0){ + m_screen = Screen::BAG; + return ItemOpen::NOT_IN_CASE; + } + m_item = found->first; + m_screen = Screen::CHOOSE_MEMBER; + return ItemOpen::OPENED; + } + virtual TeachPrompt choose_member(size_t slot) override{ + if (m_screen != Screen::CHOOSE_MEMBER || slot >= party.size()){ + error = "chose a member off the party menu"; + return TeachPrompt::FAILED; + } + m_slot = slot; + FakeMon& mon = party[slot]; + const std::string& move = tm_case[m_item].move; + if (std::find(mon.moves.begin(), mon.moves.end(), move) != mon.moves.end()){ + return TeachPrompt::ALREADY_KNOWS; + } + if (!mon.compatible.contains(move)){ + return TeachPrompt::NOT_COMPATIBLE; + } + if (mon.moves.size() < MOVE_SLOTS){ + mon.moves.push_back(move); + used_up(); + return TeachPrompt::LEARNED; + } + m_screen = Screen::DELETE_PROMPT; + return TeachPrompt::DELETE_PROMPT; + } + virtual ForgetEnd forget(size_t slot) override{ + if (m_screen != Screen::DELETE_PROMPT){ + error = "forgot a move off the prompt"; + return ForgetEnd::FAILED; + } + std::vector& moves = party[m_slot].moves; + if (is_hm_move(moves[slot])){ + return ForgetEnd::HM_REFUSED; + } + if (!forget_ignored){ + moves[slot] = tm_case[m_item].move; + } + used_up(); + m_screen = Screen::CHOOSE_MEMBER; + return ForgetEnd::LEARNED; + } + virtual void stop_teaching() override{ + stopped++; + m_screen = Screen::BAG; + } + virtual bool leave() override{ + m_screen = Screen::OVERWORLD; + return true; + } + virtual void dump_screen(std::string_view) override{} + +private: + enum class Screen{ + OVERWORLD, + BAG, + CHOOSE_MEMBER, + DELETE_PROMPT, + }; + + // A TM is used up; an HM is not. + void used_up(){ + if (m_item.starts_with("tm")){ + tm_case[m_item].count--; + } + } + + Screen m_screen = Screen::OVERWORLD; + std::string m_item; + size_t m_slot = 0; +}; + + +MemberReader reader_of(FakeTeachGame& game){ + return [&game](size_t slot){ + return game.read_member(slot); + }; +} + + +class Test_AutoStoryTeach : public UnitTest{ +public: + Test_AutoStoryTeach(std::string name, std::function body) + : UnitTest("PokemonFRLG::AutoStoryTeach - " + 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; +}; + + +FakeTeachGame console_b_party(){ + FakeTeachGame game; + game.party = { + {"squirtle", {"water-gun", "tail-whip", "bubble", "withdraw"}, {"surf", "strength", "dig", "rock-smash"}}, + {"rattata", {"tackle", "tail-whip", "quick-attack"}, {"dig", "cut"}}, + {"oddish", {"absorb", "cut", "poison-powder", "acid"}, {"cut", "flash"}}, + }; + game.tm_case = {{"hm03", {"surf", 1}}, {"tm28", {"dig", 1}}, {"hm01", {"cut", 1}}}; + return game; +} + +struct TeachCase{ + std::string name = {}; + TeachPlan plan = {}; + std::function setup = {}; + TeachEnd end = TeachEnd::LEARNED; + // The member's moves after the step. + std::vector moves = {}; + // Whether the bag was opened at all. + bool opened = true; +}; + +std::vector teach_cases_1(){ + return { + { + .name = "Surf over TAIL WHIP by name", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, + .moves = {"water-gun", "surf", "bubble", "withdraw"}, + }, + { + .name = "Strength over the fourth slot by number", + .plan = {.item = "hm04", .move = "strength", .member = 0, .forget_slot = 3}, + .setup = [](FakeTeachGame& game){ game.tm_case["hm04"] = {"strength", 1}; }, + .moves = {"water-gun", "tail-whip", "bubble", "strength"}, + }, + { + .name = "a TM into a free slot", + .plan = {.item = "tm28", .move = "dig", .member = 1}, + .moves = {"tackle", "tail-whip", "quick-attack", "dig"}, + }, + { + .name = "a move it knows already: nothing pressed", + .plan = {.item = "hm01", .move = "cut", .member = 2}, + .end = TeachEnd::ALREADY_KNOWN, + .moves = {"absorb", "cut", "poison-powder", "acid"}, + .opened = false, + }, + }; +} + +std::vector teach_cases_2(){ + return { + { + .name = "four moves and none named to forget", + .plan = {.item = "hm03", .move = "surf", .member = 0}, + .end = TeachEnd::REFUSED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + .opened = false, + }, + { + .name = "a move to forget it doesn't know (the console Squirtle has no Tackle)", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tackle"}, + .end = TeachEnd::REFUSED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + .opened = false, + }, + { + .name = "an HM move named to forget", + .plan = {.item = "tm28", .move = "dig", .member = 2, .forget_move = "cut"}, + .end = TeachEnd::REFUSED, + .moves = {"absorb", "cut", "poison-powder", "acid"}, + .opened = false, + }, + { + .name = "not compatible: given up, back on the overworld", + .plan = {.item = "hm01", .move = "cut", .member = 0, .forget_move = "withdraw"}, + .end = TeachEnd::REFUSED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + }, + }; +} + +std::vector teach_cases_3(){ + return { + { + .name = "moves read with room, but the game asks to forget one", + .plan = {.item = "hm03", .move = "surf", .member = 0}, + .setup = [](FakeTeachGame& game){ game.stale_moves = std::vector{"water-gun", "bubble"}; }, + .end = TeachEnd::FAILED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + }, + { + .name = "moves that can't be read: nothing pressed", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, + .setup = [](FakeTeachGame& game){ game.moves_readable = false; }, + .end = TeachEnd::UNREAD, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + .opened = false, + }, + { + .name = "an item the TM Case hasn't got: skipped", + .plan = {.item = "hm05", .move = "flash", .member = 2, .forget_move = "acid"}, + .end = TeachEnd::NO_ITEM, + .moves = {"absorb", "cut", "poison-powder", "acid"}, + }, + { + .name = "the TM Case didn't open", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, + .setup = [](FakeTeachGame& game){ game.open_fails = true; }, + .end = TeachEnd::FAILED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + }, + { + .name = "the move didn't land: the read after catches it", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, + .setup = [](FakeTeachGame& game){ game.forget_ignored = true; }, + .end = TeachEnd::FAILED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + }, + }; +} + +std::vector teach_cases(){ + std::vector cases; + for (std::vector part : {teach_cases_1(), teach_cases_2(), teach_cases_3()}){ + cases.insert(cases.end(), part.begin(), part.end()); + } + return cases; +} + +UnitTestResult check_teach_case(const TeachCase& item){ + QuietLogger logger; + FakeTeachGame game = console_b_party(); + if (item.setup){ + item.setup(game); + } + const TeachResult result = run_teach(logger, game, reader_of(game), item.plan); + if (!game.error.empty()){ + return item.name + ": " + game.error; + } + if (result.end != item.end){ + return std::format("{}: ended {}, expected {} ({}).", item.name, + teach_end_name(result.end), teach_end_name(item.end), result.detail); + } + if (game.party[item.plan.member].moves != item.moves){ + return item.name + ": the moves after aren't the ones expected."; + } + if ((game.items_opened > 0) != item.opened){ + return item.name + (item.opened ? ": the bag wasn't opened." : ": the bag was opened."); + } + return true; +} + +UnitTestResult test_teach(){ + for (const TeachCase& item : teach_cases()){ + const UnitTestResult result = check_teach_case(item); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + return true; +} + + +// A TM is used up, an HM is not. +UnitTestResult test_tm_used_up(){ + QuietLogger logger; + FakeTeachGame game = console_b_party(); + run_teach(logger, game, reader_of(game), {.item = "tm28", .move = "dig", .member = 1}); + run_teach(logger, game, reader_of(game), {.item = "hm03", .move = "surf", .member = 0, .forget_move = "withdraw"}); + if (game.tm_case["tm28"].count != 0 || game.tm_case["hm03"].count != 1){ + return "Expected TM28 used up and HM03 kept."; + } + return true; +} + + +UnitTestResult test_teach_variant(){ + const TeachPlan plan{.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}; + const std::string variant = teach_variant(plan); + if (variant != "teach hm03 surf slot=1 forget=tail-whip"){ + return "Unexpected variant \"" + variant + "\"."; + } + const std::optional back = parse_teach_variant(variant); + if (!back || back->item != "hm03" || back->move != "surf" || back->member != 0 || back->forget_move != "tail-whip"){ + return "\"" + variant + "\" didn't read back as its plan."; + } + const std::optional by_slot = parse_teach_variant("teach tm28 dig slot=2 forget_slot=4"); + if (!by_slot || by_slot->member != 1 || by_slot->forget_slot != 3u){ + return "\"teach tm28 dig slot=2 forget_slot=4\" didn't read back."; + } + for (std::string_view bad : { + "teach", "teach hm03 surf", "teach potion surf slot=1", "teach hm03 surf slot=7", + "teach hm03 surf slot=1 forget_slot=5", "teach hm03 slot=1", "mart PewterCity_Mart potion=1", + }){ + if (parse_teach_variant(bad)){ + return "Expected \"" + std::string(bad) + "\" refused."; + } + } + return true; +} + +} + + +void add_tests_AutoStoryTeach(UnitTestDatabase& database){ + const auto add = [&](std::string name, UnitTestResult (*body)()){ + database.add(std::move(name), body); + }; + add("TMs and HMs on a fake party", test_teach); + add("a TM is used up, an HM kept", test_tm_used_up); + add("the table's variant", test_teach_variant); + add_tests_AutoStoryTeachScreens(database); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.cpp new file mode 100644 index 0000000000..c3e6bcedb1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.cpp @@ -0,0 +1,56 @@ +/* AutoStory Variant Words + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonFRLG_AutoStoryVariantWords.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +std::vector variant_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; +} + +std::optional variant_pair(std::string_view word){ + const size_t equals = word.find('='); + if (equals == std::string_view::npos || equals == 0){ + return std::nullopt; + } + return VariantPair{word.substr(0, equals), word.substr(equals + 1)}; +} + +std::optional variant_number(std::string_view text, size_t max_digits){ + if (text.empty() || text.size() > max_digits){ + 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; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.h new file mode 100644 index 0000000000..eaa22836fd --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.h @@ -0,0 +1,43 @@ +/* AutoStory Variant Words + * + * From: https://github.com/PokemonAutomation/ + * + * Reading a GAME step's variant: words split on spaces, "key=value" + * pairs, and small numbers. The table writes variants such as + * "mart PewterCity_Mart budget=2000 potion=5"; the steps read them back. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryVariantWords_H +#define PokemonAutomation_PokemonFRLG_AutoStoryVariantWords_H + +#include +#include +#include +#include + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// `text`'s words, split on spaces, empty words dropped. +std::vector variant_words(std::string_view text); + +// A word "key=value" as its two halves; nothing without an '='. +struct VariantPair{ + std::string_view key; + std::string_view value; +}; +std::optional variant_pair(std::string_view word); + +// `text` as a number of up to `max_digits` decimal digits; nothing if it +// isn't one. +std::optional variant_number(std::string_view text, size_t max_digits = 6); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp index d282fd40bd..cd22061a59 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -8,10 +8,11 @@ * */ +#include #include #include +#include #include -#include #include #include #include "Common/Cpp/Exceptions.h" @@ -19,8 +20,10 @@ #include "CommonFramework/ImageTypes/ImageRGB32.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_OptionMenuReader.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryPcStorage.h" #include "PokemonFRLG_AutoStoryTable.h" namespace PokemonAutomation{ @@ -212,8 +215,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 12 || checkpoints[0].start){ - return "Expected 12 checkpoints, the first from a new game."; + if (checkpoints.size() != 17 || checkpoints[0].start){ + return "Expected 17 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -336,6 +339,7 @@ UnitTestResult test_pewter_aide_walk(){ // Every ROUTE and LEGS after the Running Shoes (00.10) runs; none before. UnitTestResult test_runs_after_shoes(){ bool has_shoes = false; + size_t runs = 0; for (const StoryCheckpoint& checkpoint : story_checkpoints()){ for (const StoryStep& step : checkpoint.steps){ if (step.kind != StoryStepKind::ROUTE && step.kind != StoryStepKind::LEGS){ @@ -345,15 +349,251 @@ UnitTestResult test_runs_after_shoes(){ if (move_speed(step) != expected){ return checkpoint.id + "/" + step.name + ": expected to " + move_speed_name(expected) + ", it would " + move_speed_name(move_speed(step)) + "."; } + runs += has_shoes ? 1 : 0; } has_shoes = has_shoes || checkpoint.id == "00.10"; } - if (!has_shoes){ - return "No checkpoint 00.10, where the player gets the Running Shoes."; + if (!has_shoes || runs == 0){ + return "No checkpoint 00.10, where the player gets the Running Shoes, or no route after it."; } return true; } +// A trainer who stands still but turns to look more than one way (Mt. +// Moon B2F's Grunt at (37, 21) looks around): whether they see the player +// depends on where they look as the player passes. +bool looks_around(const MapObject& object){ + return object.trainer && !object.wander && object.trainer->looks.size() > 1; +} + +// The trainer on `map` who would see the player on `tile`, other than the +// `beaten`. Wanderers and those who look around are left out: no route +// can plan around one (see wanderer_stops() and held_past_lookers()). +const MapObject* trainer_seeing(const MapData& map, const TilePos& tile, const std::set& beaten){ + for (const MapObject& object : map.objects){ + if (object.trainer && !object.wander && !looks_around(object) && !beaten.contains(&object) && trainer_may_see(map, object, tile)){ + return &object; + } + } + return nullptr; +} + +// Whether every floor tile of `map` has wild encounters, as in a cave: +// every open tile is tall grass, but for warps. No route keeps out of +// its grass. +bool wild_everywhere(const MapData& map){ + for (int y = 0; y < map.tiles.height; y++){ + for (int x = 0; x < map.tiles.width; x++){ + const TilePos tile{x, y}; + if (map.tiles.at(tile) == TileKind::OPEN && warp_at(map, tile) == nullptr){ + return false; + } + } + } + return true; +} + +// The legs a ROUTE or LEGS `step` from `pose` walks; none if it plans none. +std::vector legs_walked(const StoryPose& pose, const StoryStep& step){ + const MapPosition start{pose.map, pose.tile}; + std::optional> route; + if (step.kind == StoryStepKind::LEGS){ + route = fixed_route(story_maps(), start, step.legs); + }else if (step.target){ + const MapData* map = find_map_named(pose.map); + const std::optional facing = pose.facing ? pose.facing : map ? wall_beside(*map, pose.tile) : std::nullopt; + const StoryPose target = one_tile(*step.target); + route = plan_overworld_route(story_maps(), start, RouteGoal{{target.map, target.tile}, target.facing}, default_route_costs(), facing); + } + return route ? *route : std::vector{}; +} + +// Whether a wandering trainer may see `tile`. +bool wanderer_sees(const MapPosition& tile){ + const MapData* map = find_map_named(tile.map); + if (map == nullptr){ + return false; + } + for (const MapObject& object : map->objects){ + if (object.wander && trainer_may_see(*map, object, tile.point)){ + return true; + } + } + return false; +} + +// Whether they look the player's way can't be planned for, so the walk +// watches for a wanderer after each leg that crosses their sight, fights +// them, and goes on from where they stopped the player, if it can tell +// (run_route()'s trainer_stop(): the leg has one tile in any trainer's +// sight and is off the grass). For each leg of `step` from `pose` that +// crosses a wanderer's sight, that tile, or nothing if it can't tell. +std::vector> wanderer_stops(const StoryPose& pose, const StoryStep& step){ + std::vector> stops; + MapPosition from{pose.map, pose.tile}; + for (const RouteStep& leg : legs_walked(pose, step)){ + const std::optional c = trainer_crossing(story_maps(), from, leg); + if (c && std::any_of(c->seen.begin(), c->seen.end(), wanderer_sees)){ + stops.push_back(c->seen.size() == 1 && !leg.wild && !leg.warps ? std::optional(c->seen[0]) : std::nullopt); + } + from = leg.end; + } + return stops; +} + +// Where a leg of a ROUTE or LEGS `step` from `pose` crosses the sight of a +// trainer who looks around, the leg ends on a wall: if the trainer looks +// the player's way, they walk up beside the leg, and the walker holds it +// on to its wall after the battle. Empty if so, else the leg that doesn't. +std::string held_past_lookers(const StoryPose& pose, const StoryStep& step){ + MapPosition from{pose.map, pose.tile}; + for (const RouteStep& leg : legs_walked(pose, step)){ + const MapData* map = find_map_named(from.map); + bool crosses = false; + for (const MapPosition& tile : leg.action == RouteAction::WALK ? leg_tiles(story_maps(), from, leg) : std::vector{}){ + for (const MapObject& object : map ? map->objects : std::vector{}){ + crosses |= looks_around(object) && trainer_may_see(*map, object, tile.point); + } + } + if (crosses && !leg.to_wall){ + return "crosses a trainer's sight on " + to_string(leg) + ", not a leg to a wall."; + } + from = leg.end; + } + return ""; +} + +// The tiles a ROUTE or LEGS `step` from `pose` walks onto, in order. +std::vector tiles_walked(const StoryPose& pose, const StoryStep& step){ + std::vector tiles; + MapPosition from{pose.map, pose.tile}; + for (const RouteStep& leg : legs_walked(pose, step)){ + if (leg.action == RouteAction::WALK){ + const std::vector passed = leg_tiles(story_maps(), from, leg); + tiles.insert(tiles.end(), passed.begin() + 1, passed.end()); + } + from = leg.end; + tiles.push_back(from); + } + return tiles; +} + +// Why the player shouldn't walk onto `tile`, or empty: tall grass, a warp +// to a map not loaded (Route 4's ways into Mt. Moon), a trainer's line. +std::string tile_problem(const MapPosition& tile, const std::set& beaten){ + const MapData* map = find_map_named(tile.map); + if (map == nullptr){ + return "off the story's maps at " + to_string(tile); + } + if (is_tall_grass(*map, tile.point) && !wild_everywhere(*map)){ + return "in the grass at " + to_string(tile); + } + const MapWarp* warp = warp_at(*map, tile.point); + if (warp != nullptr && !warp->to){ + return "onto a warp to a map not loaded at " + to_string(tile); + } + const MapObject* trainer = trainer_seeing(*map, tile.point, beaten); + if (trainer != nullptr){ + return "in the line of the trainer at " + to_string(TilePos{trainer->x, trainer->y}) + " at " + to_string(tile); + } + return ""; +} + +// Moves `pose` past `step`, as check_checkpoint() does. +UnitTestResult advance(StoryPose& pose, const StoryStep& step){ + if (step.kind == StoryStepKind::ROUTE){ + return check_route(pose, step); + } + if (step.kind == StoryStepKind::LEGS){ + return check_legs(pose, step); + } + if (step.kind == StoryStepKind::STEP){ + pose = StoryPose{pose.map, pose.tile.next(step.direction), step.direction}; + } + if (step.pose_after){ + pose = one_tile(*step.pose_after); + } + return true; +} + +// `checkpoint`'s routes and legs keep to tiles with no tile_problem(); a +// step into a trainer's line is followed by the battle, which beats them. +UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set& beaten){ + StoryPose pose = one_tile(*checkpoint.start); + for (size_t c = 0; c < checkpoint.steps.size(); c++){ + const StoryStep& step = checkpoint.steps[c]; + const std::string where = checkpoint.id + "/" + step.name + ": "; + const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS; + for (const MapPosition& tile : walks ? tiles_walked(pose, step) : std::vector{}){ + if (const std::string problem = tile_problem(tile, beaten); !problem.empty()){ + return where + problem; + } + } + const std::vector> stops = walks ? wanderer_stops(pose, step) : std::vector>{}; + if (std::count(stops.begin(), stops.end(), std::nullopt) > 0){ + return where + "crosses a wandering trainer's sight where the walk can't tell where they'd stop the player."; + } + if (const std::string problem = walks ? held_past_lookers(pose, step) : ""; !problem.empty()){ + return where + problem; + } + const MapData* map = find_map_named(pose.map); + const MapObject* trainer = step.kind == StoryStepKind::STEP && map ? trainer_seeing(*map, pose.tile.next(step.direction), beaten) : nullptr; + if (trainer != nullptr && (c + 1 == checkpoint.steps.size() || checkpoint.steps[c + 1].kind != StoryStepKind::BATTLE)){ + return where + "steps into a trainer's line with no battle after it."; + } + if (trainer != nullptr){ + beaten.insert(trainer); + } + if (UnitTestResult result = advance(pose, step); result.result != UnitTestResult::PASSED){ + return checkpoint.id + " " + result.message; + } + } + return true; +} + +// From the Running Shoes on, routes and legs keep out of the grass (but +// in caves, which are all grass), off warps to maps not loaded and out of +// trainers' lines, but for the three Route 3 trainers and Mt. Moon's Grunt +// at (12, 20) the table steps in front of to fight; where they cross a +// wandering trainer's sight, the walk can place the player if the trainer +// stops them; and where they cross the sight of one who looks around, on +// a leg to a wall. +UnitTestResult test_trainer_lines(){ + std::set beaten; + bool has_shoes = false; + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (has_shoes){ + if (UnitTestResult result = check_lines(checkpoint, beaten); result.result != UnitTestResult::PASSED){ + return result; + } + } + has_shoes = has_shoes || checkpoint.id == "00.10"; + } + if (beaten.size() != 4){ + return "Expected to fight Colton, Greg and James on Route 3 and the Grunt at (12, 20) in Mt. Moon; the table fights " + + std::to_string(beaten.size()) + " trainers."; + } + return true; +} + +// 00.12's walk east over Route 3 crosses Lass Robin's sight (she wanders +// x 40, y 10..12, looking north and south, sight 3) on (40, 9) only: if +// she sees the player, the walk goes on from there (console B, +// 2026-11-02, she stopped "east 11 (to wall)" there). +UnitTestResult test_robin_crossing(){ + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.id != "00.12" || checkpoint.steps.empty() || checkpoint.steps[0].name != "route4_nurse"){ + continue; + } + const std::vector> expected{MapPosition{"Route3", {40, 9}}}; + if (wanderer_stops(one_tile(*checkpoint.start), checkpoint.steps[0]) != expected){ + return "Expected 00.12/route4_nurse to cross Lass Robin's sight once, the walk placing her stop on Route3 (40, 9)."; + } + return true; + } + return "No step 00.12/route4_nurse."; +} + // Every checkpoint that starts from a save has a place the save card // names, so a save left elsewhere is caught before it runs; and the // places of the starts that matter differ. @@ -368,7 +608,11 @@ UnitTestResult test_save_places(){ ){ return "Wrong save card places."; } - if (!save_card_place("Route22").empty()){ + if (save_card_place("Route3") != "route-3" || save_card_place("Route4_PokemonCenter_1F") != "route-4"){ + return "Wrong save card places on Route 3 and 4."; + } + // Not Route 2's or Route 4's: a map name is a place's or a building's in it. + if (!save_card_place("Route22").empty() || !save_card_place("Route24").empty() || !save_card_place("Route2").empty()){ return "A place for a map the table doesn't use."; } return true; @@ -529,168 +773,22 @@ UnitTestResult test_intro_names(){ } -size_t row_index(std::string_view id){ - const std::vector& checkpoints = story_checkpoints(); - for (size_t c = 0; c < checkpoints.size(); c++){ - if (checkpoints[c].id == id){ - return c; +// 00.12c stores the Magikarp Gift RNG bought; with Gift RNG off there is +// none, and the deposit is skipped instead of refused. +UnitTestResult test_magikarp_deposit_skippable(){ + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.id != "00.12c"){ + continue; } - } - throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No checkpoint " + std::string(id)); -} - -std::string joined_ids(const std::vector& rows){ - std::string text; - for (const StoryCheckpoint& row : rows){ - text += (text.empty() ? "" : " ") + row.id; - } - return text; -} - -const char* handoff_name(StoryHandoff handoff){ - switch (handoff){ - case StoryHandoff::NONE: - return "none"; - case StoryHandoff::SID_HELPER: - return "SID Helper"; - case StoryHandoff::STARTER_RNG: - return "Starter RNG"; - } - return "?"; -} - -struct RunPlanScenario{ - std::string name = ""; - std::string first = ""; - // The table's last row if empty. - std::string last = ""; - StoryHandoffs handoffs = {}; - std::string expected_ids = ""; - StoryHandoff expected_stop = StoryHandoff::NONE; -}; - -class Test_RunPlan : public UnitTest{ -public: - Test_RunPlan(RunPlanScenario scenario) - : UnitTest("PokemonFRLG::AutoStory - run plan: " + scenario.name) - , m_scenario(std::move(scenario)) - {} - - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - try{ - const size_t last = m_scenario.last.empty() ? story_checkpoints().size() - 1 : row_index(m_scenario.last); - const StoryRunPlan plan = plan_story_run(story_checkpoints(), row_index(m_scenario.first), last, m_scenario.handoffs); - const std::string ids = joined_ids(plan.rows); - if (ids != m_scenario.expected_ids || plan.stop != m_scenario.expected_stop){ - return "Expected rows \"" + m_scenario.expected_ids + "\", stop " + handoff_name(m_scenario.expected_stop) - + "; got \"" + ids + "\", stop " + handoff_name(plan.stop) + "."; + for (const StoryStep& step : checkpoint.steps){ + const std::optional transfer = parse_pc_variant(step.variant); + if (transfer && transfer->party_slot == 1 && transfer->if_missing == PcMissing::SKIP){ + return true; } - return true; - }catch (Exception& e){ - return std::string(e.name()) + ": " + e.message(); - } - } - -private: - const RunPlanScenario m_scenario; -}; - -void add_tests_RunPlan(UnitTestDatabase& database){ - const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10"; - const RunPlanScenario scenarios[] = { - {.name = "handoffs off: the story as before", .first = "00.01", .expected_ids = TODAY}, - {.name = "handoffs off: a range", .first = "00.02", .last = "00.04", .expected_ids = "00.02 00.03 00.04"}, - { - .name = "SID Helper: 00.01 stops on the keyboard", - .first = "00.01", - .handoffs = {.sid_helper = true}, - .expected_ids = "", - .expected_stop = StoryHandoff::SID_HELPER, - }, - { - .name = "SID Helper: back in at 00.01b", - .first = "00.01b", - .last = "00.03", - .handoffs = {.sid_helper = true}, - .expected_ids = "00.01b 00.02 00.03", - }, - {.name = "00.01b runs when started at, option off", .first = "00.01b", .last = "00.02", .expected_ids = "00.01b 00.02"}, - {.name = "SID Helper: a later start passes over it", .first = "00.02", .last = "00.03", .handoffs = {.sid_helper = true}, .expected_ids = "00.02 00.03"}, - {.name = "handoffs off: an end at 00.04 still takes the rival's battle", .first = "00.03", .last = "00.04", .expected_ids = "00.03 00.04"}, - { - .name = "Starter RNG: stops after 00.04's save", - .first = "00.02", - .handoffs = {.starter_rng = true}, - .expected_ids = "00.02 00.03 00.04", - .expected_stop = StoryHandoff::STARTER_RNG, - }, - {.name = "00.04b runs from its own save", .first = "00.04b", .last = "00.05", .expected_ids = "00.04b 00.05"}, - {.name = "Starter RNG: a later start doesn't stop", .first = "00.05", .last = "00.06", .handoffs = {.starter_rng = true}, .expected_ids = "00.05 00.06"}, - { - .name = "both handoffs, back in after SID Helper", - .first = "00.01b", - .handoffs = {.sid_helper = true, .starter_rng = true}, - .expected_ids = "00.01b 00.02 00.03 00.04", - .expected_stop = StoryHandoff::STARTER_RNG, - }, - }; - for (const RunPlanScenario& scenario : scenarios){ - database.add(scenario); - } -} - -std::string step_names(const StoryCheckpoint& checkpoint){ - std::string text; - for (const StoryStep& step : checkpoint.steps){ - text += (text.empty() ? "" : " ") + step.name; - } - return text; -} - -// With Starter RNG's handoff off, 00.04 and 00.04b run as the one 00.04 -// the story had before the split: from the lab's table to the rival's -// battle, saving once with the starter's START menu. -UnitTestResult test_starter_rows_joined(){ - const std::vector& checkpoints = story_checkpoints(); - const StoryRunPlan plan = plan_story_run(checkpoints, row_index("00.04"), row_index("00.04b"), StoryHandoffs{}); - const std::string expected_steps = - "below_squirtle squirtle_ball choose_squirtle no_nickname rival_takes_bulbasaur above_rival_trigger rival_trigger rival_battle"; - if (plan.rows.size() != 1){ - return "Expected one checkpoint, got \"" + joined_ids(plan.rows) + "\"."; - } - const StoryCheckpoint& row = plan.rows[0]; - if (row.id != "00.04" || row.title != "Squirtle, then the rival's battle" || step_names(row) != expected_steps){ - return "Expected 00.04 \"Squirtle, then the rival's battle\": " + expected_steps - + "; got " + row.id + " \"" + row.title + "\": " + step_names(row); - } - const std::optional start = story_pose(*row.start); - if (!start || start->map != "PalletTown_ProfessorOaksLab" || !(start->tile == TilePos{6, 4}) || row.menu != StartMenu::BEFORE_POKEDEX){ - return "Expected the joined 00.04 to start at the table (6, 4) and save with the starter's START menu."; - } - // Split, 00.04 saves under the ball facing it, before the starter. - const StoryCheckpoint& to_ball = checkpoints[row_index("00.04")]; - const std::optional ball = story_pose(*checkpoints[row_index("00.04b")].start); - if (to_ball.handoff != StoryHandoff::STARTER_RNG || to_ball.menu != StartMenu::BEFORE_POKEMON - || !ball || !(ball->tile == TilePos{9, 5}) || ball->facing != Direction::NORTH - ){ - return "Expected 00.04 to end under Squirtle's ball (9, 5) facing north, saving before the starter."; - } - return true; -} - -// The way back in after SID Helper loads no save and starts where 00.01 -// leaves the player, so 00.02 follows it unchanged. -UnitTestResult test_after_sid_helper_row(){ - const StoryCheckpoint& sid = story_checkpoints()[row_index("00.01b")]; - if (!sid.live_start || story_checkpoints()[0].handoff != StoryHandoff::SID_HELPER){ - return "Expected 00.01 to be SID Helper's handoff and 00.01b to start from the live game."; - } - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - if (checkpoint.live_start != (checkpoint.id == "00.01b")){ - return checkpoint.id + ": only 00.01b starts from the live game."; } + return "00.12c: expected a deposit of the second slot, skipped if the party hasn't got it."; } - return true; + return "No checkpoint 00.12c."; } // set_options()'s RIGHT presses from each value an old save can leave: @@ -737,9 +835,7 @@ UnitTestResult test_option_presses(){ void add_tests_AutoStory(UnitTestDatabase& database){ - add_tests_RunPlan(database); - database.add("00.01b: back in after SID Helper", test_after_sid_helper_row); - database.add("00.04 and 00.04b: one checkpoint with Starter RNG off", test_starter_rows_joined); + add_tests_AutoStoryRunPlan(database); database.add("every route plans, and checkpoints chain", test_routes_and_chain); database.add("step names are unique per checkpoint", test_unique_names); database.add("every save has a place on the save card", test_save_places); @@ -749,8 +845,11 @@ void add_tests_AutoStory(UnitTestDatabase& database){ database.add("Pewter Gym walks", test_pewter_gym_walks); database.add("Pewter: to the aide's trigger", test_pewter_aide_walk); database.add("routes run after the Running Shoes", test_runs_after_shoes); + database.add("Route 3: only the planned trainers' lines", test_trainer_lines); + database.add("Route 3: Lass Robin stops the walk on (40, 9)", test_robin_crossing); database.add("intro: names, the rival's from the list", test_intro_names); database.add("options: RIGHT presses from the shown value", test_option_presses); + database.add("00.12c: the Magikarp's deposit is skipped without Gift RNG", test_magikarp_deposit_skippable); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.cpp index 3341d8cef2..9dbc7d6b26 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.cpp @@ -38,23 +38,6 @@ GiftRng_Descriptor::GiftRng_Descriptor() ) {} -struct GiftRng_Descriptor::Stats : public StatsTracker{ - Stats() - : resets(m_stats["Resets"]) - , shinies(m_stats["Shinies"]) - , nonshiny(m_stats["Non-Shiny Hits"]) - , errors(m_stats["Errors"]) - { - m_display_order.emplace_back("Resets"); - m_display_order.emplace_back("Shinies"); - m_display_order.emplace_back("Non-Shiny Hits", HIDDEN_IF_ZERO); - m_display_order.emplace_back("Errors", HIDDEN_IF_ZERO); - } - std::atomic& resets; - std::atomic& shinies; - std::atomic& nonshiny; - std::atomic& errors; -}; std::unique_ptr GiftRng_Descriptor::make_stats() const{ return std::unique_ptr(new Stats()); } @@ -232,7 +215,7 @@ void GiftRng::program(SingleSwitchProgramEnvironment& env, ProControllerContext& * Settings: Text Speed fast */ - GiftRng_Descriptor::Stats& stats = env.current_stats(); + GiftRngStats& stats = env.current_stats(); home_black_border_check(env.console, context); @@ -245,242 +228,42 @@ void GiftRng::program(SingleSwitchProgramEnvironment& env, ProControllerContext& if (!seed_match.found){ throw UserSetupError(env.console, "The target Seed was not found in the seed database for this game version, language, and sound setting."); } - const std::vector SEED_VALUES = seed_match.seed_values; - const int16_t SEED_POSITION = seed_match.seed_position; - const uint64_t SEED_DELAY = seed_match.seed_delay; - const SeedButton SEED_BUTTON = seed_match.seed_button; - const BlackoutButton EXTRA_BUTTON = seed_match.extra_button; - env.log("Seed delay: " + std::to_string(SEED_DELAY) + "ms, button mode: " + seed_match.button_mode + env.log("Seed delay: " + std::to_string(seed_match.seed_delay) + "ms, button mode: " + seed_match.button_mode + ", column: " + seed_match.column_name); - BaseStats BASE_STATS; - int16_t GENDER_THRESHOLD = -1; - uint8_t LEVEL = 0; - switch (TARGET){ - case PokemonFRLG_RngTarget::magikarp: - BASE_STATS = { 20, 10, 55, 15, 20, 80 }; - GENDER_THRESHOLD = 126; - LEVEL = 5; - break; - case PokemonFRLG_RngTarget::hitmonchan: - BASE_STATS = { 50, 105, 79, 35, 110, 76 }; - GENDER_THRESHOLD = -1; - LEVEL = 25; - break; - case PokemonFRLG_RngTarget::hitmonlee: - BASE_STATS = { 50, 120, 53, 35, 110, 87 }; - GENDER_THRESHOLD = -1; - LEVEL = 25; - break; - case PokemonFRLG_RngTarget::eevee: - BASE_STATS = { 55, 55, 50, 45, 65, 55 }; - GENDER_THRESHOLD = 30; - LEVEL = 25; - break; - case PokemonFRLG_RngTarget::lapras: - BASE_STATS = { 130, 85, 80, 85, 95, 60 }; - GENDER_THRESHOLD = 126; - LEVEL = 25; - break; - case PokemonFRLG_RngTarget::omanyte: - BASE_STATS = { 35, 40, 100, 90, 55, 35 }; - GENDER_THRESHOLD = 30; - LEVEL = 5; - break; - case PokemonFRLG_RngTarget::kabuto: - BASE_STATS = { 30, 80, 90, 55, 45, 55 }; - GENDER_THRESHOLD = 30; - LEVEL = 5; - break; - case PokemonFRLG_RngTarget::aerodactyl: - BASE_STATS = { 80, 105, 65, 60, 75, 130 }; - GENDER_THRESHOLD = 30; - LEVEL = 5; - break; - case PokemonFRLG_RngTarget::gamecornerabra: - BASE_STATS = { 25, 20, 15, 105, 55, 90 }; - GENDER_THRESHOLD = 63; - break; - case PokemonFRLG_RngTarget::gamecornerclefairy: - BASE_STATS = { 70, 45, 48, 60, 65, 35 }; - GENDER_THRESHOLD = 190; - break; - case PokemonFRLG_RngTarget::gamecornerdratinifr: - case PokemonFRLG_RngTarget::gamecornerdratinilg: - BASE_STATS = { 41, 64, 45, 50, 50, 50 }; - GENDER_THRESHOLD = 126; - break; - case PokemonFRLG_RngTarget::gamecornerscyther: - BASE_STATS = { 70, 110, 80, 55, 80, 105 }; - GENDER_THRESHOLD = 126; - LEVEL = 25; - break; - case PokemonFRLG_RngTarget::gamecornerpinsir: - BASE_STATS = { 65, 125, 100, 55, 70, 85 }; - GENDER_THRESHOLD = 126; - LEVEL = 18; - break; - case PokemonFRLG_RngTarget::gamecornerporygon: - BASE_STATS = { 65, 60, 70, 85, 75, 40 }; - GENDER_THRESHOLD = -1; - break; - case PokemonFRLG_RngTarget::togepi: - case PokemonFRLG_RngTarget::togepifast: - BASE_STATS = { 35, 20, 65, 40, 65, 20 }; - GENDER_THRESHOLD = 30; - LEVEL = 5; - break; - default: - break; - } - - static const int64_t FIXED_SEED_OFFSET = -845; // milliseconds, approximate - - static const uint64_t CONTINUE_SCREEN_FRAMES = 200; - - const uint64_t INITIAL_ADVANCES_RADIUS = USE_TEACHY_TV ? 4096 : 1024; - - const uint8_t MAX_HISTORY_LENGTH = USE_TEACHY_TV ? 2 : 10; - - static const std::set SPECIES_LIST = { - "magikarp", "hitmonchan", "hitmonlee", "eevee", "lapras", - "omanyte", "kabuto", "aerodactyl", - "abra", "clefairy", "dratini", "scyther", "pinsir", "porygon", - "togepi" - }; - - env.log("RNG Target: " + std::to_string(TARGET.current_value())); - env.log("Target Seed: " + to_hex_string(TARGET_SEED)); - env.log("Target Advances: " + std::to_string(ADVANCES)); - - AdvRngSearcher searcher(TARGET_SEED, ADVANCES, AdvRngMethod::Method1); - AdvPokemonResult target_result = searcher.generate_pokemon(); - RNG_TARGET.set_target(target_result, GENDER_THRESHOLD); - log_target_pokemon(env.console, target_result); - - RngCalibrations calibrations = { - RNG_CALIBRATION.seed_calibration / FRLG_FRAME_DURATION, - RNG_CALIBRATION.csf_calibration, - RNG_CALIBRATION.advances_calibration - }; - log_calibrations(env.console, calibrations, true); - - Milliseconds launch_delay = INITIAL_LAUNCH_DELAY; - - RngUncertainHistory uncertain_history; - RngCalibrationHistory calibration_history; - - uint16_t failed_searches = 0; - - while (true){ - if (calibration_history.results.size() > 0){ - env.log("Checking for nonshiny target hit..."); - if (have_hit_target(TARGET_SEED, ADVANCES, calibration_history.results.back())){ - env.log("Target Hit!"); - stats.nonshiny++; - break; - } - env.log("Missed target."); - } - - if (failed_searches >= 5){ - env.log("Failed to find any matches 5 times in a row"); - OperationFailedExceptionWithScreenshot::fire( - ErrorReportMode::NO_ERROR_REPORT, - "Failed to find any matches 5 times in a row. Check your seed and advances settings.", - env.console - ); - break; - } - - if (stats.resets > MAX_RESETS){ - env.log("Max resets reached."); - break; - } - - send_program_status_notification( - env, NOTIFICATION_STATUS_UPDATE, - "Calibrating." - ); - env.update_stats(); - - - uint64_t advances_radius = get_advances_radius(env.console, calibration_history, INITIAL_ADVANCES_RADIUS); - - if (calibration_history.results.size() > 0){ - calibrations = get_calibrations(env.console, calibration_history, SEED_VALUES, SEED_POSITION, ADVANCES); - } - - // if previous resets had uncertain advances, slightly modify the seed delay to try to hit a different target - apply_seed_bump(calibrations, uncertain_history); - - uint64_t ingame_advances = ADVANCES - CONTINUE_SCREEN_FRAMES; - - RngTimings timings = prepare_timings( - env.console, TARGET, - SEED_DELAY, CONTINUE_SCREEN_FRAMES, ingame_advances, - USE_TEACHY_TV, calibrations, - FIXED_SEED_OFFSET, LANGUAGE - ); - - env.log("Resetting Game..."); - reset_and_perform_blind_sequence( - env.console, context, LANGUAGE, TARGET, - SEED_BUTTON, EXTRA_BUTTON, timings, - launch_delay, false, PROFILE - ); - stats.resets++; - - RNG_FILTERS.reset(); - RNG_CALIBRATION.set_calibrations(calibrations); - RNG_CALIBRATION.reset_hits(); - - bool shiny_found = check_for_shiny(env.console, context, TARGET); - - if (shiny_found){ - env.log("Shiny found!"); - stats.shinies++; - RNG_CALIBRATION.hits.set("Shiny!"); - send_program_notification( - env, - NOTIFICATION_SHINY, - COLOR_YELLOW, - "Shiny found!", - {}, "", - env.console.video().snapshot(), - true - ); - if (TAKE_VIDEO){ - pbf_press_button(context, BUTTON_CAPTURE, 2000ms, 0ms); - } - break; - } - - AdvObservedPokemon pokemon = read_summary(env.console, context, LANGUAGE, SPECIES_LIST); - if (LEVEL > 0){ - pokemon.level[0] = LEVEL; - } - AdvRngFilters filters = observation_to_filters(pokemon, BASE_STATS); - RNG_FILTERS.set(filters); - - std::vector search_hits = refine_calibration_with_rare_candy( - env, context, LANGUAGE, pokemon, filters, BASE_STATS, - uncertain_history, calibration_history, calibrations, - MAX_HISTORY_LENGTH, MAX_RARE_CANDIES, AdvRngMethod::Method1, false, - stats.errors, NOTIFICATION_ERROR_RECOVERABLE, - [&](AdvRngFilters& f){ - return get_search_results(env.console, searcher, f, SEED_VALUES, ADVANCES, advances_radius, GENDER_THRESHOLD); - }, - [&](const std::vector& h){ RNG_CALIBRATION.set_hits(h); }, - [&](const AdvRngFilters& f){ RNG_FILTERS.set(f); }, - [&](const std::vector& h){ - return all_equal(h) || all_indistinguishable(h, searcher, GENDER_THRESHOLD); - } - ); - - env.log("RNG search finished."); - update_failed_searches(failed_searches, search_hits); - + GiftRngAttempts attempts( + GiftRngTarget{ + .target = TARGET, + .seed = TARGET_SEED, + .advances = ADVANCES, + .seed_match = seed_match, + }, + GiftRngSettings{ + .language = LANGUAGE, + .profile = PROFILE, + .use_teachy_tv = USE_TEACHY_TV, + .take_video = TAKE_VIDEO, + .identify_shiny = false, + .max_resets = [this]{ return uint64_t(MAX_RESETS); }, + .max_rare_candies = [this]{ return uint64_t(MAX_RARE_CANDIES); }, + .on_reset = nullptr, + .on_read = nullptr, + }, + GiftRngDisplays{.target = RNG_TARGET, .filters = RNG_FILTERS, .calibration = RNG_CALIBRATION}, + GiftRngNotifications{ + .shiny = NOTIFICATION_SHINY, + .status_update = NOTIFICATION_STATUS_UPDATE, + .error_recoverable = NOTIFICATION_ERROR_RECOVERABLE, + }, + stats + ); + GiftRngResult result = attempts.run(env, context); + if (result.outcome == GiftRngOutcome::no_matches){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::NO_ERROR_REPORT, + "Failed to find any matches 5 times in a row. Check your seed and advances settings.", + env.console + ); } if (GO_HOME_WHEN_DONE){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.h index 3df8e600ac..1f7d8463e5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.h @@ -15,6 +15,7 @@ #include "NintendoSwitch/NintendoSwitch_SingleSwitchProgram.h" #include "NintendoSwitch/Options/NintendoSwitch_GoHomeWhenDoneOption.h" #include "PokemonFRLG_BlindNavigation.h" +#include "PokemonFRLG_GiftRngAttempts.h" #include "PokemonFRLG_RngDisplays.h" #include "PokemonFRLG_SeedsDatabase.h" @@ -25,7 +26,7 @@ namespace PokemonFRLG{ class GiftRng_Descriptor : public SingleSwitchProgramDescriptor{ public: GiftRng_Descriptor(); - struct Stats; + using Stats = GiftRngStats; virtual std::unique_ptr make_stats() const override; }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts.cpp new file mode 100644 index 0000000000..dcf9f1f9d3 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts.cpp @@ -0,0 +1,332 @@ +/* Gift RNG Attempts + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include "CommonFramework/Notifications/ProgramNotifications.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonFRLG_RngNavigation.h" +#include "PokemonFRLG_HardReset.h" +#include "PokemonFRLG_RngLoopRoutines.h" +#include "PokemonFRLG_GiftRngAttempts.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +const int64_t FIXED_SEED_OFFSET = -845; // milliseconds, approximate + +const uint64_t CONTINUE_SCREEN_FRAMES = 200; + +const std::set& gift_species_names(){ + static const std::set SPECIES_LIST = { + "magikarp", "hitmonchan", "hitmonlee", "eevee", "lapras", + "omanyte", "kabuto", "aerodactyl", + "abra", "clefairy", "dratini", "scyther", "pinsir", "porygon", + "togepi" + }; + return SPECIES_LIST; +} + +} + + +GiftRngSpecies gift_rng_species(PokemonFRLG_RngTarget target){ + switch (target){ + case PokemonFRLG_RngTarget::magikarp: + return {.base_stats = { 20, 10, 55, 15, 20, 80 }, .gender_threshold = 126, .level = 5}; + case PokemonFRLG_RngTarget::hitmonchan: + return {.base_stats = { 50, 105, 79, 35, 110, 76 }, .gender_threshold = -1, .level = 25}; + case PokemonFRLG_RngTarget::hitmonlee: + return {.base_stats = { 50, 120, 53, 35, 110, 87 }, .gender_threshold = -1, .level = 25}; + case PokemonFRLG_RngTarget::eevee: + return {.base_stats = { 55, 55, 50, 45, 65, 55 }, .gender_threshold = 30, .level = 25}; + case PokemonFRLG_RngTarget::lapras: + return {.base_stats = { 130, 85, 80, 85, 95, 60 }, .gender_threshold = 126, .level = 25}; + case PokemonFRLG_RngTarget::omanyte: + return {.base_stats = { 35, 40, 100, 90, 55, 35 }, .gender_threshold = 30, .level = 5}; + case PokemonFRLG_RngTarget::kabuto: + return {.base_stats = { 30, 80, 90, 55, 45, 55 }, .gender_threshold = 30, .level = 5}; + case PokemonFRLG_RngTarget::aerodactyl: + return {.base_stats = { 80, 105, 65, 60, 75, 130 }, .gender_threshold = 30, .level = 5}; + case PokemonFRLG_RngTarget::gamecornerabra: + return {.base_stats = { 25, 20, 15, 105, 55, 90 }, .gender_threshold = 63, .level = 0}; + case PokemonFRLG_RngTarget::gamecornerclefairy: + return {.base_stats = { 70, 45, 48, 60, 65, 35 }, .gender_threshold = 190, .level = 0}; + case PokemonFRLG_RngTarget::gamecornerdratinifr: + case PokemonFRLG_RngTarget::gamecornerdratinilg: + return {.base_stats = { 41, 64, 45, 50, 50, 50 }, .gender_threshold = 126, .level = 0}; + case PokemonFRLG_RngTarget::gamecornerscyther: + return {.base_stats = { 70, 110, 80, 55, 80, 105 }, .gender_threshold = 126, .level = 25}; + case PokemonFRLG_RngTarget::gamecornerpinsir: + return {.base_stats = { 65, 125, 100, 55, 70, 85 }, .gender_threshold = 126, .level = 18}; + case PokemonFRLG_RngTarget::gamecornerporygon: + return {.base_stats = { 65, 60, 70, 85, 75, 40 }, .gender_threshold = -1, .level = 0}; + case PokemonFRLG_RngTarget::togepi: + case PokemonFRLG_RngTarget::togepifast: + return {.base_stats = { 35, 20, 65, 40, 65, 20 }, .gender_threshold = 30, .level = 5}; + default: + return {}; + } +} + + +GiftRngOutcome gift_shiny_outcome(uint16_t seed, uint64_t advances, const std::vector& hits){ + if (hits.empty()){ + return GiftRngOutcome::shiny; + } + const bool on_target = std::any_of(hits.begin(), hits.end(), [&](const AdvRngState& hit){ + return have_hit_target(seed, advances, hit); + }); + return on_target ? GiftRngOutcome::shiny_on_target : GiftRngOutcome::shiny_off_target; +} + + +GiftRngStats::GiftRngStats() + : resets(m_stats["Resets"]) + , shinies(m_stats["Shinies"]) + , nonshiny(m_stats["Non-Shiny Hits"]) + , errors(m_stats["Errors"]) +{ + m_display_order.emplace_back("Resets"); + m_display_order.emplace_back("Shinies"); + m_display_order.emplace_back("Non-Shiny Hits", HIDDEN_IF_ZERO); + m_display_order.emplace_back("Errors", HIDDEN_IF_ZERO); +} + + +GiftRngAttempts::GiftRngAttempts( + GiftRngTarget target, + GiftRngSettings settings, + const GiftRngDisplays& displays, + const GiftRngNotifications& notifications, + GiftRngStats& stats +) + : m_target(std::move(target)) + , m_settings(std::move(settings)) + , m_displays(displays) + , m_notifications(notifications) + , m_stats(stats) + , m_species(gift_rng_species(m_target.target)) + , m_initial_advances_radius(m_settings.use_teachy_tv ? 4096 : 1024) + , m_max_history_length(m_settings.use_teachy_tv ? 2 : 10) + , m_searcher(m_target.seed, m_target.advances, AdvRngMethod::Method1) + , m_launch_delay(INITIAL_LAUNCH_DELAY) +{} + + +GiftRngResult GiftRngAttempts::run(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + env.log("RNG Target: " + std::to_string(static_cast(m_target.target))); + env.log("Target Seed: " + to_hex_string(m_target.seed)); + env.log("Target Advances: " + std::to_string(m_target.advances)); + + AdvPokemonResult target_result = m_searcher.generate_pokemon(); + m_displays.target.set_target(target_result, m_species.gender_threshold); + log_target_pokemon(env.console, target_result); + + m_calibrations = { + m_displays.calibration.seed_calibration / FRLG_FRAME_DURATION, + m_displays.calibration.csf_calibration, + m_displays.calibration.advances_calibration + }; + log_calibrations(env.console, m_calibrations, true); + + while (true){ + std::optional outcome = finished(env); + if (outcome){ + GiftRngResult result{.outcome = *outcome, .pokemon = std::nullopt}; + if (*outcome == GiftRngOutcome::target_hit){ + result.pokemon = target_result; + } + return result; + } + + reset_game(env, context); + + if (check_shiny_gift(env, context)){ + const GiftRngOutcome shiny = m_settings.identify_shiny ? identify_shiny_gift(env, context) : GiftRngOutcome::shiny; + const bool on_target = shiny == GiftRngOutcome::shiny_on_target; + report_read(true); + return GiftRngResult{.outcome = shiny, .pokemon = on_target ? std::optional(target_result) : std::nullopt}; + } + + run_attempt(env, context); + report_read(false); + } +} + +void GiftRngAttempts::report_read(bool shiny){ + if (m_settings.on_read){ + m_settings.on_read(RngResetRead{ + .resets = m_stats.resets.load(), + .hits = m_search_hits, + .hit = shiny ? std::nullopt : reset_hit(m_uncertain_history, m_calibration_history, m_search_hits), + .shiny = shiny, + }); + } +} + +std::optional GiftRngAttempts::finished(SingleSwitchProgramEnvironment& env){ + if (m_calibration_history.results.size() > 0){ + env.log("Checking for nonshiny target hit..."); + if (have_hit_target(m_target.seed, m_target.advances, m_calibration_history.results.back())){ + env.log("Target Hit!"); + m_stats.nonshiny++; + return GiftRngOutcome::target_hit; + } + env.log("Missed target."); + } + + if (m_failed_searches >= 5){ + env.log("Failed to find any matches 5 times in a row"); + return GiftRngOutcome::no_matches; + } + + if (m_stats.resets > m_settings.max_resets()){ + env.log("Max resets reached."); + return GiftRngOutcome::out_of_resets; + } + + return std::nullopt; +} + +void GiftRngAttempts::reset_game(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + send_program_status_notification( + env, m_notifications.status_update, + "Calibrating." + ); + env.update_stats(); + + + m_advances_radius = get_advances_radius(env.console, m_calibration_history, m_initial_advances_radius); + + const SeedMatch& seed_match = m_target.seed_match; + if (m_calibration_history.results.size() > 0){ + m_calibrations = get_calibrations(env.console, m_calibration_history, seed_match.seed_values, seed_match.seed_position, m_target.advances); + } + + // if previous resets had uncertain advances, slightly modify the seed delay to try to hit a different target + apply_seed_bump(m_calibrations, m_uncertain_history); + + uint64_t ingame_advances = m_target.advances - CONTINUE_SCREEN_FRAMES; + + RngTimings timings = prepare_timings( + env.console, m_target.target, + seed_match.seed_delay, CONTINUE_SCREEN_FRAMES, ingame_advances, + m_settings.use_teachy_tv, m_calibrations, + FIXED_SEED_OFFSET, m_settings.language + ); + + env.log("Resetting Game..."); + reset_and_perform_blind_sequence( + env.console, context, m_settings.language, m_target.target, + seed_match.seed_button, seed_match.extra_button, timings, + m_launch_delay, false, m_settings.profile + ); + m_stats.resets++; + m_search_hits.clear(); + if (m_settings.on_reset){ + m_settings.on_reset(GiftRngProgress{.resets = m_stats.resets.load(), .calibrations = m_calibrations}); + } + + m_displays.filters.reset(); + m_displays.calibration.set_calibrations(m_calibrations); + m_displays.calibration.reset_hits(); +} + +bool GiftRngAttempts::check_shiny_gift(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + bool shiny_found = check_for_shiny(env.console, context, m_target.target); + + if (shiny_found){ + env.log("Shiny found!"); + m_stats.shinies++; + m_displays.calibration.hits.set("Shiny!"); + send_program_notification( + env, + m_notifications.shiny, + COLOR_YELLOW, + "Shiny found!", + {}, "", + env.console.video().snapshot(), + true + ); + if (m_settings.take_video){ + pbf_press_button(context, BUTTON_CAPTURE, 2000ms, 0ms); + } + } + return shiny_found; +} + +// On the shiny's summary, as check_for_shiny() leaves it: reads it as an +// attempt does and searches near the target, without Rare Candies. +GiftRngOutcome GiftRngAttempts::identify_shiny_gift(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + AdvObservedPokemon pokemon = read_gift(env, context); + AdvRngFilters filters = observation_to_filters(pokemon, m_species.base_stats); + m_displays.filters.set(filters); + const std::vector hits = search(env, filters); + m_displays.calibration.set_hits(hits); + const GiftRngOutcome outcome = gift_shiny_outcome(m_target.seed, m_target.advances, hits); + env.log(std::format( + "Shiny gift: {} states near the target match it; {}.", hits.size(), + outcome == GiftRngOutcome::shiny_on_target ? "the target is one of them" + : outcome == GiftRngOutcome::shiny_off_target ? "the target isn't" : "not known if it is the target" + )); + return outcome; +} + +AdvObservedPokemon GiftRngAttempts::read_gift(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + AdvObservedPokemon pokemon = read_summary(env.console, context, m_settings.language, gift_species_names()); + if (m_species.level > 0){ + pokemon.level[0] = m_species.level; + } + return pokemon; +} + +std::vector GiftRngAttempts::search(SingleSwitchProgramEnvironment& env, AdvRngFilters& filters){ + m_search_hits = get_search_results( + env.console, m_searcher, filters, m_target.seed_match.seed_values, + m_target.advances, m_advances_radius, m_species.gender_threshold + ); + return m_search_hits; +} + + +void GiftRngAttempts::run_attempt(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ + AdvObservedPokemon pokemon = read_gift(env, context); + AdvRngFilters filters = observation_to_filters(pokemon, m_species.base_stats); + m_displays.filters.set(filters); + + std::vector search_hits = refine_calibration_with_rare_candy( + env, context, m_settings.language, pokemon, filters, m_species.base_stats, + m_uncertain_history, m_calibration_history, m_calibrations, + m_max_history_length, m_settings.max_rare_candies(), AdvRngMethod::Method1, false, + m_stats.errors, m_notifications.error_recoverable, + [&](AdvRngFilters& f){ + return search(env, f); + }, + [&](const std::vector& h){ m_displays.calibration.set_hits(h); }, + [&](const AdvRngFilters& f){ m_displays.filters.set(f); }, + [&](const std::vector& h){ + return all_equal(h) || all_indistinguishable(h, m_searcher, m_species.gender_threshold); + } + ); + + m_search_hits = search_hits; + env.log("RNG search finished."); + update_failed_searches(m_failed_searches, search_hits); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts.h new file mode 100644 index 0000000000..8da780ae6e --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts.h @@ -0,0 +1,191 @@ +/* Gift RNG Attempts + * + * From: https://github.com/PokemonAutomation/ + * + * Gift RNG's reset, calibrate and attempt loop, for one target. The Gift + * RNG program runs it with its options, stats and displays; another + * program (AutoStory) can run it with its own. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_GiftRngAttempts_H +#define PokemonAutomation_PokemonFRLG_GiftRngAttempts_H + +#include +#include +#include +#include +#include +#include "CommonFramework/Language.h" +#include "CommonFramework/Notifications/EventNotificationOption.h" +#include "CommonFramework/ProgramStats/StatsTracking.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_SingleSwitchProgram.h" +#include "Pokemon/Pokemon_AdvRng.h" +#include "Pokemon/Pokemon_StatsCalculation.h" +#include "PokemonFRLG_BlindNavigation.h" +#include "PokemonFRLG_RngCalibration.h" +#include "PokemonFRLG_RngDisplays.h" +#include "PokemonFRLG_SeedsDatabase.h" +#include "PokemonFRLG_StarterRngAttempts.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// What the search needs to know about a gift: its base stats, its gender +// threshold (-1 if genderless) and the level it is received at (0 to keep +// the level read from its summary). +struct GiftRngSpecies{ + BaseStats base_stats{}; + int16_t gender_threshold = -1; + uint8_t level = 0; +}; + +// The gift that "target" gives. Targets that aren't gifts get the defaults. +GiftRngSpecies gift_rng_species(PokemonFRLG_RngTarget target); + + +struct GiftRngStats : public StatsTracker{ + GiftRngStats(); + + std::atomic& resets; + std::atomic& shinies; + std::atomic& nonshiny; + std::atomic& errors; +}; + + +struct GiftRngTarget{ + PokemonFRLG_RngTarget target = PokemonFRLG_RngTarget::magikarp; + uint16_t seed = 0; + uint64_t advances = 0; + // The seed's entry in the seeds database (found). + SeedMatch seed_match; +}; + +// Where an attempt is after a reset: the resets so far, and the +// calibration that reset was timed with. +struct GiftRngProgress{ + uint64_t resets = 0; + RngCalibrations calibrations{}; +}; + +struct GiftRngSettings{ + Language language = Language::None; + // The Switch profile's position on the user selection screen; 0 for + // the default. + uint8_t profile = 0; + // Advance the RNG in the Teachy TV. + bool use_teachy_tv = false; + // Record a video when a shiny is found. + bool take_video = false; + // After a shiny, read its summary and search for the state hit, to + // tell whether it was the target (shiny_on_target or + // shiny_off_target). Off, the attempts end on the shiny at once. + bool identify_shiny = false; + // Read before each reset, since Gift RNG lets the user change it + // while running. + std::function max_resets; + // The Rare Candies at the top of the bag. Read before each search, as + // max_resets is. + std::function max_rare_candies; + // Called after each reset, if set, for a caller that logs the attempts. + std::function on_reset; + // Called after each reset's reads, if set, as on_reset is. + std::function on_read; +}; + +// Where the attempts show the target, the filters read so far and the +// calibration. The calibration display also holds the starting +// calibration. +struct GiftRngDisplays{ + RngTargetDisplay& target; + RngFilterDisplay& filters; + RngCalibrationDisplay& calibration; +}; + +struct GiftRngNotifications{ + EventNotificationOption& shiny; + EventNotificationOption& status_update; + EventNotificationOption& error_recoverable; +}; + + +enum class GiftRngOutcome{ + shiny, // The gift was shiny after a reset; not known if on the target. + shiny_on_target, // Shiny, and the target is among the states its stats match. + shiny_off_target, // Shiny, and the states its stats match aren't the target. + target_hit, // The calibration shows the target was hit; not shiny. + out_of_resets, // More resets than max_resets. + no_matches, // 5 searches in a row matched no state near the target. +}; + +// A shiny gift's outcome from the states its stats match near the target +// ("hits"): on the target if it is among them, off it if not, and just +// shiny if none match. +GiftRngOutcome gift_shiny_outcome(uint16_t seed, uint64_t advances, const std::vector& hits); + +struct GiftRngResult{ + GiftRngOutcome outcome = GiftRngOutcome::out_of_resets; + // The target's PID, nature and IVs, on target_hit and shiny_on_target. + std::optional pokemon; +}; + + +class GiftRngAttempts{ +public: + GiftRngAttempts( + GiftRngTarget target, + GiftRngSettings settings, + const GiftRngDisplays& displays, + const GiftRngNotifications& notifications, + GiftRngStats& stats + ); + GiftRngAttempts(const GiftRngAttempts&) = delete; + GiftRngAttempts& operator=(const GiftRngAttempts&) = delete; + + // From the HOME menu with the game closed: resets the game and + // receives the gift at the target's timing until the outcome is known. + // Each non-shiny gift is read, leveled with Rare Candies if needed, to + // find the state hit and recalibrate. Ends in the game on the last + // screen the attempts reached. Call once per object. + GiftRngResult run(SingleSwitchProgramEnvironment& env, ProControllerContext& context); + +private: + std::optional finished(SingleSwitchProgramEnvironment& env); + void reset_game(SingleSwitchProgramEnvironment& env, ProControllerContext& context); + bool check_shiny_gift(SingleSwitchProgramEnvironment& env, ProControllerContext& context); + GiftRngOutcome identify_shiny_gift(SingleSwitchProgramEnvironment& env, ProControllerContext& context); + AdvObservedPokemon read_gift(SingleSwitchProgramEnvironment& env, ProControllerContext& context); + std::vector search(SingleSwitchProgramEnvironment& env, AdvRngFilters& filters); + void run_attempt(SingleSwitchProgramEnvironment& env, ProControllerContext& context); + void report_read(bool shiny); + + const GiftRngTarget m_target; + const GiftRngSettings m_settings; + GiftRngDisplays m_displays; + GiftRngNotifications m_notifications; + GiftRngStats& m_stats; + const GiftRngSpecies m_species; + const uint64_t m_initial_advances_radius; + const uint8_t m_max_history_length; + + AdvRngSearcher m_searcher; + RngCalibrations m_calibrations{}; + Milliseconds m_launch_delay; + RngUncertainHistory m_uncertain_history; + RngCalibrationHistory m_calibration_history; + uint64_t m_advances_radius = 0; + uint16_t m_failed_searches = 0; + // The last search's hits, for on_read. + std::vector m_search_hits; +}; + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.cpp new file mode 100644 index 0000000000..c76a7e90f9 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.cpp @@ -0,0 +1,159 @@ +/* Gift RNG Attempts Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Gift RNG's per-target base stats, gender thresholds and levels, as Gift + * RNG set them before its attempts moved out of the program; and whether + * a shiny gift was the target. + * + */ + +#include +#include +#include +#include +#include "PokemonFRLG_GiftRngAttempts.h" +#include "PokemonFRLG_GiftRngAttempts_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace Pokemon; + + +namespace{ + +struct GiftRow{ + PokemonFRLG_RngTarget target = PokemonFRLG_RngTarget::magikarp; + std::string name = ""; + BaseStats base_stats{}; + int16_t gender_threshold = -1; + uint8_t level = 0; +}; + +const std::vector GIFT_ROWS = { + {.target = PokemonFRLG_RngTarget::magikarp, .name = "Magikarp", .base_stats = {20, 10, 55, 15, 20, 80}, .gender_threshold = 126, .level = 5}, + {.target = PokemonFRLG_RngTarget::hitmonchan, .name = "Hitmonchan", .base_stats = {50, 105, 79, 35, 110, 76}, .gender_threshold = -1, .level = 25}, + {.target = PokemonFRLG_RngTarget::hitmonlee, .name = "Hitmonlee", .base_stats = {50, 120, 53, 35, 110, 87}, .gender_threshold = -1, .level = 25}, + {.target = PokemonFRLG_RngTarget::eevee, .name = "Eevee", .base_stats = {55, 55, 50, 45, 65, 55}, .gender_threshold = 30, .level = 25}, + {.target = PokemonFRLG_RngTarget::lapras, .name = "Lapras", .base_stats = {130, 85, 80, 85, 95, 60}, .gender_threshold = 126, .level = 25}, + {.target = PokemonFRLG_RngTarget::omanyte, .name = "Omanyte", .base_stats = {35, 40, 100, 90, 55, 35}, .gender_threshold = 30, .level = 5}, + {.target = PokemonFRLG_RngTarget::kabuto, .name = "Kabuto", .base_stats = {30, 80, 90, 55, 45, 55}, .gender_threshold = 30, .level = 5}, + {.target = PokemonFRLG_RngTarget::aerodactyl, .name = "Aerodactyl", .base_stats = {80, 105, 65, 60, 75, 130}, .gender_threshold = 30, .level = 5}, + {.target = PokemonFRLG_RngTarget::gamecornerabra, .name = "Game Corner Abra", .base_stats = {25, 20, 15, 105, 55, 90}, .gender_threshold = 63, .level = 0}, + {.target = PokemonFRLG_RngTarget::gamecornerclefairy, .name = "Game Corner Clefairy", .base_stats = {70, 45, 48, 60, 65, 35}, .gender_threshold = 190, .level = 0}, + {.target = PokemonFRLG_RngTarget::gamecornerdratinifr, .name = "Game Corner Dratini FR", .base_stats = {41, 64, 45, 50, 50, 50}, .gender_threshold = 126, .level = 0}, + {.target = PokemonFRLG_RngTarget::gamecornerdratinilg, .name = "Game Corner Dratini LG", .base_stats = {41, 64, 45, 50, 50, 50}, .gender_threshold = 126, .level = 0}, + {.target = PokemonFRLG_RngTarget::gamecornerscyther, .name = "Game Corner Scyther", .base_stats = {70, 110, 80, 55, 80, 105}, .gender_threshold = 126, .level = 25}, + {.target = PokemonFRLG_RngTarget::gamecornerpinsir, .name = "Game Corner Pinsir", .base_stats = {65, 125, 100, 55, 70, 85}, .gender_threshold = 126, .level = 18}, + {.target = PokemonFRLG_RngTarget::gamecornerporygon, .name = "Game Corner Porygon", .base_stats = {65, 60, 70, 85, 75, 40}, .gender_threshold = -1, .level = 0}, + {.target = PokemonFRLG_RngTarget::togepi, .name = "Togepi", .base_stats = {35, 20, 65, 40, 65, 20}, .gender_threshold = 30, .level = 5}, + {.target = PokemonFRLG_RngTarget::togepifast, .name = "Togepi pre-approved", .base_stats = {35, 20, 65, 40, 65, 20}, .gender_threshold = 30, .level = 5}, +}; + +std::string to_string(const BaseStats& stats){ + return std::format("{}/{}/{}/{}/{}/{}", stats.hp, stats.attack, stats.defense, stats.spatk, stats.spdef, stats.speed); +} +std::string to_string(const GiftRngSpecies& species){ + return std::format( + "base stats {}, gender threshold {}, level {}", + to_string(species.base_stats), species.gender_threshold, species.level + ); +} + +class Test_GiftSpecies : public UnitTest{ +public: + explicit Test_GiftSpecies(const GiftRow& row) + : UnitTest("PokemonFRLG::GiftRngAttempts - Gift species " + row.name) + , m_row(row) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::string expected = to_string(GiftRngSpecies{ + .base_stats = m_row.base_stats, + .gender_threshold = m_row.gender_threshold, + .level = m_row.level, + }); + const std::string got = to_string(gift_rng_species(m_row.target)); + if (expected != got){ + return "Expected " + expected + "; got " + got; + } + return true; + } + +private: + GiftRow m_row; +}; + + +// Target: seed 0x70FE, advance 1000. +struct ShinyRow{ + std::string name = ""; + // The (seed, advance) of each state the shiny's stats match. + std::vector> hits = {}; + GiftRngOutcome expected = GiftRngOutcome::shiny; +}; + +const std::vector SHINY_ROWS = { + {.name = "the target alone", .hits = {{0x70fe, 1000}}, .expected = GiftRngOutcome::shiny_on_target}, + {.name = "the target among others", .hits = {{0x70fe, 998}, {0x70fe, 1000}, {0x70ff, 1000}}, .expected = GiftRngOutcome::shiny_on_target}, + {.name = "another advance", .hits = {{0x70fe, 1002}}, .expected = GiftRngOutcome::shiny_off_target}, + {.name = "another seed", .hits = {{0x70fd, 1000}}, .expected = GiftRngOutcome::shiny_off_target}, + {.name = "no match", .hits = {}, .expected = GiftRngOutcome::shiny}, +}; + +const char* outcome_name(GiftRngOutcome outcome){ + switch (outcome){ + case GiftRngOutcome::shiny: return "shiny"; + case GiftRngOutcome::shiny_on_target: return "shiny_on_target"; + case GiftRngOutcome::shiny_off_target: return "shiny_off_target"; + case GiftRngOutcome::target_hit: return "target_hit"; + case GiftRngOutcome::out_of_resets: return "out_of_resets"; + case GiftRngOutcome::no_matches: return "no_matches"; + } + return "?"; +} + +class Test_GiftShinyOutcome : public UnitTest{ +public: + explicit Test_GiftShinyOutcome(const ShinyRow& row) + : UnitTest("PokemonFRLG::GiftRngAttempts - Shiny " + row.name) + , m_row(row) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + std::vector hits; + for (const auto& [seed, advance] : m_row.hits){ + AdvRngState hit{}; + hit.seed = seed; + hit.advance = advance; + hits.push_back(hit); + } + const GiftRngOutcome got = gift_shiny_outcome(0x70fe, 1000, hits); + if (got != m_row.expected){ + return std::string("Expected ") + outcome_name(m_row.expected) + ", got " + outcome_name(got); + } + return true; + } + +private: + ShinyRow m_row; +}; + +} + + +void add_tests_GiftRngAttempts(UnitTestDatabase& database){ + for (const GiftRow& row : GIFT_ROWS){ + database.add(row); + } + for (const ShinyRow& row : SHINY_ROWS){ + database.add(row); + } +} + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.h new file mode 100644 index 0000000000..e9795466f5 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.h @@ -0,0 +1,24 @@ +/* Gift RNG Attempts Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_GiftRngAttempts_Tests_H +#define PokemonAutomation_PokemonFRLG_GiftRngAttempts_Tests_H + +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The pure parts of the Gift RNG attempts. +void add_tests_GiftRngAttempts(UnitTestDatabase& database); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.cpp new file mode 100644 index 0000000000..cd79be3b96 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.cpp @@ -0,0 +1,127 @@ +/* Gift RNG Console Steps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Common/Cpp/Exceptions.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "PokemonFRLG_StarterRngConsoleSteps.h" +#include "PokemonFRLG_GiftRngConsoleSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +StarterRngOutcome loop_outcome(GiftRngOutcome outcome){ + switch (outcome){ + case GiftRngOutcome::shiny: + return StarterRngOutcome::shiny; + case GiftRngOutcome::shiny_on_target: + return StarterRngOutcome::shiny_on_target; + case GiftRngOutcome::shiny_off_target: + return StarterRngOutcome::shiny_off_target; + case GiftRngOutcome::target_hit: + return StarterRngOutcome::target_hit; + case GiftRngOutcome::out_of_resets: + return StarterRngOutcome::out_of_resets; + case GiftRngOutcome::no_matches: + return StarterRngOutcome::no_matches; + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown Gift RNG outcome."); +} + + +namespace{ + +// Gift RNG's settings for one try: no resets after the try's deadline, +// its resets reported to the loop, and its reads to `on_read`. +GiftRngSettings try_settings( + Language language, const StarterRngAttemptCall& call, std::function on_read +){ + return GiftRngSettings{ + .language = language, + .profile = 0, + .use_teachy_tv = false, + .take_video = false, + .identify_shiny = true, + .max_resets = [deadline = call.deadline, max_resets = call.max_resets]{ + return current_time() >= deadline ? uint64_t(0) : max_resets; + }, + .max_rare_candies = []{ return uint64_t(0); }, + .on_reset = [on_reset = call.on_reset](const GiftRngProgress& progress){ + if (on_reset){ + on_reset(StarterRngProgress{.resets = progress.resets, .calibrations = progress.calibrations}); + } + }, + .on_read = std::move(on_read), + }; +} + +} + + +ConsoleGiftRngSteps::ConsoleGiftRngSteps( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + const GiftRngConsoleSettings& settings, const GiftRngNotifications& notifications +) + : m_env(env) + , m_context(context) + , m_settings(settings) + , m_notifications(notifications) + // TODO: LeafGreen, once AutoStory plays it. + , m_seeds(seeds_json_path(true, settings.language)) +{ + m_calibration_display.set_calibrations(settings.calibration); +} + +WallClock ConsoleGiftRngSteps::now(){ + return current_time(); +} + +StarterRngResult ConsoleGiftRngSteps::run_attempt(const RngAttempt& attempt, const StarterRngAttemptCall& call){ + // Gift RNG's default Nearby Seed Radius. + const int SEED_RADIUS = 5; + const SeedMatch seed_match = m_seeds.find_seed(attempt.target.seed.seed, m_settings.sound, SEED_RADIUS); + if (!seed_match.found){ + throw InternalProgramError(&m_env.logger(), PA_CURRENT_FUNCTION, "The plan's seed isn't in the seed list it came from."); + } + + GiftRngStats stats; + GiftRngAttempts attempts( + GiftRngTarget{ + .target = PokemonFRLG_RngTarget::magikarp, + .seed = attempt.target.seed.seed, + .advances = attempt.target.advances, + .seed_match = seed_match, + }, + try_settings( + m_settings.language, call, reset_line_logger(m_env.console, "Gift RNG", attempt, seed_match, call.max_resets) + ), + GiftRngDisplays{ + .target = m_target_display, + .filters = m_filter_display, + .calibration = m_calibration_display, + }, + m_notifications, + stats + ); + try{ + const GiftRngResult result = attempts.run(m_env, m_context); + return StarterRngResult{.outcome = loop_outcome(result.outcome), .pokemon = result.pokemon}; + }catch (OperationFailedException& e){ + // Gift RNG refuses timings it can't press (check_timings()) this + // way; a failure in the game sends an error report. + if (e.error_report_mode() != ErrorReportMode::NO_ERROR_REPORT){ + throw; + } + throw StarterRngTimingsRefused(e.message()); + } +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.h new file mode 100644 index 0000000000..9ceb3cae48 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.h @@ -0,0 +1,73 @@ +/* Gift RNG Console Steps + * + * From: https://github.com/PokemonAutomation/ + * + * The RNG plan loop's steps (PokemonFRLG_StarterRngLoop.h) on the console + * for Gift RNG's Magikarp: each try runs Gift RNG's attempts + * (PokemonFRLG_GiftRngAttempts.h) from the save beside Route 4's salesman. + * The loop counts tries in Starter RNG's outcomes; Gift RNG's map onto + * them one to one. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_GiftRngConsoleSteps_H +#define PokemonAutomation_PokemonFRLG_GiftRngConsoleSteps_H + +#include "Common/Cpp/Time.h" +#include "CommonFramework/Language.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_SingleSwitchProgram.h" +#include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts.h" +#include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_RngDisplays.h" +#include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_RngPlan.h" +#include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_SeedsDatabase.h" +#include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_StarterRngAttempts.h" +#include "VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_StarterRngLoop.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Gift RNG's outcome as the loop counts it. +StarterRngOutcome loop_outcome(GiftRngOutcome outcome); + + +struct GiftRngConsoleSettings{ + Language language = Language::English; + SoundSetting sound = SoundSetting::Mono; + // The calibration the first try starts from. + RngCalibrations calibration = {}; +}; + +// The loop's steps on the console, with Gift RNG's displays, stats and +// calibration kept here (AutoStory doesn't show them). Each try after the +// first starts from the calibration the last one ended with. Magikarp, FireRed, +// the Switch's default profile, no Teachy TV and no Rare Candies. +class ConsoleGiftRngSteps : public StarterRngLoopSteps{ +public: + ConsoleGiftRngSteps( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + const GiftRngConsoleSettings& settings, const GiftRngNotifications& notifications + ); + + virtual WallClock now() override; + virtual StarterRngResult run_attempt(const RngAttempt& attempt, const StarterRngAttemptCall& call) override; + +private: + SingleSwitchProgramEnvironment& m_env; + ProControllerContext& m_context; + GiftRngConsoleSettings m_settings; + GiftRngNotifications m_notifications; + SeedsDatabase m_seeds; + RngTargetDisplay m_target_display; + RngFilterDisplay m_filter_display; + RngCalibrationDisplay m_calibration_display; +}; + + + +} +} +} +#endif diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index e1dee1738e..7dfc7c1c28 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 @@ -1767,6 +1769,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryDetector.h Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.cpp Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h + Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp + Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TrainerCardDetector.cpp Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TrainerCardDetector.h Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleLevelUpReader.cpp @@ -1781,6 +1785,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleSelectionArrowDetector.cpp Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleSelectionArrowDetector.h + Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.cpp + Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleTextBoxDetector.h Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_DaycareManDetector.cpp Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_DaycareManDetector.h Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_DigitReader.cpp @@ -1822,6 +1828,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp @@ -1837,6 +1846,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryAbilities_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -1849,26 +1861,52 @@ 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_AutoStoryChoice.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryChoice_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 Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.h 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 Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -1904,6 +1942,12 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_EncountersDatabase.h Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.cpp Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRng.h + Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts.cpp + Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts.h + Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.h + Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.cpp + Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_GiftRngConsoleSteps.h Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_HardReset.cpp Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_HardReset.h Source/VideoGames/PokemonFRLG/Programs/RngManipulation/PokemonFRLG_LocationsDatabase.cpp diff --git a/devtools/maps/frlg_maps.py b/devtools/maps/frlg_maps.py index 3a4ef4a25a..f72a9ecca0 100644 --- a/devtools/maps/frlg_maps.py +++ b/devtools/maps/frlg_maps.py @@ -4,21 +4,24 @@ Usage: python3 devtools/maps/frlg_maps.py --out SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps \ - CeruleanCity_PokemonCenter_1F CeruleanCity Route24 \ - CeruleanCave_1F CeruleanCave_2F CeruleanCave_B1F + Route3 Route4 Route4_PokemonCenter_1F python3 devtools/maps/frlg_maps.py --out python3 devtools/maps/frlg_maps.py --out --check Downloads each map's data/maps//map.json, its layout's map.bin and its tilesets' metatile_attributes.bin at --commit into --cache, then writes /.json in the schema of Pokemon::MapData (Pokemon_MapData.h). +A map whose header doesn't allow running (indoors) says so; a trainer has +their sight and the ways they look. Warps and connections lead only to maps in the same run: a warp to another map has no destination, and a connection to one is left out. A warp arrives at point "warp_" of its map, the tile of that map's warp n. Other named points and flag sets come from POINTS and FLAG_SETS below. -Without map names, the maps are the ones already in --out. --check writes -nothing: it regenerates them and fails if any file differs. The program +Without map names, the maps are the ones already in --out; with them, those +maps are added to the ones in --out, so warps and connections still join +them all. --check writes nothing: it regenerates them and fails if any file +differs. The program builds in the files of SerialPrograms/Source/VideoGames/PokemonFRLG/ Overworld/Maps, the folder to write and check. """ @@ -54,6 +57,15 @@ # Ledges, by the way the player hops over them. LEDGES = {'MB_JUMP_NORTH': '^', 'MB_JUMP_SOUTH': 'v', 'MB_JUMP_WEST': '<', 'MB_JUMP_EAST': '>'} FLOOR = ('MB_NORMAL', 'MB_CAVE', 'MB_ROCK_STAIRS', 'MB_TALL_GRASS') +# A metatile's encounter type (METATILE_ATTRIBUTE_ENCOUNTER_TYPE in +# include/fieldmap.h): on a map with land encounters, a step onto a LAND +# tile may start a wild battle, as on every floor tile of a cave. +ENCOUNTER_TYPE_SHIFT, ENCOUNTER_TYPE_MASK = 24, 0x7 +TILE_ENCOUNTER_LAND = 1 +# Open tiles closed on one side, and the region that lists those of them +# with wild encounters, which their glyph can't say. +CLOSED_GLYPHS = '8264' +WILD_REGION = 'wild' DIRECTIONS = {'up': 'north', 'down': 'south', 'left': 'west', 'right': 'east'} # Places and flag sets the decompilation doesn't name, written into the map @@ -70,6 +82,8 @@ 'PewterCity': {'gym_front': (15, 17)}, 'PewterCity_PokemonCenter_1F': {'nurse_front': (7, 4)}, 'PewterCity_Gym': {'brock_front': (6, 6)}, + # The Magikarp salesman is at (1, 3), west of `salesman_front`. + 'Route4_PokemonCenter_1F': {'nurse_front': (7, 4), 'salesman_front': (2, 3)}, # The tile in front of the PC. 'CeruleanCity_PokemonCenter_1F': {'pc': (11, 2), 'nurse_front': (7, 4)}, # Where the player stands after entering from Cerulean City. @@ -148,14 +162,36 @@ def tile_glyph(behavior, collision, unhandled, sign_event=False): return '.', None -def grid(decomp, layout, names, unhandled, signs=frozenset()): - """The map's tile rows, elevation rows, and how the warp on each tile is - entered, by (x, y), where it isn't by walking into it.""" +def land_encounter(attributes): + """Whether, on a map with land encounters, a step onto a tile with these + metatile attributes may start a wild battle.""" + return (attributes >> ENCOUNTER_TYPE_SHIFT) & ENCOUNTER_TYPE_MASK == TILE_ENCOUNTER_LAND + + +def encounter_glyph(glyph, attributes): + """On a map with land encounters: `glyph` for a tile with these metatile + attributes, open floor where wild Pokemon appear written as tall grass.""" + return '~' if glyph == '.' and land_encounter(attributes) else glyph + + +def wild_edge(glyph, attributes): + """On a map with land encounters: whether a tile closed on one side, with + these metatile attributes, has wild encounters. Its glyph can't say so; + the map lists it in region WILD_REGION.""" + return glyph in CLOSED_GLYPHS and land_encounter(attributes) + + +def grid(decomp, layout, names, unhandled, signs=frozenset(), land_encounters=False): + """The map's tile rows, elevation rows, how the warp on each tile is + entered, by (x, y), where it isn't by walking into it, and the tiles closed + on one side that have wild encounters. With `land_encounters`, open floor + where wild Pokemon appear (a cave's) is written as tall grass, the glyph + for "open, with wild encounters".""" width, height = layout['width'], layout['height'] blocks = decomp.read(layout['blockdata_filepath']) primary = tileset_attributes(decomp, layout['primary_tileset'], 'primary') secondary = tileset_attributes(decomp, layout['secondary_tileset'], 'secondary') - tiles, elevation, entries = [], [], {} + tiles, elevation, entries, wild_edges = [], [], {}, [] for y in range(height): row, levels = '', '' for x in range(width): @@ -164,13 +200,17 @@ def grid(decomp, layout, names, unhandled, signs=frozenset()): attributes = primary[metatile] if metatile < 640 else secondary[metatile - 640] behavior = names.get(attributes & 0x1ff, hex(attributes & 0x1ff)) glyph, entry = tile_glyph(behavior, collision, unhandled, (x, y) in signs) + if land_encounters and entry is None: + glyph = encounter_glyph(glyph, attributes) + if wild_edge(glyph, attributes): + wild_edges.append([x, y]) row += glyph if entry: entries[(x, y)] = entry levels += '%x' % level tiles.append(row) elevation.append(levels) - return tiles, elevation, entries + return tiles, elevation, entries, wild_edges def wander_box(obj, tiles): @@ -189,12 +229,50 @@ def wander_box(obj, tiles): } +# Which way a person stands, by how they move. +FACINGS = { + 'MOVEMENT_TYPE_FACE_UP': 'north', 'MOVEMENT_TYPE_FACE_DOWN': 'south', + 'MOVEMENT_TYPE_FACE_LEFT': 'west', 'MOVEMENT_TYPE_FACE_RIGHT': 'east', +} +# The ways a trainer who turns looks. A trainer who moves some other way +# (wanders, looks around) may look any way. +TURNING_LOOKS = { + 'MOVEMENT_TYPE_FACE_DOWN_AND_UP': ['south', 'north'], + 'MOVEMENT_TYPE_FACE_LEFT_AND_RIGHT': ['west', 'east'], + 'MOVEMENT_TYPE_FACE_UP_AND_LEFT': ['north', 'west'], + 'MOVEMENT_TYPE_FACE_UP_AND_RIGHT': ['north', 'east'], + 'MOVEMENT_TYPE_FACE_DOWN_AND_LEFT': ['south', 'west'], + 'MOVEMENT_TYPE_FACE_DOWN_AND_RIGHT': ['south', 'east'], + 'MOVEMENT_TYPE_WANDER_UP_AND_DOWN': ['north', 'south'], + 'MOVEMENT_TYPE_WANDER_LEFT_AND_RIGHT': ['west', 'east'], +} +ALL_LOOKS = ['north', 'east', 'south', 'west'] + + +def trainer(obj): + """A trainer's sight and, unless they only face one way, the ways they + look; None for anyone who doesn't battle on sight.""" + sight = int(obj.get('trainer_sight_or_berry_tree_id') or 0) + if obj.get('trainer_type') != 'TRAINER_TYPE_NORMAL' or sight < 1: + return None + movement = obj.get('movement_type', '') + if movement in FACINGS: + return {'sight': sight} + return {'sight': sight, 'looks': TURNING_LOOKS.get(movement, ALL_LOOKS)} + + def map_object(obj, tiles): kind = obj['graphics_id'].replace('OBJ_EVENT_GFX_', '').lower() data = {'name': kind, 'kind': 'item' if kind == 'item_ball' else 'npc', 'x': obj['x'], 'y': obj['y']} box = wander_box(obj, tiles) if box: data['wander'] = box + sight = trainer(obj) + if sight: + # A trainer who faces one way looks that way. + if 'looks' not in sight: + data['facing'] = FACINGS[obj['movement_type']] + data['trainer'] = sight flag = obj.get('flag') or '0' if flag != '0': data['hide_flag'] = flag @@ -240,10 +318,18 @@ def arrivals(sources, loaded): return arriving -def map_json(decomp, name, sources, layouts, names, loaded, unhandled): +def land_encounter_maps(decomp): + """The ids of the maps that have land encounters (src/data/wild_encounters.json).""" + groups = json.loads(decomp.read('src/data/wild_encounters.json'))['wild_encounter_groups'] + return {e['map'] for group in groups for e in group.get('encounters', []) if 'land_mons' in e} + + +def map_json(decomp, name, sources, layouts, names, loaded, unhandled, land_maps=frozenset()): source = sources[name] signs = frozenset((e['x'], e['y']) for e in source.get('bg_events', []) if e.get('type') == 'sign') - tiles, elevation, entries = grid(decomp, layouts[source['layout']], names, unhandled, signs) + tiles, elevation, entries, wild_edges = grid( + decomp, layouts[source['layout']], names, unhandled, signs, source['id'] in land_maps + ) warp_events = source.get('warp_events', []) points = {n: [x, y] for n, (x, y) in POINTS.get(name, {}).items()} for index in sorted(arrivals(sources, loaded)[name]): @@ -251,8 +337,10 @@ def map_json(decomp, name, sources, layouts, names, loaded, unhandled): data = { 'name': name, 'source': {'decomp_file': f'data/maps/{name}/map.json', 'revision': COMMIT, 'date': COMMIT_DATE}, + 'allow_running': source.get('allow_running', True), 'tiles': tiles, 'elevation': elevation, 'points': points, + 'regions': {WILD_REGION: wild_edges} if wild_edges else {}, 'warps': [warp(event, entries, loaded, sources) for event in warp_events], # A few objects stand off the map, where no route meets them. 'objects': [ @@ -265,8 +353,12 @@ def map_json(decomp, name, sources, layouts, names, loaded, unhandled): for c in (source.get('connections') or []) if c['map'] in loaded ], } + if not data['regions']: + del data['regions'] if ground_only(tiles, elevation): del data['elevation'] + if data['allow_running']: + del data['allow_running'] return data @@ -284,8 +376,8 @@ def main(): parser.add_argument('--check', action='store_true', help='check the files in --out instead of writing them') parser.add_argument('maps', nargs='*', help='default: the maps already in --out') args = parser.parse_args() - if not args.maps and os.path.isdir(args.out): - args.maps = sorted(f[:-5] for f in os.listdir(args.out) if f.endswith('.json')) + if os.path.isdir(args.out): + args.maps = sorted(set(args.maps) | {f[:-5] for f in os.listdir(args.out) if f.endswith('.json')}) if not args.maps: parser.error('no maps given, and none in --out') @@ -295,7 +387,8 @@ def main(): sources = {m: json.loads(decomp.read(f'data/maps/{m}/map.json')) for m in args.maps} loaded = {source['id']: m for m, source in sources.items()} unhandled = {} - maps = [(name, json_text(map_json(decomp, name, sources, layouts, names, loaded, unhandled))) for name in args.maps] + land_maps = land_encounter_maps(decomp) + maps = [(name, json_text(map_json(decomp, name, sources, layouts, names, loaded, unhandled, land_maps))) for name in args.maps] if unhandled: print(f'Non-collision behaviors written as floor: {unhandled}') if args.check: diff --git a/devtools/maps/tests/test_frlg_maps.py b/devtools/maps/tests/test_frlg_maps.py new file mode 100644 index 0000000000..96fc4b5eda --- /dev/null +++ b/devtools/maps/tests/test_frlg_maps.py @@ -0,0 +1,69 @@ +import unittest + +from devtools.maps import frlg_maps + +TILES = ['.....'] * 5 + + +def event(**fields): + obj = {'graphics_id': 'OBJ_EVENT_GFX_BUG_CATCHER', 'x': 2, 'y': 2, 'movement_type': 'MOVEMENT_TYPE_FACE_RIGHT', + 'movement_range_x': 1, 'movement_range_y': 1, 'trainer_type': 'TRAINER_TYPE_NORMAL', + 'trainer_sight_or_berry_tree_id': '3', 'flag': '0'} + obj.update(fields) + return obj + + +class TrainerTest(unittest.TestCase): + def test_facing_trainer_looks_ahead(self): + data = frlg_maps.map_object(event(), TILES) + self.assertEqual(data['facing'], 'east') + self.assertEqual(data['trainer'], {'sight': 3}) + + def test_wandering_trainer_looks_both_ways(self): + data = frlg_maps.map_object(event(movement_type='MOVEMENT_TYPE_WANDER_UP_AND_DOWN'), TILES) + self.assertNotIn('facing', data) + self.assertEqual(data['trainer'], {'sight': 3, 'looks': ['north', 'south']}) + self.assertEqual(data['wander'], {'x0': 2, 'y0': 1, 'x1': 2, 'y1': 3}) + + def test_trainer_looking_around_looks_every_way(self): + data = frlg_maps.map_object(event(movement_type='MOVEMENT_TYPE_LOOK_AROUND'), TILES) + self.assertEqual(data['trainer']['looks'], frlg_maps.ALL_LOOKS) + + def test_others_are_no_trainers(self): + for fields in ({'trainer_type': 'TRAINER_TYPE_NONE'}, {'trainer_sight_or_berry_tree_id': '0'}): + data = frlg_maps.map_object(event(**fields), TILES) + self.assertNotIn('trainer', data) + self.assertNotIn('facing', data) + + +class EncounterTest(unittest.TestCase): + # MtMoon_1F's cave floor: behavior MB_CAVE (0x08), encounter type LAND. + CAVE_FLOOR = 0x08 | (frlg_maps.TILE_ENCOUNTER_LAND << frlg_maps.ENCOUNTER_TYPE_SHIFT) + + def test_cave_floor_has_wild_encounters(self): + self.assertEqual(frlg_maps.encounter_glyph('.', self.CAVE_FLOOR), '~') + + def test_floor_without_encounters_stays_open(self): + self.assertEqual(frlg_maps.encounter_glyph('.', 0x08), '.') + + def test_walls_stay_walls(self): + self.assertEqual(frlg_maps.encounter_glyph('#', self.CAVE_FLOOR), '#') + + # MtMoon_B2F's floor closed on the north: MB_IMPASSABLE_NORTH (0x32), + # encounter type LAND, as wild as the cave floor around it. + CLOSED_NORTH = 0x32 | (frlg_maps.TILE_ENCOUNTER_LAND << frlg_maps.ENCOUNTER_TYPE_SHIFT) + + def test_closed_side_floor_has_wild_encounters(self): + for glyph in '8264': + self.assertTrue(frlg_maps.wild_edge(glyph, self.CLOSED_NORTH)) + + def test_closed_side_floor_without_encounters_is_not_wild(self): + self.assertFalse(frlg_maps.wild_edge('8', 0x32)) + + def test_other_tiles_are_no_wild_edges(self): + for glyph in '.~#': + self.assertFalse(frlg_maps.wild_edge(glyph, self.CAVE_FLOOR)) + + +if __name__ == '__main__': + unittest.main()