diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp index c87bebb817..28e0f4e1f3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.cpp @@ -28,12 +28,14 @@ WhiteDialogDetector::WhiteDialogDetector(Color color) : m_right_box(0.923385, 0.748077, 0.00615385, 0.204577) , m_top_box(0.0704615, 0.741846, 0.859077, 0.00623077) , m_bottom_box(0.0716923, 0.943308, 0.851692, 0.00934615) + , m_frame_box(0.0704615, 0.7288, 0.859077, 0.0048) {} void WhiteDialogDetector::make_overlays(VideoOverlaySet& items) const{ const BoxOption& GAME_BOX = GameSettings::instance().GAME_BOX; items.add(COLOR_RED, GAME_BOX.inner_to_outer(m_right_box)); items.add(COLOR_RED, GAME_BOX.inner_to_outer(m_top_box)); items.add(COLOR_RED, GAME_BOX.inner_to_outer(m_bottom_box)); + items.add(COLOR_RED, GAME_BOX.inner_to_outer(m_frame_box)); } bool WhiteDialogDetector::detect(const ImageViewRGB32& screen){ ImageViewRGB32 game_screen = extract_box_reference(screen, GameSettings::instance().GAME_BOX); @@ -41,9 +43,15 @@ bool WhiteDialogDetector::detect(const ImageViewRGB32& screen){ ImageViewRGB32 right_image = extract_box_reference(game_screen, m_right_box); ImageViewRGB32 top_image = extract_box_reference(game_screen, m_top_box); ImageViewRGB32 bottom_image = extract_box_reference(game_screen, m_bottom_box); + ImageViewRGB32 frame_image = extract_box_reference(game_screen, m_frame_box); + // The dialog's top frame is light blue, about (165, 214, 230). A white area with no + // frame, like the S.S. Anne's hull, is pure white there. The default is_white() would + // accept the light blue too, so this asks for a brighter white than any frame color. + const bool frame_is_white = is_white(frame_image, 720); if (is_white(right_image) && is_white(top_image) && is_white(bottom_image) + && !frame_is_white ){ return true; } @@ -127,6 +135,44 @@ bool SelectionDialogDetector::detect(const ImageViewRGB32& screen){ return false; } +class Test_WhiteDialogDetector : public ImageUnitTest{ +public: + + Test_WhiteDialogDetector( + const std::string& image, + bool expected + ) + : ImageUnitTest("PokemonFRLG::WhiteDialogDetector - " + image, {image}) + , m_expected(expected) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + WhiteDialogDetector detector(COLOR_RED); + const ImageRGB32& image = images[0]; + return detector.detect(image) == m_expected; + }; + +private: + bool m_expected; +}; + +void add_tests_WhiteDialogDetector(UnitTestDatabase& database){ + // The S.S. Anne's white hull fills the dialog area, with no frame around it. + database.add("PokemonFRLG/WhiteDialogDetector/SSAnne1F_Corridor_False.png", false); + database.add("PokemonFRLG/WhiteDialogDetector/SSAnne1F_CorridorWorker_False.png", false); + // Real dialogs, from the other dialog detectors' folders. + database.add("PokemonFRLG/AdvanceWhiteDialogDetector/English-Bulba_True.png", true); + database.add("PokemonFRLG/AdvanceWhiteDialogDetector/Ger-KennyExile-Sq_True.png", true); + database.add("PokemonFRLG/AdvanceWhiteDialogDetector/MacOS-not-ili-Char_True.png", true); + database.add("PokemonFRLG/AdvanceWhiteDialogDetector/Spanish-AlejaKaiser-Char_True.png", true); + database.add("PokemonFRLG/Dialog/white_dialog_jap-112434221413.png", true); + database.add("PokemonFRLG/Dialog/white_dialog_red_text_spa-205200755327.png", true); + database.add("PokemonFRLG/SelectionDialogDetector/English-Bulba-720p_True.png", true); +} + class Test_AdvanceWhiteDialogDetector : public ImageUnitTest{ public: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h index b9041ac1a1..1192549a61 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h @@ -73,6 +73,7 @@ class WhiteDialogDetector : public StaticScreenDetector{ ImageFloatBox m_right_box; ImageFloatBox m_top_box; ImageFloatBox m_bottom_box; + ImageFloatBox m_frame_box; }; class WhiteDialogWatcher : public DetectorToFinder{ public: @@ -134,11 +135,15 @@ class SelectionDialogWatcher : public DetectorToFinder{ {} }; +// Register white dialog detector unit tests. +void add_tests_WhiteDialogDetector(UnitTestDatabase& database); +// Register advance white dialog detector unit tests. void add_tests_AdvanceWhiteDialogDetector(UnitTestDatabase& database); +// Register selection dialog detector unit tests. void add_tests_SelectionDialogDetector(UnitTestDatabase& database); -} -} -} +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation #endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp index 29e1f14054..c1a89a6176 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp @@ -73,6 +73,7 @@ class PlaceNameMatcher : public OCR::SmallDictionaryMatcher{ {"cerulean-city", "Cerulean City"}, {"route-24", "Route 24"}, {"route-25", "Route 25"}, + {"vermilion-city", "Vermilion City"}, }){ JsonArray names; names.push_back(name); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h index 0bf34fd1fb..cc07c4b7b3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h @@ -33,8 +33,8 @@ ImageFloatBox save_card_title_box(); // The place on the save card: "pallet-town", "viridian-city", // "viridian-forest", "pewter-city", "route-1" to "route-4", "cerulean-city", -// "route-24", "route-25"; empty if it can't be read, as in a language the -// reader has no names for. +// "route-24", "route-25", "vermilion-city"; empty if it can't be read, as +// in a language the reader has no names for. std::string read_save_card_location(Logger& logger, Language language, const ImageViewRGB32& screen); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.cpp index 66c68224cf..4c1e599bba 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.cpp @@ -35,16 +35,23 @@ namespace{ constexpr double ROW_STEP = 28; constexpr double NAME_TOP = 22; constexpr double PP_TOP = 33; +// A move's type badge, x 123 to 154 and y 22 to 33 on the top row: an +// empty slot shows the pane's light background there instead. +constexpr double BADGE_LEFT = 125; +constexpr double BADGE_TOP = 24; +constexpr double BADGE_WIDTH = 27; +constexpr double BADGE_HEIGHT = 8; // The cursor box's left edge, x 120 to 121, from y 20 to 44 on the top // row: (255, 72, 0). constexpr double CURSOR_EDGE_LEFT = 120; constexpr double CURSOR_EDGE_TOP = 24; constexpr double CURSOR_EDGE_HEIGHT = 16; -// The level-up moves of Squirtle's and Magikarp's lines (pret +// The level-up moves of Squirtle's, Magikarp's and Oddish's lines (pret // pokefirered src/data/pokemon/level_up_learnsets.h), then the TMs' // and HMs' moves not among them (src/data/items.h; Hyper Beam, Protect -// and Rain Dance are above), as the game writes them. +// and Rain Dance are above), as the game writes them +// (src/data/text/move_names.h). constexpr std::pair MOVE_NAMES[] = { {"tackle", "Tackle"}, {"tail-whip", "Tail Whip"}, @@ -64,6 +71,17 @@ constexpr std::pair MOVE_NAMES[] = { {"leer", "Leer"}, {"twister", "Twister"}, {"dragon-dance", "Dragon Dance"}, + {"absorb", "Absorb"}, + {"sweet-scent", "Sweet Scent"}, + {"poison-powder", "PoisonPowder"}, + {"stun-spore", "Stun Spore"}, + {"sleep-powder", "Sleep Powder"}, + {"acid", "Acid"}, + {"moonlight", "Moonlight"}, + {"petal-dance", "Petal Dance"}, + {"aromatherapy", "Aromatherapy"}, + {"mega-drain", "Mega Drain"}, + {"magical-leaf", "Magical Leaf"}, {"hyper-beam", "Hyper Beam"}, {"focus-punch", "Focus Punch"}, {"dragon-claw", "Dragon Claw"}, @@ -172,6 +190,24 @@ bool cursor_red(uint32_t pixel){ return r > 200 && g < 140 && b < 90; } +// Whether row `row` holds a move: its type badge shows, mostly darker +// than the pane's light background. +bool row_has_move(const ImageViewRGB32& screen, size_t row){ + const ImageFloatBox box( + BADGE_LEFT / 240, (BADGE_TOP + ROW_STEP * double(row)) / 160, BADGE_WIDTH / 240, BADGE_HEIGHT / 160 + ); + const ImageViewRGB32 badge = extract_box_reference(game_box(screen), box); + size_t dark = 0; + for (size_t y = 0; y < badge.height(); y++){ + for (size_t x = 0; x < badge.width(); x++){ + const uint32_t pixel = badge.pixel(x, y); + dark += ((pixel >> 16) & 0xff) + ((pixel >> 8) & 0xff) + (pixel & 0xff) < 600 ? 1 : 0; + } + } + // Measured: a third to three quarters of a badge; none of an empty slot. + return dark * 5 > badge.width() * badge.height(); +} + int read_pp(Logger& logger, const ImageViewRGB32& screen, const ImageFloatBox& box){ const int pp = read_digits_waterfill_template(logger, extract_box_reference(game_box(screen), box), DigitTemplateType::StatBox); // A move has at most 64 PP (40 with three PP Ups). @@ -202,12 +238,12 @@ std::string read_move_name(Logger& logger, Language language, const ImageViewRGB std::vector read_summary_moves(Logger& logger, Language language, const ImageViewRGB32& screen){ std::vector moves; for (size_t row = 0; row < SUMMARY_MOVE_ROWS - 1; row++){ - std::string move = read_summary_move_name(logger, language, screen, row); - if (move.empty()){ + // The game packs the moves to the top: the first empty slot ends them. + if (!row_has_move(screen, row)){ break; } moves.push_back(SummaryMove{ - .move = std::move(move), + .move = read_summary_move_name(logger, language, screen, row), .pp = read_pp(logger, screen, summary_move_pp_box(row)), .max_pp = read_pp(logger, screen, summary_move_max_pp_box(row)), }); @@ -247,7 +283,8 @@ namespace{ // A capture of a summary page, named _.png. Its // golden file holds the page shown; on the first page the species and the // level; on KNOWN MOVES, or a move being picked, each move's name, PP and -// full PP, then the cursor's row (from 1) or "none". +// full PP ("unknown" for a move with no name read), then the cursor's +// row (from 1) or "none". class Test_SummaryScreen : public ImageUnitTest{ public: Test_SummaryScreen(const std::string& image) @@ -285,7 +322,7 @@ class Test_SummaryScreen : public ImageUnitTest{ const std::vector known = read_summary_moves(logger, language, screen); for (size_t c = 0; c < known.size(); c++){ labels.push_back(std::format("move {}", c + 1)); - values.push_back(known[c].move); + values.push_back(known[c].move.empty() ? "unknown" : known[c].move); labels.push_back(std::format("move {} PP", c + 1)); values.push_back(std::to_string(known[c].pp)); labels.push_back(std::format("move {} full PP", c + 1)); @@ -311,6 +348,9 @@ void add_tests_SummaryMovesReader(UnitTestDatabase& database){ database.add("PokemonFRLG/SummaryMovesReader/MoveDetail_eng.png"); // The party menu: no summary page. database.add("PokemonFRLG/SummaryMovesReader/PartyMenu_eng.png"); + // FireRed on console B, 2026-11-03: the Oddish caught for Cut, Lv12, with + // ABSORB, SWEET SCENT and two empty slots. + database.add("PokemonFRLG/SummaryMovesReader/Oddish_KnownMoves_eng.png"); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h index f5bd20bf27..8fd5fd5887 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SummaryMovesReader.h @@ -56,6 +56,7 @@ std::string read_move_name(Logger& logger, Language language, const ImageViewRGB // A single move entry read from the summary moves screen. struct SummaryMove{ + // Empty for a move the reader has no name for. std::string move; // -1 if not read. The game draws a move's PP in color once it is down // to half, which the digit templates may not read. @@ -63,7 +64,8 @@ struct SummaryMove{ int max_pp = -1; }; -// The moves from the top, up to the first row whose name can't be read. +// The moves from the top, up to the first empty slot. A move whose name +// isn't read keeps its row, with its name empty. std::vector read_summary_moves(Logger& logger, Language language, const ImageViewRGB32& screen); // The row (0 to 4) the red cursor box is on, if one shows. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp index 7763b32f4a..9ec2eaa77c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp @@ -57,6 +57,12 @@ bool solid(const ImageViewRGB32& screen, const GameBox& box, const FloatPixel& c return euclidean_distance(stats.average, color) < 40 && stats.stddev.sum() < 40; } +// The box is a fine pattern of light colors averaging near `color`. +bool patterned(const ImageViewRGB32& screen, const GameBox& box, const FloatPixel& color){ + const ImageStats stats = image_stats(game_crop(screen, box)); + return euclidean_distance(stats.average, color) < 30 && stats.stddev.sum() < 70; +} + // Measured on the captures (console B, 2026-11-03). Left of the title, the // frame's color tells the screen: the bag's orange, the TM CASE's mint, // the party menu's dark teal. @@ -65,6 +71,11 @@ const FloatPixel BAG_ORANGE(255, 210, 94); const FloatPixel TM_CASE_MINT(193, 234, 207); const FloatPixel PARTY_TEAL(18, 109, 107); const FloatPixel WHITE(255, 255, 255); +// The TM's scene: pale blue diamonds on both sides of the member, around +// (198, 234, 255) with a stddev near 30 for the pattern. +constexpr GameBox SCENE_LEFT{0, 20, 16, 90}; +constexpr GameBox SCENE_RIGHT{224, 20, 16, 90}; +const FloatPixel SCENE_BLUE(198, 234, 255); // The item menu's box, between its rows (OPEN at y 109, REGISTER 125, // CANCEL 141); blue text box or list below it when it is closed. @@ -202,6 +213,8 @@ const NameMatcher& text_matcher(){ {"stop-question", "Stop trying to teach"}, {"did-not-learn", "did not learn the"}, {"not-compatible", "are not compatible"}, + {"machine-set", "Machine set"}, + {"learned", "learned"}, }; return matcher; } @@ -216,6 +229,8 @@ TeachText teach_text(const std::string& slug){ {"stop-question", TeachText::STOP_QUESTION}, {"did-not-learn", TeachText::DID_NOT_LEARN}, {"not-compatible", TeachText::NOT_COMPATIBLE}, + {"machine-set", TeachText::MACHINE_SET}, + {"learned", TeachText::LEARNED}, }; for (const auto& [name, text] : texts){ if (name == slug){ @@ -271,6 +286,7 @@ const char* teach_screen_name(TeachScreen screen){ case TeachScreen::PARTY_TEXT: return "party_text"; case TeachScreen::PARTY_YES_NO: return "party_yes_no"; case TeachScreen::KNOWN_MOVES: return "known_moves"; + case TeachScreen::TM_SCENE: return "tm_scene"; } return "?"; } @@ -286,6 +302,8 @@ const char* teach_text_name(TeachText text){ case TeachText::STOP_QUESTION: return "stop_question"; case TeachText::DID_NOT_LEARN: return "did_not_learn"; case TeachText::NOT_COMPATIBLE: return "not_compatible"; + case TeachText::MACHINE_SET: return "machine_set"; + case TeachText::LEARNED: return "learned"; } return "?"; } @@ -301,6 +319,9 @@ TeachScreen read_teach_screen(const ImageViewRGB32& screen){ if (solid(screen, CORNER, PARTY_TEAL)){ return read_party_screen(screen); } + if (patterned(screen, SCENE_LEFT, SCENE_BLUE) && patterned(screen, SCENE_RIGHT, SCENE_BLUE)){ + return TeachScreen::TM_SCENE; + } if (read_summary_move_cursor(screen)){ return TeachScreen::KNOWN_MOVES; } @@ -350,7 +371,7 @@ TeachText read_teach_text(Logger& logger, Language language, const ImageViewRGB3 if (kind == TeachScreen::PARTY_PROMPT){ return teach_text(text_matcher().read(logger, language, game_crop(screen, PROMPT_LINE), DARK_TEXT_FILTERS())); } - if (kind != TeachScreen::PARTY_TEXT && kind != TeachScreen::PARTY_YES_NO){ + if (kind != TeachScreen::PARTY_TEXT && kind != TeachScreen::PARTY_YES_NO && kind != TeachScreen::TM_SCENE){ return TeachText::UNKNOWN; } // The phrase either line matches best. @@ -415,8 +436,8 @@ void add_list( // then: in the bag, its pocket, the key items from the top (KEY ITEMS // only) and the cursor's row (from 1, or "none"); in the TM CASE, its // moves from the top and the cursor's row; in an item menu or YES/NO, the -// cursor's row; over the party menu, the message; on KNOWN MOVES, the five -// rows' moves and the cursor's row. +// cursor's row; over the party menu or in the TM's scene, the message; on +// KNOWN MOVES, the five rows' moves and the cursor's row. class Test_TeachScreen : public ImageUnitTest{ public: Test_TeachScreen(const std::string& image) @@ -467,6 +488,7 @@ class Test_TeachScreen : public ImageUnitTest{ [[fallthrough]]; case TeachScreen::PARTY_PROMPT: case TeachScreen::PARTY_TEXT: + case TeachScreen::TM_SCENE: labels.push_back("text"); values.push_back(teach_text_name(read_teach_text(logger, language, screen))); return; @@ -506,6 +528,8 @@ void add_tests_TeachScreenReader(UnitTestDatabase& database){ "KnownMoves_eng.png", "StopQuestion_eng.png", "DidNotLearn_eng.png", + // HM01 to an Oddish with two free slots (00.23, console B). + "MachineSet_eng.png", }){ database.add(std::string("PokemonFRLG/TeachScreenReader/") + image); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h index a7a70a59d8..18ffe8df80 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h @@ -1,119 +1,129 @@ -/* 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{ - - +/* 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, the TM's scene's pale blue diamonds. Names + * and messages are read by OCR, in English only so far: in another + * language nothing is read. + * + */ + +#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{ + + // The screen or menu shown while teaching a TM or HM. -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, -}; +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, + // The TM's scene over the member (pokefirered + // pokemon_special_anim.c), on its blue diamonds: with a free slot, + // right after A on the member; after a forget, "1, 2 and... Poof!", + // " forgot .", "And...". Then "Machine set!", which waits + // for A (B cancels the teach), the disc's wobble, " learned + // !" and the TM CASE. + TM_SCENE, +}; // The human-readable name of a teach screen. -const char* teach_screen_name(TeachScreen screen); - +const char* teach_screen_name(TeachScreen screen); + // Reads the active teach screen from an image. -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; +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; // The number of visible rows in the TM Case. -constexpr size_t TM_CASE_ROWS = 5; +constexpr size_t TM_CASE_ROWS = 5; // The number of visible rows in the item menu. -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." -}; +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." + MACHINE_SET, // "Machine set!", in the TM's scene + LEARNED, // " learned !" +}; // The human-readable name of a teach dialog message. -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); - - +const char* teach_text_name(TeachText text); + +// The message over the party menu or in the TM's scene, from the prompt's +// or the message box's first two lines. +TeachText read_teach_text(Logger& logger, Language language, const ImageViewRGB32& screen); + + // Register teach screen reader unit tests. -void add_tests_TeachScreenReader(UnitTestDatabase& database); - - - +void add_tests_TeachScreenReader(UnitTestDatabase& database); + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation -#endif +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.cpp index 4b8905da59..3bb7b24878 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.cpp @@ -9,6 +9,7 @@ #include #include #include "CommonFramework/ImageTools/ImageBoxes.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_BattleHpBarReader.h" namespace PokemonAutomation{ @@ -26,6 +27,10 @@ namespace{ // row and one lighter one, whose pixels were counted, read 17/55 HP as // 15% of the bar, and as 2% with the box a pixel lower. const ImageFloatBox HP_BAR_BOX(174.0 / 240, 90.0 / 160, 48.0 / 240, 5.0 / 160); +// The opponent's bar, in its box top left: 48 pixels from x 52, its +// lighter rows at y 33 to 35 (console B, 2026-11-03, the S.S. Anne rival's +// Pidgeotto at full HP and Raticate at 7 of 48 pixels). +const ImageFloatBox OPPONENT_HP_BAR_BOX(52.0 / 240, 32.0 / 160, 48.0 / 240, 5.0 / 160); // A column needs this many pixels of a fill color to count as filled: a // lighter row is about 6 pixels tall at 1080p. @@ -52,11 +57,8 @@ std::optional fill_color(uint32_t pixel){ return std::nullopt; } -} // namespace - - -HpBarReading read_player_hp_bar(const ImageViewRGB32& game){ - const ImageViewRGB32 bar = extract_box_reference(game, HP_BAR_BOX); +HpBarReading read_hp_bar(const ImageViewRGB32& game, const ImageFloatBox& box){ + const ImageViewRGB32 bar = extract_box_reference(game, box); size_t counts[3] = {}; size_t filled = 0; for (size_t x = 0; x < bar.width(); x++){ @@ -80,6 +82,22 @@ HpBarReading read_player_hp_bar(const ImageViewRGB32& game){ return reading; } +} // namespace + + +HpBarReading read_player_hp_bar(const ImageViewRGB32& game){ + return read_hp_bar(game, HP_BAR_BOX); +} + +HpBarReading read_opponent_hp_bar(const ImageViewRGB32& game){ + return read_hp_bar(game, OPPONENT_HP_BAR_BOX); +} + +std::optional read_opponent_hp_fill(const ImageViewRGB32& screen){ + const HpBarReading bar = read_opponent_hp_bar(extract_box_reference(screen, GameSettings::instance().GAME_BOX)); + return bar.seen ? std::optional(bar.fill) : std::nullopt; +} + } // namespace PokemonFRLG diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h index 163a7be512..253f3bb0f0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h @@ -2,14 +2,15 @@ * * From: https://github.com/PokemonAutomation/ * - * The HP bar in the player's box on a battle screen: its color and how - * much of it is filled. + * The HP bars on a battle screen, the player's and the opponent's: their + * color and how much of them is filled. * */ #ifndef PokemonAutomation_PokemonFRLG_BattleHpBarReader_H #define PokemonAutomation_PokemonFRLG_BattleHpBarReader_H +#include #include "CommonFramework/ImageTypes/ImageViewRGB32.h" namespace PokemonAutomation{ @@ -37,6 +38,13 @@ struct HpBarReading{ // `game`: the game's screen, the Game Box of the video. HpBarReading read_player_hp_bar(const ImageViewRGB32& game); +// The opponent's HP bar, in its box top left, the same way. +HpBarReading read_opponent_hp_bar(const ImageViewRGB32& game); + +// That bar's filled share on `screen` (the whole video, its Game Box +// taken from the settings), none if no fill shows. +std::optional read_opponent_hp_fill(const ImageViewRGB32& screen); + } // namespace PokemonFRLG diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.cpp index 78da90a3c2..c5a737fc69 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.cpp @@ -4,6 +4,10 @@ * */ +#include +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" #include "PokemonFRLG_BattleScreenDetector.h" namespace PokemonAutomation{ @@ -30,6 +34,48 @@ bool BattleScreenDetector::detect(const ImageViewRGB32& screen){ } +namespace{ + +// On the 240x160 screen: the battle YES/NO box's left frame is 2 pixels of +// slate blue at x 187-188, from y 66 to 109 (console B, 2026-11-03, +// "Delete a move to make room for BITE?"). This box takes 3 pixels either +// side of it, over the rows clear of its corners. +const ImageFloatBox YES_NO_FRAME_BOX(184.0 / 240, 70.0 / 160, 8.0 / 240, 34.0 / 160); +// The rows with frame in them, of the box's: nearly all of them. +constexpr double FRAME_ROWS = 0.9; +// Pixels of frame a row needs: about a third of a game pixel. +constexpr size_t FRAME_PIXELS = 2; + +// The frame's slate blue, measured (120, 115, 145) with its lighter edge +// (147, 155, 218); not the empty HP bar's dark gray (75, 74, 87), the +// white or the HP box's cream. +bool frame_color(uint32_t pixel){ + const int r = (pixel >> 16) & 0xff; + const int g = (pixel >> 8) & 0xff; + const int b = pixel & 0xff; + return r >= 95 && r <= 170 && g >= 90 && g <= 175 && b >= 120 && b <= 240 && b >= r + 15; +} + +} // namespace + +bool battle_yes_no_shows(const ImageViewRGB32& screen){ + if (!BattleLearnDialogDetector(COLOR_RED).detect(screen)){ + return false; + } + const ImageViewRGB32 game = extract_box_reference(screen, GameSettings::instance().GAME_BOX); + const ImageViewRGB32 frame = extract_box_reference(game, YES_NO_FRAME_BOX); + size_t rows = 0; + for (size_t y = 0; y < frame.height(); y++){ + size_t pixels = 0; + for (size_t x = 0; x < frame.width(); x++){ + pixels += frame_color(frame.pixel(x, y)) ? 1 : 0; + } + rows += pixels >= FRAME_PIXELS ? 1 : 0; + } + return frame.height() > 0 && rows >= FRAME_ROWS * frame.height(); +} + + } // namespace PokemonFRLG } // namespace NintendoSwitch diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h index 88516f4313..ba5a578efb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h @@ -49,6 +49,15 @@ class BattleScreenWatcher : public DetectorToFinder{ }; +// A YES/NO box in a battle (a move to learn): BattleLearnDialogDetector, +// and the box's slate-blue left frame. The detector alone reads white +// where the box would be and the teal text box, which a foe's send-out +// flash shows on the S.S. Anne's white floor (console B, 2026-11-03, m68: +// "RIVAL GREEN sent out RATICATE!" and "...KADABRA!" taken for a move +// prompt, NO at one and YES at the other). +bool battle_yes_no_shows(const ImageViewRGB32& screen); + + } // namespace PokemonFRLG } // namespace NintendoSwitch diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json index 51f0256ec9..9334a3aa83 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity.json @@ -90,10 +90,12 @@ "000000000000000333303333333303333000000000000000" ], "points": { + "warp_1": [30, 11], "warp_3": [22, 19], "warp_4": [31, 21], "warp_6": [29, 28], - "warp_7": [1, 12] + "warp_7": [1, 12], + "warp_9": [31, 8] }, "warps": [ { @@ -104,7 +106,9 @@ { "x": 30, "y": 11, - "enter": "walk_into" + "enter": "walk_into", + "to_map": "CeruleanCity_House2", + "to_point": "warp_1" }, { "x": 15, @@ -152,12 +156,16 @@ { "x": 31, "y": 8, - "enter": "walk_south" + "enter": "walk_south", + "to_map": "CeruleanCity_House2", + "to_point": "warp_3" }, { "x": 31, "y": 9, - "enter": "walk_into" + "enter": "walk_into", + "to_map": "CeruleanCity_House2", + "to_point": "warp_3" }, { "x": 14, @@ -272,6 +280,11 @@ "to_map": "Route24", "offset": 12 }, + { + "side": "south", + "to_map": "Route5", + "offset": 0 + }, { "side": "west", "to_map": "Route4", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_House2.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_House2.json new file mode 100644 index 0000000000..2abad6df50 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/CeruleanCity_House2.json @@ -0,0 +1,76 @@ +{ + "name": "CeruleanCity_House2", + "source": { + "decomp_file": "data/maps/CeruleanCity_House2/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "##########", + "####.#####", + "........##", + ".........#", + "...##.....", + "...##.....", + "..........", + "#.......##", + "##########" + ], + "points": { + "warp_1": [3, 7], + "warp_3": [4, 1] + }, + "warps": [ + { + "x": 2, + "y": 7, + "enter": "walk_into", + "to_map": "CeruleanCity", + "to_point": "warp_1" + }, + { + "x": 3, + "y": 7, + "enter": "walk_south", + "to_map": "CeruleanCity", + "to_point": "warp_1" + }, + { + "x": 4, + "y": 7, + "enter": "walk_into", + "to_map": "CeruleanCity", + "to_point": "warp_1" + }, + { + "x": 4, + "y": 1, + "enter": "cave_entrance", + "to_map": "CeruleanCity", + "to_point": "warp_9" + } + ], + "objects": [ + { + "name": "hiker", + "kind": "npc", + "x": 1, + "y": 2 + }, + { + "name": "lass", + "kind": "npc", + "x": 7, + "y": 6, + "wander": { + "x0": 6, + "y0": 5, + "x1": 8, + "y1": 7 + } + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route5.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route5.json new file mode 100644 index 0000000000..1b4d929c1d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route5.json @@ -0,0 +1,86 @@ +{ + "name": "Route5", + "source": { + "decomp_file": "data/maps/Route5/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#vvvvvvvv#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#vvvvvvvv#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#vvvvvvvv#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#~~~~~~~~#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#vvvvvvvv#....###############", + "###############....#........#....###############", + "###############....#........#....###############", + "###############....#.#####..#....###############", + "###############....#.#####..#....###############", + "###############....#.#####..#....###############", + "###############....#.##D##..#....###############", + "###############....#........#....###############", + "###############....vvvvvvvvvv....###############", + "###############..................###############", + "###############...............##################", + "###############...............##################", + "###############...............#D################", + "###############.......#....#....S###############", + "###############.......######.....###############", + "################################################", + "##############........######......##############", + "################################################", + "################################################", + "########################..######################", + "#######################....#####################" + ], + "points": { + "warp_0": [31, 31] + }, + "warps": [ + { + "x": 31, + "y": 31, + "enter": "walk_into", + "to_map": "UndergroundPath_NorthEntrance", + "to_point": "warp_1" + }, + { + "x": 23, + "y": 25, + "enter": "walk_into" + }, + { + "x": 24, + "y": 32, + "enter": "walk_south" + }, + { + "x": 25, + "y": 32, + "enter": "walk_south" + } + ], + "objects": [], + "flag_sets": {}, + "connections": [ + { + "side": "north", + "to_map": "CeruleanCity", + "offset": 0 + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route6.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route6.json new file mode 100644 index 0000000000..4ce8f0cda2 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/Route6.json @@ -0,0 +1,176 @@ +{ + "name": "Route6", + "source": { + "decomp_file": "data/maps/Route6/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "########################", + "########################", + "########################", + "########################", + "########################", + "##.......###..###.....##", + "##.......#......#.....##", + "##vvv.vvvv......vvv.vv##", + "##....................##", + "##....................##", + "##....................##", + "##vvvvvvv.vvvvvvvv######", + "##................######", + "##................#D####", + "##....................##", + "##~~~~...~~~~~~......S##", + "##~~~~...~~~~~~~~~~~~~##", + "##~~~~...~~~~~~~~~~~~~##", + "##~~~~...~~~~~~~~~~~~~##", + "##~~~~..............~~##", + "##~~~~..............~~##", + "##~~~~..............~~##", + "##~~~~~~~~~~~~~~~...~~##", + "##wwwwwwwwwwwww~~...~~##", + "##wwwwwwwwwwwww~~...~~##", + "##wwwwwwwwwwwww~~...~~##", + "##wwwwwwwwwwwww~~...~~##", + "##..................~~##", + "##..................~~##", + "##..................~~##", + "#######......~~~~#######", + "##....#......~~~~#....##", + "#######......~~~~#######", + "#######......~~~~#######", + "##########...###########", + "######...#...#....######", + "########.#...###########", + "########.#...###########", + "wwwwwwwww#...###########", + "wwwwwwwww#...###########" + ], + "elevation": [ + "000000000000000000000000", + "000000000000000000000000", + "000000000000000000000000", + "000000000000000000000000", + "000000000000000000000000", + "003333333000330003333300", + "003333333033333303333300", + "000003000033333300030000", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "000000000300000000000000", + "003333333333333333000000", + "003333333333333333000000", + "003333333333333333333300", + "003333333333333333333000", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "001111111111111333333300", + "001111111111111333333300", + "001111111111111333333300", + "001111111111111333333300", + "003333333333333333333300", + "003333333333333333333300", + "003333333333333333333300", + "000000033333333330000000", + "003333033333333330333300", + "000000033333333330000000", + "000000033333333330000000", + "000000000033300000000000", + "000000333033303333000000", + "000000003033300000000000", + "000000003033300000000000", + "111111111033300000000000", + "000000001033300000000000" + ], + "points": { + "warp_0": [19, 13] + }, + "warps": [ + { + "x": 19, + "y": 13, + "enter": "walk_into", + "to_map": "UndergroundPath_SouthEntrance", + "to_point": "warp_1" + }, + { + "x": 12, + "y": 5, + "enter": "cave_entrance" + }, + { + "x": 13, + "y": 5, + "enter": "cave_entrance" + } + ], + "objects": [ + { + "name": "bug_catcher", + "kind": "npc", + "x": 3, + "y": 16, + "facing": "east", + "trainer": { + "sight": 5 + } + }, + { + "name": "camper", + "kind": "npc", + "x": 12, + "y": 21 + }, + { + "name": "picnicker", + "kind": "npc", + "x": 13, + "y": 21 + }, + { + "name": "bug_catcher", + "kind": "npc", + "x": 20, + "y": 25, + "facing": "west", + "trainer": { + "sight": 3 + } + }, + { + "name": "picnicker", + "kind": "npc", + "x": 13, + "y": 32, + "facing": "west", + "trainer": { + "sight": 3 + } + }, + { + "name": "camper", + "kind": "npc", + "x": 13, + "y": 33, + "facing": "west", + "trainer": { + "sight": 3 + } + } + ], + "flag_sets": {}, + "connections": [ + { + "side": "south", + "to_map": "VermilionCity", + "offset": -12 + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_1F_Corridor.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_1F_Corridor.json new file mode 100644 index 0000000000..ef4b147d64 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_1F_Corridor.json @@ -0,0 +1,148 @@ +{ + "name": "SSAnne_1F_Corridor", + "source": { + "decomp_file": "data/maps/SSAnne_1F_Corridor/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + ".................#####.........", + ".................##D##.........", + ".................#...#.........", + ".................#...#.........", + ".................#...#.........", + ".................#...#.........", + "#.################...##########", + "##################...##########", + "###...........................#", + "###...........................#", + "#.............................#", + "#...######################....#", + "#...######################....#", + "#...######################....#", + "#...######################....#", + "#...######################....#", + "#...########################..#", + "#...########################..#", + "#...########################..#", + ".###.##########################", + "###############################" + ], + "elevation": [ + "0000000000000000000000000000000", + "0000000000000000000000000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0000000000000000003330000000000", + "0003333333333333333333333333330", + "0003333333333333333333333333330", + "0333333333333333333333333333330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000033330", + "0333000000000000000000000000330", + "0333000000000000000000000000330", + "0000000000000000000000000000000", + "0000000000000000000000000000000" + ], + "points": { + "warp_0": [3, 8], + "warp_2": [19, 1], + "warp_3": [20, 0] + }, + "warps": [ + { + "x": 3, + "y": 8, + "enter": "walk_west", + "to_map": "SSAnne_2F_Corridor", + "to_point": "warp_0" + }, + { + "x": 2, + "y": 18, + "enter": "walk_south" + }, + { + "x": 19, + "y": 1, + "enter": "walk_into", + "to_map": "SSAnne_Exterior", + "to_point": "warp_2" + }, + { + "x": 20, + "y": 0, + "enter": "walk_into", + "to_map": "SSAnne_Exterior", + "to_point": "warp_3" + }, + { + "x": 3, + "y": 20, + "enter": "walk_into" + }, + { + "x": 28, + "y": 17, + "enter": "walk_west" + }, + { + "x": 5, + "y": 10, + "enter": "walk_south" + }, + { + "x": 8, + "y": 10, + "enter": "walk_south" + }, + { + "x": 11, + "y": 10, + "enter": "walk_south" + }, + { + "x": 14, + "y": 10, + "enter": "walk_south" + }, + { + "x": 17, + "y": 10, + "enter": "walk_south" + }, + { + "x": 23, + "y": 10, + "enter": "walk_south" + }, + { + "x": 20, + "y": 10, + "enter": "walk_south" + } + ], + "objects": [ + { + "name": "sailor", + "kind": "npc", + "x": 18, + "y": 8 + }, + { + "name": "worker_m", + "kind": "npc", + "x": 12, + "y": 9 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_2F_Corridor.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_2F_Corridor.json new file mode 100644 index 0000000000..9f74e4ce17 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_2F_Corridor.json @@ -0,0 +1,108 @@ +{ + "name": "SSAnne_2F_Corridor", + "source": { + "decomp_file": "data/maps/SSAnne_2F_Corridor/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "##################################", + "##################################", + "#..###########################.###", + "#..###########################.###", + "#....#########################...#", + "#....#########################...#", + "#....#########################...#", + "#....#########################...#", + "#....#######################.....#", + "#....#######################.....#", + "#....#D###D###D###D###D###D#.....#", + "###..............................#", + "###..............................#", + "###..............................#", + "#................................#", + "##################################" + ], + "points": { + "warp_0": [2, 2], + "warp_2": [30, 2] + }, + "warps": [ + { + "x": 2, + "y": 2, + "enter": "walk_east", + "to_map": "SSAnne_1F_Corridor", + "to_point": "warp_0" + }, + { + "x": 3, + "y": 12, + "enter": "walk_west" + }, + { + "x": 30, + "y": 2, + "enter": "walk_east", + "to_map": "SSAnne_CaptainsOffice", + "to_point": "warp_0" + }, + { + "x": 6, + "y": 10, + "enter": "walk_into" + }, + { + "x": 10, + "y": 10, + "enter": "walk_into" + }, + { + "x": 14, + "y": 10, + "enter": "walk_into" + }, + { + "x": 18, + "y": 10, + "enter": "walk_into" + }, + { + "x": 22, + "y": 10, + "enter": "walk_into" + }, + { + "x": 26, + "y": 10, + "enter": "walk_into" + } + ], + "objects": [ + { + "name": "blue", + "kind": "npc", + "x": 30, + "y": 2, + "hide_flag": "FLAG_HIDE_SS_ANNE_RIVAL" + }, + { + "name": "sailor", + "kind": "npc", + "x": 2, + "y": 7, + "wander": { + "x0": 2, + "y0": 5, + "x1": 2, + "y1": 9 + } + } + ], + "flag_sets": { + "new_game": [ + "FLAG_HIDE_SS_ANNE_RIVAL" + ] + }, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_CaptainsOffice.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_CaptainsOffice.json new file mode 100644 index 0000000000..03a230a27e --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_CaptainsOffice.json @@ -0,0 +1,42 @@ +{ + "name": "SSAnne_CaptainsOffice", + "source": { + "decomp_file": "data/maps/SSAnne_CaptainsOffice/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "#########", + "#########", + "#......##", + "####.#..#", + "####....#", + "#.......#", + "###.....#", + "###.....#", + "###.....#", + "#########" + ], + "points": { + "warp_0": [3, 7] + }, + "warps": [ + { + "x": 3, + "y": 7, + "enter": "walk_west", + "to_map": "SSAnne_2F_Corridor", + "to_point": "warp_2" + } + ], + "objects": [ + { + "name": "captain", + "kind": "npc", + "x": 5, + "y": 4 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_Exterior.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_Exterior.json new file mode 100644 index 0000000000..20658451d2 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/SSAnne_Exterior.json @@ -0,0 +1,129 @@ +{ + "name": "SSAnne_Exterior", + "source": { + "decomp_file": "data/maps/SSAnne_Exterior/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "wwwwwwwwww##wwwwwwwwwwwwwwwwww#####wwwwwwwwww#########################", + "wwwwwwwwww##wwwwwwwwwwwwwwwwww#####wwwwwwwwww#########################", + "wwww##wwwwwwwwwwwwwwwwwwwwwwww#####wwwwwwwwww##........###.###########", + "wwww##wwwwwwwwwwwwww####wwwwww#####wwwwwwwww###........###.###########", + "##wwwwwwww##ww##ww######www##w#####w##wwww#####............###########", + "##wwwww##w##########www#########.#######ww##wwwwwwwwwwwww..###########", + "wwww##w####w##ww##wwwww####ww##...##ww####wwwwwwwwwwwwwww..###########", + "wwww####w##wwwwwwwwwwwwwwwwwwww...wwwwww##wwwwwwwwwwwwwww..###########", + "www##w##wwwwwwwwwwwwwwwwwwwwwww...wwwwwwwwwwwwwwwwwwwwwww..###########", + "w####wwwwwwwwwwwwwwwwwwwwwwwwww...wwwwwwwwwwwwwwwwwwwwwww..###########", + "w##wwwwwwwwwwwwwwwwwwww##wwwwww#.#wwwwww##wwwwwwwwwwwwwww..###########", + "ww##wwwwwwwwwwwwwwwww####ww#####.###ww######wwwwwwwwwwwww..###########", + "ww##wwwwwwwwwwwwwwwww##ww#######.#######ww##wwwwwwwwwwwww..###########", + "##wwwwwwwwwwwwwwwwww##www##www##.##w##wwwwww##wwwwwwwwwww..###########", + "##wwwwwwwwwwwwwwww####wwwwwwww##.##wwwwwwwww##wwwwwwwwwww..###########", + "w##wwwwwwwwwwwwwww##wwwwwwwwww######wwwwwwwww##wwwwwwwwww..###########", + "w##wwwwwwwwwwwwww##wwwwwwwwwwwwwwwwwwwwwwwwww##ww##wwwwww..###########", + "ww##wwwwwwwwww##w##wwwwwwwwwwwwwwwwwwwwwwwwwwww####wwwwww..###########", + "ww##w##wwwww######wwwwwwwwwwwwwwwwwwwwwwwwwwwww##w##wwwww..###########", + "www####w##ww##ww##wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##wwwww..###########", + "www##w######wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##www..###########", + "wwwwww##ww##wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##www..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##wwww..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww####ww..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##ww..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##www..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##www..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww##ww..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww####..###########", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww#################", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww###############", + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww################" + ], + "elevation": [ + "1111111111001111111111111111110000011111111110000000000000000000000000", + "1111111111001111111111111111110000011111111110000000000000000000000011", + "1111001111111111111111111111110000011111111110033333333000300000000000", + "1111001111111111111100001111110000011111111100033333333000300000000000", + "0011111111001100110000001110010000010011110000033333333333300000000000", + "0011111001000000000011100000000030000000110011111111111113300000000000", + "1111001000010011001111100001100333001100001111111111111113300000000011", + "1111000010011111111111111111111333111111001111111111111113300000000000", + "1110010011111111111111111111111333111111111111111111111113300000000000", + "1000011111111111111111111111111333111111111111111111111113300000000000", + "1001111111111111111111100111111030111111001111111111111113300000000000", + "1100111111111111111110000110000030001100000011111111111113300000000000", + "1100111111111111111110011000000030000000110011111111111113300000000000", + "0011111111111111111100111001111030110011111100111111111113300000000000", + "0011111111111111110000111110110030011011111100111111111113300000000000", + "1001111111111111110011111011110000011110111110011111111113300000000000", + "1001111111111111100111101111111111111111110110011001111113300000000000", + "1100111111111100100111011111111111111111111101100001111113300000000000", + "1100100111110000001110111111111111111111111111100100111113300000000000", + "1110000100110011001111111111111111111111111111011100111113300000000000", + "1110010000001111111011111111111111111111111111101111001113300000000000", + "1111110011001111101111111111111111111111111111110111001113300000000000", + "1111111111111111011111111111111111111111111111111110011113300000000000", + "1111111111111011111111111111111111111111111111111010000113300000000000", + "1111111111111111111111111111111111111111111111111111100113300000000000", + "1111111111111111111111111111111111111111111111111101001113300000000000", + "1111111111111111111111111111111111111111111111111110001113300000000000", + "1111111111111111111111111111111111111111111111111111100113300000000000", + "1111111111111111111111111111111111111111111111111111100003300000000000", + "1111111111111111111111111111111111111111111111111111000000000000000000", + "1111111111111111111111111111111111111111111111111111110000000000000000", + "1111111111111111111111111111111111111111111111111111110000000000000000" + ], + "points": { + "warp_1": [32, 5], + "warp_2": [32, 14], + "warp_3": [33, 15] + }, + "warps": [ + { + "x": 31, + "y": 5, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_0" + }, + { + "x": 32, + "y": 5, + "enter": "walk_north", + "to_map": "VermilionCity", + "to_point": "warp_1" + }, + { + "x": 32, + "y": 14, + "enter": "walk_south", + "to_map": "SSAnne_1F_Corridor", + "to_point": "warp_2" + }, + { + "x": 33, + "y": 15, + "enter": "walk_into", + "to_map": "SSAnne_1F_Corridor", + "to_point": "warp_3" + }, + { + "x": 33, + "y": 5, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_2" + } + ], + "objects": [ + { + "name": "ss_anne", + "kind": "npc", + "x": 30, + "y": 16, + "hide_flag": "FLAG_HIDE_SS_ANNE" + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthEntrance.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthEntrance.json new file mode 100644 index 0000000000..7ae2377b26 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthEntrance.json @@ -0,0 +1,64 @@ +{ + "name": "UndergroundPath_NorthEntrance", + "source": { + "decomp_file": "data/maps/UndergroundPath_NorthEntrance/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "#############", + "#############", + "#...........#", + "..##.##..##..", + "..#..##...#..", + "..#..##...#..", + "..##.....##..", + ".............", + "#...........#", + "#############" + ], + "points": { + "warp_1": [6, 8], + "warp_3": [7, 4] + }, + "warps": [ + { + "x": 5, + "y": 8, + "enter": "walk_into", + "to_map": "Route5", + "to_point": "warp_0" + }, + { + "x": 6, + "y": 8, + "enter": "walk_south", + "to_map": "Route5", + "to_point": "warp_0" + }, + { + "x": 7, + "y": 8, + "enter": "walk_into", + "to_map": "Route5", + "to_point": "warp_0" + }, + { + "x": 7, + "y": 4, + "enter": "walk_west", + "to_map": "UndergroundPath_NorthSouthTunnel", + "to_point": "warp_0" + } + ], + "objects": [ + { + "name": "little_girl", + "kind": "npc", + "x": 5, + "y": 6 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthSouthTunnel.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthSouthTunnel.json new file mode 100644 index 0000000000..72752cc45a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_NorthSouthTunnel.json @@ -0,0 +1,96 @@ +{ + "name": "UndergroundPath_NorthSouthTunnel", + "source": { + "decomp_file": "data/maps/UndergroundPath_NorthSouthTunnel/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "########", + "########", + "#....###", + "#....###", + "#....###", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "#......#", + "###....#", + "###....#", + "###....#", + "########" + ], + "points": { + "warp_0": [4, 3], + "warp_1": [3, 60] + }, + "warps": [ + { + "x": 4, + "y": 3, + "enter": "walk_east", + "to_map": "UndergroundPath_NorthEntrance", + "to_point": "warp_3" + }, + { + "x": 3, + "y": 60, + "enter": "walk_west", + "to_map": "UndergroundPath_SouthEntrance", + "to_point": "warp_3" + } + ], + "objects": [], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_SouthEntrance.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_SouthEntrance.json new file mode 100644 index 0000000000..000f7e215e --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/UndergroundPath_SouthEntrance.json @@ -0,0 +1,64 @@ +{ + "name": "UndergroundPath_SouthEntrance", + "source": { + "decomp_file": "data/maps/UndergroundPath_SouthEntrance/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "#############", + "#############", + "#...........#", + "..##.##..##..", + "..#..##...#..", + "..#..##...#..", + "..##.....##..", + ".............", + "#...........#", + "#############" + ], + "points": { + "warp_1": [6, 8], + "warp_3": [7, 4] + }, + "warps": [ + { + "x": 5, + "y": 8, + "enter": "walk_into", + "to_map": "Route6", + "to_point": "warp_0" + }, + { + "x": 6, + "y": 8, + "enter": "walk_south", + "to_map": "Route6", + "to_point": "warp_0" + }, + { + "x": 7, + "y": 8, + "enter": "walk_into", + "to_map": "Route6", + "to_point": "warp_0" + }, + { + "x": 7, + "y": 4, + "enter": "walk_west", + "to_map": "UndergroundPath_NorthSouthTunnel", + "to_point": "warp_1" + } + ], + "objects": [ + { + "name": "woman_2", + "kind": "npc", + "x": 5, + "y": 6 + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json new file mode 100644 index 0000000000..3e6026ea80 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity.json @@ -0,0 +1,243 @@ +{ + "name": "VermilionCity", + "source": { + "decomp_file": "data/maps/VermilionCity/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "tiles": [ + "wwwwwwwwwwwwwwwwwwwww#...#######################", + "wwwwwwwwwwwwwwwwwwwww#...#######################", + "wwwwww####wwwwwwwwwww#...#######################", + "wwww######www#####www#...#######################", + "wwww##ww##############...#######################", + "www##www##############...#######################", + "www##www#D###SSD######...########S##############", + "ww##wwww.................................#######", + "ww##wwww.................................#######", + "w##wwwww.................................#######", + "w##wwwwwwwwwww...........................#######", + "w##wwwwwwwwwww...........................#######", + "w##wwwwwwwwwwwwwwwww.....................#######", + "w##wwwwwwwwwwwwwwwww.....................#######", + "w##wwwwwwww.....wwww.....................#######", + "ww##wwww...#####..####.....####..........#######", + "ww##wwww...#####..####.....####..........##.....", + "www##www..S#D###..#D##.....SSD###############S##", + "www##www..........................S.............", + "ww##wwww........................................", + "ww##wwww........................................", + "ww##wwww.......................www.....wwwwwwwww", + "ww##wwww...#######.........####www.....wwwwwwwww", + "www##www...#######..wwww...####www64.64wwwwwwwww", + "www##www##S########.wwww...#D##www.....ww#######", + "www##www.....#D#....wwww.......wwww...www#######", + "www##www............wwwwwwwwwwwwwww...www#######", + "ww##wwww............wwwwwwwwwwwwwww...www#######", + "ww##wwww............ww................www#######", + "ww##wwwwwwwwwwwwwwwwww................www##wwwww", + "ww##wwwwwwwwwwwwwwwwww................www##wwwww", + "www##wwwwwwwwwwwwwwwww...wwwwwwwwwwwwwwww##wwwww", + "www##wwwwwwwwwwwwwwwww...wwwwwwwwwwwwwwww##wwwww", + "wwwww##wwwwwwwwwwwww##...####wwwwwwwwwww##wwwwww", + "wwwww##wwwwwwwww######...########wwww##w##wwwwww", + "wwwwww##wwwwww######ww###wwww####wwww##w##wwwwww", + "wwwwww####wwww##wwwwww###wwwwwwww##wwwww##wwwwww", + "wwwwwwww######wwwwwwww.#.wwwwwwww########wwwwwww", + "wwwwwwwwww####wwwwwwww.#.wwwwwwwwww######wwwwwww", + "wwwwwwwwwwwwwwwwwwwwww.#.wwwwwwwwwwwwwwwwwwwwwww" + ], + "elevation": [ + "000000000000000000001033300000000000000000000000", + "000000000000000000001033300000000000000000000000", + "000001000011111111111033300000000000000000000000", + "000100000011100000111033300000000000000000000000", + "000100110000000000000033300000000000000000000000", + "011001110000000000000033300000000000000000000000", + "011001110000000000000033300000000000000000000000", + "010011113333333333333333333333333333333330000000", + "010011113333333333333333333333333333333330000000", + "000111113333333333333333333333333333333330000000", + "000111111111113333333333333333333333333330000000", + "000111111111113333333333333333333333333330000000", + "000111111111111111113333333333333333333330000000", + "000111111111111111113333333333333333333330000000", + "000111111111111111113333333333333333333330000000", + "010011113330000033000033333000033333333330000000", + "010011113330000033000033333000033333333330033333", + "001001113300000033000033333000000000000000000000", + "001001113333333333333333333333333303333333333333", + "000011113333333333333333333333333333333333333333", + "000011113333333333333333333333333333333333333333", + "000011113333333333333333333333311133333111111111", + "000011113330000000333333333000011133333111111111", + "001001113330000000331111333000011133333111111111", + "001001110000000000031111333000011133333110000000", + "001001113333300033331111333333311113331110000000", + "011001113333333333331111111111111113331110000000", + "010011113333333333331111111111111113331110000000", + "010011113333333333331133333333333333331110000000", + "010011111111111111111133333333333333331110011111", + "010011111111111111111133333333333333331110011000", + "011001111111111111111133311111111111111110010000", + "001001111111111111111133311111111111111110010000", + "001110011111111111110033300001111111111100110000", + "000110011111111100000033300000000111100100110000", + "000011001111110000000000000110000111100100110000", + "000001000011110011111100011111111001111100100000", + "000001110000001110001100011000001000000001100000", + "000000001100001000000100010000000010000001000000", + "000000000000000000000100010000000000000000000000" + ], + "points": { + "warp_0": [22, 34], + "warp_1": [23, 34], + "warp_2": [24, 34], + "warp_4": [15, 6], + "warp_9": [14, 25] + }, + "warps": [ + { + "x": 22, + "y": 34, + "enter": "walk_south", + "to_map": "SSAnne_Exterior", + "to_point": "warp_1" + }, + { + "x": 23, + "y": 34, + "enter": "walk_south", + "to_map": "SSAnne_Exterior", + "to_point": "warp_1" + }, + { + "x": 24, + "y": 34, + "enter": "walk_south", + "to_map": "SSAnne_Exterior", + "to_point": "warp_1" + }, + { + "x": 9, + "y": 6, + "enter": "walk_into" + }, + { + "x": 15, + "y": 6, + "enter": "walk_into", + "to_map": "VermilionCity_PokemonCenter_1F", + "to_point": "warp_1" + }, + { + "x": 12, + "y": 17, + "enter": "walk_into" + }, + { + "x": 19, + "y": 17, + "enter": "walk_into" + }, + { + "x": 29, + "y": 17, + "enter": "walk_into" + }, + { + "x": 28, + "y": 24, + "enter": "walk_into" + }, + { + "x": 14, + "y": 25, + "enter": "walk_into", + "to_map": "VermilionCity_Gym", + "to_point": "warp_1" + } + ], + "objects": [ + { + "name": "woman_1", + "kind": "npc", + "x": 22, + "y": 11, + "wander": { + "x0": 18, + "y0": 11, + "x1": 26, + "y1": 11 + } + }, + { + "name": "old_man_1", + "kind": "npc", + "x": 17, + "y": 9 + }, + { + "name": "old_man_2", + "kind": "npc", + "x": 36, + "y": 10 + }, + { + "name": "machop", + "kind": "npc", + "x": 35, + "y": 11, + "wander": { + "x0": 33, + "y0": 9, + "x1": 37, + "y1": 13 + } + }, + { + "name": "sailor", + "kind": "npc", + "x": 31, + "y": 30, + "wander": { + "x0": 27, + "y0": 30, + "x1": 35, + "y1": 30 + } + }, + { + "name": "sailor", + "kind": "npc", + "x": 24, + "y": 33 + }, + { + "name": "cut_tree", + "kind": "npc", + "x": 19, + "y": 24, + "hide_flag": "FLAG_TEMP_12" + }, + { + "name": "scientist", + "kind": "npc", + "x": 25, + "y": 7, + "hide_flag": "FLAG_HIDE_VERMILION_CITY_OAKS_AIDE" + } + ], + "flag_sets": { + "new_game": [ + "FLAG_HIDE_VERMILION_CITY_OAKS_AIDE" + ] + }, + "connections": [ + { + "side": "north", + "to_map": "Route6", + "offset": 12 + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_Gym.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_Gym.json new file mode 100644 index 0000000000..2ed0d496e1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_Gym.json @@ -0,0 +1,137 @@ +{ + "name": "VermilionCity_Gym", + "source": { + "decomp_file": "data/maps/VermilionCity_Gym/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "###########", + "###########", + "#..#...#..#", + "#.........#", + "#.........#", + "#.........#", + "####...####", + "####...####", + "...........", + "...........", + ".#.#.#.#.#.", + "...........", + ".#.#.#.#.#.", + "...........", + ".#.#.#.#.#.", + "...........", + "...........", + "...S...S...", + "...........", + "...........", + "###########" + ], + "elevation": [ + "00000000000", + "00000000000", + "03303330330", + "03333333330", + "03333333330", + "03333333330", + "00000000000", + "00000000000", + "33333333333", + "33333333333", + "30303030303", + "33333333333", + "30303030303", + "33333333333", + "30303030303", + "33333333333", + "33333333333", + "33303330333", + "33333333333", + "33333333333", + "00000000000" + ], + "points": { + "warp_1": [5, 19] + }, + "warps": [ + { + "x": 4, + "y": 19, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_9" + }, + { + "x": 5, + "y": 19, + "enter": "walk_south", + "to_map": "VermilionCity", + "to_point": "warp_9" + }, + { + "x": 6, + "y": 19, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_9" + } + ], + "objects": [ + { + "name": "lt_surge", + "kind": "npc", + "x": 5, + "y": 2 + }, + { + "name": "balding_man", + "kind": "npc", + "x": 2, + "y": 11, + "trainer": { + "sight": 3, + "looks": [ + "north", + "east", + "south", + "west" + ] + } + }, + { + "name": "sailor", + "kind": "npc", + "x": 8, + "y": 13, + "trainer": { + "sight": 3, + "looks": [ + "north", + "east", + "south", + "west" + ] + } + }, + { + "name": "gym_guy", + "kind": "npc", + "x": 4, + "y": 17 + }, + { + "name": "gentleman", + "kind": "npc", + "x": 7, + "y": 8, + "facing": "west", + "trainer": { + "sight": 3 + } + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_PokemonCenter_1F.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_PokemonCenter_1F.json new file mode 100644 index 0000000000..5e53a3ba51 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/Maps/VermilionCity_PokemonCenter_1F.json @@ -0,0 +1,125 @@ +{ + "name": "VermilionCity_PokemonCenter_1F", + "source": { + "decomp_file": "data/maps/VermilionCity_PokemonCenter_1F/map.json", + "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", + "date": "2026-10-31" + }, + "allow_running": false, + "tiles": [ + "#.#############", + "###############", + "....###..##....", + "....#######....", + "...............", + "...............", + "#..........##..", + "...........##..", + "...............", + "###############" + ], + "elevation": [ + "030000000000000", + "000000000000000", + "333300033003333", + "333300000003333", + "333333333333333", + "330333333333333", + "044033333330033", + "330333333330033", + "333333333333333", + "000000000000000" + ], + "points": { + "nurse_front": [7, 4], + "warp_1": [7, 8] + }, + "warps": [ + { + "x": 6, + "y": 8, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_4" + }, + { + "x": 7, + "y": 8, + "enter": "walk_south", + "to_map": "VermilionCity", + "to_point": "warp_4" + }, + { + "x": 8, + "y": 8, + "enter": "walk_into", + "to_map": "VermilionCity", + "to_point": "warp_4" + }, + { + "x": 1, + "y": 6, + "enter": "step_on" + } + ], + "objects": [ + { + "name": "nurse", + "kind": "npc", + "x": 7, + "y": 2 + }, + { + "name": "man", + "kind": "npc", + "x": 4, + "y": 8, + "wander": { + "x0": 3, + "y0": 8, + "x1": 5, + "y1": 8 + } + }, + { + "name": "hiker", + "kind": "npc", + "x": 12, + "y": 5 + }, + { + "name": "youngster", + "kind": "npc", + "x": 14, + "y": 4, + "wander": { + "x0": 14, + "y0": 3, + "x1": 14, + "y1": 5 + } + }, + { + "name": "cooltrainer_f", + "kind": "npc", + "x": 6, + "y": 4 + }, + { + "name": "0", + "kind": "npc", + "x": 2, + "y": 1, + "hide_flag": "FLAG_HIDE_FAME_CHECKER_LT_SURGE_JOURNAL" + }, + { + "name": "0", + "kind": "npc", + "x": 3, + "y": 1, + "hide_flag": "FLAG_HIDE_FAME_CHECKER_LT_SURGE_JOURNAL" + } + ], + "flag_sets": {}, + "connections": [] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp index f874ff29bc..dc8541ed63 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -54,6 +54,23 @@ const char* const MAP_NAMES[] = { "CeruleanCity_Gym", "Route25", "Route25_SeaCottage", + // The burgled house, the way south out of Cerulean; Route 5, the + // Underground Path and Route 6 to Vermilion City, and the S.S. + // Anne's way to the Captain. + "CeruleanCity_House2", + "Route5", + "UndergroundPath_NorthEntrance", + "UndergroundPath_NorthSouthTunnel", + "UndergroundPath_SouthEntrance", + "Route6", + "VermilionCity", + "VermilionCity_PokemonCenter_1F", + "SSAnne_Exterior", + "SSAnne_1F_Corridor", + "SSAnne_2F_Corridor", + "SSAnne_CaptainsOffice", + // Vermilion Gym, past the tree Cut fells. + "VermilionCity_Gym", "CeruleanCave_1F", "CeruleanCave_2F", "CeruleanCave_B1F", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h index f6d2271a0e..7fa58f230e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h @@ -6,8 +6,10 @@ * new game in Pallet Town through Viridian City, Route 2 and Viridian * Forest to Brock's gym in Pewter City, Route 3, Mt. Moon and Route 4; * and Cerulean City, its Pokemon Center, Mart and Gym, Route 24 (Nugget - * Bridge) and Route 25 to Bill's Sea Cottage, and the three floors of - * Cerulean Cave, where Mewtwo waits on B1F. + * Bridge) and Route 25 to Bill's Sea Cottage; the burgled house, Route 5, + * the Underground Path and Route 6 to Vermilion City and its Pokemon + * Center, and the S.S. Anne's corridors to the Captain's office; and the + * three floors of Cerulean Cave, where Mewtwo waits on B1F. * * Each map is a JSON file, Maps/.json next to this file, generated by * devtools/maps/frlg_maps.py and built into the program by CMake diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp index ccec06a0c9..e10f6d7816 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp @@ -1,29 +1,30 @@ -/* Overworld Maps Tests - * - * From: https://github.com/PokemonAutomation/ - * - */ - -#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{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -namespace{ - +/* Overworld Maps Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#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{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + struct OverworldMapsInput{ std::string name; UnitTestResult (*body)(); @@ -38,516 +39,617 @@ struct OverworldMapsTestCase{ OverworldMapsExpectation expectation; }; -class Test_OverworldMaps : public UnitTest{ -public: +class Test_OverworldMaps : public UnitTest{ +public: explicit Test_OverworldMaps(OverworldMapsTestCase scenario) : UnitTest("PokemonFRLG::OverworldMaps - " + scenario.input.name) , m_scenario(std::move(scenario)) - {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - try{ + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ return m_scenario.input.body(); - }catch (Exception& e){ - return std::string(e.name()) + ": " + e.message(); - } - } - -private: + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: OverworldMapsTestCase m_scenario; -}; - - -const char* const VALID_MAP = R"({ - "name": "Test", - "source": {"decomp_file": "data/maps/Test/map.json", "revision": "test", "date": "2026-10-31"}, - "tiles": ["#.w", "~D."], - "elevation": ["031", "3f3"], - "points": {"door": [1, 1]}, - "warps": [ - {"x": 1, "y": 1, "enter": "walk_into", "to_map": "Other", "to_point": "warp_2"}, - {"x": 2, "y": 1, "enter": "walk_south"} - ], - "objects": [{"name": "nurse", "kind": "npc", "x": 0, "y": 1, "wander": {"x0": 0, "y0": 1, "x1": 1, "y1": 1}, "hide_flag": "FLAG_X"}], - "flag_sets": {"later": ["FLAG_X", "FLAG_Y"]}, - "connections": [{"side": "south", "to_map": "Other", "offset": -4}] -})"; - -// The ParseException that parsing `json` throws, or empty if it doesn't. -std::string parse_error(const std::string& json){ - try{ - parse_map(json, "Test.json"); - }catch (ParseException& e){ - return e.message(); - } - return ""; -} - -// Whether `text` contains every one of `parts`. -bool mentions(const std::string& text, std::initializer_list parts){ - for (const char* part : parts){ - if (text.find(part) == std::string::npos){ - return false; - } - } - return true; -} - -UnitTestResult test_valid_map(){ - const MapData map = parse_map(VALID_MAP, "Test.json"); - if (map.name != "Test" || map.tiles.width != 3 || map.tiles.height != 2 - || map.tiles.at({0, 1}) != TileKind::TALL_GRASS || exit_mat(map, {2, 1}) != Direction::SOUTH - ){ - return "Wrong name, size or tiles."; - } - if (elevation_at(map, {2, 0}) != 1 || elevation_at(map, {1, 1}) != 15){ - return "Wrong elevation."; - } - const MapWarp* door = warp_at(map, {1, 1}); - if (map.warps.size() != 2 || door == nullptr || !door->to || door->to->map != "Other" || door->to->point != "warp_2"){ - return "Wrong warps."; - } - const MapObject* nurse = object_at(map, {1, 1}); - if (map.objects.size() != 1 || nurse == nullptr || nurse->name != "nurse" || nurse->hide_flag != "FLAG_X"){ - return "Wrong objects."; - } - const MapConnection& connection = map.connections.at(0); - if (connection.side != Direction::SOUTH || connection.offset != -4 || connection.to_map != "Other"){ - return "Wrong connections."; - } - if (map.points.at("door") != TilePos{1, 1} || map.flag_sets.at("later") != std::set{"FLAG_X", "FLAG_Y"}){ - return "Wrong points or flag sets."; - } - return true; -} - -UnitTestResult test_missing_field(){ - std::string json = VALID_MAP; - json.replace(json.find("\"tiles\""), 7, "\"cells\""); - const std::string error = parse_error(json); - if (!mentions(error, {"Test.json", "tiles", "missing"})){ - return "Expected an error naming the missing tiles, got: \"" + error + "\""; - } - return true; -} - -UnitTestResult test_elevation_left_out(){ - std::string json = VALID_MAP; - json.erase(json.find("\"elevation\""), std::string("\"elevation\": [\"031\", \"3f3\"],").size()); - const MapData map = parse_map(json, "Test.json"); - for (int y = 0; y < 2; y++){ - for (int x = 0; x < 3; x++){ - if (elevation_at(map, {x, y}) != ELEVATION_GROUND){ - return "Expected ground everywhere without an elevation grid."; - } - } - } - return true; -} - -UnitTestResult test_bad_field_type(){ - std::string json = VALID_MAP; - json.replace(json.find("\"x\": 1"), 6, "\"x\": \"1\""); - const std::string error = parse_error(json); - if (!mentions(error, {"Test.json", "warps[0]", "x", "integer"})){ - return "Expected an error naming warps[0].x, got: \"" + error + "\""; - } - return true; -} - -UnitTestResult test_bad_row_length(){ - std::string json = VALID_MAP; - json.replace(json.find("\"~D.\""), 5, "\"~D\""); - const std::string error = parse_error(json); - if (!mentions(error, {"Test.json", "tiles[1]", "string of 3 glyphs"})){ - return "Expected an error naming the short row, got: \"" + error + "\""; - } - return true; -} - -UnitTestResult test_unknown_tile(){ - std::string json = VALID_MAP; - json.replace(json.find("\"#.w\""), 5, "\"#.?\""); - const std::string error = parse_error(json); - if (!mentions(error, {"Test.json", "tiles[0]", "unknown glyph '?'", "(2, 0)"})){ - return "Expected an error naming the unknown tile, got: \"" + error + "\""; - } - return true; -} - -// Route 3's Lass Robin, shrunk: she wanders x 2, y 2..4 looking north and -// south, sight 3, below a corridor (row 1) walled off at x 2 above it; a -// bug catcher at (5, 4) looks west, sight 2. -const char* const TRAINERS_MAP = R"({ - "name": "Trainers", - "source": {"decomp_file": "", "revision": "test", "date": "2026-11-02"}, - "tiles": ["##.####", ".......", ".......", ".......", ".......", "~~~~~~~"], - "objects": [ - {"name": "lass", "kind": "npc", "x": 2, "y": 3, "wander": {"x0": 2, "y0": 2, "x1": 2, "y1": 4}, "trainer": {"sight": 3, "looks": ["north", "south"]}}, - {"name": "bug_catcher", "kind": "npc", "x": 5, "y": 4, "facing": "west", "trainer": {"sight": 2}}, - {"name": "youngster", "kind": "npc", "x": 6, "y": 1} - ] -})"; - -// A wanderer sees from every tile she may stand on, short of a wall; a -// trainer standing still only along his facing; a non-trainer nothing. -UnitTestResult test_trainer_sight(){ - const MapData map = parse_map(TRAINERS_MAP, "Trainers.json"); - const MapObject& lass = map.objects[0]; - const MapObject& bug_catcher = map.objects[1]; - const MapObject& youngster = map.objects[2]; - struct Case{ - const MapObject* object; - TilePos tile; - bool sees; - }; - const Case cases[] = { - {&lass, {2, 1}, true}, // from (2, 2..4), north - {&lass, {2, 0}, true}, // the gap in the wall, 2 tiles up from (2, 2) - {&lass, {2, 5}, true}, // south, from (2, 4) - {&lass, {1, 1}, false}, - {&bug_catcher, {3, 4}, true}, - {&bug_catcher, {2, 4}, false}, // past his sight - {&bug_catcher, {5, 3}, false}, // not his way - {&youngster, {5, 1}, false}, - }; - for (const Case& c : cases){ - if (trainer_may_see(map, *c.object, c.tile) != c.sees){ - return c.object->name + (c.sees ? " should see " : " shouldn't see ") + Pokemon::to_string(c.tile) + "."; - } - } - return true; -} - -UnitTestResult test_not_json(){ - const std::string error = parse_error("{\"name\": "); - if (!mentions(error, {"Test.json", "not a JSON object"})){ - return "Expected an error for broken JSON, got: \"" + error + "\""; - } - 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; -} - -// Whether a route plans from Cerulean's Pokemon Center, at its nurse, to -// `goal` on `maps`. -bool plans_from_cerulean_nurse(const std::vector& maps, const RouteGoal& goal){ - const MapPosition nurse{"CeruleanCity_PokemonCenter_1F", map_point(maps, "CeruleanCity_PokemonCenter_1F", "nurse_front")}; - return plan_overworld_route(maps, nurse, goal, default_route_costs(), Direction::NORTH).has_value(); -} - -// Cerulean City's Mart and Gym and Route 25's Sea Cottage load with the -// points the story walks to, and join the city: from its Pokemon Center -// a route plans to the Mart's clerk, to Misty, and north over Nugget -// Bridge (Route 24) and east along Route 25 into Bill's Sea Cottage. -UnitTestResult test_cerulean_maps(){ - const std::vector maps = overworld_maps(); - for (const char* name : {"CeruleanCity_Mart", "CeruleanCity_Gym", "Route25", "Route25_SeaCottage"}){ - if (built_in(maps, name) == nullptr){ - return std::string("Missing map ") + name + "."; - } - } - const MapPosition clerk{"CeruleanCity_Mart", map_point(maps, "CeruleanCity_Mart", "clerk_front")}; - const MapPosition misty{"CeruleanCity_Gym", map_point(maps, "CeruleanCity_Gym", "misty_front")}; - if (clerk.point != TilePos{4, 3} || misty.point != TilePos{8, 7}){ - return "Unexpected clerk_front or misty_front."; - } - if (!plans_from_cerulean_nurse(maps, RouteGoal{clerk, Direction::WEST})){ - return "No route from Cerulean's Pokemon Center to the Mart's clerk."; - } - if (!plans_from_cerulean_nurse(maps, RouteGoal{misty, Direction::NORTH})){ - return "No route from Cerulean's Pokemon Center to Misty."; - } - const MapPosition bill_front{"Route25_SeaCottage", {7, 7}}; - if (!plans_from_cerulean_nurse(maps, RouteGoal{bill_front, Direction::NORTH})){ - return "No route from Cerulean's Pokemon Center over Nugget Bridge and Route 25 into Bill's Sea Cottage."; - } - 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"({ - "name": "Hall", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-02"}, - "tiles": ["######", "#....#", "#....#", "######"], - "warps": [{"x": 1, "y": 2, "enter": "step_on"}] -})"; - -// Holding west along the escalator's row rides it: a leg there ends by -// counting tiles, never by holding to the wall behind it (console B, -// 2026-11-02, Route 4's Pokemon Center). -UnitTestResult test_no_hold_onto_escalator(){ - const std::vector maps{parse_map(ESCALATOR_HALL, "Hall")}; - if (hold_to_wall(maps, {"Hall", {4, 2}}, false, Direction::WEST)){ - return "Expected no hold west along the escalator's row."; - } - const std::optional> route = plan_overworld_route(maps, {"Hall", {4, 2}}, {{"Hall", {2, 1}}, std::nullopt}); - if (!route || !(follow_overworld_route(maps, {"Hall", {4, 2}}, *route) == MapPosition{"Hall", {2, 1}})){ - return "Expected a route from (4, 2) to (2, 1)."; - } - for (const RouteStep& step : *route){ - if (step.to_wall && step.direction == Direction::WEST && step.end.point == TilePos{2, 2}){ - return "Expected no hold west next to the escalator: " + to_string(step); - } - } - 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; -} - +}; + + +const char* const VALID_MAP = R"({ + "name": "Test", + "source": {"decomp_file": "data/maps/Test/map.json", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#.w", "~D."], + "elevation": ["031", "3f3"], + "points": {"door": [1, 1]}, + "warps": [ + {"x": 1, "y": 1, "enter": "walk_into", "to_map": "Other", "to_point": "warp_2"}, + {"x": 2, "y": 1, "enter": "walk_south"} + ], + "objects": [{"name": "nurse", "kind": "npc", "x": 0, "y": 1, "wander": {"x0": 0, "y0": 1, "x1": 1, "y1": 1}, "hide_flag": "FLAG_X"}], + "flag_sets": {"later": ["FLAG_X", "FLAG_Y"]}, + "connections": [{"side": "south", "to_map": "Other", "offset": -4}] +})"; + +// The ParseException that parsing `json` throws, or empty if it doesn't. +std::string parse_error(const std::string& json){ + try{ + parse_map(json, "Test.json"); + }catch (ParseException& e){ + return e.message(); + } + return ""; +} + +// Whether `text` contains every one of `parts`. +bool mentions(const std::string& text, std::initializer_list parts){ + for (const char* part : parts){ + if (text.find(part) == std::string::npos){ + return false; + } + } + return true; +} + +UnitTestResult test_valid_map(){ + const MapData map = parse_map(VALID_MAP, "Test.json"); + if (map.name != "Test" || map.tiles.width != 3 || map.tiles.height != 2 + || map.tiles.at({0, 1}) != TileKind::TALL_GRASS || exit_mat(map, {2, 1}) != Direction::SOUTH + ){ + return "Wrong name, size or tiles."; + } + if (elevation_at(map, {2, 0}) != 1 || elevation_at(map, {1, 1}) != 15){ + return "Wrong elevation."; + } + const MapWarp* door = warp_at(map, {1, 1}); + if (map.warps.size() != 2 || door == nullptr || !door->to || door->to->map != "Other" || door->to->point != "warp_2"){ + return "Wrong warps."; + } + const MapObject* nurse = object_at(map, {1, 1}); + if (map.objects.size() != 1 || nurse == nullptr || nurse->name != "nurse" || nurse->hide_flag != "FLAG_X"){ + return "Wrong objects."; + } + const MapConnection& connection = map.connections.at(0); + if (connection.side != Direction::SOUTH || connection.offset != -4 || connection.to_map != "Other"){ + return "Wrong connections."; + } + if (map.points.at("door") != TilePos{1, 1} || map.flag_sets.at("later") != std::set{"FLAG_X", "FLAG_Y"}){ + return "Wrong points or flag sets."; + } + return true; +} + +UnitTestResult test_missing_field(){ + std::string json = VALID_MAP; + json.replace(json.find("\"tiles\""), 7, "\"cells\""); + const std::string error = parse_error(json); + if (!mentions(error, {"Test.json", "tiles", "missing"})){ + return "Expected an error naming the missing tiles, got: \"" + error + "\""; + } + return true; +} + +UnitTestResult test_elevation_left_out(){ + std::string json = VALID_MAP; + json.erase(json.find("\"elevation\""), std::string("\"elevation\": [\"031\", \"3f3\"],").size()); + const MapData map = parse_map(json, "Test.json"); + for (int y = 0; y < 2; y++){ + for (int x = 0; x < 3; x++){ + if (elevation_at(map, {x, y}) != ELEVATION_GROUND){ + return "Expected ground everywhere without an elevation grid."; + } + } + } + return true; +} + +UnitTestResult test_bad_field_type(){ + std::string json = VALID_MAP; + json.replace(json.find("\"x\": 1"), 6, "\"x\": \"1\""); + const std::string error = parse_error(json); + if (!mentions(error, {"Test.json", "warps[0]", "x", "integer"})){ + return "Expected an error naming warps[0].x, got: \"" + error + "\""; + } + return true; +} + +UnitTestResult test_bad_row_length(){ + std::string json = VALID_MAP; + json.replace(json.find("\"~D.\""), 5, "\"~D\""); + const std::string error = parse_error(json); + if (!mentions(error, {"Test.json", "tiles[1]", "string of 3 glyphs"})){ + return "Expected an error naming the short row, got: \"" + error + "\""; + } + return true; +} + +UnitTestResult test_unknown_tile(){ + std::string json = VALID_MAP; + json.replace(json.find("\"#.w\""), 5, "\"#.?\""); + const std::string error = parse_error(json); + if (!mentions(error, {"Test.json", "tiles[0]", "unknown glyph '?'", "(2, 0)"})){ + return "Expected an error naming the unknown tile, got: \"" + error + "\""; + } + return true; +} + +// Route 3's Lass Robin, shrunk: she wanders x 2, y 2..4 looking north and +// south, sight 3, below a corridor (row 1) walled off at x 2 above it; a +// bug catcher at (5, 4) looks west, sight 2. +const char* const TRAINERS_MAP = R"({ + "name": "Trainers", + "source": {"decomp_file": "", "revision": "test", "date": "2026-11-02"}, + "tiles": ["##.####", ".......", ".......", ".......", ".......", "~~~~~~~"], + "objects": [ + {"name": "lass", "kind": "npc", "x": 2, "y": 3, "wander": {"x0": 2, "y0": 2, "x1": 2, "y1": 4}, "trainer": {"sight": 3, "looks": ["north", "south"]}}, + {"name": "bug_catcher", "kind": "npc", "x": 5, "y": 4, "facing": "west", "trainer": {"sight": 2}}, + {"name": "youngster", "kind": "npc", "x": 6, "y": 1} + ] +})"; + +// A wanderer sees from every tile she may stand on, short of a wall; a +// trainer standing still only along his facing; a non-trainer nothing. +UnitTestResult test_trainer_sight(){ + const MapData map = parse_map(TRAINERS_MAP, "Trainers.json"); + const MapObject& lass = map.objects[0]; + const MapObject& bug_catcher = map.objects[1]; + const MapObject& youngster = map.objects[2]; + struct Case{ + const MapObject* object; + TilePos tile; + bool sees; + }; + const Case cases[] = { + {&lass, {2, 1}, true}, // from (2, 2..4), north + {&lass, {2, 0}, true}, // the gap in the wall, 2 tiles up from (2, 2) + {&lass, {2, 5}, true}, // south, from (2, 4) + {&lass, {1, 1}, false}, + {&bug_catcher, {3, 4}, true}, + {&bug_catcher, {2, 4}, false}, // past his sight + {&bug_catcher, {5, 3}, false}, // not his way + {&youngster, {5, 1}, false}, + }; + for (const Case& c : cases){ + if (trainer_may_see(map, *c.object, c.tile) != c.sees){ + return c.object->name + (c.sees ? " should see " : " shouldn't see ") + Pokemon::to_string(c.tile) + "."; + } + } + return true; +} + +UnitTestResult test_not_json(){ + const std::string error = parse_error("{\"name\": "); + if (!mentions(error, {"Test.json", "not a JSON object"})){ + return "Expected an error for broken JSON, got: \"" + error + "\""; + } + 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; +} + +// Whether a route plans from Cerulean's Pokemon Center, at its nurse, to +// `goal` on `maps`. +bool plans_from_cerulean_nurse(const std::vector& maps, const RouteGoal& goal){ + const MapPosition nurse{"CeruleanCity_PokemonCenter_1F", map_point(maps, "CeruleanCity_PokemonCenter_1F", "nurse_front")}; + return plan_overworld_route(maps, nurse, goal, default_route_costs(), Direction::NORTH).has_value(); +} + +// Cerulean City's Mart and Gym and Route 25's Sea Cottage load with the +// points the story walks to, and join the city: from its Pokemon Center +// a route plans to the Mart's clerk, to Misty, and north over Nugget +// Bridge (Route 24) and east along Route 25 into Bill's Sea Cottage. +UnitTestResult test_cerulean_maps(){ + const std::vector maps = overworld_maps(); + for (const char* name : {"CeruleanCity_Mart", "CeruleanCity_Gym", "Route25", "Route25_SeaCottage"}){ + if (built_in(maps, name) == nullptr){ + return std::string("Missing map ") + name + "."; + } + } + const MapPosition clerk{"CeruleanCity_Mart", map_point(maps, "CeruleanCity_Mart", "clerk_front")}; + const MapPosition misty{"CeruleanCity_Gym", map_point(maps, "CeruleanCity_Gym", "misty_front")}; + if (clerk.point != TilePos{4, 3} || misty.point != TilePos{8, 7}){ + return "Unexpected clerk_front or misty_front."; + } + if (!plans_from_cerulean_nurse(maps, RouteGoal{clerk, Direction::WEST})){ + return "No route from Cerulean's Pokemon Center to the Mart's clerk."; + } + if (!plans_from_cerulean_nurse(maps, RouteGoal{misty, Direction::NORTH})){ + return "No route from Cerulean's Pokemon Center to Misty."; + } + const MapPosition bill_front{"Route25_SeaCottage", {7, 7}}; + if (!plans_from_cerulean_nurse(maps, RouteGoal{bill_front, Direction::NORTH})){ + return "No route from Cerulean's Pokemon Center over Nugget Bridge and Route 25 into Bill's Sea Cottage."; + } + return true; +} + +// Cerulean's burgled house, Route 5, the Underground Path, Route 6, +// Vermilion City and the S.S. Anne load and join: from Cerulean's Pokemon +// Center a route plans through the house, its back yard and the +// Underground Path to Vermilion's nurse, and from there onto +// the ship and up to the Captain. The rival a new game hides +// (FLAG_HIDE_SS_ANNE_RIVAL, flag set "new_game") stands on the Captain's +// door until then: with him there, no route reaches the Captain. With +// Surf's cost below 0, no route surfs. +UnitTestResult test_vermilion_maps(){ + const std::vector all = overworld_maps(); + for (const char* name : { + "CeruleanCity_House2", "Route5", "UndergroundPath_NorthEntrance", "UndergroundPath_NorthSouthTunnel", "UndergroundPath_SouthEntrance", + "Route6", "VermilionCity", "VermilionCity_PokemonCenter_1F", + "SSAnne_Exterior", "SSAnne_1F_Corridor", "SSAnne_2F_Corridor", "SSAnne_CaptainsOffice", + }){ + if (built_in(all, name) == nullptr){ + return std::string("Missing map ") + name + "."; + } + } + const std::set new_game = map_flag_set(all, "new_game"); + if (new_game.count("FLAG_HIDE_SS_ANNE_RIVAL") == 0){ + return "Expected FLAG_HIDE_SS_ANNE_RIVAL in flag set new_game."; + } + const std::vector maps = overworld_maps(new_game); + const MapPosition nurse{"VermilionCity_PokemonCenter_1F", map_point(maps, "VermilionCity_PokemonCenter_1F", "nurse_front")}; + if (nurse.point != TilePos{7, 4}){ + return "Unexpected nurse_front in Vermilion's Pokemon Center."; + } + if (!plans_from_cerulean_nurse(maps, RouteGoal{nurse, Direction::NORTH})){ + return "No route from Cerulean's Pokemon Center through the burgled house and the Underground Path to Vermilion's."; + } + const RouteGoal captain{MapPosition{"SSAnne_CaptainsOffice", {5, 5}}, Direction::NORTH}; + RouteCosts walking = default_route_costs(); + walking[RouteCost::SURF] = -1; + const std::optional> aboard = plan_overworld_route(maps, nurse, captain, walking, Direction::NORTH); + if (!aboard){ + return "No route from Vermilion's Pokemon Center onto the S.S. Anne to the Captain."; + } + // The harbor's water (20..23, 23..27) is the shorter way south; a + // player without Surf walks round it. + for (const RouteStep& step : *aboard){ + if (step.action == RouteAction::SURF){ + return "Expected the way aboard on land, not " + to_string(step) + "."; + } + } + if (plan_overworld_route(all, nurse, captain, default_route_costs(), Direction::NORTH)){ + return "Expected the rival on the Captain's door to block the way while shown."; + } + return true; +} + +// Vermilion Gym loads and joins the city by its door (14, 25), behind the +// tree (19, 24), an object Cut removes: from the Center no route on land +// reaches the gym, from the tree's tile one does. The beams on rows 6 and +// 7 are written off (OPENED_TILES), so a route reaches Lt. Surge (5, 2); +// the cans on rows 10, 12 and 14 are walls. Dwayne (8, 13) and Baily +// (2, 11) look every way, sight 3; Tucker (7, 8) west. +UnitTestResult test_vermilion_gym_map(){ + const std::vector maps = overworld_maps(map_flag_set(overworld_maps(), "new_game")); + const MapData* gym = built_in(maps, "VermilionCity_Gym"); + if (gym == nullptr){ + return "Missing map VermilionCity_Gym."; + } + RouteCosts walking = default_route_costs(); + walking[RouteCost::SURF] = -1; + const MapPosition nurse{"VermilionCity_PokemonCenter_1F", {7, 4}}; + const RouteGoal door{MapPosition{"VermilionCity_Gym", {5, 18}}, std::nullopt}; + if (plan_overworld_route(maps, nurse, door, walking, Direction::NORTH)){ + return "Expected the tree (19, 24) to shut the gym off from the Center."; + } + if (!plan_overworld_route(maps, MapPosition{"VermilionCity", {19, 24}}, door, walking, Direction::SOUTH)){ + return "No route from the tree's tile into the gym."; + } + if (!plan_overworld_route(maps, MapPosition{"VermilionCity_Gym", {5, 8}}, RouteGoal{MapPosition{"VermilionCity_Gym", {5, 3}}, Direction::NORTH}, walking, Direction::NORTH)){ + return "Expected a route through the beams, off, to Lt. Surge."; + } + for (int can : {1, 3, 5, 7, 9}){ + for (int row : {10, 12, 14}){ + if (tile_at(*gym, TilePos{can, row}) != TileKind::WALL){ + return std::format("Expected a can at ({}, {}).", can, row); + } + } + } + struct Seen{ + const char* name; + TilePos tile; + bool seen; + }; + for (const Seen& check : std::initializer_list{ + {"sailor", {8, 15}, true}, {"sailor", {7, 15}, false}, {"sailor", {5, 13}, true}, {"sailor", {4, 13}, false}, + {"balding_man", {2, 13}, true}, {"balding_man", {3, 13}, false}, {"balding_man", {5, 11}, true}, {"balding_man", {6, 11}, false}, + {"gentleman", {4, 8}, true}, {"gentleman", {5, 9}, false}, {"gentleman", {8, 8}, false}, + }){ + const auto object = std::find_if(gym->objects.begin(), gym->objects.end(), [&](const MapObject& o){ return o.name == check.name; }); + if (object == gym->objects.end() || trainer_may_see(*gym, *object, check.tile) != check.seen){ + return std::format("{} {} ({}, {}).", check.name, check.seen ? "should see" : "shouldn't see", check.tile.x, check.tile.y); + } + } + return true; +} + +// A planned step walks where its map doesn't let the player run, and only +// there: out of Pewter's Pokemon Center to the Gym. +UnitTestResult test_walk_only_indoors(){ + 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"({ + "name": "Hall", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-02"}, + "tiles": ["######", "#....#", "#....#", "######"], + "warps": [{"x": 1, "y": 2, "enter": "step_on"}] +})"; + +// Holding west along the escalator's row rides it: a leg there ends by +// counting tiles, never by holding to the wall behind it (console B, +// 2026-11-02, Route 4's Pokemon Center). +UnitTestResult test_no_hold_onto_escalator(){ + const std::vector maps{parse_map(ESCALATOR_HALL, "Hall")}; + if (hold_to_wall(maps, {"Hall", {4, 2}}, false, Direction::WEST)){ + return "Expected no hold west along the escalator's row."; + } + const std::optional> route = plan_overworld_route(maps, {"Hall", {4, 2}}, {{"Hall", {2, 1}}, std::nullopt}); + if (!route || !(follow_overworld_route(maps, {"Hall", {4, 2}}, *route) == MapPosition{"Hall", {2, 1}})){ + return "Expected a route from (4, 2) to (2, 1)."; + } + for (const RouteStep& step : *route){ + if (step.to_wall && step.direction == Direction::WEST && step.end.point == TilePos{2, 2}){ + return "Expected no hold west next to the escalator: " + to_string(step); + } + } + 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; +} + const OverworldMapsTestCase OVERWORLD_MAPS_CASES[] = { { @@ -618,19 +720,27 @@ const OverworldMapsTestCase OVERWORLD_MAPS_CASES[] = { .input = {.name = "Cerulean's Mart and Gym, Route 25 and Bill's Sea Cottage", .body = test_cerulean_maps}, .expectation = {.description = "validates Cerulean City, Gym, Mart, Route 25 and Sea Cottage map data"}, }, + { + .input = {.name = "Route 5 and 6 to Vermilion City and the S.S. Anne", .body = test_vermilion_maps}, + .expectation = {.description = "validates Route 5, Route 6, Vermilion City and S.S. Anne maps"}, + }, + { + .input = {.name = "Vermilion Gym: behind the tree, beams, cans and trainers' sight", .body = test_vermilion_gym_map}, + .expectation = {.description = "validates Vermilion Gym layout, electric fences and trainer sightlines"}, + }, }; } // namespace - - -void add_tests_OverworldMaps(UnitTestDatabase& database){ + + +void add_tests_OverworldMaps(UnitTestDatabase& database){ for (const auto& scenario : OVERWORLD_MAPS_CASES){ database.add(scenario); } -} - - - +} + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index 1816a97ff6..62e20d0528 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -285,6 +285,30 @@ WalkedTiles walked_near(const RouteStep& step, int estimate, Milliseconds tile_t }; } +std::vector with_warp_slack(std::vector route){ + const auto same_axis = [](Direction a, Direction b){ return (int)a % 2 == (int)b % 2; }; + std::optional further; + for (RouteStep& step : route){ + if (step.action != RouteAction::WALK){ + continue; + } + if (step.warps){ + if (further && step.direction != *further && same_axis(step.direction, *further)){ + step.warp_slack = 1; + } + further.reset(); + continue; + } + if (step.to_wall && further && same_axis(step.direction, *further)){ + further.reset(); + } + if (!step.to_wall && !step.connection_steps.empty()){ + further = step.direction; + } + } + return route; +} + WalkedTiles whole_leg(const RouteStep& step){ return WalkedTiles{.min = 0, .max = step.tiles}; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index 51029bcf8e..157917283e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -47,6 +47,15 @@ constexpr Milliseconds STOP_TIMING_SLACK(300); // (Pokemon::LegResult::tiles), give or take STOP_TIMING_SLACK at // `tile_time` a tile: a tile either side walking, two running. WalkedTiles walked_near(const RouteStep& step, int estimate, Milliseconds tile_time); +// `route` with RouteStep::warp_slack set. An open leg over a map +// connection is held a tile longer for the game holding the player up +// there, which it doesn't always do: south from Route 6 into Vermilion +// City it didn't, the player ended a tile further south, and the north 1 +// onto the Pokemon Center's door stopped below it (console B, 2026-11-03, +// row 00.24). Until a leg to a wall that way or a warp places the player +// again, they may stand a tile further on; a warp walked back that way is +// held a tile further for it. +std::vector with_warp_slack(std::vector route); // Anywhere on `step`, for a stop the timing doesn't place. WalkedTiles whole_leg(const RouteStep& step); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp index 8a64b28702..9053f963ea 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.cpp @@ -211,7 +211,7 @@ class RouteRun{ , m_calls(calls) , m_lines(calls, planning) , m_steps(calls, planning != nullptr ? planning->wall_check : WallCheck::TAP) - , m_plan(route) + , m_plan(with_warp_slack(route)) , m_at(planning != nullptr ? planning->start : std::nullopt) , m_facing(facing) {} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index bd24190fa1..b6953fedbe 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -32,7 +32,7 @@ namespace{ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ Pokemon::LegSpec leg; leg.direction = step.direction; - leg.tiles = step.tiles; + leg.tiles = step.tiles + step.warp_slack; leg.to_wall = step.to_wall; leg.tile_time = tile_time(step, speed); leg.stop_on_interrupt = !step.to_wall; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index b362f41941..47158d6cf9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -288,7 +288,7 @@ class StepModel{ std::optional> press(const State& state, Direction direction) const{ const std::optional transition = PokemonFRLG::press(m_maps, state, direction); - if (!transition){ + if (!transition || (transition->action == RouteAction::SURF && route_cost(m_costs, RouteCost::SURF) < 0)){ return std::nullopt; } Pokemon::Step step; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index a7503a77ae..7d03dedf95 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -73,6 +73,12 @@ struct RouteStep{ // by the tiles alone ended a tile short (console B, 2026-11-03, rows // 00.14, 00.16 and 00.20). std::vector connection_steps = {}; + // WALK onto a warp: how many tiles further the player may stand from + // it than planned, which the hold goes on for, stopping at the fade. + // An open leg over a map connection before it may end a tile further + // on, when the game doesn't hold the player up there (console B, + // 2026-11-03, row 00.24). Set by with_warp_slack() when a route runs. + int warp_slack = 0; }; // Formats a RouteStep as a string. std::string to_string(const RouteStep& step); @@ -90,7 +96,7 @@ enum class RouteCost{ TILE, TURN, // extra for a leg in a new direction WARP, // the fade in and out - SURF, // the dialog and the animation + SURF, // the dialog and the animation; below 0, never GRASS_TILE, // extra for tall grass, where wild Pokemon appear // Extra for a leg that ends where the player could keep walking: it has // to be timed, and if a message or a battle stops it part-way, where diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 1eda6c4a9e..93fdda5aaf 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -53,6 +53,8 @@ constexpr Milliseconds TAP_TIME(340); constexpr Milliseconds NO_BATTLE_WAIT(15000); constexpr Milliseconds BATTLE_TIME(10000); constexpr Milliseconds MESSAGE_CLOSE(1000); +// The questions, " used CUT!" and the tree falling. +constexpr Milliseconds CUT_TIME(4000); // Rows of black below a small room, seen from this near its bottom. constexpr int BLACK_BELOW_ROWS = 5; constexpr int SMALL_ROOM_ROWS = 20; @@ -301,6 +303,10 @@ bool OverworldSim::after_step(const Step& step, Milliseconds pressed){ update_view(); if (step.warped){ m_state.warps++; + for (auto& [index, tree] : m_cut_trees){ + m_maps[index].objects.push_back(tree); + } + m_cut_trees.clear(); m_frozen = true; schedule(pressed + timing.tile, SimScreen::FADE, false); schedule(pressed + timing.tile + timing.fade, SimScreen::OVERWORLD, true); @@ -371,7 +377,7 @@ void OverworldSim::walk_hold(Direction direction, Milliseconds duration, int sho bump(direction, pressed); return; } - if (step->map != m_map && !step->warped){ + if (m_script.connections_stall && step->map != m_map && !step->warped){ // The step onto a connected map waits a tile while the game // loads it: a hold timed by its tiles alone ended a tile short // (console B, 2026-11-03, rows 00.14, 00.16 and 00.20). @@ -478,6 +484,24 @@ bool OverworldSim::close_message(){ return true; } +bool OverworldSim::cut_tree(){ + std::vector& objects = m_maps[m_map].objects; + const TilePos ahead = m_player.position.next(m_state.facing); + for (size_t c = 0; c < objects.size(); c++){ + if (objects[c].name == "cut_tree" && objects[c].x == ahead.x && objects[c].y == ahead.y + && m_state.screen == SimScreen::OVERWORLD && !m_frozen + ){ + m_cut_trees.emplace_back(m_map, objects[c]); + objects.erase(objects.begin() + c); + advance_to(m_now + CUT_TIME); + note("cut the tree on " + to_string(MapPosition{map().name, ahead}) + "."); + return true; + } + } + violation("no tree to cut " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); + return false; +} + // @@ -614,7 +638,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, const std::vector& ints = interrupts(step); Pokemon::LegSpec leg; leg.direction = step.direction; - leg.tiles = step.tiles; + leg.tiles = step.tiles + step.warp_slack; leg.to_wall = step.to_wall; leg.tile_time = TILE_TIME; leg.stop_on_interrupt = !step.to_wall; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 392af86e97..b27b6f1057 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include "Common/Cpp/Time.h" #include "Common/Cpp/TestRunners/UnitTestDatabase.h" @@ -108,6 +109,11 @@ struct SimScript{ // don't mark as grass: Mt. Moon B2F's tiles closed on one side ('8') // are cave floor too (console B, 2026-11-03). std::set wild_floor_maps; + // Whether the game holds the player up a tile on a step onto a + // connected map. It did north onto Nugget Bridge (console B, + // 2026-11-03, rows 00.16 and 00.20), not south from Route 6 into + // Vermilion City (row 00.24). + bool connections_stall = true; SimTiming timing; }; @@ -180,6 +186,11 @@ class OverworldSim{ // battle follows it. bool close_message(); + // Cuts the tree the player faces (an object named "cut_tree") with + // YES to the game's question: it is gone until the player next warps, + // as its hide flag is a FLAG_TEMP. Returns whether there was one. + bool cut_tree(); + private: // A screen that shows at `at`; `unfreeze`: the player can move again. struct Pending{ @@ -230,6 +241,8 @@ class OverworldSim{ std::optional m_trainer_speaking; // The trainer whose battle is on screen or about to show. std::optional m_trainer; + // The trees cut since the last warp, and their maps. + std::vector> m_cut_trees; int m_grass_steps = 0; int m_holds = 0; SimState m_state; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index 52ba4a998a..2f3b3db492 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -1,970 +1,990 @@ -/* 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{ - -struct OverworldSimInput{ - std::string name; - std::function body; -}; - -struct OverworldSimExpectation{ -}; - -struct OverworldSimCase{ - OverworldSimInput input; - OverworldSimExpectation expectation; -}; - -class Test_OverworldSim : public UnitTest{ -public: - explicit Test_OverworldSim(OverworldSimCase test_case) - : UnitTest("PokemonFRLG::OverworldSim - " + test_case.input.name) - , m_case(std::move(test_case)) - {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - try{ - return m_case.input.body(); - }catch (Exception& e){ - return std::string(e.name()) + ": " + e.message(); - } - } - -private: - OverworldSimCase m_case; -}; - - -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; -} - -// Story row 00.14: from Route 4 east of Mt. Moon along the upper road to -// above the ledge row 9, out of Lass Crissy's line (75, 3); one step -// south hops the ledge onto the lower road two tiles down; from there -// into Cerulean City and its Pokemon Center by one door. No route plans -// past the ledge: the upper road reaches the city only by water. -UnitTestResult test_route4_to_cerulean(){ - const SimCase upper{.start = at("Route4", 32, 6), .facing = Direction::SOUTH, .goal = at("Route4", 93, 8)}; - const SimRun walk = run_case(upper); - if (!arrived(walk, upper.goal) || !walk.state.trainers_beaten.empty()){ - return "Expected the upper road walked to above the ledge, no trainer met:" + describe(walk); - } - OverworldSim sim(maps(), at("Route4", 93, 8), Direction::SOUTH); - sim.tap(Direction::SOUTH); - sim.watch({SimWatcher::MESSAGE}, std::chrono::milliseconds(2000)); - if (!(sim.state().position == at("Route4", 93, 10))){ - return "Expected the step south to hop the ledge onto (93, 10), not " + to_string(sim.state().position) + "."; - } - const SimCase lower{.start = at("Route4", 93, 10), .facing = Direction::SOUTH, .goal = at("CeruleanCity_PokemonCenter_1F", 7, 4)}; - const SimRun now = run_case(lower); - if (!arrived(now, lower.goal) || now.state.warps != 1 || !now.state.trainers_beaten.empty()){ - return "Expected Cerulean's Pokemon Center reached by its door, no trainer met:" + describe(now); - } - return true; -} - -// Story rows 00.14b and 00.14c in Cerulean Gym (pokefirered map.json): -// from the Center into the Gym, up column 9 to (9, 13), west to (4, 13), -// then to walls round the pool's west and north sides and west to (9, 7) -// below Misty's platform, meeting no one; and back from beside her (9, 6) -// the same way round to (9, 13) and out to the Center. -UnitTestResult test_cerulean_gym_walks(){ - const SimCase in{.start = at("CeruleanCity_PokemonCenter_1F", 7, 4), .facing = Direction::NORTH, .goal = at("CeruleanCity_Gym", 9, 13)}; - const SimRun entered = run_case(in); - if (!arrived(entered, in.goal) || entered.state.warps != 2 || !entered.state.trainers_beaten.empty()){ - return "Expected the Gym entered and (9, 13) reached, no trainer met:" + describe(entered); - } - SimCase west{.start = in.goal, .facing = Direction::NORTH, .goal = at("CeruleanCity_Gym", 4, 13)}; - west.route = std::vector{leg(Direction::WEST, 5, west.goal, true)}; - const SimRun walkway = run_case(west); - if (!arrived(walkway, west.goal) || !walkway.state.trainers_beaten.empty()){ - return "Expected the wall (4, 13) reached west from (9, 13), no trainer met:" + describe(walkway); - } - SimCase up{.start = west.goal, .facing = Direction::WEST, .goal = at("CeruleanCity_Gym", 9, 7)}; - up.route = std::vector{ - leg(Direction::NORTH, 3, at("CeruleanCity_Gym", 4, 10), true), - leg(Direction::EAST, 8, at("CeruleanCity_Gym", 12, 10), true), - leg(Direction::NORTH, 3, at("CeruleanCity_Gym", 12, 7), true), - leg(Direction::WEST, 3, up.goal, false), - }; - const SimRun walk = run_case(up); - if (!arrived(walk, up.goal) || !walk.state.trainers_beaten.empty()){ - return "Expected (9, 7) below Misty's platform reached round the pool, no trainer met:" + describe(walk); - } - SimCase out{.start = at("CeruleanCity_Gym", 9, 6), .facing = Direction::WEST, .goal = at("CeruleanCity_Gym", 9, 13)}; - out.route = std::vector{ - leg(Direction::SOUTH, 1, at("CeruleanCity_Gym", 9, 7), false), - leg(Direction::EAST, 3, at("CeruleanCity_Gym", 12, 7), true), - leg(Direction::SOUTH, 3, at("CeruleanCity_Gym", 12, 10), true), - leg(Direction::WEST, 8, at("CeruleanCity_Gym", 4, 10), true), - leg(Direction::SOUTH, 3, at("CeruleanCity_Gym", 4, 13), true), - leg(Direction::EAST, 5, out.goal, true), - }; - const SimRun back = run_case(out); - if (!arrived(back, out.goal) || !back.state.trainers_beaten.empty()){ - return "Expected (9, 13) reached from beside Misty round the pool, no trainer met:" + describe(back); - } - const SimCase nurse{.start = out.goal, .facing = Direction::EAST, .goal = at("CeruleanCity_PokemonCenter_1F", 7, 4)}; - const SimRun healed = run_case(nurse); - if (!arrived(healed, nurse.goal) || healed.state.warps != 2 || !healed.state.trainers_beaten.empty()){ - return "Expected the Center reached from (9, 13), no trainer met:" + describe(healed); - } - return true; -} - -// The straight ways in Cerulean Gym that 00.14b and 00.14c keep off: the -// step onto (9, 12) meets Swimmer Luis, and the step onto (8, 7), -// misty_front, Picnicker Diana. -UnitTestResult test_cerulean_gym_lines(){ - const struct{ - MapPosition from; - Direction direction; - MapPosition onto; - const char* trainer; - } LINES[] = { - {at("CeruleanCity_Gym", 9, 13), Direction::NORTH, at("CeruleanCity_Gym", 9, 12), "swimmer_m_water"}, - {at("CeruleanCity_Gym", 9, 7), Direction::WEST, at("CeruleanCity_Gym", 8, 7), "picnicker"}, - }; - for (const auto& line : LINES){ - SimCase c{.start = line.from, .facing = line.direction, .goal = line.onto}; - c.route = std::vector{leg(line.direction, 1, c.goal, false)}; - const SimRun now = run_case(c); - if (!arrived(now, c.goal) || now.state.trainers_beaten != std::vector{line.trainer}){ - return std::format("Expected the {} fought on the step onto {}:", line.trainer, to_string(line.onto)) + describe(now); - } - } - return true; -} - -// Story rows 00.15 and 00.16: out of Cerulean's Pokemon Center by its mat -// to below the rival's trigger row, and on north across the city's edge -// onto Nugget Bridge, below Cale's row, meeting no one. -UnitTestResult test_cerulean_to_bridge(){ - const MapPosition nurse = at("CeruleanCity_PokemonCenter_1F", 7, 4); - for (const MapPosition& goal : {at("CeruleanCity", 23, 7), at("Route24", 11, 32)}){ - const SimCase c{.start = nurse, .facing = Direction::NORTH, .goal = goal}; - const SimRun now = run_case(c); - if (!arrived(now, c.goal) || now.state.warps != 1 || !now.state.trainers_beaten.empty()){ - return "Expected " + to_string(goal) + " reached by the Center's mat, no trainer met:" + describe(now); - } - } - return true; -} - -// Story row 00.16 up Nugget Bridge: the short legs between the five -// trainers' rows meet no one; and the step from the tile below each row -// onto it is in that trainer's sight, beside them, so they fight there -// without walking: Cale, Ali, Timmy, Reli and Ethan. -UnitTestResult test_nugget_bridge_legs(){ - SimCase between{.start = at("Route24", 11, 31), .facing = Direction::NORTH, .goal = at("Route24", 11, 29)}; - between.route = std::vector{leg(Direction::NORTH, 2, between.goal, false)}; - const SimRun walk = run_case(between); - if (!arrived(walk, between.goal) || !walk.state.trainers_beaten.empty()){ - return "Expected the leg between two rows walked, no trainer met:" + describe(walk); - } - const struct{ - int row; - const char* trainer; - } LINES[] = {{31, "bug_catcher"}, {28, "lass"}, {25, "youngster"}, {22, "lass"}, {19, "camper"}}; - for (const auto& line : LINES){ - SimCase c{.start = at("Route24", 11, line.row + 1), .facing = Direction::NORTH, .goal = at("Route24", 11, line.row)}; - c.route = std::vector{leg(Direction::NORTH, 1, c.goal, false)}; - const SimRun now = run_case(c); - if (!arrived(now, c.goal) || now.state.trainers_beaten != std::vector{line.trainer}){ - return std::format("Expected the {} on row {} fought beside the player:", line.trainer, line.row) + describe(now); - } - } - return true; -} - -} // namespace - - - -const OverworldSimCase OVERWORLD_SIM_CASES[] = { - {.input = {"the forest's north gate: no fade or black read as a battle", test_forest_north_gate}, .expectation = {},}, - {.input = {"a battle on a leg onto a warp", test_battle_on_warp_leg}, .expectation = {},}, - {.input = {"the START menu left open", test_start_menu_open}, .expectation = {},}, - {.input = {"a battle at the end of a leg", test_battle_at_end_of_leg}, .expectation = {},}, - {.input = {"a late battle stops an open leg", test_late_battle_mid_leg}, .expectation = {},}, - {.input = {"a trainer's battle is fought, not fled", test_trainer_not_fled}, .expectation = {},}, - {.input = {"no hold over a ledge", test_no_hold_over_ledge}, .expectation = {},}, - {.input = {"a short leg, then a sign", test_short_leg_then_sign}, .expectation = {},}, - {.input = {"a short leg, nothing seen", test_short_leg_unseen}, .expectation = {},}, - {.input = {"a trainer on the way out of Pewter Gym", test_trainer_on_way_out}, .expectation = {},}, - {.input = {"out of Viridian Mart from the clerk's scene", test_mart_exit}, .expectation = {},}, - {.input = {"Mt. Moon: down the ladders to B2F", test_mt_moon_ladders}, .expectation = {},}, - {.input = {"Mt. Moon: the Moon Stone is a short detour", test_moon_stone_detour}, .expectation = {},}, - {.input = {"Mt. Moon: the Grunt who looks around stops a leg to a wall", test_mt_moon_grunt_mid_leg}, .expectation = {},}, - {.input = {"Mt. Moon: a short open leg before the Grunt's line", test_mt_moon_short_leg}, .expectation = {},}, - {.input = {"Mt. Moon: a late wild battle in the cave", test_mt_moon_late_battle}, .expectation = {},}, - {.input = {"Mt. Moon: after the HELIX, out to Route 4", test_mt_moon_way_out}, .expectation = {},}, - {.input = {"Mt. Moon: a battle on the leg onto 1F's ladder", test_mt_moon_battle_on_ladder_leg}, .expectation = {},}, - {.input = {"Mt. Moon: a battle seen only after a leg to a wall", test_mt_moon_battle_seen_after_wall_leg}, .expectation = {},}, - {.input = {"Mt. Moon: black after a leg not marked wild", test_mt_moon_black_after_unmarked_leg}, .expectation = {},}, - {.input = {"Mt. Moon: a battle seen only before the next leg", test_mt_moon_battle_before_next_leg}, .expectation = {},}, - {.input = {"Route 4 east, down its ledge, to Cerulean's Pokemon Center", test_route4_to_cerulean}, .expectation = {},}, - {.input = {"Cerulean Gym: to Misty and back round the pool, no trainer met", test_cerulean_gym_walks}, .expectation = {},}, - {.input = {"Cerulean Gym: the straight ways meet Luis and Diana", test_cerulean_gym_lines}, .expectation = {},}, - {.input = {"Cerulean: the Center to the rival's row and Nugget Bridge", test_cerulean_to_bridge}, .expectation = {},}, - {.input = {"Nugget Bridge: each trainer met on the step onto their row", test_nugget_bridge_legs}, .expectation = {},},}; - -void add_tests_OverworldSim(UnitTestDatabase& database){ - for (const auto& item : OVERWORLD_SIM_CASES){ - database.add(item); - } -} - - - -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation +/* 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{ + +struct OverworldSimInput{ + std::string name; + std::function body; +}; + +struct OverworldSimExpectation{ +}; + +struct OverworldSimCase{ + OverworldSimInput input; + OverworldSimExpectation expectation; +}; + +class Test_OverworldSim : public UnitTest{ +public: + explicit Test_OverworldSim(OverworldSimCase test_case) + : UnitTest("PokemonFRLG::OverworldSim - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_case.input.body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + OverworldSimCase m_case; +}; + + +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; +} + +// Story row 00.14: from Route 4 east of Mt. Moon along the upper road to +// above the ledge row 9, out of Lass Crissy's line (75, 3); one step +// south hops the ledge onto the lower road two tiles down; from there +// into Cerulean City and its Pokemon Center by one door. No route plans +// past the ledge: the upper road reaches the city only by water. +UnitTestResult test_route4_to_cerulean(){ + const SimCase upper{.start = at("Route4", 32, 6), .facing = Direction::SOUTH, .goal = at("Route4", 93, 8)}; + const SimRun walk = run_case(upper); + if (!arrived(walk, upper.goal) || !walk.state.trainers_beaten.empty()){ + return "Expected the upper road walked to above the ledge, no trainer met:" + describe(walk); + } + OverworldSim sim(maps(), at("Route4", 93, 8), Direction::SOUTH); + sim.tap(Direction::SOUTH); + sim.watch({SimWatcher::MESSAGE}, std::chrono::milliseconds(2000)); + if (!(sim.state().position == at("Route4", 93, 10))){ + return "Expected the step south to hop the ledge onto (93, 10), not " + to_string(sim.state().position) + "."; + } + const SimCase lower{.start = at("Route4", 93, 10), .facing = Direction::SOUTH, .goal = at("CeruleanCity_PokemonCenter_1F", 7, 4)}; + const SimRun now = run_case(lower); + if (!arrived(now, lower.goal) || now.state.warps != 1 || !now.state.trainers_beaten.empty()){ + return "Expected Cerulean's Pokemon Center reached by its door, no trainer met:" + describe(now); + } + return true; +} + +// Story rows 00.14b and 00.14c in Cerulean Gym (pokefirered map.json): +// from the Center into the Gym, up column 9 to (9, 13), west to (4, 13), +// then to walls round the pool's west and north sides and west to (9, 7) +// below Misty's platform, meeting no one; and back from beside her (9, 6) +// the same way round to (9, 13) and out to the Center. +UnitTestResult test_cerulean_gym_walks(){ + const SimCase in{.start = at("CeruleanCity_PokemonCenter_1F", 7, 4), .facing = Direction::NORTH, .goal = at("CeruleanCity_Gym", 9, 13)}; + const SimRun entered = run_case(in); + if (!arrived(entered, in.goal) || entered.state.warps != 2 || !entered.state.trainers_beaten.empty()){ + return "Expected the Gym entered and (9, 13) reached, no trainer met:" + describe(entered); + } + SimCase west{.start = in.goal, .facing = Direction::NORTH, .goal = at("CeruleanCity_Gym", 4, 13)}; + west.route = std::vector{leg(Direction::WEST, 5, west.goal, true)}; + const SimRun walkway = run_case(west); + if (!arrived(walkway, west.goal) || !walkway.state.trainers_beaten.empty()){ + return "Expected the wall (4, 13) reached west from (9, 13), no trainer met:" + describe(walkway); + } + SimCase up{.start = west.goal, .facing = Direction::WEST, .goal = at("CeruleanCity_Gym", 9, 7)}; + up.route = std::vector{ + leg(Direction::NORTH, 3, at("CeruleanCity_Gym", 4, 10), true), + leg(Direction::EAST, 8, at("CeruleanCity_Gym", 12, 10), true), + leg(Direction::NORTH, 3, at("CeruleanCity_Gym", 12, 7), true), + leg(Direction::WEST, 3, up.goal, false), + }; + const SimRun walk = run_case(up); + if (!arrived(walk, up.goal) || !walk.state.trainers_beaten.empty()){ + return "Expected (9, 7) below Misty's platform reached round the pool, no trainer met:" + describe(walk); + } + SimCase out{.start = at("CeruleanCity_Gym", 9, 6), .facing = Direction::WEST, .goal = at("CeruleanCity_Gym", 9, 13)}; + out.route = std::vector{ + leg(Direction::SOUTH, 1, at("CeruleanCity_Gym", 9, 7), false), + leg(Direction::EAST, 3, at("CeruleanCity_Gym", 12, 7), true), + leg(Direction::SOUTH, 3, at("CeruleanCity_Gym", 12, 10), true), + leg(Direction::WEST, 8, at("CeruleanCity_Gym", 4, 10), true), + leg(Direction::SOUTH, 3, at("CeruleanCity_Gym", 4, 13), true), + leg(Direction::EAST, 5, out.goal, true), + }; + const SimRun back = run_case(out); + if (!arrived(back, out.goal) || !back.state.trainers_beaten.empty()){ + return "Expected (9, 13) reached from beside Misty round the pool, no trainer met:" + describe(back); + } + const SimCase nurse{.start = out.goal, .facing = Direction::EAST, .goal = at("CeruleanCity_PokemonCenter_1F", 7, 4)}; + const SimRun healed = run_case(nurse); + if (!arrived(healed, nurse.goal) || healed.state.warps != 2 || !healed.state.trainers_beaten.empty()){ + return "Expected the Center reached from (9, 13), no trainer met:" + describe(healed); + } + return true; +} + +// The straight ways in Cerulean Gym that 00.14b and 00.14c keep off: the +// step onto (9, 12) meets Swimmer Luis, and the step onto (8, 7), +// misty_front, Picnicker Diana. +UnitTestResult test_cerulean_gym_lines(){ + const struct{ + MapPosition from; + Direction direction; + MapPosition onto; + const char* trainer; + } LINES[] = { + {at("CeruleanCity_Gym", 9, 13), Direction::NORTH, at("CeruleanCity_Gym", 9, 12), "swimmer_m_water"}, + {at("CeruleanCity_Gym", 9, 7), Direction::WEST, at("CeruleanCity_Gym", 8, 7), "picnicker"}, + }; + for (const auto& line : LINES){ + SimCase c{.start = line.from, .facing = line.direction, .goal = line.onto}; + c.route = std::vector{leg(line.direction, 1, c.goal, false)}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.trainers_beaten != std::vector{line.trainer}){ + return std::format("Expected the {} fought on the step onto {}:", line.trainer, to_string(line.onto)) + describe(now); + } + } + return true; +} + +// Story rows 00.15 and 00.16: out of Cerulean's Pokemon Center by its mat +// to below the rival's trigger row, and on north across the city's edge +// onto Nugget Bridge, below Cale's row, meeting no one. +UnitTestResult test_cerulean_to_bridge(){ + const MapPosition nurse = at("CeruleanCity_PokemonCenter_1F", 7, 4); + for (const MapPosition& goal : {at("CeruleanCity", 23, 7), at("Route24", 11, 32)}){ + const SimCase c{.start = nurse, .facing = Direction::NORTH, .goal = goal}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.warps != 1 || !now.state.trainers_beaten.empty()){ + return "Expected " + to_string(goal) + " reached by the Center's mat, no trainer met:" + describe(now); + } + } + return true; +} + +// Story row 00.16 up Nugget Bridge: the short legs between the five +// trainers' rows meet no one; and the step from the tile below each row +// onto it is in that trainer's sight, beside them, so they fight there +// without walking: Cale, Ali, Timmy, Reli and Ethan. +UnitTestResult test_nugget_bridge_legs(){ + SimCase between{.start = at("Route24", 11, 31), .facing = Direction::NORTH, .goal = at("Route24", 11, 29)}; + between.route = std::vector{leg(Direction::NORTH, 2, between.goal, false)}; + const SimRun walk = run_case(between); + if (!arrived(walk, between.goal) || !walk.state.trainers_beaten.empty()){ + return "Expected the leg between two rows walked, no trainer met:" + describe(walk); + } + const struct{ + int row; + const char* trainer; + } LINES[] = {{31, "bug_catcher"}, {28, "lass"}, {25, "youngster"}, {22, "lass"}, {19, "camper"}}; + for (const auto& line : LINES){ + SimCase c{.start = at("Route24", 11, line.row + 1), .facing = Direction::NORTH, .goal = at("Route24", 11, line.row)}; + c.route = std::vector{leg(Direction::NORTH, 1, c.goal, false)}; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.trainers_beaten != std::vector{line.trainer}){ + return std::format("Expected the {} on row {} fought beside the player:", line.trainer, line.row) + describe(now); + } + } + return true; +} + +} // namespace + + + +// Story row 00.24 from Route 6 to Vermilion City's nurse: the open leg +// south 14 crosses onto Vermilion City and is held a tile longer for the +// game holding the player up there. It didn't: the player ended a tile +// south of (23, 7), the leg west on (15, 8), and the north 1 onto the +// Center's door only reached the tile below it: "no fade in and out" +// (console B, 2026-11-03). Either way the door is reached. +UnitTestResult test_route6_to_vermilion_center(){ + const MapPosition nurse = at("VermilionCity_PokemonCenter_1F", 7, 4); + for (const bool stalls : {true, false}){ + SimCase c{.start = at("Route6", 11, 33), .facing = Direction::EAST, .goal = nurse}; + c.script.connections_stall = stalls; + const SimRun now = run_case(c); + if (!arrived(now, c.goal) || now.state.warps != 1){ + return std::format("Expected the nurse reached by the Center's door, the crossing {}:", stalls ? "stalled" : "not stalled") + describe(now); + } + } + return true; +} + +const OverworldSimCase OVERWORLD_SIM_CASES[] = { + {.input = {"the forest's north gate: no fade or black read as a battle", test_forest_north_gate}, .expectation = {},}, + {.input = {"a battle on a leg onto a warp", test_battle_on_warp_leg}, .expectation = {},}, + {.input = {"the START menu left open", test_start_menu_open}, .expectation = {},}, + {.input = {"a battle at the end of a leg", test_battle_at_end_of_leg}, .expectation = {},}, + {.input = {"a late battle stops an open leg", test_late_battle_mid_leg}, .expectation = {},}, + {.input = {"a trainer's battle is fought, not fled", test_trainer_not_fled}, .expectation = {},}, + {.input = {"no hold over a ledge", test_no_hold_over_ledge}, .expectation = {},}, + {.input = {"a short leg, then a sign", test_short_leg_then_sign}, .expectation = {},}, + {.input = {"a short leg, nothing seen", test_short_leg_unseen}, .expectation = {},}, + {.input = {"a trainer on the way out of Pewter Gym", test_trainer_on_way_out}, .expectation = {},}, + {.input = {"out of Viridian Mart from the clerk's scene", test_mart_exit}, .expectation = {},}, + {.input = {"Mt. Moon: down the ladders to B2F", test_mt_moon_ladders}, .expectation = {},}, + {.input = {"Mt. Moon: the Moon Stone is a short detour", test_moon_stone_detour}, .expectation = {},}, + {.input = {"Mt. Moon: the Grunt who looks around stops a leg to a wall", test_mt_moon_grunt_mid_leg}, .expectation = {},}, + {.input = {"Mt. Moon: a short open leg before the Grunt's line", test_mt_moon_short_leg}, .expectation = {},}, + {.input = {"Mt. Moon: a late wild battle in the cave", test_mt_moon_late_battle}, .expectation = {},}, + {.input = {"Mt. Moon: after the HELIX, out to Route 4", test_mt_moon_way_out}, .expectation = {},}, + {.input = {"Mt. Moon: a battle on the leg onto 1F's ladder", test_mt_moon_battle_on_ladder_leg}, .expectation = {},}, + {.input = {"Mt. Moon: a battle seen only after a leg to a wall", test_mt_moon_battle_seen_after_wall_leg}, .expectation = {},}, + {.input = {"Mt. Moon: black after a leg not marked wild", test_mt_moon_black_after_unmarked_leg}, .expectation = {},}, + {.input = {"Mt. Moon: a battle seen only before the next leg", test_mt_moon_battle_before_next_leg}, .expectation = {},}, + {.input = {"Route 4 east, down its ledge, to Cerulean's Pokemon Center", test_route4_to_cerulean}, .expectation = {},}, + {.input = {"Cerulean Gym: to Misty and back round the pool, no trainer met", test_cerulean_gym_walks}, .expectation = {},}, + {.input = {"Cerulean Gym: the straight ways meet Luis and Diana", test_cerulean_gym_lines}, .expectation = {},}, + {.input = {"Cerulean: the Center to the rival's row and Nugget Bridge", test_cerulean_to_bridge}, .expectation = {},}, + {.input = {"Nugget Bridge: each trainer met on the step onto their row", test_nugget_bridge_legs}, .expectation = {},}, {.input = {"Route 6 into Vermilion's Pokemon Center, the crossing stalled or not", test_route6_to_vermilion_center}, .expectation = {}}, +}; + +void add_tests_OverworldSim(UnitTestDatabase& database){ + for (const auto& item : OVERWORLD_SIM_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation \ No newline at end of file diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 2f9848360a..8e57b1cc73 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -147,7 +147,7 @@ class RepelWoreOffInterrupt : public MessageInterrupt{ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ - Pokemon::LegSpec leg{step.direction, step.tiles, step.to_wall, tile_time(step, speed)}; + Pokemon::LegSpec leg{step.direction, step.tiles + step.warp_slack, step.to_wall, tile_time(step, speed)}; leg.stalled_steps = step.connection_steps; return leg; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 15459736b4..ccc75acb09 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -52,6 +52,7 @@ #include "Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryTable.h" +#include "Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.h" #include "Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.h" #include "Programs/RngManipulation/PokemonFRLG_RngPlanInputs_Tests.h" @@ -734,26 +735,11 @@ class Test_NamingKeys : public UnitTest{ }; const NamingKeysScenario NAMING_KEYS_CASES[] = { - { - .input = {.name = "one key", .text = "A"}, - .expectation = {.expected_actions = "A"}, - }, - { - .input = {.name = "next key", .text = "AB"}, - .expectation = {.expected_actions = "A RIGHT A"}, - }, - { - .input = {.name = "lower case page", .text = "a"}, - .expectation = {.expected_actions = "SELECT A"}, - }, - { - .input = {.name = "full player name", .text = "ABCDEFG"}, - .expectation = {}, - }, - { - .input = {.name = "mixed case", .text = "Kosmos"}, - .expectation = {}, - }, + {.input = {.name = "one key", .text = "A"}, .expectation = {.expected_actions = "A"}}, + {.input = {.name = "next key", .text = "AB"}, .expectation = {.expected_actions = "A RIGHT A"}}, + {.input = {.name = "lower case page", .text = "a"}, .expectation = {.expected_actions = "SELECT A"}}, + {.input = {.name = "full player name", .text = "ABCDEFG"}, .expectation = {}}, + {.input = {.name = "mixed case", .text = "Kosmos"}, .expectation = {}}, }; void add_tests_NamingKeys(UnitTestDatabase& database){ @@ -896,7 +882,22 @@ static void add_tests_AutoStoryReaders(UnitTestDatabase& database){ add_tests_AutoStoryScreenEvents(database); } +// The naming screen's cursor, plans, keys and text. +static void add_tests_Naming(UnitTestDatabase& database){ + add_tests_NamingCursor(database); + add_tests_NamingPlan(database); + add_tests_NamingKeys(database); + add_tests_NamingText(database); +} + +// The story's rows on the overworld sim, a file of tests per stage. +static void add_tests_AutoStoryRows(UnitTestDatabase& database){ + add_tests_AutoStoryVermilion(database); + add_tests_AutoStoryVermilionGym(database); +} + void add_tests(UnitTestDatabase& database){ + add_tests_WhiteDialogDetector(database); add_tests_AdvanceWhiteDialogDetector(database); add_tests_BoxCellOccupiedDetector(database); add_tests_BoxCellReader(database); @@ -934,16 +935,15 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStory(database); add_tests_AutoStoryExport(database); add_tests_AutoStorySimulation(database); + add_tests_AutoStoryRows(database); + add_tests_AutoStoryTrashCans(database); add_tests_AutoStoryPaceSim(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_Naming(database); add_tests_RngManipulation(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp index 55c25f82ce..0f4ef4de91 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp @@ -555,6 +555,30 @@ void answer_yes_no(ConsoleHandle& console, ProControllerContext& context, bool y ); } +std::optional talk_and_count_pages(ConsoleHandle& console, ProControllerContext& context){ + WhiteDialogWatcher opened(COLOR_RED); + const int shown = run_until( + console, context, + [](ProControllerContext& context){ + pbf_press_button(context, BUTTON_A, 160ms, 3000ms); + }, + {opened} + ); + if (shown < 0){ + return std::nullopt; + } + // Fast text prints a page in well under a second; an A before it + // ends would only finish it. + pbf_wait(context, 1500ms); + pbf_press_button(context, BUTTON_A, 160ms, 1200ms); + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + const size_t pages = screen && WhiteDialogDetector(COLOR_RED).detect(screen) ? 2 : 1; + console.log(std::format("Message: {} page(s).", pages)); + mash_until_overworld(console, context, std::chrono::seconds(20)); + return pages; +} + void step_once(ProControllerContext& context, Direction direction, std::optional facing){ if (facing != direction){ pbf_press_dpad(context, dpad(direction), TURN_TAP, TURN_SETTLE); @@ -576,7 +600,7 @@ std::optional step_until_seen(int max_tiles, const std::function& s std::optional step_into_line( ConsoleHandle& console, ProControllerContext& context, - Direction direction, std::optional facing, int max_tiles + Direction direction, std::optional facing, int max_tiles, Milliseconds watch ){ return step_until_seen( max_tiles, @@ -587,7 +611,7 @@ std::optional step_into_line( [&]{ WhiteDialogWatcher message(COLOR_RED); BattleScreenWatcher battle(COLOR_RED); - return wait_until(console, context, TRAINER_LINE_WATCH, {message, battle}) >= 0; + return wait_until(console, context, watch, {message, battle}) >= 0; } ); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h index ce9b714e76..e08122242b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h @@ -112,6 +112,13 @@ void mash_until_overworld(ConsoleHandle& console, ProControllerContext& context, // answers NO. Throws OperationFailedException after `timeout`. void answer_yes_no(ConsoleHandle& console, ProControllerContext& context, bool yes, Button advance, Milliseconds timeout); +// From the overworld, facing something that answers A with a message (a +// sign, Vermilion Gym's trash cans): A, then how many pages the message +// has, one or two, then on to the overworld. Language-free: one A after +// the first page has printed either closes the message or shows its +// second page. Nothing if no message showed. +std::optional talk_and_count_pages(ConsoleHandle& console, ProControllerContext& context); + // From the overworld: one step `direction`, onto a trigger or into a door. // `facing` is the way the player faces, if known; a player facing another // way is turned first. @@ -130,12 +137,17 @@ std::optional step_until_seen(int max_tiles, const std::function& s // with no tiles to walk up (PokemonFRLG_OverworldRecoveryWalk.cpp). inline constexpr Milliseconds TRAINER_LINE_WATCH(2500); +// How long a step into the line of a trainer who looks around waits for +// them to turn to the player (TRAINER_WAIT_VARIANT). +inline constexpr Milliseconds TRAINER_WAIT_WATCH(30000); + // From the overworld: step_until_seen() `direction`, turning first -// unless `facing` is `direction`, each step watched TRAINER_LINE_WATCH -// for a trainer's message or battle. +// unless `facing` is `direction`, each step watched `watch` for a +// trainer's message or battle. std::optional step_into_line( ConsoleHandle& console, ProControllerContext& context, - Pokemon::Direction direction, std::optional facing, int max_tiles + Pokemon::Direction direction, std::optional facing, int max_tiles, + Milliseconds watch = TRAINER_LINE_WATCH ); // From the overworld, turns to face `direction` without stepping: the diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 52283f5166..9d5e93b652 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -107,7 +107,7 @@ BattleScreen read_battle_screen(const ImageViewRGB32& screen, bool confirmed){ if (BattleMenuDetector(COLOR_RED).detect(screen)){ return BattleScreen::MENU; } - if (BattleLearnDialogDetector(COLOR_RED).detect(screen)){ + if (battle_yes_no_shows(screen)){ return BattleScreen::LEARN; } if (!confirmed && !BattleScreenDetector(COLOR_RED).detect(screen)){ @@ -127,7 +127,7 @@ BattleTextScreen read_text_screen(const ImageViewRGB32& screen){ .text_box = BattleDialogDetector(COLOR_RED).detect(screen), .hp_box = BattleScreenDetector(COLOR_RED).detect(screen), .arrow = AdvanceBattleDialogDetector(COLOR_RED).detect(screen), - .yes_no = BattleLearnDialogDetector(COLOR_RED).detect(screen) || yes_no_cursor(screen).has_value(), + .yes_no = battle_yes_no_shows(screen) || yes_no_cursor(screen).has_value(), }; } @@ -167,8 +167,7 @@ bool bag_row_is( // From the battle menu: `item`, in `row` (from 0) of the Items pocket, on // the starter. The bag opens on the pocket used last; Items is the first. -// Returns whether it was used; if the row holds something else (THOROUGH), -// backs out to the battle menu. Not run on the console yet. +// Returns whether it was used; else (THOROUGH) backs out to the menu. bool use_item( ConsoleHandle& console, ProControllerContext& context, int row, const std::string& item, StoryChecks checks, Language language @@ -207,8 +206,7 @@ void run_away(ProControllerContext& context){ pbf_press_button(context, BUTTON_A, 160ms, 500ms); } -// FIGHT, then the move in `slot` from the top left. The cursor doesn't -// move onto an empty slot. +// FIGHT, then the move in `slot` from the top left; the cursor stays off empty slots. void use_move(ProControllerContext& context, size_t slot){ tap(context, DPAD_LEFT); tap(context, DPAD_UP); @@ -283,12 +281,12 @@ bool forget_move(ConsoleHandle& console, ProControllerContext& context, size_t s return true; } -// The party list in battle opens on the lead: down to `slot`, then A, and -// SHIFT, the top of its menu. TurnResult item_result(bool used){ return used ? TurnResult::DONE : TurnResult::NOT_DONE; } +// The party list in battle opens on the lead: down to `slot`, then A, and +// SHIFT, the top of its menu. void send_member(ProControllerContext& context, size_t slot){ for (size_t c = 0; c < slot; c++){ pbf_press_dpad(context, DPAD_DOWN, 160ms, 400ms); @@ -392,7 +390,7 @@ class BattleLoop{ if (!m_confirmed){ return false; } - if (m_move_chosen && BattleOutOfPpDetector(COLOR_RED).detect(screen)){ + if (m_move_chosen && m_refusal.no_pp_due(now) && BattleOutOfPpDetector(COLOR_RED).detect(screen)){ m_console.log("Battle: no PP left for that move; choosing again.", COLOR_ORANGE); m_decisions.turn_done(TurnChoice::MOVE, TurnResult::NOT_DONE); m_move_chosen = false; @@ -514,9 +512,10 @@ class BattleLoop{ ErrorReportMode::SEND_ERROR_REPORT, "fight_battle(): no battle on screen.", m_console ); } - if (now - m_start > BATTLE_TIMEOUT){ + // Neither the battle nor the overworld for a minute: a stop, not 10 minutes of + and B. + if (now - m_start > BATTLE_TIMEOUT || m_end.overworld_lost(now)){ OperationFailedExceptionWithScreenshot::fire( - ErrorReportMode::SEND_ERROR_REPORT, "fight_battle(): the battle took over 10 minutes.", m_console + ErrorReportMode::SEND_ERROR_REPORT, now - m_start > BATTLE_TIMEOUT ? std::string("fight_battle(): the battle took over 10 minutes.") : std::format("fight_battle(): no battle screen and no START menu for 60 s after the battle (turn {}{}).", m_decisions.turns(), m_decisions.lost() ? ", the lead fainted: the battle is lost" : ""), m_console ); } } @@ -586,6 +585,8 @@ class BattleLoop{ if (!m_foes.species().empty()){ m_foes.at_menu(read_trainer_foe(m_console.logger(), m_language, screen, m_foes.species())); m_supplies.foe_out = m_foes.out(); + m_supplies.foes_fainted = m_foes.fainted(); + m_supplies.foe_hp_fill = read_opponent_hp_fill(screen); } if (m_refusal.refused(current_time())){ m_console.log("Battle: the move list came back without a turn (Disable?); choosing another move.", COLOR_ORANGE); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h index a07e0d6a08..6ca186175e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h @@ -25,6 +25,7 @@ #include #include +#include #include #include #include "Common/Cpp/TestRunners/UnitTestDatabase.h" @@ -95,6 +96,11 @@ struct Supplies{ // The slot of the trainer's party out, as the battle loop tracks it // (TrainerFoeTracker): the lead's attacks are chosen against it. std::optional foe_out = {}; + // Its HP bar's filled share at the last battle menu, none if not read: + // its HP for the lead's attacks and Potions. Full while none. + std::optional foe_hp_fill = {}; + // The slots of that party seen fainted (TrainerFoeTracker): not to come. + std::set foes_fainted = {}; // The wild Pokemon of the battle going on, as read at its first battle // menu (read_wild_foes()): the lead's attacks are chosen against it. // Empty when it wasn't read; trainer battles never use it. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index 20febd96d3..fe0a5c9578 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -60,6 +60,16 @@ constexpr StoryTrainer STORY_TRAINERS[] = { {.map = "Route25", .battle = "picnicker_kelsey", .trainer = "TRAINER_PICNICKER_KELSEY"}, {.map = "Route25", .battle = "hiker_nob", .trainer = "TRAINER_HIKER_NOB"}, {.map = "Route25", .battle = "lass_haley", .trainer = "TRAINER_LASS_HALEY"}, + // The burgled house's Rocket (33, 6), CeruleanCity_EventScript_GruntTrigger. + {.map = "CeruleanCity", .battle = "rocket_grunt", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_5"}, + // Route 6's Jeff (13, 33), across the only way south. + {.map = "Route6", .battle = "camper_jeff", .trainer = "TRAINER_CAMPER_JEFF"}, + {.map = "SSAnne_2F_Corridor", .battle = "rival_ss_anne", .trainer = "TRAINER_RIVAL_SS_ANNE_BULBASAUR"}, + // Vermilion Gym's three, before the trash cans, and Lt. Surge. + {.map = "VermilionCity_Gym", .battle = "sailor_dwayne", .trainer = "TRAINER_SAILOR_DWAYNE"}, + {.map = "VermilionCity_Gym", .battle = "engineer_baily", .trainer = "TRAINER_ENGINEER_BAILY"}, + {.map = "VermilionCity_Gym", .battle = "gentleman_tucker", .trainer = "TRAINER_GENTLEMAN_TUCKER"}, + {.map = "VermilionCity_Gym", .battle = "lt_surge", .trainer = "TRAINER_LEADER_LT_SURGE"}, }; using JsonTable = std::map>; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp index 2126f89ef6..57fc597e03 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.cpp @@ -5,6 +5,7 @@ */ #include +#include #include #include #include @@ -24,8 +25,6 @@ constexpr size_t PARTY_SLOTS = 6; constexpr int MIN_POTIONS_TO_FIGHT = 2; // RUN this many times ("Can't escape!") before fighting instead. constexpr int RUN_TRIES = 4; -// PSN read at this many turns in a row before an Antidote. -constexpr int PSN_READS = 2; // An evolution screen held this long was an evolution, not a text box // between two others. constexpr Milliseconds EVOLUTION_SEEN = std::chrono::milliseconds(5000); @@ -39,6 +38,10 @@ constexpr Milliseconds BATTLE_GONE = std::chrono::milliseconds(3000); constexpr Milliseconds OVERWORLD_CHECK = std::chrono::milliseconds(3000); // A and B pressed blind for this long with no battle menu: give up. constexpr Milliseconds BLIND_PRESSES = std::chrono::milliseconds(30000); +// No battle on screen for this long, and START has not opened the menu: +// stop. Longer than a gym leader's speech after the battle and a +// whiteout's walk to the nurse, with B pressed through them. +constexpr Milliseconds NO_OVERWORLD = std::chrono::milliseconds(60000); // Turns a move turned down is left alone: the longest Disable (pret // battle_script_commands.c Cmd_disablelastusedattack, 2 to 5 turns). constexpr int DISABLE_TURNS = 5; @@ -50,15 +53,51 @@ constexpr Milliseconds REFUSED_WITHIN = std::chrono::milliseconds(7000); constexpr size_t LOW_ATTACK_PP = 10; // The move prompts a turn can bring: two moves offered, NO then YES each. constexpr int MOVE_PROMPTS = 4; +// The levels the lead can gain in a battle, for the moves it can be +// offered: a story battle gives one or two. +constexpr uint8_t MAX_LEVELS_IN_BATTLE = 3; +// The lead is taken as seeded once the chance that a foe's Leech Seed hit +// it is over this. +constexpr double SEEDED_CHANCE = 0.5; + +SimStatus sim_status(StatusCondition status){ + switch (status){ + case StatusCondition::SLEEP: + return SimStatus::SLEEP; + case StatusCondition::POISON: + return SimStatus::POISON; + case StatusCondition::PARALYSIS: + return SimStatus::PARALYSIS; + default: + return SimStatus::NONE; + } +} -// The trainer's Pokemon out, at full HP (its damage taken isn't read), +// The trainer's Pokemon out, with the HP its bar shows (full if not read), // when the battle loop knows which. std::optional foe_out(const Supplies& supplies){ if (supplies.trainer.empty() || !supplies.foe_out){ return std::nullopt; } const std::vector party = trainer_mons(supplies.trainer); - return *supplies.foe_out < party.size() ? trainer_battler(party[*supplies.foe_out]) : std::nullopt; + std::optional foe = *supplies.foe_out < party.size() ? trainer_battler(party[*supplies.foe_out]) : std::nullopt; + if (foe && supplies.foe_hp_fill){ + const double hp = std::round(*supplies.foe_hp_fill * foe->body.stats.hp); + foe->hp = static_cast(std::clamp(hp, 1, foe->body.stats.hp)); + } + return foe; +} + +// The trainer's Pokemon still to come: not out and not seen fainted, at +// full HP. +std::vector foes_to_come(const Supplies& supplies, const SimTrainer& trainer){ + std::vector to_come; + for (size_t slot = 0; slot < trainer.party.size(); slot++){ + if (slot != supplies.foe_out && !supplies.foes_fainted.contains(slot)){ + to_come.push_back(trainer.party[slot]); + } + } + return to_come; } // A trainer's battle: against the foe out, the best expected damage at @@ -114,7 +153,7 @@ std::string no_potion_reason(const Supplies& supplies, const PartyStatus& status if (supplies.potions <= 0){ return "none left"; } - const bool asked = supplies.risk == BattleRisk::HIGH ? status.hp_red : status.hp_red || status.hp_yellow; + const bool asked = supplies.risk == BattleRisk::HIGH ? c.max_hit >= c.hp : status.hp_red || status.hp_yellow; if (!asked){ return std::format("HP {} and the next hit can't faint the lead", bar_name(status)); } @@ -135,27 +174,26 @@ std::string move_chances_text(const PotionCase& c){ return text.empty() ? "none" : text; } -// Against a known foe: a Potion at BattleRisk::HIGH when its next turn -// could faint the lead (potion_now(), the HP from the bar's share) or the -// bar is red, at LOW whenever the bar isn't green; either only if -// potion_pays(). Else, asleep, an Awakening if awakening_now(). +std::string potion_numbers(const PotionCase& c, bool seeded){ + return std::format( + "{}/{} HP{}, the foe's next turn {} HP or less {:.0f}% of the time (mean {:.0f}, up to {}; {}), {} of them before the lead's attacks faint it", + c.hp, c.max_hp, seeded ? " (taken as seeded)" : "", c.likely_hit, 100 * POTION_COVER_PERCENTILE, + c.expected_hit, c.max_hit, move_chances_text(c), c.hits_to_ko + ); +} + +// Against a known foe at BattleRisk::LOW: a Potion whenever the bar isn't +// green, if potion_pays(). Else, asleep, an Awakening if awakening_now(). ItemTurn known_foe_items( const Supplies& supplies, const PartyStatus& status, int awakenings, const SimBattler& own, const PotionCase& c ){ const bool hurt = status.hp_red || status.hp_yellow; - const std::string numbers = std::format( - "{}/{} HP, the foe's next turn {} HP or less {:.0f}% of the time (mean {:.0f}, up to {}; {}), {} of them before the lead's attacks faint it", - c.hp, c.max_hp, c.likely_hit, 100 * POTION_COVER_PERCENTILE, c.expected_hit, c.max_hit, move_chances_text(c), c.hits_to_ko - ); + const std::string numbers = potion_numbers(c, own.seeded); if (awakening_now(c, own, awakenings)){ return {TurnChoice::AWAKENING, "an Awakening: asleep, and the lead outlasts the turn it costs; " + numbers}; } - const bool high = supplies.risk == BattleRisk::HIGH; - if (high && potion_now(c, supplies.potions)){ - return {TurnChoice::POTION, "a Potion: the next turn could faint the lead, and it covers that turn; " + numbers}; - } - if (supplies.potions > 0 && (high ? status.hp_red : hurt) && potion_pays(c)){ + if (supplies.potions > 0 && hurt && potion_pays(c)){ return {TurnChoice::POTION, "a Potion: it covers the next turn, or outlasts the foe; " + numbers}; } if (!hurt && own.status != SimStatus::SLEEP){ @@ -164,15 +202,46 @@ ItemTurn known_foe_items( return {TurnChoice::MOVE, "no Potion: " + no_potion_reason(supplies, status, c) + "; " + numbers}; } -// The item this turn, from the bar and the badge as read. -ItemTurn item_turn(const Supplies& supplies, const PartyStatus& status, double seen_hit, int awakenings){ +// Against a known foe at BattleRisk::HIGH: the win-chance sim's own_turn(), +// so the chance it gives is the one the loop plays for. +ItemTurn sim_items( + const Supplies& supplies, const PartyStatus& status, int awakenings, + const SimBattler& own, const SimBattler& foe, const SimTrainer& trainer, double seen_hit +){ + const std::vector to_come = foes_to_come(supplies, trainer); + const SimItems items{.potions = supplies.potions, .awakenings = awakenings, .antidotes = supplies.antidotes}; + const OwnTurn turn = own_turn(own, items, foe, trainer, to_come.size() + 1, to_come, seen_hit); + const PotionCase c = potion_case(own, foe, trainer, seen_hit); + const std::string numbers = potion_numbers(c, own.seeded); + if (turn.awakening){ + return {TurnChoice::AWAKENING, "an Awakening: asleep, and the lead outlasts the turn it costs; " + numbers}; + } + if (turn.top_up){ + return {TurnChoice::POTION, "a Potion: topping up before the knockout, a stronger foe to come; " + numbers}; + } + if (turn.potion){ + return {TurnChoice::POTION, "a Potion: the next turn could faint the lead, and it covers that turn; " + numbers}; + } + if (!status.hp_red && !status.hp_yellow && own.status != SimStatus::SLEEP){ + return {}; + } + return {TurnChoice::MOVE, "no Potion: " + no_potion_reason(supplies, status, c) + "; " + numbers}; +} + +// The item this turn, from the bar and the badge as read, and the chance +// the lead is seeded. +ItemTurn item_turn(const Supplies& supplies, const PartyStatus& status, double seen_hit, int awakenings, bool seeded){ const std::optional foe = foe_out(supplies); std::optional own = own_battler(supplies.lead, status.hp_seen ? status.hp_fill : 1.0); const std::optional trainer = sim_trainer(supplies.trainer); if (!foe || !own || !trainer){ return unknown_foe_items(supplies, status, seen_hit, awakenings); } - own->status = status.status == StatusCondition::SLEEP ? SimStatus::SLEEP : SimStatus::NONE; + own->status = sim_status(status.status); + own->seeded = seeded; + if (supplies.risk == BattleRisk::HIGH){ + return sim_items(supplies, status, awakenings, *own, *foe, *trainer, seen_hit); + } return known_foe_items(supplies, status, awakenings, *own, potion_case(*own, *foe, *trainer, seen_hit)); } @@ -208,11 +277,16 @@ bool nurse_before_battle(const Supplies& supplies){ BattleDecisions::BattleDecisions(Supplies& supplies, BattleKind kind) : m_supplies(supplies) , m_kind(kind) -{} +{ + if (supplies.lead.level > 0){ + m_level_cap = static_cast(std::min(battle_level(supplies.lead) + MAX_LEVELS_IN_BATTLE, 255)); + } +} TurnAction BattleDecisions::turn(const PartyStatus& status){ m_turns++; m_move_prompts = 0; + m_declined_delete = false; m_before_forget.reset(); count_down_refused(); m_last_move_slot.reset(); @@ -245,13 +319,15 @@ TurnAction BattleDecisions::lead_turn(const PartyStatus& status){ return TurnAction{.choice = TurnChoice::RUN}; } see_hit(status); + see_seed_chance(); // Awakenings are kept for trainers: a wild battle with a status is run from. const int awakenings = m_kind == BattleKind::TRAINER ? m_supplies.awakenings : 0; - const ItemTurn items = m_items_off ? ItemTurn{} : item_turn(m_supplies, status, m_seen_hit, awakenings); + const bool seeded = 1 - m_unseeded > SEEDED_CHANCE; + const ItemTurn items = m_items_off ? ItemTurn{} : item_turn(m_supplies, status, m_seen_hit, awakenings, seeded); if (items.choice != TurnChoice::MOVE){ return TurnAction{.choice = items.choice, .heal_reason = items.reason}; } - if (!m_items_off && m_psn_reads >= PSN_READS && !m_antidote_used && m_supplies.antidotes > 0){ + if (!m_items_off && m_psn_reads >= PSN_TURNS_BEFORE_ANTIDOTE && !m_antidote_used && m_supplies.antidotes > 0){ m_antidote_used = true; return TurnAction{.choice = TurnChoice::ANTIDOTE, .heal_reason = items.reason}; } @@ -260,6 +336,17 @@ TurnAction BattleDecisions::lead_turn(const PartyStatus& status){ return action; } +void BattleDecisions::see_seed_chance(){ + m_unseeded *= 1 - m_seed_pending; + m_seed_pending = 0; + const std::optional foe = foe_out(m_supplies); + const std::optional own = own_battler(m_supplies.lead); + const std::optional trainer = sim_trainer(m_supplies.trainer); + if (foe && own && trainer){ + m_seed_pending = seed_chance(*trainer, *foe, *own); + } +} + void BattleDecisions::see_hit(const PartyStatus& status){ if (m_supplies.foe_out != m_hit_by){ m_seen_hit = 0; @@ -325,8 +412,10 @@ void BattleDecisions::turn_done(TurnChoice choice, TurnResult result){ } const bool done = result == TurnResult::DONE; if (!done && choice == TurnChoice::MOVE){ - if (m_last_move_slot){ + if (m_last_move_slot && !m_no_pp_read.insert(*m_last_move_slot).second){ no_pp_left(m_supplies.lead, *m_last_move_slot); + }else{ + move_refused(); } return; } @@ -381,6 +470,10 @@ PromptAnswer BattleDecisions::prompt(bool lead_box_gone){ } PartyMember learning = m_supplies.lead; const LearnDecision decision = learn_move(learning); + if (!decision.move || (m_level_cap && battle_level(learning) > *m_level_cap)){ + // No move the lead can have reached: not a move prompt. + return PromptAnswer{.answer = YesNo::NO}; + } if (decision.forget_slot){ m_before_forget = m_supplies.lead; return PromptAnswer{.answer = YesNo::YES, .forget_slot = decision.forget_slot, .learned = std::move(learning)}; @@ -458,6 +551,10 @@ void MoveRefusalWatch::no_battle_screen(){ m_list_back = m_chosen.has_value(); } +bool MoveRefusalWatch::no_pp_due(WallClock now) const{ + return m_chosen && now - *m_chosen < REFUSED_WITHIN; +} + bool MoveRefusalWatch::refused(WallClock now){ const bool refused = m_chosen && m_list_back && now - *m_chosen < REFUSED_WITHIN; m_chosen.reset(); @@ -472,6 +569,10 @@ bool BattleEndWatch::pressed_blind(WallClock now){ return now - *m_blind_since >= BLIND_PRESSES; } +bool BattleEndWatch::overworld_lost(WallClock now) const{ + return m_last_battle && m_last_check && now - *m_last_battle >= NO_OVERWORLD; +} + } // namespace PokemonFRLG diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h index cd0b05be57..5fb5f5a6cc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleDecisions.h @@ -20,6 +20,7 @@ #define PokemonAutomation_PokemonFRLG_AutoStoryBattleDecisions_H #include +#include #include #include #include @@ -57,7 +58,10 @@ struct TurnAction{ enum class TurnResult{ DONE, // An item not used (another item in its row), or a move the game said - // has no PP left. + // has no PP left. The first time for a move it is taken as turned + // down (REFUSED): Kadabra's Disable, or a misread (console B, + // 2026-11-03, m68: "no PP left" read after the battle's last turn); the + // second time its PP is 0. NOT_DONE, // The game turned the move down although it has PP left: a foe's // Disable (console B, 2026-11-03, Mt. Moon: Miguel's Grimer disabled @@ -111,11 +115,15 @@ class BattleDecisions{ // A YES/NO in or after the battle. With the lead's box gone and the // opponent's there, it is "Use next POKEMON?"; else the move prompts: - // "Delete a move to make room?", then "Stop learning?" after a NO, or - // after YES and a move list closed with nothing forgotten. Past - // MOVE_PROMPTS since the last turn, a plain YES, which ends them - // either way (console B, 2026-11-03: NO and YES out of step with the - // game went round for 75 s). + // "Delete a move to make room?", then "Stop learning?" after a NO (in + // the same turn), or after YES and a move list closed with nothing + // forgotten. Past MOVE_PROMPTS since the last turn, a plain YES, which + // ends them either way (console B, 2026-11-03: NO and YES out of step + // with the game went round for 75 s). With no move the lead can have + // reached to offer (none left, or one more than MAX_LEVELS_IN_BATTLE + // above its level read before the battle), it is no move prompt, such + // as the trainer's "Will change POKEMON?": NO, and the lead + // is left as it was. PromptAnswer prompt(bool lead_box_gone); // The party list opened with the lead fainted: the slot to send in, @@ -135,6 +143,8 @@ class BattleDecisions{ void see_hit(const PartyStatus& status); void count_down_refused(); void move_refused(); + // The chance the foe's last turn seeded the lead taken in. + void see_seed_chance(); Supplies& m_supplies; const BattleKind m_kind; @@ -163,6 +173,16 @@ class BattleDecisions{ std::optional m_hit_by; // The slots the game turned down, and the turns until they are tried again. std::map m_refused; + // The slots "no PP left" was read for once. + std::set m_no_pp_read; + // The highest level a move offered can be for, from the lead's level + // read before the battle; none if it wasn't read. + std::optional m_level_cap; + // The chance the lead isn't seeded, from the foes' Leech Seed chances + // at each turn (the game doesn't show it), and the chance at the turn + // just chosen, taken in at the next one. + double m_unseeded = 1.0; + double m_seed_pending = 0; // The members sent in after the lead fainted. std::set m_sent; }; @@ -221,6 +241,10 @@ class BattleEndWatch{ // BLIND_PRESSES with no battle menu: the loop stops, and the walker, // which finds the player again, goes on. bool pressed_blind(WallClock now); + // True once START has looked for the overworld and no battle has shown + // for NO_OVERWORLD: neither is coming back (console B, 2026-11-03, + // after losing to Lt. Surge: 8 minutes of + and B on a grey screen). + bool overworld_lost(WallClock now) const; private: std::optional m_last_battle; @@ -236,6 +260,10 @@ class BattleEndWatch{ class MoveRefusalWatch{ public: void move_chosen(WallClock now); + // Whether a "no PP left" read at `now` counts: no later than a + // refusal's menu would come (console B, 2026-11-03, m68: one read 37 s + // after the battle's last move, as the battle ended). + bool no_pp_due(WallClock now) const; // No battle screen read while the battle goes on. void no_battle_screen(); // At the battle menu: true if the move chosen before it was turned down. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp index 0469a3a896..b42742a543 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.cpp @@ -25,6 +25,11 @@ constexpr int NOT_STRONGEST = 1; constexpr int MAX_STAGE = 6; // More turns than this to faint a foe counts as never. constexpr size_t TURNS_CAP = 30; +// A top-up gives the foe a turn: not when that turn lowers the lead's +// stages or gives it a status this often (the S.S. Anne rival's Pidgeotto, +// Sand-Attack; Raticate, Tail Whip: topping up against them lost more +// battles to the Ivysaur than it won). +constexpr double MAX_TOP_UP_HARM = 0.15; // The hits a multi-hit move is taken to land when sizing a foe's hit. constexpr uint32_t MULTI_HITS = 3; constexpr uint32_t CRIT_CHANCE = 16; @@ -200,6 +205,20 @@ int cv_toxic(const SimBattler& user, const SimBattler& target, std::mt19937& rng return score - (hp_percent(target) > 50 || random_below(rng, 50) ? 0 : 3); } +// AI_CV_EvasionUp, without the target's Toxic, Ingrain and Curse, which +// the sim doesn't play. +int cv_evasion_up(const SimBattler& user, const SimBattler& target, std::mt19937& rng){ + int score = 0; + score += hp_percent(user) >= 90 && !random_below(rng, 100) ? 3 : 0; + score -= level(user, Stage::EVASION) >= 9 && !random_below(rng, 128) ? 1 : 0; + score += target.seeded && !random_below(rng, 70) ? 3 : 0; + if (hp_percent(user) > 70 || level(user, Stage::EVASION) == MAX_STAGE){ + return score; + } + const bool low = hp_percent(user) < 40 || hp_percent(target) < 40; + return score - (low || !random_below(rng, 70) ? 2 : 0); +} + int cv_heal(const SimBattler& user, const SimBattler& target, std::mt19937& rng){ if (hp_percent(user) == 100){ return -3; @@ -225,6 +244,9 @@ int viability(const MoveData& move, const SimBattler& user, const SimBattler& ta if (effect == "accuracy-down"){ return cv_accuracy_down(user, target, rng); } + if (effect == "evasion-up"){ + return cv_evasion_up(user, target, rng); + } if (effect == "speed-down"){ return faster(target, user) ? (random_below(rng, 70) ? 0 : 2) : -3; } @@ -298,9 +320,12 @@ uint32_t lowest_roll(const SimBattler& own, const SimBattler& foe, const MoveDat return damage_range(own.body, foe.body, move, false).min * hits_of(move); } +// At the move's own accuracy: the battle loop doesn't see stages. A +// two-turn move does half its damage a turn. double mean_per_turn(const SimBattler& own, const SimBattler& foe, const MoveData& move){ const double accuracy = move.accuracy == 0 ? 1.0 : move.accuracy / 100.0; - return average_damage(own.body, foe.body, move) * hits_of(move) * accuracy; + const double turns = two_turn(move) ? 2.0 : 1.0; + return average_damage(own.body, foe.body, move) * hits_of(move) * accuracy / turns; } // Turns to faint `foe` with `per_turn` a turn, the AI healing it as @@ -557,6 +582,16 @@ uint32_t crit_odds(const MoveData& move){ return move.effect == "high-critical" ? HIGH_CRIT_CHANCE : CRIT_CHANCE; } +bool two_turn(const MoveData& move){ + const std::string_view effect = move.effect; + return effect == "semi-invulnerable" || effect == "solar-beam" || effect == "razor-wind" + || effect == "skull-bash" || effect == "sky-attack"; +} + +bool semi_invulnerable(const SimBattler& battler){ + return !battler.charging.empty() && data_of(battler.charging).effect == "semi-invulnerable"; +} + double NextTurnDamage::mean() const{ double sum = 0; for (size_t damage = 0; damage < hp_lost.size(); damage++){ @@ -647,7 +682,8 @@ std::optional planned_attack_slot( const bool faints = lowest_roll(own, foe, move) >= foe.hp; const int sure = move.accuracy == 0 ? 100 : move.accuracy; // Fewest turns, then surest, then weakest, then most PP left. - const size_t turns = faints ? 1 : turns_to_faint(foe, items, foes_left, mean_per_turn(own, foe, move)); + const size_t first = two_turn(move) ? 2 : 1; + const size_t turns = faints ? first : turns_to_faint(foe, items, foes_left, mean_per_turn(own, foe, move)); const Rank rank{turns, -(faints ? sure : 0), move.power, -own.moves[slot].pp}; if (!best || rank < best_rank){ best = slot; @@ -688,6 +724,7 @@ PotionCase potion_case(const SimBattler& own, const SimBattler& foe, const SimTr .expected_hit = std::max(next.mean(), seen_hit), .likely_hit = std::max(next.percentile(POTION_COVER_PERCENTILE), seen), .max_hit = std::max(next.most(), seen), + .turns_to_ko = turns, .hits_to_ko = hits, .move_chances = std::move(chances), }; @@ -718,33 +755,98 @@ bool awakening_now(const PotionCase& c, const SimBattler& own, int awakenings){ return c.max_hit > 0 && own.hp > c.likely_hit; } +double seed_chance(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& own){ + const std::vector chances = ai_move_chances(trainer, foe, own); + for (size_t slot = 0; slot < foe.moves.size(); slot++){ + const MoveData& move = data_of(foe.moves[slot].move); + if (foe.moves[slot].move == "leech-seed" && !own.seeded){ + return chances[slot] * moves_this_turn(foe) * hit_percent(foe, own, move) / 100.0; + } + } + return 0; +} + +namespace{ + +// The chance the foe's turn (`c`) leaves the lead worse off for the rest of +// the battle: a stat stage down, a status or Leech Seed, by a move that does +// no damage. +double lasting_harm_chance(const PotionCase& c){ + double chance = 0; + for (const auto& [move, picked] : c.move_chances){ + const MoveData& data = data_of(move); + const std::string_view effect = data.effect; + const bool lasting = effect.ends_with("-down") || effect == "poison" || effect == "toxic" + || effect == "sleep" || effect == "paralyze" || effect == "leech-seed"; + chance += data.power == 0 && lasting ? picked : 0; + } + return chance; +} + +} // namespace + +bool top_up_now( + const PotionCase& c, const SimBattler& own, const SimTrainer& trainer, + const std::vector& to_come, int potions +){ + const double heal = std::min(POTION_HP, c.max_hp - c.hp); + if (potions <= 0 || c.turns_to_ko != 1 || 2 * c.expected_hit > POTION_HP || heal - c.expected_hit < POTION_HP / 4.0){ + return false; + } + if (lasting_harm_chance(c) > MAX_TOP_UP_HARM){ + return false; + } + return std::ranges::any_of(to_come, [&](const SimBattler& next){ + return next.hp > 0 && next_turn_damage(trainer, next, own).mean() > c.expected_hit; + }); +} + +namespace{ + +// own_turn()'s planned move when it surely faints `foe` first. +bool sure_first_ko( + const SimBattler& own, const SimBattler& foe, const std::vector& items, + size_t foes_left, std::optional slot +){ + if (!slot || own.status == SimStatus::SLEEP){ + return false; + } + const MoveData& move = data_of(own.moves[*slot].move); + const bool foe_priority = std::ranges::any_of(foe.moves, [](const SimMove& known){ + return known.pp > 0 && data_of(known.move).priority > 0; + }); + const bool first = speed_of(own) > speed_of(foe) && !foe_priority && move.priority >= 0 && !two_turn(move); + const bool sure = move.accuracy == 0 || move.accuracy >= 100; + return first && sure && lowest_roll(own, foe, move) >= foe.hp && !ai_item_choice(foe, items, foes_left); +} + +} // namespace + OwnTurn own_turn( const SimBattler& own, const SimItems& own_items, const SimBattler& foe, - const SimTrainer& trainer, size_t foes_left + const SimTrainer& trainer, size_t foes_left, + const std::vector& to_come, double seen_hit ){ - const std::vector& items = trainer.items; - const std::optional slot = planned_attack_slot(own, foe, items, foes_left); - if (slot && own.status != SimStatus::SLEEP){ - const MoveData& move = data_of(own.moves[*slot].move); - const bool foe_priority = std::ranges::any_of(foe.moves, [](const SimMove& known){ - return known.pp > 0 && data_of(known.move).priority > 0; - }); - const bool first = speed_of(own) > speed_of(foe) && !foe_priority && move.priority >= 0; - const bool sure = move.accuracy == 0 || move.accuracy >= 100; - if (first && sure && lowest_roll(own, foe, move) >= foe.hp && !ai_item_choice(foe, items, foes_left)){ - return OwnTurn{.potion = false, .slot = slot}; - } - } + const std::optional slot = planned_attack_slot(own, foe, trainer.items, foes_left); const bool may_heal = own_items.potions > 0 && own.hp < own.body.stats.hp; const bool may_wake = own_items.awakenings > 0 && own.status == SimStatus::SLEEP; - if (!may_heal && !may_wake){ + std::optional c; + if (may_heal && !to_come.empty() && own.status != SimStatus::SLEEP){ + c = potion_case(own, foe, trainer, seen_hit); + if (top_up_now(*c, own, trainer, to_come, own_items.potions)){ + return OwnTurn{.potion = true, .slot = std::nullopt, .awakening = false, .top_up = true}; + } + } + if (sure_first_ko(own, foe, trainer.items, foes_left, slot) || (!may_heal && !may_wake)){ return OwnTurn{.potion = false, .slot = slot}; } - const PotionCase c = potion_case(own, foe, trainer); - if (awakening_now(c, own, own_items.awakenings)){ + if (!c){ + c = potion_case(own, foe, trainer, seen_hit); + } + if (awakening_now(*c, own, own_items.awakenings)){ return OwnTurn{.potion = false, .slot = std::nullopt, .awakening = true}; } - if (potion_now(c, own_items.potions)){ + if (potion_now(*c, own_items.potions)){ return OwnTurn{.potion = true, .slot = std::nullopt}; } return OwnTurn{.potion = false, .slot = slot}; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h index a8a113ed57..fc5bb46896 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim.h @@ -1,270 +1,313 @@ -/* AutoStory Battle Sim - * - * From: https://github.com/PokemonAutomation/ - * - * The story's lead against a trainer's party as Gen 3 FireRed plays it, - * for the battles fought at BattleRisk::HIGH: the Pokemon on each side, - * the trainer AI's choices (its items, its moves, its next Pokemon) and - * the lead's (its move, a Potion). PokemonFRLG_AutoStoryWinChance.h plays - * whole battles from these. - * - * The trainer's side is pret's: party, levels, IVs, natures, moves, items - * and AI flags (trainers.json). The lead is what its summary showed - * when the run started (species, level, moves and PP, stats from POKEMON - * SKILLS), with the levels since (battle_level()) added at OWN_GENES; - * without a summary, its species and battle_level() at OWN_GENES. - * - * Approximations, from pret/pokefirered: - * - The AI's move (battle_ai_script_commands.c, data/battle_ai_scripts.s): - * every move starts at 100; CHECK_BAD_MOVE takes 10 off a move the - * target is immune to, a status the target already has, Leech Seed on - * a seeded or Grass target, and a stat change past +-6; TRY_TO_FAINT - * adds 4 to a move that faints the target at the mean roll (6 for Quick - * Attack) and takes 1 off the damaging moves that aren't its strongest; - * CHECK_VIABILITY scores the status moves the early trainers carry - * (AI_CV_AttackDown, DefenseDown, AccuracyDown, SpeedDown, Poison, - * Toxic for Leech Seed too, Paralyze, Heal) by HP, speed and stages, - * with their random parts. The best score wins, ties at random. The - * other scripts, and CHECK_VIABILITY on damaging moves, are left out. - * - The AI's items (battle_ai_switch_items.c ShouldUseItem) as the game - * has them for the HP items: Full Restore below 1/4 HP, the others - * below 1/4 or when more HP is missing than the item heals; each item - * once, and item i > 0 only once at most itemsNo - i + 1 Pokemon are - * left. Other items are not used. - * - The AI's next Pokemon (GetMostSuitableMonToSwitchInto): the one the - * lead's types hit hardest that has a super effective move against the - * lead, else the one whose strongest move does the lead most damage, - * else the first left. - */ - -#ifndef PokemonAutomation_PokemonFRLG_AutoStoryBattleSim_H -#define PokemonAutomation_PokemonFRLG_AutoStoryBattleSim_H - -#include -#include -#include -#include -#include -#include -#include -#include -#include "Common/Cpp/TestRunners/UnitTestDatabase.h" -#include "PokemonFRLG_AutoStoryBattleData.h" -#include "PokemonFRLG_AutoStoryDamage.h" -#include "PokemonFRLG_AutoStoryParty.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -// The lead's IVs and EVs, which the story doesn't read: middling IVs, no -// EVs, a neutral nature (Hardy). -inline constexpr Genes OWN_GENES{.iv = 15, .ev = 0, .nature = 0}; - -// HP a Potion heals. -inline constexpr uint16_t POTION_HP = 20; - -// A Potion is used only when its HP covers this share of the foe's -// next-turn damage (next_turn_damage()): at 0.80, a turn that undoes it -// must be rarer than 1 in 5. -inline constexpr double POTION_COVER_PERCENTILE = 0.80; - -// The lead's items in the bag that the sim plays. -struct SimItems{ - int potions = 0; - int awakenings = 0; -}; - +/* AutoStory Battle Sim + * + * From: https://github.com/PokemonAutomation/ + * + * The story's lead against a trainer's party as Gen 3 FireRed plays it, + * for the battles fought at BattleRisk::HIGH: the Pokemon on each side, + * the trainer AI's choices (its items, its moves, its next Pokemon) and + * the lead's (its move, a Potion). PokemonFRLG_AutoStoryWinChance.h plays + * whole battles from these. + * + * The trainer's side is pret's: party, levels, IVs, natures, moves, items + * and AI flags (trainers.json). The lead is what its summary showed + * when the run started (species, level, moves and PP, stats from POKEMON + * SKILLS), with the levels since (battle_level()) added at OWN_GENES; + * without a summary, its species and battle_level() at OWN_GENES. + * + * Approximations, from pret/pokefirered: + * - The AI's move (battle_ai_script_commands.c, data/battle_ai_scripts.s): + * every move starts at 100; CHECK_BAD_MOVE takes 10 off a move the + * target is immune to, a status the target already has, Leech Seed on + * a seeded or Grass target, and a stat change past +-6; TRY_TO_FAINT + * adds 4 to a move that faints the target at the mean roll (6 for Quick + * Attack) and takes 1 off the damaging moves that aren't its strongest; + * CHECK_VIABILITY scores the status moves the early trainers carry + * (AI_CV_AttackDown, DefenseDown, AccuracyDown, SpeedDown, Poison, + * Toxic for Leech Seed too, Paralyze, Heal, EvasionUp) by HP, speed and stages, + * with their random parts. The best score wins, ties at random. The + * other scripts, and CHECK_VIABILITY on damaging moves, are left out. + * - The AI's items (battle_ai_switch_items.c ShouldUseItem) as the game + * has them for the HP items: Full Restore below 1/4 HP, the others + * below 1/4 or when more HP is missing than the item heals; each item + * once, and item i > 0 only once at most itemsNo - i + 1 Pokemon are + * left. Other items are not used. + * - The AI's next Pokemon (GetMostSuitableMonToSwitchInto): the one the + * lead's types hit hardest that has a super effective move against the + * lead, else the one whose strongest move does the lead most damage, + * else the first left. + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryBattleSim_H +#define PokemonAutomation_PokemonFRLG_AutoStoryBattleSim_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_AutoStoryBattleData.h" +#include "PokemonFRLG_AutoStoryDamage.h" +#include "PokemonFRLG_AutoStoryParty.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The lead's IVs and EVs, which the story doesn't read: middling IVs, no +// EVs, a neutral nature (Hardy). +inline constexpr Genes OWN_GENES{.iv = 15, .ev = 0, .nature = 0}; + +// HP a Potion heals. +inline constexpr uint16_t POTION_HP = 20; + +// A Potion is used only when its HP covers this share of the foe's +// next-turn damage (next_turn_damage()): at 0.80, a turn that undoes it +// must be rarer than 1 in 5. +inline constexpr double POTION_COVER_PERCENTILE = 0.80; + +// An Antidote is used, once a battle, on the turn the lead is read +// poisoned this many turns in a row, if no Potion or Awakening comes +// first. +inline constexpr int PSN_TURNS_BEFORE_ANTIDOTE = 2; + +// The lead's items in the bag that the sim plays. +struct SimItems{ + int potions = 0; + int awakenings = 0; + int antidotes = 0; +}; + // Simulated battler status condition. -enum class SimStatus{ - NONE, - SLEEP, - POISON, // and Toxic, as plain poison - PARALYSIS, -}; - +enum class SimStatus{ + NONE, + SLEEP, + POISON, // and Toxic, as plain poison + PARALYSIS, +}; + // A move in simulated combat. -struct SimMove{ - std::string move = {}; - uint8_t pp = 0; -}; - -// A Pokemon in the sim. -struct SimBattler{ - std::string species = {}; - Combatant body = {}; - uint16_t hp = 0; - // In the move list's slot order. - std::vector moves = {}; - SimStatus status = SimStatus::NONE; - // SLEEP: the game's counter (2 to 5): each turn takes one off, and it - // moves on the turn it reaches 0. - uint8_t sleep = 0; - bool seeded = false; -}; - -// The lead with `hp_share` of its HP (1 for full), its moves and PP as -// the story counts them, at battle_level(): its stats as read -// (PartyMember::stats) plus what the levels since add with `genes`, else -// from `genes`. None for a species the data doesn't have. -std::optional own_battler(const PartyMember& lead, double hp_share = 1.0, const Genes& genes = OWN_GENES); - -// A trainer's Pokemon at full HP and PP. -std::optional trainer_battler(const TrainerMon& mon); - +struct SimMove{ + std::string move = {}; + uint8_t pp = 0; +}; + +// A Pokemon in the sim. +struct SimBattler{ + std::string species = {}; + Combatant body = {}; + uint16_t hp = 0; + // In the move list's slot order. + std::vector moves = {}; + SimStatus status = SimStatus::NONE; + // SLEEP: the game's counter (2 to 5): each turn takes one off, and it + // moves on the turn it reaches 0. + uint8_t sleep = 0; + bool seeded = false; + // The two-turn move (two_turn()) it charged last turn and strikes + // with this turn, "" for none. + std::string charging = {}; +}; + +// The lead with `hp_share` of its HP (1 for full), its moves and PP as +// the story counts them, at battle_level(): its stats as read +// (PartyMember::stats) plus what the levels since add with `genes`, else +// from `genes`. None for a species the data doesn't have. +std::optional own_battler(const PartyMember& lead, double hp_share = 1.0, const Genes& genes = OWN_GENES); + +// A trainer's Pokemon at full HP and PP. +std::optional trainer_battler(const TrainerMon& mon); + // Simulated opposing trainer state. -struct SimTrainer{ - std::vector party = {}; - // Each used once; a used one becomes "". - std::vector items = {}; - bool check_bad_move = false; - bool try_to_faint = false; - bool check_viability = false; -}; -// None for a trainer the data doesn't have (or a species in its party). -std::optional sim_trainer(std::string_view trainer); - - -// The HP the AI's `item` heals (255 for all of it), 0 for one that -// doesn't heal. -uint16_t ai_item_hp(std::string_view item); - -// The item the AI uses on `foe` this turn (its index in `items`), with -// `foes_left` Pokemon on its side not fainted, none for its move. -std::optional ai_item_choice(const SimBattler& foe, const std::vector& items, size_t foes_left); - -// The scores the AI gives `foe`'s moves against `target` (see the top); -// 0 for a move with no PP. In slot order. `rng` for CHECK_VIABILITY's -// random parts. -std::vector ai_move_scores(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& target, std::mt19937& rng); - -// How often the AI picks each of `foe`'s moves against `target` now, in -// slot order: ai_move_scores() drawn AI_MOVE_SAMPLES times from a fixed -// seed, ties split evenly. The chances change with the state (a status -// on the target, Leech Seed, stages, HP). All 0 with no PP left. -inline constexpr size_t AI_MOVE_SAMPLES = 1000; +struct SimTrainer{ + std::vector party = {}; + // Each used once; a used one becomes "". + std::vector items = {}; + bool check_bad_move = false; + bool try_to_faint = false; + bool check_viability = false; +}; +// None for a trainer the data doesn't have (or a species in its party). +std::optional sim_trainer(std::string_view trainer); + + +// The HP the AI's `item` heals (255 for all of it), 0 for one that +// doesn't heal. +uint16_t ai_item_hp(std::string_view item); + +// The item the AI uses on `foe` this turn (its index in `items`), with +// `foes_left` Pokemon on its side not fainted, none for its move. +std::optional ai_item_choice(const SimBattler& foe, const std::vector& items, size_t foes_left); + +// The scores the AI gives `foe`'s moves against `target` (see the top); +// 0 for a move with no PP. In slot order. `rng` for CHECK_VIABILITY's +// random parts. +std::vector ai_move_scores(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& target, std::mt19937& rng); + +// How often the AI picks each of `foe`'s moves against `target` now, in +// slot order: ai_move_scores() drawn AI_MOVE_SAMPLES times from a fixed +// seed, ties split evenly. The chances change with the state (a status +// on the target, Leech Seed, stages, HP). All 0 with no PP left. +inline constexpr size_t AI_MOVE_SAMPLES = 1000; // Calculates the probability distribution of enemy AI move selection. -std::vector ai_move_chances(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& target); - -// The chance in percent that `user`'s `move` hits `target`: its accuracy -// at the user's accuracy and the target's evasion stages -// (sAccuracyStageRatios); 100 for a move that never misses. -uint32_t hit_percent(const SimBattler& user, const SimBattler& target, const MoveData& move); - -// A move's crit odds: 1 in 16, 1 in 8 for a high-crit move. -uint32_t crit_odds(const MoveData& move); - -// The HP `own` loses over the foe's next turn, as the AI picks its move -// (ai_move_chances()): each damaging move's 16 rolls, crit chance, -// accuracy with stages and multi-hit counts; a poison or Leech Seed that -// lands this turn and the poison and Leech Seed already on `own` (1/8 HP -// each at the end of the turn). An asleep foe doesn't move, a paralysed -// one 1 turn in 4. A Potion comes before both moves (items act first), -// so all of it lands after one. -struct NextTurnDamage{ - // ai_move_chances(). - std::vector move_chances = {}; - // hp_lost[d]: the chance of losing d HP. - std::vector hp_lost = {}; - - double mean() const; - // The least HP lost at least `share` of the time. - uint32_t percentile(double share) const; - uint32_t most() const; -}; +std::vector ai_move_chances(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& target); + +// The chance in percent that `user`'s `move` hits `target`: its accuracy +// at the user's accuracy and the target's evasion stages +// (sAccuracyStageRatios); 100 for a move that never misses. +uint32_t hit_percent(const SimBattler& user, const SimBattler& target, const MoveData& move); + +// A move's crit odds: 1 in 16, 1 in 8 for a high-crit move. +uint32_t crit_odds(const MoveData& move); + +// A move that charges a turn and hits the next: Dig, Fly, Dive, Bounce, +// Solar Beam (no sun in the sim), Razor Wind, Skull Bash, Sky Attack. +bool two_turn(const MoveData& move); + +// Under ground, in the air or under water: charging Dig, Fly, Dive or +// Bounce. pret AccuracyCalcHelper misses every move at it before the +// never-miss check (Shock Wave too); moves on the user itself (Double +// Team) don't check. +bool semi_invulnerable(const SimBattler& battler); + +// The HP `own` loses over the foe's next turn, as the AI picks its move +// (ai_move_chances()): each damaging move's 16 rolls, crit chance, +// accuracy with stages and multi-hit counts; a poison or Leech Seed that +// lands this turn and the poison and Leech Seed already on `own` (1/8 HP +// each at the end of the turn). An asleep foe doesn't move, a paralysed +// one 1 turn in 4. A Potion comes before both moves (items act first), +// so all of it lands after one. +struct NextTurnDamage{ + // ai_move_chances(). + std::vector move_chances = {}; + // hp_lost[d]: the chance of losing d HP. + std::vector hp_lost = {}; + + double mean() const; + // The least HP lost at least `share` of the time. + uint32_t percentile(double share) const; + uint32_t most() const; +}; // Calculates incoming damage distribution across the foe's next turn. -NextTurnDamage next_turn_damage(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& own); - -// The party slot the AI sends out after `fainted`, of the ones still up. -size_t ai_next_foe(const std::vector& party, size_t fainted, const SimBattler& lead); - - -// BattleRisk::LOW: the slot with PP left whose mean damage against `foe` -// is highest. None if no move does damage. -std::optional best_damage_slot(const SimBattler& own, const SimBattler& foe); - -// BattleRisk::HIGH: a move that faints `foe` at the lowest roll, the -// weakest of them to keep the strong moves' PP; else the one that faints -// it in the fewest turns at the mean roll and accuracy, counting the -// heals the AI would use on it from `items` (ai_item_choice()), ties to -// the weaker move. None if no move does damage. -std::optional planned_attack_slot( - const SimBattler& own, const SimBattler& foe, - const std::vector& items, size_t foes_left -); - -// The most `foe` can do to `own` with one move, no crit, highest roll. -uint32_t foe_max_hit(const SimBattler& foe, const SimBattler& own); - -// What a Potion now is weighed on. -struct PotionCase{ - uint16_t hp = 0; - uint16_t max_hp = 0; - // The HP the foe's next turn takes (next_turn_damage()): on average, - // at POTION_COVER_PERCENTILE and at most. - double expected_hit = 0; - uint32_t likely_hit = 0; - uint32_t max_hit = 0; - // The foe's hits the lead takes before its attacks faint the foe, if - // it attacks from now on. - size_t hits_to_ko = 0; - // The foe's moves and ai_move_chances(), for the log. - std::vector> move_chances = {}; -}; -// `own` against `foe`, played by `trainer`'s AI: next_turn_damage(), each -// figure raised to `seen_hit` (HP the last hit took, as the battle loop -// read it) if more; the lead's planned move against the foe at its HP now. -PotionCase potion_case(const SimBattler& own, const SimBattler& foe, const SimTrainer& trainer, double seen_hit = 0); - -// At either BattleRisk: a Potion is worth the turn only if its HP is more -// than the foe's next turn takes at POTION_COVER_PERCENTILE, or if it lets -// the lead last until its attacks faint the foe (at the mean) when -// attacking now wouldn't. Otherwise it attacks: a Potion the next turn -// undoes only gives the foe another turn (console B, 2026-11-03, m51: five -// Potions at 8/60 HP each undone by Misty's Starmie, then no Potions and -// a faint). -bool potion_pays(const PotionCase& c); - -// BattleRisk::HIGH: a Potion now, with `potions` left: the foe's next turn -// could faint the lead (PotionCase::max_hit) and potion_pays(). -bool potion_now(const PotionCase& c, int potions); - -// At either BattleRisk, `own` asleep with an Awakening left: one now if -// the foe's next turn can hurt it and it outlasts that turn at -// POTION_COVER_PERCENTILE (`c`). The game counts sleep down only on turns -// the lead tries to move (pret battle_util.c AtkCanceller_UnableToUseMove), -// so the turn saves the rest of the 1 to 4 asleep. Below that the lead's -// move is tried instead, to wake and attack, or a Potion as the Potion -// rules say. -bool awakening_now(const PotionCase& c, const SimBattler& own, int awakenings); - -// The lead's turn at BattleRisk::HIGH. -struct OwnTurn{ - bool potion = false; - // Not a Potion or an Awakening: the move's slot, none if no move does - // damage. - std::optional slot = std::nullopt; - bool awakening = false; -}; -// planned_attack_slot()'s move when it surely faints `foe` first (the -// lead is awake and faster, the lowest roll faints it, it never misses and -// the AI won't heal first), else a Potion if potion_now(), else an -// Awakening if awakening_now(), else that move. `trainer`: its AI and the -// items it has left. -OwnTurn own_turn( - const SimBattler& own, const SimItems& own_items, const SimBattler& foe, - const SimTrainer& trainer, size_t foes_left -); - - -// The damage calculator, the sim's choices and its win chance. -void add_tests_AutoStoryBattleSim(UnitTestDatabase& database); - - - +NextTurnDamage next_turn_damage(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& own); + +// The chance `foe`'s next turn seeds `own`: the AI's chance of Leech Seed +// times its accuracy. 0 if it has none, or `own` is seeded or Grass. +double seed_chance(const SimTrainer& trainer, const SimBattler& foe, const SimBattler& own); + +// The party slot the AI sends out after `fainted`, of the ones still up. +size_t ai_next_foe(const std::vector& party, size_t fainted, const SimBattler& lead); + + +// BattleRisk::LOW: the slot with PP left whose mean damage a turn against +// `foe` is highest. None if no move does damage. +std::optional best_damage_slot(const SimBattler& own, const SimBattler& foe); + +// BattleRisk::HIGH: a move that faints `foe` at the lowest roll, the +// weakest of them to keep the strong moves' PP; else the one that faints +// it in the fewest turns at the mean roll and accuracy, a two-turn move +// at half its damage a turn (console B, m73: Dig against Lt. Surge's +// Raichu, the Raichu's Double Team landing while the lead was under +// ground), counting the +// heals the AI would use on it from `items` (ai_item_choice()), ties to +// the weaker move. None if no move does damage. +std::optional planned_attack_slot( + const SimBattler& own, const SimBattler& foe, + const std::vector& items, size_t foes_left +); + +// The most `foe` can do to `own` with one move, no crit, highest roll. +uint32_t foe_max_hit(const SimBattler& foe, const SimBattler& own); + +// What a Potion now is weighed on. +struct PotionCase{ + uint16_t hp = 0; + uint16_t max_hp = 0; + // The HP the foe's next turn takes (next_turn_damage()): on average, + // at POTION_COVER_PERCENTILE and at most. + double expected_hit = 0; + uint32_t likely_hit = 0; + uint32_t max_hit = 0; + // The lead's attacks that faint the foe at the mean, and the foe's hits + // the lead takes before they do, if it attacks from now on. + size_t turns_to_ko = 0; + size_t hits_to_ko = 0; + // The foe's moves and ai_move_chances(), for the log. + std::vector> move_chances = {}; +}; +// `own` against `foe`, played by `trainer`'s AI: next_turn_damage(), each +// figure raised to `seen_hit` (HP the last hit took, as the battle loop +// read it) if more; the lead's planned move against the foe at its HP now. +PotionCase potion_case(const SimBattler& own, const SimBattler& foe, const SimTrainer& trainer, double seen_hit = 0); + +// At either BattleRisk: a Potion is worth the turn only if its HP is more +// than the foe's next turn takes at POTION_COVER_PERCENTILE, or if it lets +// the lead last until its attacks faint the foe (at the mean) when +// attacking now wouldn't. Otherwise it attacks: a Potion the next turn +// undoes only gives the foe another turn (console B, 2026-11-03, m51: five +// Potions at 8/60 HP each undone by Misty's Starmie, then no Potions and +// a faint). +bool potion_pays(const PotionCase& c); + +// BattleRisk::HIGH: a Potion now, with `potions` left: the foe's next turn +// could faint the lead (PotionCase::max_hit) and potion_pays(). +bool potion_now(const PotionCase& c, int potions); + +// At either BattleRisk, `own` asleep with an Awakening left: one now if +// the foe's next turn can hurt it and it outlasts that turn at +// POTION_COVER_PERCENTILE (`c`). The game counts sleep down only on turns +// the lead tries to move (pret battle_util.c AtkCanceller_UnableToUseMove), +// so the turn saves the rest of the 1 to 4 asleep. Below that the lead's +// move is tried instead, to wake and attack, or a Potion as the Potion +// rules say. +bool awakening_now(const PotionCase& c, const SimBattler& own, int awakenings); + +// Task #77's top-up before the knockout, with `potions` left: the lead's +// next attack faints the foe out at the mean, whose turn takes at most +// half a Potion on average (`c`), a Potion heals at least a quarter of its +// HP more than that, and a foe in `to_come` hits harder on average +// (next_turn_damage()). Each such turn costs a small hit and heals more, +// so the lead meets the stronger foe near full. +bool top_up_now( + const PotionCase& c, const SimBattler& own, const SimTrainer& trainer, + const std::vector& to_come, int potions +); + +// The lead's turn at BattleRisk::HIGH. +struct OwnTurn{ + bool potion = false; + // Not a Potion or an Awakening: the move's slot, none if no move does + // damage. + std::optional slot = std::nullopt; + bool awakening = false; + // The Potion is top_up_now()'s. + bool top_up = false; +}; +// A Potion if top_up_now() (`to_come`: the trainer's Pokemon not out and +// not fainted), else planned_attack_slot()'s move when it surely faints +// `foe` first (the lead is awake and faster, the lowest roll faints it, it +// never misses, takes one turn and the AI won't heal first), else a Potion if +// potion_now(), else an Awakening if awakening_now(), else that move. +// `trainer`: its AI and the items it has left. `seen_hit`: as +// potion_case() takes it. +OwnTurn own_turn( + const SimBattler& own, const SimItems& own_items, const SimBattler& foe, + const SimTrainer& trainer, size_t foes_left, + const std::vector& to_come = std::vector(), double seen_hit = 0 +); + + +// The damage calculator, the sim's choices and its win chance. +void add_tests_AutoStoryBattleSim(UnitTestDatabase& database); + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation -#endif +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp index f39d1d567c..9a640ebaaf 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleSim_Tests.cpp @@ -1,744 +1,904 @@ -/* AutoStory Battle Sim Tests - * - * From: https://github.com/PokemonAutomation/ - * - * The Gen 3 damage calculator against numbers worked by hand and the - * Elite Four Farmer's Starmie table, the move chosen against the foe out, - * the trainer AI's items and next Pokemon, and the win chance before the - * Cerulean rival. - * - */ - -#include -#include -#include -#include -#include -#include -#include -#include "Common/Cpp/Exceptions.h" -#include "Common/Cpp/TestRunners/UnitTest.h" -#include "PokemonFRLG_AutoStoryBattle.h" -#include "PokemonFRLG_AutoStoryBattleDecisions.h" -#include "PokemonFRLG_AutoStoryBattleSim.h" -#include "PokemonFRLG_AutoStoryFoeTracker.h" -#include "PokemonFRLG_AutoStoryWinChance.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -namespace{ - -struct AutoStoryBattleSimInput{ - std::string name; - UnitTestResult (*body)(); -}; - -struct AutoStoryBattleSimExpectation{ - const char* description; -}; - -struct AutoStoryBattleSimTestCase{ - AutoStoryBattleSimInput input; - AutoStoryBattleSimExpectation expectation; -}; - -class Test_AutoStoryBattleSim : public UnitTest{ -public: - explicit Test_AutoStoryBattleSim(AutoStoryBattleSimTestCase scenario) - : UnitTest("PokemonFRLG::AutoStoryBattleSim - " + scenario.input.name) - , m_scenario(std::move(scenario)) - {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - try{ - return m_scenario.input.body(); - }catch (Exception& e){ - return std::string(e.name()) + ": " + e.message(); - } - } - -private: - AutoStoryBattleSimTestCase m_scenario; -}; - -constexpr std::string_view RIVAL = "TRAINER_RIVAL_CERULEAN_BULBASAUR"; -constexpr std::string_view MISTY = "TRAINER_LEADER_MISTY"; - -std::string stats_text(const Stats& s){ - return std::format("{}/{}/{}/{}/{}/{}", s.hp, s.attack, s.defense, s.sp_attack, s.sp_defense, s.speed); -} - -std::string range_text(const DamageRange& r){ - return std::format("{}-{}", r.min, r.max); -} - -// The story's Wartortle at Lv22, OWN_GENES. -Combatant wartortle22(){ - return *combatant("wartortle", 22, OWN_GENES); -} - -// The rival's Bulbasaur: Lv18, pret iv 100 (IVs 12), Hardy. -Combatant rival_bulbasaur(){ - return *combatant("bulbasaur", 18, Genes{.iv = trainer_iv(100), .ev = 0, .nature = 0}); -} - - -// Worked by hand from CalculateMonStats and CalculateBaseDamage: -// Wartortle Lv22 IV15: HP (2*59+15)*22/100+32 = 61, Attack 141*22/100+5 = 36, -// ... Bite on the Bulbasaur (Sp. Def 142*18/100+5 = 30): 36*60*10/30/50+2 -// = 16, no STAB, neutral: 13 to 16; a crit doubles the 16 before the -// roll: 27 to 32. Water Gun: 36*40*10/30/50+2 = 11, STAB 16, Grass 0.5x -// 8: 6 to 8. Its Vine Whip back: 30*35*9/43/50+2 = 6, STAB 9, Water 2x -// 18: 15 to 18. -UnitTestResult test_hand_numbers(){ - const Combatant own = wartortle22(); - const Combatant foe = rival_bulbasaur(); - if (stats_text(own.stats) != "61/36/43/36/43/33" || stats_text(foe.stats) != "46/24/24/30/30/23"){ - return "Stats: expected 61/36/43/36/43/33 and 46/24/24/30/30/23, got " + stats_text(own.stats) + " and " + stats_text(foe.stats) + "."; - } - const std::vector> cases = { - {"Bite", damage_range(own, foe, *move_data("bite"), false), "13-16"}, - {"Bite, crit", damage_range(own, foe, *move_data("bite"), true), "27-32"}, - {"Water Gun", damage_range(own, foe, *move_data("water-gun"), false), "6-8"}, - {"Vine Whip", damage_range(foe, own, *move_data("vine-whip"), false), "15-18"}, - {"Dragon Rage", damage_range(own, foe, *move_data("dragon-rage"), false), "40-40"}, - }; - for (const auto& [name, range, expected] : cases){ - if (range_text(range) != expected){ - return std::format("{}: expected {}, got {}.", name, expected, range_text(range)); - } - } - // Tail Whip's -1: Defense 24 * 10 / 15 = 16; Tackle 36*35*10/16/50+2 = 17. - Combatant whipped = foe; - whipped.stages[static_cast(Stage::DEFENSE)] = -1; - const DamageRange tackle = damage_range(own, whipped, *move_data("tackle"), false); - if (range_text(tackle) != "14-17"){ - return "Tackle after Tail Whip: expected 14-17, got " + range_text(tackle) + "."; - } - return true; -} - - -// The Elite Four Farmer's STARMIE_PLAN (PokemonFRLG_EliteFourFarmer.cpp): -// each pick "one-hit KOs each rematch opponent (min damage roll, no crit)" -// at Sp. Atk 302, Lv100. Every rematch party's Pokemon of that species. -const std::map& starmie_plan(){ - static const std::map PLAN = { - {"dewgong", "thunderbolt"}, {"cloyster", "thunderbolt"}, {"piloswine", "surf"}, - {"jynx", "surf"}, {"lapras", "thunderbolt"}, {"steelix", "surf"}, - {"hitmonchan", "psychic"}, {"hitmonlee", "psychic"}, {"machamp", "psychic"}, - {"gengar", "psychic"}, {"crobat", "thunderbolt"}, {"misdreavus", "surf"}, - {"arbok", "psychic"}, {"gyarados", "thunderbolt"}, {"dragonite", "ice-beam"}, - {"kingdra", "psychic"}, {"aerodactyl", "surf"}, {"heracross", "psychic"}, - {"alakazam", "surf"}, {"tyranitar", "surf"}, {"arcanine", "surf"}, - {"exeggutor", "ice-beam"}, {"blastoise", "thunderbolt"}, {"venusaur", "psychic"}, - {"charizard", "thunderbolt"}, - }; - return PLAN; -} - -Combatant starmie(uint16_t sp_attack){ - Combatant body = *combatant("starmie", 100, OWN_GENES); - body.stats.sp_attack = sp_attack; - body.volcano_badge = true; - return body; -} - -// The species whose pick misses the OHKO at `sp_attack`; "" for none. -std::string first_miss(uint16_t sp_attack, std::string_view only = ""){ - for (std::string_view trainer : { - "TRAINER_ELITE_FOUR_LORELEI_2", "TRAINER_ELITE_FOUR_BRUNO_2", "TRAINER_ELITE_FOUR_AGATHA_2", "TRAINER_ELITE_FOUR_LANCE_2", - "TRAINER_CHAMPION_REMATCH_SQUIRTLE", "TRAINER_CHAMPION_REMATCH_BULBASAUR", "TRAINER_CHAMPION_REMATCH_CHARMANDER", - }){ - for (const TrainerMon& mon : trainer_mons(trainer)){ - const auto pick = starmie_plan().find(mon.species); - if (pick == starmie_plan().end() || (!only.empty() && mon.species != only)){ - continue; - } - const Combatant foe = *combatant(mon.species, mon.level, Genes{.iv = trainer_iv(mon.iv), .ev = 0, .nature = mon.nature}); - const DamageRange range = damage_range(starmie(sp_attack), foe, *move_data(pick->second), false); - if (range.min < foe.stats.hp){ - return std::format("{} {} Lv{} ({} HP, {} {})", trainer, mon.species, mon.level, foe.stats.hp, pick->second, range_text(range)); - } - } - } - return ""; -} - -UnitTestResult test_elite_four_table(){ - const std::string miss = first_miss(302); - if (!miss.empty()){ - return "At Sp. Atk 302, no OHKO at the lowest roll: " + miss + "."; - } - if (first_miss(301, "kingdra").empty()){ - return "Expected Kingdra to set the 302 Sp. Atk requirement: Psychic OHKOs it at 301 too."; - } - return true; -} - - -// Misty's Staryu (Water) and Starmie (Water/Psychic): a lead with -// Thundershock and Bite thunders the Staryu (2x against 1x) and bites the -// Starmie (both 2x, Bite stronger). As the battle loop asks, with the -// foe out known; summing over the party picked one move for both. -UnitTestResult test_misty_per_foe(){ - const PartyMember lead = member_from_summary("wartortle", 22, { - {.move = "tackle", .pp = 35, .max_pp = 35}, {.move = "bite", .pp = 25, .max_pp = 25}, - {.move = "thunder-shock", .pp = 30, .max_pp = 30}, {.move = "withdraw", .pp = 40, .max_pp = 40}, - }); - for (const BattleRisk risk : {BattleRisk::LOW, BattleRisk::HIGH}){ - for (const auto& [out, expected] : {std::pair{0, 2}, {1, 1}}){ - Supplies supplies; - supplies.lead = lead; - supplies.potions = 0; - supplies.trainer = std::string(MISTY); - supplies.foes = trainer_party(MISTY); - supplies.risk = risk; - supplies.foe_out = out; - BattleDecisions decisions(supplies, BattleKind::TRAINER); - const PartyStatus green{.hp_seen = true, .hp_yellow = false, .hp_red = false, .hp_fill = 1, .status = StatusCondition::NONE}; - const size_t slot = decisions.turn(green).slot; - if (slot != expected){ - return std::format("Risk {}, foe {}: expected slot {}, got {}.", battle_risk_name(risk), out, expected, slot); - } - } - } - return true; -} - - -// ShouldUseItem for Misty's Super Potion on her Starmie (56 HP): below -// 1/4 (14) or more than 50 missing; a Full Restore only below 1/4. A -// second item waits until at most itemsNo - 1 + 1 Pokemon are left. -UnitTestResult test_ai_heal_rule(){ - SimBattler starmie = *trainer_battler(trainer_mons(MISTY)[1]); - if (starmie.body.stats.hp != 56){ - return std::format("Expected Misty's Starmie at 56 HP, got {}.", starmie.body.stats.hp); - } - const std::vector super = {"super-potion"}; - const std::vector, size_t, bool>> cases = { - {56, super, 1, false}, {20, super, 1, false}, {13, super, 1, true}, {5, super, 1, true}, - {20, {"full-restore"}, 1, false}, {13, {"full-restore"}, 1, true}, - {13, {"", "hyper-potion"}, 3, false}, {13, {"", "hyper-potion"}, 2, true}, - }; - for (const auto& [hp, items, left, uses] : cases){ - starmie.hp = hp; - if (ai_item_choice(starmie, items, left).has_value() != uses){ - return std::format("At {} HP with {} items, {} left: expected {}.", hp, items.size(), left, uses ? "an item" : "none"); - } - } - // Each item once: the heal it would use is gone after. - starmie.hp = 6; - std::vector once = super; - once[*ai_item_choice(starmie, once, 1)].clear(); - if (ai_item_choice(starmie, once, 1)){ - return "Expected the Super Potion used only once."; - } - return true; -} - - -// GetMostSuitableMonToSwitchInto against a Wartortle after the rival's -// Pidgeotto: Water hits Abra and Rattata 1x and Bulbasaur 0.25x, but -// only Bulbasaur has a move super effective on Water (Vine Whip): it -// comes next, not Abra. The tracker, with no name read, follows that. -UnitTestResult test_next_foe(){ - const PartyMember lead = grown_to(new_member("squirtle", 5), 22); - TrainerFoeTracker tracker(RIVAL, lead); - if (tracker.out() != 0){ - return "Expected the rival's Pidgeotto out first."; - } - for (int i = 0; i < 3; i++){ - tracker.boxes_seen(BattleBoxes{.lead = true, .foe = false}); - } - tracker.at_menu(""); - if (tracker.out() != 3){ - return std::format("Expected Bulbasaur (slot 3) after Pidgeotto, got {}.", tracker.out() ? std::to_string(*tracker.out()) : "none"); - } - tracker.at_menu("abra"); - if (tracker.out() != 1){ - return "Expected the name read (Abra) over the AI's choice."; - } - return true; -} - - -// The default risks: the rival and gym leaders HIGH, route trainers and -// grunts LOW. -UnitTestResult test_risks(){ - const std::vector> cases = { - {"CeruleanCity", "rival_cerulean", BattleRisk::HIGH}, - {"CeruleanCity_Gym", "misty", BattleRisk::HIGH}, - {"PewterCity_Gym", "brock", BattleRisk::HIGH}, - {"Route24", "lass_ali", BattleRisk::LOW}, - {"MtMoon_B2F", "rocket_grunt", BattleRisk::LOW}, - {"Route1", "nobody", BattleRisk::LOW}, - }; - for (const auto& [map, battle, risk] : cases){ - if (story_battle_risk(map, battle) != risk){ - return std::format("{} on {}: expected {}.", battle, map, battle_risk_name(risk)); - } - } - return true; -} - - -// The Cerulean rival (Pidgeotto, Abra, Rattata, then a Bulbasaur with -// Sleep Powder, Poison Powder and Leech Seed): the Lv22 Wartortle with 5 -// Potions that 00.15's GRIND plans for wins about a quarter of the sim's -// battles, and is refused with the reason; at Lv30 it wins over 90%. -UnitTestResult test_rival_win_chance(){ - const std::optional planned = story_win_chance(grown_to(new_member("squirtle", 5), 22), SimItems{.potions = 5}, 1.0, RIVAL); - const std::optional refusal = planned ? win_chance_refusal(*planned) : std::nullopt; - if (!planned || planned->chance < 0.15 || planned->chance > 0.40){ - return std::format("Lv22, 5 Potions: expected 15% to 40%, got {:.1f}%.", planned ? 100 * planned->chance : 0.0); - } - if (refusal != std::format("win chance {:.0f}% < 50% with {}: grind first", std::floor(100 * planned->chance), planned->inputs)){ - return "Lv22, 5 Potions: expected a refusal with the chance, got \"" + refusal.value_or("") + "\"."; - } - const std::optional ground = story_win_chance(grown_to(new_member("squirtle", 5), 30), SimItems{.potions = 5}, 1.0, RIVAL); - if (!ground || ground->chance < 0.90 || win_chance_refusal(*ground)){ - return std::format("Lv30, 5 Potions: expected over 90% and no refusal, got {:.1f}%.", ground ? 100 * ground->chance : 0.0); - } - return true; -} - - -// Console B, 2026-11-03 (m56): the Cerulean rival at a sim chance of -// 56% was won 2 of 3 in verify mode, so a battle that close is fought, -// with verify mode as the backstop; one under half is still refused. -UnitTestResult test_threshold_m56(){ - const WinEstimate m56{.battles = 1000, .wins = 560, .chance = 0.56, .inputs = "m56"}; - if (const std::optional refusal = win_chance_refusal(m56)){ - return "56%: expected the battle fought, got \"" + *refusal + "\"."; - } - const WinEstimate close{.battles = 1000, .wins = 490, .chance = 0.49, .inputs = "close"}; - if (win_chance_refusal(close) != "win chance 49% < 50% with close: grind first"){ - return "49%: expected a refusal naming 50%, got \"" + win_chance_refusal(close).value_or("") + "\"."; - } - return true; -} - - -// Console B, 2026-11-03 (run m51), Misty: the Wartortle's moves, Bite, -// Tail Whip, Bubble, Withdraw (Water Gun was lost earlier). -std::vector m51_moves(){ - return { - {.move = "bite", .pp = 25, .max_pp = 25}, {.move = "tail-whip", .pp = 30, .max_pp = 30}, - {.move = "bubble", .pp = 30, .max_pp = 30}, {.move = "withdraw", .pp = 40, .max_pp = 40}, - }; -} - -// m51's Wartortle as its summary would read: `level`, 60 HP at Lv20, -// the other stats at OWN_GENES. -PartyMember m51_lead(uint8_t level){ - Stats stats = combatant("wartortle", 20, OWN_GENES)->stats; - stats.hp = 60; - PartyMember lead = member_from_summary("wartortle", 20, m51_moves(), stats); - lead.moves_through_level = level; - return lead; -} - -// m51's Wartortle Lv20 at `hp` of 60. -SimBattler m51_wartortle(uint16_t hp){ - SimBattler own = *own_battler(m51_lead(20)); - own.hp = hp; - return own; -} - -// m51, turns 8 to 13: at 8/60 HP against Misty's Starmie, which hit -// about 20 a turn, the HIGH tier's Potion (+20) was undone by each hit, -// five times over; then Bite once with no Potions left, and it fainted. -// The loop replayed at either tier: once a Potion is seen undone by the -// next hit, no more, but Bite, which also flinches. -UnitTestResult test_m51_potion_loop(){ - for (const BattleRisk risk : {BattleRisk::LOW, BattleRisk::HIGH}){ - Supplies supplies; - supplies.lead = m51_lead(20); - supplies.potions = 5; - supplies.trainer = std::string(MISTY); - supplies.foes = trainer_party(MISTY); - supplies.risk = risk; - supplies.foe_out = 1; - BattleDecisions decisions(supplies, BattleKind::TRAINER); - const PartyStatus red{.hp_seen = true, .hp_yellow = false, .hp_red = true, .hp_fill = 8.0 / 60, .status = StatusCondition::NONE}; - int potions = 0; - for (int turn = 8; turn <= 13; turn++){ - const TurnAction action = decisions.turn(red); - if (action.choice == TurnChoice::POTION){ - potions++; - }else if (action.choice != TurnChoice::MOVE || action.slot != 0){ - return std::format("Risk {}, turn {}: expected a Potion or Bite (slot 0), got slot {}.", battle_risk_name(risk), turn, action.slot); - } - decisions.turn_done(action.choice, TurnResult::DONE); - } - if (potions > 1){ - return std::format("Risk {}: {} Potions at 8/60 HP over turns 8 to 13, each undone by the next hit; expected at most 1.", battle_risk_name(risk), potions); - } - } - return true; -} - -// The sim's own choice by the same rule: at 8/60 a Potion against a foe -// whose hit undoes it, no Potion; against one whose hit could faint the -// lead but heals less than 20, a Potion. -UnitTestResult test_sim_potion_rule(){ - const SimTrainer misty = *sim_trainer(MISTY); - SimBattler starmie = misty.party[1]; - const SimBattler own = m51_wartortle(8); - // Starmie as m51 saw it: at least the Potion's 20 a hit. - starmie.body.stats.attack = static_cast(starmie.body.stats.attack * 2); - starmie.body.stats.sp_attack = static_cast(starmie.body.stats.sp_attack * 2); - const uint32_t likely = potion_case(own, starmie, misty).likely_hit; - if (likely < POTION_HP){ - return std::format("Expected Starmie's next turn at 20 or more {:.0f}% of the time, got {}.", 100 * POTION_COVER_PERCENTILE, likely); - } - const OwnTurn sim = own_turn(own, SimItems{.potions = 5}, starmie, misty, 1); - if (sim.potion || sim.slot != size_t(0)){ - return std::format( - "Sim at 8/60 against Starmie (max hit {}): expected Bite (slot 0), got {}.", - foe_max_hit(starmie, own), sim.potion ? "a Potion" : "slot " + std::to_string(sim.slot.value_or(9)) - ); - } - const SimBattler& staryu = misty.party[0]; - const SimBattler low = m51_wartortle(6); - const uint32_t hit = foe_max_hit(staryu, low); - if (hit < 6 || hit >= POTION_HP){ - return std::format("Expected Staryu's best hit on the Wartortle from 6 to 19, got {}.", hit); - } - if (!own_turn(low, SimItems{.potions = 5}, staryu, misty, 2).potion){ - return std::format("At 6/60 against Staryu (max hit {}): expected a Potion.", hit); - } - return true; -} - -// The sim plays the lead the summary showed, not the one the story -// counts: m51's Wartortle (Lv20, 60 HP, Bite, Tail Whip, Bubble, -// Withdraw) with 2 Potions wins about 98% against Misty; the story's own -// Wartortle Lv20 (Tackle, Bite, Water Gun, Withdraw; 56 HP at OWN_GENES) -// about 95%, from other inputs (both near 100% with 5). m51 lost: the -// sim leaves out Water Pulse's confusion, among others -// (PokemonFRLG_AutoStoryWinChance.h). -UnitTestResult test_misty_real_lead(){ - const std::optional real = story_win_chance(m51_lead(20), SimItems{.potions = 2}, 1.0, MISTY); - const std::optional story = story_win_chance(grown_to(new_member("squirtle", 5), 20), SimItems{.potions = 2}, 1.0, MISTY); - if (!real || !story){ - return "Expected win chances for both leads."; - } - const std::string expected = "wartortle Lv20 60/60 HP: bite 25/25, tail-whip 30/30, bubble 30/30, withdraw 40/40; 2 Potions"; - if (real->inputs != expected){ - return "m51's lead: expected the inputs \"" + expected + "\", got \"" + real->inputs + "\"."; - } - if (real->chance < 0.90 || story->chance < 0.85 || real->chance == story->chance || story->inputs == real->inputs){ - return std::format( - "Expected m51's lead ({:.1f}%) and the story's ({:.1f}%, {}) to differ, both over 85%.", - 100 * real->chance, 100 * story->chance, story->inputs - ); - } - return true; -} - -// The Cerulean rival against m51's moves (no Water Gun), the levels -// since the summary added to its stats: refused at Lv20 and 22 (about -// 30%, 43%), fought at Lv25 and 30 (about 75%, 96%). The refusal names -// the chance and the lead it was played with. -UnitTestResult test_rival_real_moves(){ - const std::vector> cases = { - {20, 0.20, 0.45}, {22, 0.25, 0.50}, {25, 0.50, 0.85}, {30, 0.90, 1.00}, - }; - for (const auto& [level, low, high] : cases){ - const std::optional estimate = story_win_chance(m51_lead(level), SimItems{.potions = 5}, 1.0, RIVAL); - if (!estimate || estimate->chance < low || estimate->chance >= high){ - return std::format("Lv{}: expected {:.0f}% to {:.0f}%, got {:.1f}%.", level, 100 * low, 100 * high, estimate ? 100 * estimate->chance : 0.0); - } - const bool refused = win_chance_refusal(*estimate).has_value(); - if (refused != (level < 25)){ - return std::format("Lv{} at {:.1f}%: expected {}.", level, 100 * estimate->chance, level < 25 ? "a refusal" : "no refusal"); - } - } - const std::optional lv22 = story_win_chance(m51_lead(22), SimItems{.potions = 5}, 1.0, RIVAL); - const std::string refusal = win_chance_refusal(*lv22).value_or(""); - if (refusal.find("wartortle Lv22 65/65 HP: bite") == std::string::npos || refusal.find("; 5 Potions") == std::string::npos){ - return "Lv22: expected the refusal to name the lead's level, HP, moves and Potions, got \"" + refusal + "\"."; - } - return true; -} - - -// Console B, 2026-11-03 (m56, 00.15b, run 2): the Wartortle Lv22, 65 HP, -// Bite, Water Pulse, Bubble and Withdraw. -PartyMember m56_lead(){ - Stats stats = combatant("wartortle", 22, OWN_GENES)->stats; - stats.hp = 65; - std::vector moves; - for (const char* move : {"bite", "water-pulse", "bubble", "withdraw"}){ - moves.push_back(SummaryMove{.move = move, .pp = max_pp(move), .max_pp = max_pp(move)}); - } - return member_from_summary("wartortle", 22, moves, stats); -} - -// m56's supplies against the rival's Bulbasaur (slot 3), at HIGH risk. -Supplies m56_supplies(int awakenings){ - Supplies supplies; - supplies.lead = m56_lead(); - supplies.potions = 15; - supplies.parlyz_heals = 1; - supplies.awakenings = awakenings; - supplies.trainer = std::string(RIVAL); - supplies.foes = trainer_party(RIVAL); - supplies.risk = BattleRisk::HIGH; - supplies.foe_out = 3; - return supplies; -} - -// m56's bar at `hp` of 65, with `status`. -PartyStatus m56_bar(int hp, StatusCondition status){ - const double fill = hp / 65.0; - return PartyStatus{.hp_seen = true, .hp_yellow = fill <= 0.5 && fill > 0.2, .hp_red = fill <= 0.2, .hp_fill = fill, .status = status}; -} - -// m56 run 2 against the rival's Bulbasaur (Sleep Powder, Leech Seed, -// Vine Whip): 49, 42, then 34/65 asleep, then 13/65 asleep, Bite every -// turn and no Potion; it fainted. The 21 HP of turn 6 were Vine Whip and -// Leech Seed, more than a Potion's 20, and an item's turn doesn't count -// sleep down (pret battle_util.c AtkCanceller_UnableToUseMove), so a -// Potion at 13 HP is undone each turn while the lead stays asleep: the -// move is right there, and the log says why. With an Awakening, it is -// used on turn 6, while the lead outlasts the hit its turn costs. -UnitTestResult test_rival_sleep_turns(){ - const std::vector turns = { - m56_bar(49, StatusCondition::NONE), m56_bar(42, StatusCondition::NONE), - m56_bar(34, StatusCondition::SLEEP), m56_bar(13, StatusCondition::SLEEP), - }; - Supplies played = m56_supplies(0); - BattleDecisions as_played(played, BattleKind::TRAINER); - TurnAction action; - for (const PartyStatus& status : turns){ - action = as_played.turn(status); - as_played.turn_done(action.choice, TurnResult::DONE); - } - if (action.choice != TurnChoice::MOVE || action.heal_reason.find("undoes it") == std::string::npos){ - return std::format( - "No Awakening, turn 7 at 13/65 asleep: expected a move and a reason naming the Potion undone, got choice {} and \"{}\".", - static_cast(action.choice), action.heal_reason - ); - } - Supplies supplied = m56_supplies(2); - BattleDecisions with_awakenings(supplied, BattleKind::TRAINER); - for (size_t turn = 0; turn < 3; turn++){ - action = with_awakenings.turn(turns[turn]); - with_awakenings.turn_done(action.choice, TurnResult::DONE); - } - if (action.choice != TurnChoice::AWAKENING || supplied.awakenings != 1 || action.heal_reason.empty()){ - return std::format( - "2 Awakenings, turn 6 at 34/65 asleep: expected an Awakening, with a reason, got choice {}, {} left, \"{}\".", - static_cast(action.choice), supplied.awakenings, action.heal_reason - ); - } - return true; -} - -// The sim's lead asleep, as the battle loop decides: an Awakening while it -// outlasts the foe's next hit; below that, not (its turn would only let -// the foe faint it); none against a foe that can't hurt it. -UnitTestResult test_sim_awakening_rule(){ - const SimTrainer rival = *sim_trainer(RIVAL); - const SimBattler& bulbasaur = rival.party[3]; - SimBattler own = *own_battler(m56_lead()); - own.status = SimStatus::SLEEP; - own.sleep = 4; - own.hp = 34; - if (!own_turn(own, SimItems{.potions = 15, .awakenings = 2}, bulbasaur, rival, 1).awakening){ - return "Asleep at 34/65 against the Bulbasaur with 2 Awakenings: expected one."; - } - if (own_turn(own, SimItems{.potions = 15, .awakenings = 0}, bulbasaur, rival, 1).awakening){ - return "Asleep with no Awakenings: expected none used."; - } - own.hp = 8; - if (own_turn(own, SimItems{.potions = 0, .awakenings = 2}, bulbasaur, rival, 1).awakening){ - return "Asleep at 8/65, below the Bulbasaur's hit: expected no Awakening."; - } - own.hp = 34; - const SimBattler& abra = rival.party[1]; - if (own_turn(own, SimItems{.potions = 15, .awakenings = 2}, abra, rival, 4).awakening){ - return "Asleep against Abra (Teleport only): expected no Awakening."; - } - return true; -} - -// m56's Wartortle Lv22 with 15 Potions against the rival: Awakenings -// against the Bulbasaur's Sleep Powder win more battles (about 54% with -// none, 59% with 2), and the inputs name them. -UnitTestResult test_rival_awakenings(){ - const std::optional none = story_win_chance(m56_lead(), SimItems{.potions = 15}, 1.0, RIVAL); - const std::optional three = story_win_chance(m56_lead(), SimItems{.potions = 15, .awakenings = 3}, 1.0, RIVAL); - if (!none || !three || three->chance < none->chance + 0.05){ - return std::format( - "Lv22, 15 Potions: expected 3 Awakenings to win at least 5 points more than none, got {:.1f}% and {:.1f}%.", - none ? 100 * none->chance : 0.0, three ? 100 * three->chance : 0.0 - ); - } - if (three->inputs.find("; 15 Potions, 3 Awakenings") == std::string::npos){ - return "Expected the inputs to name the Awakenings, got \"" + three->inputs + "\"."; - } - return true; -} - -// m56's Wartortle awake at 26/65 against the rival's Bulbasaur at full -// HP: the AI spends most turns on Sleep Powder and Leech Seed, so a -// Vine Whip that undoes a Potion is the rare turn (m56 run 2: about 8 HP -// a turn for three turns). With 15 Potions, a Potion. -UnitTestResult test_rival_bulbasaur_potion(){ - const SimTrainer rival = *sim_trainer(RIVAL); - const SimBattler& bulbasaur = rival.party[3]; - SimBattler own = *own_battler(m56_lead()); - own.hp = 26; - if (!own_turn(own, SimItems{.potions = 15}, bulbasaur, rival, 1).potion){ - return std::format("Awake at 26/65 against the Bulbasaur with 15 Potions: expected a Potion (max hit {}).", foe_max_hit(bulbasaur, own)); - } - return true; -} - -// The foe's next turn: a Bulbasaur with Vine Whip alone uses it every -// turn, from its lowest roll to its highest crit, never missing; with all -// four moves, no Sleep Powder on an asleep lead, and a seeded lead loses -// its eighth whatever the foe does. -UnitTestResult test_next_turn_damage(){ - const SimTrainer rival = *sim_trainer(RIVAL); - SimBattler bulbasaur = rival.party[3]; - SimBattler own = *own_battler(m56_lead()); - own.hp = 26; - const std::vector all = bulbasaur.moves; - bulbasaur.moves = {SimMove{.move = "vine-whip", .pp = 25}}; - const NextTurnDamage vine_whip = next_turn_damage(rival, bulbasaur, own); - const MoveData& move = *move_data("vine-whip"); - const uint32_t lowest = damage_range(bulbasaur.body, own.body, move, false).min; - const uint32_t highest = damage_range(bulbasaur.body, own.body, move, true).max; - if (vine_whip.move_chances != std::vector{1.0} || vine_whip.percentile(0.01) != lowest || vine_whip.most() != highest){ - return std::format( - "Vine Whip alone: expected it at 100%, {} to {} HP, got {:.2f}, {} to {}.", - lowest, highest, vine_whip.move_chances.empty() ? 0.0 : vine_whip.move_chances[0], vine_whip.percentile(0.01), vine_whip.most() - ); - } - bulbasaur.moves = all; - own.status = SimStatus::SLEEP; - own.sleep = 3; - const NextTurnDamage asleep = next_turn_damage(rival, bulbasaur, own); - if (asleep.move_chances[0] != 0 || std::abs(std::accumulate(asleep.move_chances.begin(), asleep.move_chances.end(), 0.0) - 1) > 1e-9){ - return std::format("Asleep: expected no Sleep Powder and chances adding to 1, got Sleep Powder {:.2f}.", asleep.move_chances[0]); - } - own.status = SimStatus::NONE; - own.seeded = true; - const uint32_t eighth = own.body.stats.hp / 8; - if (next_turn_damage(rival, bulbasaur, own).percentile(0.001) != eighth){ - return std::format("Seeded: expected at least {} HP lost, got {}.", eighth, next_turn_damage(rival, bulbasaur, own).percentile(0.001)); - } - return true; -} - -// m56's Wartortle Lv22 against the rival: with the Potions sized on the -// foe's likely turn, 15 Potions win over half the battles (about 54%; -// 44% when the Potion was sized on the Bulbasaur's biggest hit), more -// than 5 (about 51%). More Potions don't win much more: the battles lost -// are lost seeded, poisoned or asleep, where the Bulbasaur's turn and the -// drain take more than a Potion heals. -UnitTestResult test_rival_potions_raise_chance(){ - const std::optional five = story_win_chance(m56_lead(), SimItems{.potions = 5}, 1.0, RIVAL); - const std::optional fifteen = story_win_chance(m56_lead(), SimItems{.potions = 15}, 1.0, RIVAL); - if (!five || !fifteen || fifteen->chance < 0.50 || fifteen->chance <= five->chance){ - return std::format( - "m56's lead: expected 15 Potions to win over 50% and more than 5, got {:.1f}% and {:.1f}% with 5.", - fifteen ? 100 * fifteen->chance : 0.0, five ? 100 * five->chance : 0.0 - ); - } - return true; -} - -const AutoStoryBattleSimTestCase AUTO_STORY_BATTLE_SIM_CASES[] = { - { - .input = {.name = "Gen 3 stats and damage worked by hand", .body = test_hand_numbers}, - .expectation = {.description = "verifies Gen 3 stat calculation and damage formula numbers"}, - }, - { - .input = {.name = "the Elite Four Farmer's Starmie picks OHKO at Sp. Atk 302", .body = test_elite_four_table}, - .expectation = {.description = "verifies OHKO move selection against Elite Four Starmie"}, - }, - { - .input = {.name = "Misty: Thundershock on Staryu, Bite on Starmie", .body = test_misty_per_foe}, - .expectation = {.description = "validates targeted attack decisions in Misty gym battle"}, - }, - { - .input = {.name = "the trainer AI's healing items", .body = test_ai_heal_rule}, - .expectation = {.description = "simulates opponent trainer AI heal threshold logic"}, - }, - { - .input = {.name = "the rival's next Pokemon when no name is read", .body = test_next_foe}, - .expectation = {.description = "predicts next pokemon in rival roster when unread"}, - }, - { - .input = {.name = "battle risks by trainer", .body = test_risks}, - .expectation = {.description = "evaluates risk classification across various trainers"}, - }, - { - .input = {.name = "Cerulean rival: refused at Lv22, fought at Lv30", .body = test_rival_win_chance}, - .expectation = {.description = "simulates battle level threshold for Cerulean rival"}, - }, - { - .input = {.name = "m51: no Potion the foe's hit undoes", .body = test_m51_potion_loop}, - .expectation = {.description = "avoids endless potion loop when damage outpaces heal"}, - }, - { - .input = {.name = "the sim's Potion: only when it outheals the hit", .body = test_sim_potion_rule}, - .expectation = {.description = "verifies potion usage condition based on damage expectations"}, - }, - { - .input = {.name = "Misty: m51's lead as the summary showed it", .body = test_misty_real_lead}, - .expectation = {.description = "simulates Misty battle using lead Pokemon status"}, - }, - { - .input = {.name = "Cerulean rival with m51's moves at Lv20 to 30", .body = test_rival_real_moves}, - .expectation = {.description = "simulates Cerulean rival across level ranges"}, - }, - { - .input = {.name = "m56: asleep at red HP, a Potion undone; an Awakening first", .body = test_rival_sleep_turns}, - .expectation = {.description = "prioritizes awakening item over potion when sleep state persists"}, - }, - { - .input = {.name = "the sim's Awakening: asleep, outlasting the next hit", .body = test_sim_awakening_rule}, - .expectation = {.description = "validates simulated awakening rule under incoming damage threshold"}, - }, - { - .input = {.name = "Cerulean rival: Awakenings raise the win chance", .body = test_rival_awakenings}, - .expectation = {.description = "verifies win rate improvement against Cerulean rival with awakenings"}, - }, - { - .input = {.name = "Cerulean rival: a Potion against the Bulbasaur", .body = test_rival_bulbasaur_potion}, - .expectation = {.description = "simulates potion usage against rival Bulbasaur moveset"}, - }, - { - .input = {.name = "Cerulean rival: 15 Potions win over half", .body = test_rival_potions_raise_chance}, - .expectation = {.description = "verifies >=50% win probability against rival with 15 potions"}, - }, - { - .input = {.name = "the foe's next-turn damage and move chances", .body = test_next_turn_damage}, - .expectation = {.description = "evaluates enemy next-turn damage distribution and move selection weights"}, - }, -}; - -} // namespace - - -void add_tests_AutoStoryBattleSim(UnitTestDatabase& database){ - for (const auto& scenario : AUTO_STORY_BATTLE_SIM_CASES){ - database.add(scenario); - } - -} - - - -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation +/* AutoStory Battle Sim Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The Gen 3 damage calculator against numbers worked by hand and the + * Elite Four Farmer's Starmie table, the move chosen against the foe out, + * the trainer AI's items and next Pokemon, and the win chance before the + * Cerulean rival. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryBattle.h" +#include "PokemonFRLG_AutoStoryBattleDecisions.h" +#include "PokemonFRLG_AutoStoryBattleSim.h" +#include "PokemonFRLG_AutoStoryFoeTracker.h" +#include "PokemonFRLG_AutoStoryWinChance.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct AutoStoryBattleSimInput{ + std::string name; + UnitTestResult (*body)(); +}; + +struct AutoStoryBattleSimExpectation{ + const char* description; +}; + +struct AutoStoryBattleSimTestCase{ + AutoStoryBattleSimInput input; + AutoStoryBattleSimExpectation expectation; +}; + +class Test_AutoStoryBattleSim : public UnitTest{ +public: + explicit Test_AutoStoryBattleSim(AutoStoryBattleSimTestCase scenario) + : UnitTest("PokemonFRLG::AutoStoryBattleSim - " + scenario.input.name) + , m_scenario(std::move(scenario)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_scenario.input.body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + AutoStoryBattleSimTestCase m_scenario; +}; + +constexpr std::string_view RIVAL = "TRAINER_RIVAL_CERULEAN_BULBASAUR"; +constexpr std::string_view MISTY = "TRAINER_LEADER_MISTY"; +constexpr std::string_view SS_ANNE_RIVAL = "TRAINER_RIVAL_SS_ANNE_BULBASAUR"; + +std::string stats_text(const Stats& s){ + return std::format("{}/{}/{}/{}/{}/{}", s.hp, s.attack, s.defense, s.sp_attack, s.sp_defense, s.speed); +} + +std::string range_text(const DamageRange& r){ + return std::format("{}-{}", r.min, r.max); +} + +// The story's Wartortle at Lv22, OWN_GENES. +Combatant wartortle22(){ + return *combatant("wartortle", 22, OWN_GENES); +} + +// The rival's Bulbasaur: Lv18, pret iv 100 (IVs 12), Hardy. +Combatant rival_bulbasaur(){ + return *combatant("bulbasaur", 18, Genes{.iv = trainer_iv(100), .ev = 0, .nature = 0}); +} + + +// Worked by hand from CalculateMonStats and CalculateBaseDamage: +// Wartortle Lv22 IV15: HP (2*59+15)*22/100+32 = 61, Attack 141*22/100+5 = 36, +// ... Bite on the Bulbasaur (Sp. Def 142*18/100+5 = 30): 36*60*10/30/50+2 +// = 16, no STAB, neutral: 13 to 16; a crit doubles the 16 before the +// roll: 27 to 32. Water Gun: 36*40*10/30/50+2 = 11, STAB 16, Grass 0.5x +// 8: 6 to 8. Its Vine Whip back: 30*35*9/43/50+2 = 6, STAB 9, Water 2x +// 18: 15 to 18. +UnitTestResult test_hand_numbers(){ + const Combatant own = wartortle22(); + const Combatant foe = rival_bulbasaur(); + if (stats_text(own.stats) != "61/36/43/36/43/33" || stats_text(foe.stats) != "46/24/24/30/30/23"){ + return "Stats: expected 61/36/43/36/43/33 and 46/24/24/30/30/23, got " + stats_text(own.stats) + " and " + stats_text(foe.stats) + "."; + } + const std::vector> cases = { + {"Bite", damage_range(own, foe, *move_data("bite"), false), "13-16"}, + {"Bite, crit", damage_range(own, foe, *move_data("bite"), true), "27-32"}, + {"Water Gun", damage_range(own, foe, *move_data("water-gun"), false), "6-8"}, + {"Vine Whip", damage_range(foe, own, *move_data("vine-whip"), false), "15-18"}, + {"Dragon Rage", damage_range(own, foe, *move_data("dragon-rage"), false), "40-40"}, + }; + for (const auto& [name, range, expected] : cases){ + if (range_text(range) != expected){ + return std::format("{}: expected {}, got {}.", name, expected, range_text(range)); + } + } + // Tail Whip's -1: Defense 24 * 10 / 15 = 16; Tackle 36*35*10/16/50+2 = 17. + Combatant whipped = foe; + whipped.stages[static_cast(Stage::DEFENSE)] = -1; + const DamageRange tackle = damage_range(own, whipped, *move_data("tackle"), false); + if (range_text(tackle) != "14-17"){ + return "Tackle after Tail Whip: expected 14-17, got " + range_text(tackle) + "."; + } + return true; +} + + +// The Elite Four Farmer's STARMIE_PLAN (PokemonFRLG_EliteFourFarmer.cpp): +// each pick "one-hit KOs each rematch opponent (min damage roll, no crit)" +// at Sp. Atk 302, Lv100. Every rematch party's Pokemon of that species. +const std::map& starmie_plan(){ + static const std::map PLAN = { + {"dewgong", "thunderbolt"}, {"cloyster", "thunderbolt"}, {"piloswine", "surf"}, + {"jynx", "surf"}, {"lapras", "thunderbolt"}, {"steelix", "surf"}, + {"hitmonchan", "psychic"}, {"hitmonlee", "psychic"}, {"machamp", "psychic"}, + {"gengar", "psychic"}, {"crobat", "thunderbolt"}, {"misdreavus", "surf"}, + {"arbok", "psychic"}, {"gyarados", "thunderbolt"}, {"dragonite", "ice-beam"}, + {"kingdra", "psychic"}, {"aerodactyl", "surf"}, {"heracross", "psychic"}, + {"alakazam", "surf"}, {"tyranitar", "surf"}, {"arcanine", "surf"}, + {"exeggutor", "ice-beam"}, {"blastoise", "thunderbolt"}, {"venusaur", "psychic"}, + {"charizard", "thunderbolt"}, + }; + return PLAN; +} + +Combatant starmie(uint16_t sp_attack){ + Combatant body = *combatant("starmie", 100, OWN_GENES); + body.stats.sp_attack = sp_attack; + body.volcano_badge = true; + return body; +} + +// The species whose pick misses the OHKO at `sp_attack`; "" for none. +std::string first_miss(uint16_t sp_attack, std::string_view only = ""){ + for (std::string_view trainer : { + "TRAINER_ELITE_FOUR_LORELEI_2", "TRAINER_ELITE_FOUR_BRUNO_2", "TRAINER_ELITE_FOUR_AGATHA_2", "TRAINER_ELITE_FOUR_LANCE_2", + "TRAINER_CHAMPION_REMATCH_SQUIRTLE", "TRAINER_CHAMPION_REMATCH_BULBASAUR", "TRAINER_CHAMPION_REMATCH_CHARMANDER", + }){ + for (const TrainerMon& mon : trainer_mons(trainer)){ + const auto pick = starmie_plan().find(mon.species); + if (pick == starmie_plan().end() || (!only.empty() && mon.species != only)){ + continue; + } + const Combatant foe = *combatant(mon.species, mon.level, Genes{.iv = trainer_iv(mon.iv), .ev = 0, .nature = mon.nature}); + const DamageRange range = damage_range(starmie(sp_attack), foe, *move_data(pick->second), false); + if (range.min < foe.stats.hp){ + return std::format("{} {} Lv{} ({} HP, {} {})", trainer, mon.species, mon.level, foe.stats.hp, pick->second, range_text(range)); + } + } + } + return ""; +} + +UnitTestResult test_elite_four_table(){ + const std::string miss = first_miss(302); + if (!miss.empty()){ + return "At Sp. Atk 302, no OHKO at the lowest roll: " + miss + "."; + } + if (first_miss(301, "kingdra").empty()){ + return "Expected Kingdra to set the 302 Sp. Atk requirement: Psychic OHKOs it at 301 too."; + } + return true; +} + + +// Misty's Staryu (Water) and Starmie (Water/Psychic): a lead with +// Thundershock and Bite thunders the Staryu (2x against 1x) and bites the +// Starmie (both 2x, Bite stronger). As the battle loop asks, with the +// foe out known; summing over the party picked one move for both. +UnitTestResult test_misty_per_foe(){ + const PartyMember lead = member_from_summary("wartortle", 22, { + {.move = "tackle", .pp = 35, .max_pp = 35}, {.move = "bite", .pp = 25, .max_pp = 25}, + {.move = "thunder-shock", .pp = 30, .max_pp = 30}, {.move = "withdraw", .pp = 40, .max_pp = 40}, + }); + for (const BattleRisk risk : {BattleRisk::LOW, BattleRisk::HIGH}){ + for (const auto& [out, expected] : {std::pair{0, 2}, {1, 1}}){ + Supplies supplies; + supplies.lead = lead; + supplies.potions = 0; + supplies.trainer = std::string(MISTY); + supplies.foes = trainer_party(MISTY); + supplies.risk = risk; + supplies.foe_out = out; + BattleDecisions decisions(supplies, BattleKind::TRAINER); + const PartyStatus green{.hp_seen = true, .hp_yellow = false, .hp_red = false, .hp_fill = 1, .status = StatusCondition::NONE}; + const size_t slot = decisions.turn(green).slot; + if (slot != expected){ + return std::format("Risk {}, foe {}: expected slot {}, got {}.", battle_risk_name(risk), out, expected, slot); + } + } + } + return true; +} + + +// ShouldUseItem for Misty's Super Potion on her Starmie (56 HP): below +// 1/4 (14) or more than 50 missing; a Full Restore only below 1/4. A +// second item waits until at most itemsNo - 1 + 1 Pokemon are left. +UnitTestResult test_ai_heal_rule(){ + SimBattler starmie = *trainer_battler(trainer_mons(MISTY)[1]); + if (starmie.body.stats.hp != 56){ + return std::format("Expected Misty's Starmie at 56 HP, got {}.", starmie.body.stats.hp); + } + const std::vector super = {"super-potion"}; + const std::vector, size_t, bool>> cases = { + {56, super, 1, false}, {20, super, 1, false}, {13, super, 1, true}, {5, super, 1, true}, + {20, {"full-restore"}, 1, false}, {13, {"full-restore"}, 1, true}, + {13, {"", "hyper-potion"}, 3, false}, {13, {"", "hyper-potion"}, 2, true}, + }; + for (const auto& [hp, items, left, uses] : cases){ + starmie.hp = hp; + if (ai_item_choice(starmie, items, left).has_value() != uses){ + return std::format("At {} HP with {} items, {} left: expected {}.", hp, items.size(), left, uses ? "an item" : "none"); + } + } + // Each item once: the heal it would use is gone after. + starmie.hp = 6; + std::vector once = super; + once[*ai_item_choice(starmie, once, 1)].clear(); + if (ai_item_choice(starmie, once, 1)){ + return "Expected the Super Potion used only once."; + } + return true; +} + + +// GetMostSuitableMonToSwitchInto against a Wartortle after the rival's +// Pidgeotto: Water hits Abra and Rattata 1x and Bulbasaur 0.25x, but +// only Bulbasaur has a move super effective on Water (Vine Whip): it +// comes next, not Abra. The tracker, with no name read, follows that. +UnitTestResult test_next_foe(){ + const PartyMember lead = grown_to(new_member("squirtle", 5), 22); + TrainerFoeTracker tracker(RIVAL, lead); + if (tracker.out() != 0){ + return "Expected the rival's Pidgeotto out first."; + } + for (int i = 0; i < 3; i++){ + tracker.boxes_seen(BattleBoxes{.lead = true, .foe = false}); + } + tracker.at_menu(""); + if (tracker.out() != 3){ + return std::format("Expected Bulbasaur (slot 3) after Pidgeotto, got {}.", tracker.out() ? std::to_string(*tracker.out()) : "none"); + } + tracker.at_menu("abra"); + if (tracker.out() != 1){ + return "Expected the name read (Abra) over the AI's choice."; + } + return true; +} + + +// The default risks: the rival and gym leaders HIGH, route trainers and +// grunts LOW. +UnitTestResult test_risks(){ + const std::vector> cases = { + {"CeruleanCity", "rival_cerulean", BattleRisk::HIGH}, + {"CeruleanCity_Gym", "misty", BattleRisk::HIGH}, + {"PewterCity_Gym", "brock", BattleRisk::HIGH}, + {"Route24", "lass_ali", BattleRisk::LOW}, + {"MtMoon_B2F", "rocket_grunt", BattleRisk::LOW}, + {"Route1", "nobody", BattleRisk::LOW}, + }; + for (const auto& [map, battle, risk] : cases){ + if (story_battle_risk(map, battle) != risk){ + return std::format("{} on {}: expected {}.", battle, map, battle_risk_name(risk)); + } + } + return true; +} + + +// The Cerulean rival (Pidgeotto, Abra, Rattata, then a Bulbasaur with +// Sleep Powder, Poison Powder and Leech Seed): the Lv22 Wartortle with 5 +// Potions that 00.15's GRIND plans for wins about a quarter of the sim's +// battles, and is refused with the reason; at Lv30 it wins over 90%. +UnitTestResult test_rival_win_chance(){ + const std::optional planned = story_win_chance(grown_to(new_member("squirtle", 5), 22), SimItems{.potions = 5}, 1.0, RIVAL); + const std::optional refusal = planned ? win_chance_refusal(*planned) : std::nullopt; + if (!planned || planned->chance < 0.15 || planned->chance > 0.40){ + return std::format("Lv22, 5 Potions: expected 15% to 40%, got {:.1f}%.", planned ? 100 * planned->chance : 0.0); + } + if (refusal != std::format("win chance {:.0f}% < 50% with {}: grind first", std::floor(100 * planned->chance), planned->inputs)){ + return "Lv22, 5 Potions: expected a refusal with the chance, got \"" + refusal.value_or("") + "\"."; + } + const std::optional ground = story_win_chance(grown_to(new_member("squirtle", 5), 30), SimItems{.potions = 5}, 1.0, RIVAL); + if (!ground || ground->chance < 0.90 || win_chance_refusal(*ground)){ + return std::format("Lv30, 5 Potions: expected over 90% and no refusal, got {:.1f}%.", ground ? 100 * ground->chance : 0.0); + } + return true; +} + + +// Console B, 2026-11-03 (m56): the Cerulean rival at a sim chance of +// 56% was won 2 of 3 in verify mode, so a battle that close is fought, +// with verify mode as the backstop; one under half is still refused. +UnitTestResult test_threshold_m56(){ + const WinEstimate m56{.battles = 1000, .wins = 560, .chance = 0.56, .inputs = "m56"}; + if (const std::optional refusal = win_chance_refusal(m56)){ + return "56%: expected the battle fought, got \"" + *refusal + "\"."; + } + const WinEstimate close{.battles = 1000, .wins = 490, .chance = 0.49, .inputs = "close"}; + if (win_chance_refusal(close) != "win chance 49% < 50% with close: grind first"){ + return "49%: expected a refusal naming 50%, got \"" + win_chance_refusal(close).value_or("") + "\"."; + } + return true; +} + + +// Console B, 2026-11-03 (run m51), Misty: the Wartortle's moves, Bite, +// Tail Whip, Bubble, Withdraw (Water Gun was lost earlier). +std::vector m51_moves(){ + return { + {.move = "bite", .pp = 25, .max_pp = 25}, {.move = "tail-whip", .pp = 30, .max_pp = 30}, + {.move = "bubble", .pp = 30, .max_pp = 30}, {.move = "withdraw", .pp = 40, .max_pp = 40}, + }; +} + +// m51's Wartortle as its summary would read: `level`, 60 HP at Lv20, +// the other stats at OWN_GENES. +PartyMember m51_lead(uint8_t level){ + Stats stats = combatant("wartortle", 20, OWN_GENES)->stats; + stats.hp = 60; + PartyMember lead = member_from_summary("wartortle", 20, m51_moves(), stats); + lead.moves_through_level = level; + return lead; +} + +// m51's Wartortle Lv20 at `hp` of 60. +SimBattler m51_wartortle(uint16_t hp){ + SimBattler own = *own_battler(m51_lead(20)); + own.hp = hp; + return own; +} + +// m51, turns 8 to 13: at 8/60 HP against Misty's Starmie, which hit +// about 20 a turn, the HIGH tier's Potion (+20) was undone by each hit, +// five times over; then Bite once with no Potions left, and it fainted. +// The loop replayed at either tier: once a Potion is seen undone by the +// next hit, no more, but Bite, which also flinches. +UnitTestResult test_m51_potion_loop(){ + for (const BattleRisk risk : {BattleRisk::LOW, BattleRisk::HIGH}){ + Supplies supplies; + supplies.lead = m51_lead(20); + supplies.potions = 5; + supplies.trainer = std::string(MISTY); + supplies.foes = trainer_party(MISTY); + supplies.risk = risk; + supplies.foe_out = 1; + BattleDecisions decisions(supplies, BattleKind::TRAINER); + const PartyStatus red{.hp_seen = true, .hp_yellow = false, .hp_red = true, .hp_fill = 8.0 / 60, .status = StatusCondition::NONE}; + int potions = 0; + for (int turn = 8; turn <= 13; turn++){ + const TurnAction action = decisions.turn(red); + if (action.choice == TurnChoice::POTION){ + potions++; + }else if (action.choice != TurnChoice::MOVE || action.slot != 0){ + return std::format("Risk {}, turn {}: expected a Potion or Bite (slot 0), got slot {}.", battle_risk_name(risk), turn, action.slot); + } + decisions.turn_done(action.choice, TurnResult::DONE); + } + if (potions > 1){ + return std::format("Risk {}: {} Potions at 8/60 HP over turns 8 to 13, each undone by the next hit; expected at most 1.", battle_risk_name(risk), potions); + } + } + return true; +} + +// The sim's own choice by the same rule: at 8/60 a Potion against a foe +// whose hit undoes it, no Potion; against one whose hit could faint the +// lead but heals less than 20, a Potion. +UnitTestResult test_sim_potion_rule(){ + const SimTrainer misty = *sim_trainer(MISTY); + SimBattler starmie = misty.party[1]; + const SimBattler own = m51_wartortle(8); + // Starmie as m51 saw it: at least the Potion's 20 a hit. + starmie.body.stats.attack = static_cast(starmie.body.stats.attack * 2); + starmie.body.stats.sp_attack = static_cast(starmie.body.stats.sp_attack * 2); + const uint32_t likely = potion_case(own, starmie, misty).likely_hit; + if (likely < POTION_HP){ + return std::format("Expected Starmie's next turn at 20 or more {:.0f}% of the time, got {}.", 100 * POTION_COVER_PERCENTILE, likely); + } + const OwnTurn sim = own_turn(own, SimItems{.potions = 5}, starmie, misty, 1); + if (sim.potion || sim.slot != size_t(0)){ + return std::format( + "Sim at 8/60 against Starmie (max hit {}): expected Bite (slot 0), got {}.", + foe_max_hit(starmie, own), sim.potion ? "a Potion" : "slot " + std::to_string(sim.slot.value_or(9)) + ); + } + const SimBattler& staryu = misty.party[0]; + const SimBattler low = m51_wartortle(6); + const uint32_t hit = foe_max_hit(staryu, low); + if (hit < 6 || hit >= POTION_HP){ + return std::format("Expected Staryu's best hit on the Wartortle from 6 to 19, got {}.", hit); + } + if (!own_turn(low, SimItems{.potions = 5}, staryu, misty, 2).potion){ + return std::format("At 6/60 against Staryu (max hit {}): expected a Potion.", hit); + } + return true; +} + +// The sim plays the lead the summary showed, not the one the story +// counts: m51's Wartortle (Lv20, 60 HP, Bite, Tail Whip, Bubble, +// Withdraw) with 2 Potions wins about 98% against Misty; the story's own +// Wartortle Lv20 (Tackle, Bite, Water Gun, Withdraw; 56 HP at OWN_GENES) +// about 95%, from other inputs (both near 100% with 5). m51 lost: the +// sim leaves out Water Pulse's confusion, among others +// (PokemonFRLG_AutoStoryWinChance.h). +UnitTestResult test_misty_real_lead(){ + const std::optional real = story_win_chance(m51_lead(20), SimItems{.potions = 2}, 1.0, MISTY); + const std::optional story = story_win_chance(grown_to(new_member("squirtle", 5), 20), SimItems{.potions = 2}, 1.0, MISTY); + if (!real || !story){ + return "Expected win chances for both leads."; + } + const std::string expected = "wartortle Lv20 60/60 HP: bite 25/25, tail-whip 30/30, bubble 30/30, withdraw 40/40; 2 Potions"; + if (real->inputs != expected){ + return "m51's lead: expected the inputs \"" + expected + "\", got \"" + real->inputs + "\"."; + } + if (real->chance < 0.90 || story->chance < 0.85 || real->chance == story->chance || story->inputs == real->inputs){ + return std::format( + "Expected m51's lead ({:.1f}%) and the story's ({:.1f}%, {}) to differ, both over 85%.", + 100 * real->chance, 100 * story->chance, story->inputs + ); + } + return true; +} + +// The Cerulean rival against m51's moves (no Water Gun), the levels +// since the summary added to its stats: refused at Lv20 and 22 (about +// 30%, 43%), fought at Lv25 and 30 (about 75%, 96%). The refusal names +// the chance and the lead it was played with. +UnitTestResult test_rival_real_moves(){ + const std::vector> cases = { + {20, 0.20, 0.45}, {22, 0.25, 0.50}, {25, 0.50, 0.85}, {30, 0.90, 1.00}, + }; + for (const auto& [level, low, high] : cases){ + const std::optional estimate = story_win_chance(m51_lead(level), SimItems{.potions = 5}, 1.0, RIVAL); + if (!estimate || estimate->chance < low || estimate->chance >= high){ + return std::format("Lv{}: expected {:.0f}% to {:.0f}%, got {:.1f}%.", level, 100 * low, 100 * high, estimate ? 100 * estimate->chance : 0.0); + } + const bool refused = win_chance_refusal(*estimate).has_value(); + if (refused != (level < 25)){ + return std::format("Lv{} at {:.1f}%: expected {}.", level, 100 * estimate->chance, level < 25 ? "a refusal" : "no refusal"); + } + } + const std::optional lv22 = story_win_chance(m51_lead(22), SimItems{.potions = 5}, 1.0, RIVAL); + const std::string refusal = win_chance_refusal(*lv22).value_or(""); + if (refusal.find("wartortle Lv22 65/65 HP: bite") == std::string::npos || refusal.find("; 5 Potions") == std::string::npos){ + return "Lv22: expected the refusal to name the lead's level, HP, moves and Potions, got \"" + refusal + "\"."; + } + return true; +} + + +// Console B, 2026-11-03 (m56, 00.15b, run 2): the Wartortle Lv22, 65 HP, +// Bite, Water Pulse, Bubble and Withdraw. +PartyMember m56_lead(){ + Stats stats = combatant("wartortle", 22, OWN_GENES)->stats; + stats.hp = 65; + std::vector moves; + for (const char* move : {"bite", "water-pulse", "bubble", "withdraw"}){ + moves.push_back(SummaryMove{.move = move, .pp = max_pp(move), .max_pp = max_pp(move)}); + } + return member_from_summary("wartortle", 22, moves, stats); +} + +// m56's supplies against the rival's Bulbasaur (slot 3), at HIGH risk. +Supplies m56_supplies(int awakenings){ + Supplies supplies; + supplies.lead = m56_lead(); + supplies.potions = 15; + supplies.parlyz_heals = 1; + supplies.awakenings = awakenings; + supplies.trainer = std::string(RIVAL); + supplies.foes = trainer_party(RIVAL); + supplies.risk = BattleRisk::HIGH; + supplies.foe_out = 3; + return supplies; +} + +// m56's bar at `hp` of 65, with `status`. +PartyStatus m56_bar(int hp, StatusCondition status){ + const double fill = hp / 65.0; + return PartyStatus{.hp_seen = true, .hp_yellow = fill <= 0.5 && fill > 0.2, .hp_red = fill <= 0.2, .hp_fill = fill, .status = status}; +} + +// m56 run 2 against the rival's Bulbasaur (Sleep Powder, Leech Seed, +// Vine Whip): 49, 42, then 34/65 asleep, then 13/65 asleep, Bite every +// turn and no Potion; it fainted. The 21 HP of turn 6 were Vine Whip and +// Leech Seed, more than a Potion's 20, and an item's turn doesn't count +// sleep down (pret battle_util.c AtkCanceller_UnableToUseMove), so a +// Potion at 13 HP is undone each turn while the lead stays asleep: the +// move is right there, and the log says why. With an Awakening, it is +// used on turn 6, while the lead outlasts the hit its turn costs. +UnitTestResult test_rival_sleep_turns(){ + const std::vector turns = { + m56_bar(49, StatusCondition::NONE), m56_bar(42, StatusCondition::NONE), + m56_bar(34, StatusCondition::SLEEP), m56_bar(13, StatusCondition::SLEEP), + }; + Supplies played = m56_supplies(0); + BattleDecisions as_played(played, BattleKind::TRAINER); + TurnAction action; + for (const PartyStatus& status : turns){ + action = as_played.turn(status); + as_played.turn_done(action.choice, TurnResult::DONE); + } + if (action.choice != TurnChoice::MOVE || action.heal_reason.find("undoes it") == std::string::npos){ + return std::format( + "No Awakening, turn 7 at 13/65 asleep: expected a move and a reason naming the Potion undone, got choice {} and \"{}\".", + static_cast(action.choice), action.heal_reason + ); + } + Supplies supplied = m56_supplies(2); + BattleDecisions with_awakenings(supplied, BattleKind::TRAINER); + for (size_t turn = 0; turn < 3; turn++){ + action = with_awakenings.turn(turns[turn]); + with_awakenings.turn_done(action.choice, TurnResult::DONE); + } + if (action.choice != TurnChoice::AWAKENING || supplied.awakenings != 1 || action.heal_reason.empty()){ + return std::format( + "2 Awakenings, turn 6 at 34/65 asleep: expected an Awakening, with a reason, got choice {}, {} left, \"{}\".", + static_cast(action.choice), supplied.awakenings, action.heal_reason + ); + } + return true; +} + +// The sim's lead asleep, as the battle loop decides: an Awakening while it +// outlasts the foe's next hit; below that, not (its turn would only let +// the foe faint it); none against a foe that can't hurt it. +UnitTestResult test_sim_awakening_rule(){ + const SimTrainer rival = *sim_trainer(RIVAL); + const SimBattler& bulbasaur = rival.party[3]; + SimBattler own = *own_battler(m56_lead()); + own.status = SimStatus::SLEEP; + own.sleep = 4; + own.hp = 34; + if (!own_turn(own, SimItems{.potions = 15, .awakenings = 2}, bulbasaur, rival, 1).awakening){ + return "Asleep at 34/65 against the Bulbasaur with 2 Awakenings: expected one."; + } + if (own_turn(own, SimItems{.potions = 15, .awakenings = 0}, bulbasaur, rival, 1).awakening){ + return "Asleep with no Awakenings: expected none used."; + } + own.hp = 8; + if (own_turn(own, SimItems{.potions = 0, .awakenings = 2}, bulbasaur, rival, 1).awakening){ + return "Asleep at 8/65, below the Bulbasaur's hit: expected no Awakening."; + } + own.hp = 34; + const SimBattler& abra = rival.party[1]; + if (own_turn(own, SimItems{.potions = 15, .awakenings = 2}, abra, rival, 4).awakening){ + return "Asleep against Abra (Teleport only): expected no Awakening."; + } + return true; +} + +// m56's Wartortle Lv22 with 15 Potions against the rival: Awakenings +// against the Bulbasaur's Sleep Powder win more battles (about 54% with +// none, 59% with 2), and the inputs name them. +UnitTestResult test_rival_awakenings(){ + const std::optional none = story_win_chance(m56_lead(), SimItems{.potions = 15}, 1.0, RIVAL); + const std::optional three = story_win_chance(m56_lead(), SimItems{.potions = 15, .awakenings = 3}, 1.0, RIVAL); + if (!none || !three || three->chance < none->chance + 0.05){ + return std::format( + "Lv22, 15 Potions: expected 3 Awakenings to win at least 5 points more than none, got {:.1f}% and {:.1f}%.", + none ? 100 * none->chance : 0.0, three ? 100 * three->chance : 0.0 + ); + } + if (three->inputs.find("; 15 Potions, 3 Awakenings") == std::string::npos){ + return "Expected the inputs to name the Awakenings, got \"" + three->inputs + "\"."; + } + return true; +} + +// m56's Wartortle awake at 26/65 against the rival's Bulbasaur at full +// HP: the AI spends most turns on Sleep Powder and Leech Seed, so a +// Vine Whip that undoes a Potion is the rare turn (m56 run 2: about 8 HP +// a turn for three turns). With 15 Potions, a Potion. +UnitTestResult test_rival_bulbasaur_potion(){ + const SimTrainer rival = *sim_trainer(RIVAL); + const SimBattler& bulbasaur = rival.party[3]; + SimBattler own = *own_battler(m56_lead()); + own.hp = 26; + if (!own_turn(own, SimItems{.potions = 15}, bulbasaur, rival, 1).potion){ + return std::format("Awake at 26/65 against the Bulbasaur with 15 Potions: expected a Potion (max hit {}).", foe_max_hit(bulbasaur, own)); + } + return true; +} + +// The foe's next turn: a Bulbasaur with Vine Whip alone uses it every +// turn, from its lowest roll to its highest crit, never missing; with all +// four moves, no Sleep Powder on an asleep lead, and a seeded lead loses +// its eighth whatever the foe does. +UnitTestResult test_next_turn_damage(){ + const SimTrainer rival = *sim_trainer(RIVAL); + SimBattler bulbasaur = rival.party[3]; + SimBattler own = *own_battler(m56_lead()); + own.hp = 26; + const std::vector all = bulbasaur.moves; + bulbasaur.moves = {SimMove{.move = "vine-whip", .pp = 25}}; + const NextTurnDamage vine_whip = next_turn_damage(rival, bulbasaur, own); + const MoveData& move = *move_data("vine-whip"); + const uint32_t lowest = damage_range(bulbasaur.body, own.body, move, false).min; + const uint32_t highest = damage_range(bulbasaur.body, own.body, move, true).max; + if (vine_whip.move_chances != std::vector{1.0} || vine_whip.percentile(0.01) != lowest || vine_whip.most() != highest){ + return std::format( + "Vine Whip alone: expected it at 100%, {} to {} HP, got {:.2f}, {} to {}.", + lowest, highest, vine_whip.move_chances.empty() ? 0.0 : vine_whip.move_chances[0], vine_whip.percentile(0.01), vine_whip.most() + ); + } + bulbasaur.moves = all; + own.status = SimStatus::SLEEP; + own.sleep = 3; + const NextTurnDamage asleep = next_turn_damage(rival, bulbasaur, own); + if (asleep.move_chances[0] != 0 || std::abs(std::accumulate(asleep.move_chances.begin(), asleep.move_chances.end(), 0.0) - 1) > 1e-9){ + return std::format("Asleep: expected no Sleep Powder and chances adding to 1, got Sleep Powder {:.2f}.", asleep.move_chances[0]); + } + own.status = SimStatus::NONE; + own.seeded = true; + const uint32_t eighth = own.body.stats.hp / 8; + if (next_turn_damage(rival, bulbasaur, own).percentile(0.001) != eighth){ + return std::format("Seeded: expected at least {} HP lost, got {}.", eighth, next_turn_damage(rival, bulbasaur, own).percentile(0.001)); + } + return true; +} + +// m56's Wartortle Lv22 against the rival: with the Potions sized on the +// foe's likely turn, 15 Potions win over half the battles (about 54%; +// 44% when the Potion was sized on the Bulbasaur's biggest hit), more +// than 5 (about 51%). More Potions don't win much more: the battles lost +// are lost seeded, poisoned or asleep, where the Bulbasaur's turn and the +// drain take more than a Potion heals. +UnitTestResult test_rival_potions_raise_chance(){ + const std::optional five = story_win_chance(m56_lead(), SimItems{.potions = 5}, 1.0, RIVAL); + const std::optional fifteen = story_win_chance(m56_lead(), SimItems{.potions = 15}, 1.0, RIVAL); + if (!five || !fifteen || fifteen->chance < 0.50 || fifteen->chance <= five->chance){ + return std::format( + "m56's lead: expected 15 Potions to win over 50% and more than 5, got {:.1f}% and {:.1f}% with 5.", + fifteen ? 100 * fifteen->chance : 0.0, five ? 100 * five->chance : 0.0 + ); + } + return true; +} + + + +// 00.22's lead: Wartortle Lv27 at 78 HP, its other stats at OWN_GENES. +PartyMember ss_anne_lead(){ + Stats stats = combatant("wartortle", 27, OWN_GENES)->stats; + stats.hp = 78; + std::vector moves; + for (const char* move : {"bite", "water-pulse", "bubble", "withdraw"}){ + moves.push_back(SummaryMove{.move = move, .pp = max_pp(move), .max_pp = max_pp(move)}); + } + return member_from_summary("wartortle", 27, moves, stats); +} + +UnitTestResult test_ss_anne_rival(){ + const SimTrainer rival = *sim_trainer(SS_ANNE_RIVAL); + const SimBattler own = *own_battler(ss_anne_lead()); + const WinEstimate base = estimate_win_chance(own, SimItems{.potions = 15}, rival); + const WinEstimate antidote = estimate_win_chance(own, SimItems{.potions = 15, .antidotes = 1}, rival); + SimBattler mega_kick = own; + mega_kick.moves[2] = SimMove{.move = "mega-kick", .pp = 5}; + const WinEstimate kicked = estimate_win_chance(mega_kick, SimItems{.potions = 15}, rival); + if (base.chance < 0.25 || base.chance > 0.45 || antidote.chance < base.chance + 0.03 || kicked.chance < base.chance + 0.15){ + return std::format( + "Lv27, 15 Potions: expected 25-45%, an Antidote 3 points more and Mega Kick 15 more, got {:.1f}%, {:.1f}%, {:.1f}%.", + 100 * base.chance, 100 * antidote.chance, 100 * kicked.chance + ); + } + const auto& by_foe = base.losses.by_foe; + const size_t to_ivysaur = by_foe.contains("ivysaur") ? by_foe.at("ivysaur") : 0; + if (2 * to_ivysaur < base.battles - base.wins || 2 * base.losses.seeded < base.battles - base.wins){ + return "Expected most losses to the Ivysaur and seeded, got \"" + loss_text(base) + "\"."; + } + const std::string text = loss_text(base); + if (!text.starts_with(std::format("lost {}: ivysaur {}", base.battles - base.wins, to_ivysaur)) || text.find("; seeded ") == std::string::npos){ + return "Expected the loss text to lead with the Ivysaur and name the seeded, got \"" + text + "\"."; + } + return true; +} + +// Regression, console B 2026-11-03, m68, the S.S. Anne rival: a YES/NO +// read at "RIVAL GREEN sent out RATICATE!" (turn 5) and "...KADABRA!" +// (turn 7), with no move to learn. The first was taken for "Delete a move +// to make room for PROTECT?" (Lv31): NO, and "Stop learning?" due, which +// the second got: YES. From then on the lead was figured at Lv31: "34/88 +// HP" for 31/78. Neither is a move prompt: NO, the lead as it was. +UnitTestResult test_ss_anne_send_out_prompts(){ + Supplies supplies; + supplies.lead = ss_anne_lead(); + supplies.trainer = std::string(SS_ANNE_RIVAL); + supplies.foes = trainer_party(SS_ANNE_RIVAL); + supplies.risk = BattleRisk::HIGH; + const PartyMember before = supplies.lead; + BattleDecisions decisions(supplies, BattleKind::TRAINER); + const PartyStatus green{.hp_seen = true, .hp_yellow = false, .hp_red = false, .hp_fill = 1.0, .status = StatusCondition::NONE}; + for (int turn = 1; turn <= 7; turn++){ + decisions.turn(green); + if (turn != 5 && turn != 7){ + continue; + } + const PromptAnswer answer = decisions.prompt(false); + if (answer.answer != YesNo::NO || answer.forget_slot){ + return std::format("Turn {}: expected NO to a YES/NO with no move to learn.", turn); + } + } + if (battle_level(supplies.lead) != 27 || supplies.lead.moves_through_level != before.moves_through_level){ + return std::format("Expected the lead still at Lv27, got Lv{}.", battle_level(supplies.lead)); + } + const uint16_t max_hp = own_battler(supplies.lead)->body.stats.hp; + return max_hp == 78 ? UnitTestResult(true) : UnitTestResult(std::format("Expected 78 max HP, as the screen shows, got {}.", max_hp)); +} + +// The battle loop's Potion against a known foe at BattleRisk::HIGH is the +// win-chance sim's (own_turn()), poisoned or not, so the chance the sim +// gives is the one the loop plays for. Before, the loop left out the +// poison's eighth a turn, and used a Potion at red HP the sim wouldn't. +// Whether the loop and the sim agree on a Potion against the rival's +// `slot`, out first, at `percent` of the lead's HP with `status`. +UnitTestResult loop_potion_is_sim(size_t slot, StatusCondition status, int percent){ + const SimTrainer rival = *sim_trainer(SS_ANNE_RIVAL); + Supplies supplies; + supplies.lead = ss_anne_lead(); + supplies.trainer = std::string(SS_ANNE_RIVAL); + supplies.foes = trainer_party(SS_ANNE_RIVAL); + supplies.risk = BattleRisk::HIGH; + supplies.potions = 15; + supplies.foe_out = slot; + const double fill = percent / 100.0; + const PartyStatus read{.hp_seen = true, .hp_yellow = fill <= 0.5 && fill > 0.2, .hp_red = fill <= 0.2, .hp_fill = fill, .status = status}; + BattleDecisions decisions(supplies, BattleKind::TRAINER); + const bool loop = decisions.turn(read).choice == TurnChoice::POTION; + SimBattler own = *own_battler(ss_anne_lead(), fill); + own.status = status == StatusCondition::POISON ? SimStatus::POISON : SimStatus::NONE; + std::vector to_come = rival.party; + to_come.erase(to_come.begin() + static_cast(slot)); + const bool sim = own_turn(own, SimItems{.potions = 15}, rival.party[slot], rival, rival.party.size(), to_come).potion; + if (loop == sim){ + return true; + } + return std::format( + "{} at {}% HP, {}: the loop {} a Potion, the sim {}.", rival.party[slot].species, percent, + status_name(status), loop ? "uses" : "doesn't use", sim ? "does" : "doesn't" + ); +} + +UnitTestResult test_loop_potion_is_sim(){ + for (size_t slot : {size_t(1), size_t(3)}){ + for (StatusCondition status : {StatusCondition::NONE, StatusCondition::POISON}){ + for (int percent = 5; percent < 100; percent += 5){ + const UnitTestResult result = loop_potion_is_sim(slot, status, percent); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + } + } + return true; +} + +// Task #77, the top-up: against the S.S. Anne rival's Kadabra, which the +// lead's next attack faints and whose turn takes little (Teleport, +// Disable), a Potion at 45/78 HP while the Ivysaur is to come; not with +// nothing to come, nor near full HP. +UnitTestResult test_top_up(){ + const SimTrainer rival = *sim_trainer(SS_ANNE_RIVAL); + const SimBattler& kadabra = rival.party[2]; + const std::vector to_come{rival.party[3]}; + SimBattler own = *own_battler(ss_anne_lead()); + own.hp = 45; + const PotionCase c = potion_case(own, kadabra, rival); + if (!own_turn(own, SimItems{.potions = 15}, kadabra, rival, 2, to_come).top_up){ + return std::format("45/78 HP against the Kadabra (mean hit {:.1f}, {} turns to faint it): expected a top-up.", c.expected_hit, c.turns_to_ko); + } + if (own_turn(own, SimItems{.potions = 15}, kadabra, rival, 1).potion){ + return "Expected no top-up with no foe to come."; + } + own.hp = 70; + if (own_turn(own, SimItems{.potions = 15}, kadabra, rival, 2, to_come).potion){ + return "Expected no top-up at 70/78 HP."; + } + return true; +} + +} // namespace + + +const AutoStoryBattleSimTestCase AUTO_STORY_BATTLE_SIM_CASES[] = { + { + .input = {.name = "Gen 3 stats and damage worked by hand", .body = test_hand_numbers}, + .expectation = {.description = "verifies Gen 3 stat calculation and damage formula numbers"}, + }, + { + .input = {.name = "the Elite Four Farmer's Starmie picks OHKO at Sp. Atk 302", .body = test_elite_four_table}, + .expectation = {.description = "verifies OHKO move selection against Elite Four Starmie"}, + }, + { + .input = {.name = "Misty: Thundershock on Staryu, Bite on Starmie", .body = test_misty_per_foe}, + .expectation = {.description = "validates targeted attack decisions in Misty gym battle"}, + }, + { + .input = {.name = "the trainer AI's healing items", .body = test_ai_heal_rule}, + .expectation = {.description = "simulates opponent trainer AI heal threshold logic"}, + }, + { + .input = {.name = "the rival's next Pokemon when no name is read", .body = test_next_foe}, + .expectation = {.description = "predicts next pokemon in rival roster when unread"}, + }, + { + .input = {.name = "battle risks by trainer", .body = test_risks}, + .expectation = {.description = "evaluates risk classification across various trainers"}, + }, + { + .input = {.name = "Cerulean rival: refused at Lv22, fought at Lv30", .body = test_rival_win_chance}, + .expectation = {.description = "simulates battle level threshold for Cerulean rival"}, + }, + { + .input = {.name = "m51: no Potion the foe's hit undoes", .body = test_m51_potion_loop}, + .expectation = {.description = "avoids endless potion loop when damage outpaces heal"}, + }, + { + .input = {.name = "the sim's Potion: only when it outheals the hit", .body = test_sim_potion_rule}, + .expectation = {.description = "verifies potion usage condition based on damage expectations"}, + }, + { + .input = {.name = "Misty: m51's lead as the summary showed it", .body = test_misty_real_lead}, + .expectation = {.description = "simulates Misty battle using lead Pokemon status"}, + }, + { + .input = {.name = "Cerulean rival with m51's moves at Lv20 to 30", .body = test_rival_real_moves}, + .expectation = {.description = "simulates Cerulean rival across level ranges"}, + }, + { + .input = {.name = "m56: asleep at red HP, a Potion undone; an Awakening first", .body = test_rival_sleep_turns}, + .expectation = {.description = "prioritizes awakening item over potion when sleep state persists"}, + }, + { + .input = {.name = "the sim's Awakening: asleep, outlasting the next hit", .body = test_sim_awakening_rule}, + .expectation = {.description = "validates simulated awakening rule under incoming damage threshold"}, + }, + { + .input = {.name = "Cerulean rival: Awakenings raise the win chance", .body = test_rival_awakenings}, + .expectation = {.description = "verifies win rate improvement against Cerulean rival with awakenings"}, + }, + { + .input = {.name = "Cerulean rival: a Potion against the Bulbasaur", .body = test_rival_bulbasaur_potion}, + .expectation = {.description = "simulates potion usage against rival Bulbasaur moveset"}, + }, + { + .input = {.name = "Cerulean rival: 15 Potions win over half", .body = test_rival_potions_raise_chance}, + .expectation = {.description = "verifies >=50% win probability against rival with 15 potions"}, + }, + { + .input = {.name = "the foe's next-turn damage and move chances", .body = test_next_turn_damage}, + .expectation = {.description = "evaluates enemy next-turn damage distribution and move selection weights"}, + }, + { + .input = {.name = "S.S. Anne rival: lost to the Ivysaur, seeded", .body = test_ss_anne_rival}, + .expectation = {.description = "evaluates battle simulation against S.S. Anne rival"}, + }, + { + .input = {.name = "S.S. Anne rival: the send-outs' YES/NO is NO, the lead kept at Lv27", .body = test_ss_anne_send_out_prompts}, + .expectation = {.description = "declines send-out prompt to keep lead battling"}, + }, + { + .input = {.name = "the battle loop's Potion is the sim's, poisoned or not", .body = test_loop_potion_is_sim}, + .expectation = {.description = "verifies in-battle potion decision matches simulation"}, + }, + { + .input = {.name = "a top-up before the knockout, a stronger foe to come", .body = test_top_up}, + .expectation = {.description = "tops up HP before knocking out foe when stronger foe remains"}, + }, +}; + +void add_tests_AutoStoryBattleSim(UnitTestDatabase& database){ + for (const auto& scenario : AUTO_STORY_BATTLE_SIM_CASES){ + database.add(scenario); + } + +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 244f8a2c8c..0dda0baa36 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -25,6 +25,7 @@ #include "PokemonFRLG_AutoStorySummary.h" #include "PokemonFRLG_AutoStoryTable.h" #include "PokemonFRLG_AutoStoryTeachScreens.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" #include "PokemonFRLG_AutoStoryWinChance.h" namespace PokemonAutomation{ @@ -102,7 +103,9 @@ std::optional route_start(const Pokemon::Pose& pose){ return MapPosition{pose.map, tiles[0]}; } -Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step){ +// `maps`: where the route is planned; the story's but where a step knows +// better (the trash cans' walks, past trainers who have walked up). +Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step, const std::vector& maps = story_maps()){ const std::vector tiles = Pokemon::tiles_of(state.pose); const bool known = state.pose.certainty == Pokemon::PoseCertainty::TILE || (state.pose.certainty == Pokemon::PoseCertainty::SET && tiles.size() > 1); @@ -122,7 +125,7 @@ Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step){ return Pokemon::outcome_failed("bad_target", step.target ? Pokemon::to_string(*step.target) : "none"); } const RouteGoal goal{{target->map, target->tile}, target->facing}; - const std::optional> route = plan_story_route(story_maps(), state.pose, facing, goal); + const std::optional> route = plan_story_route(maps, state.pose, facing, goal); if (!route){ return Pokemon::outcome_failed("no_route", Pokemon::to_string(state.pose) + " to " + to_string(goal.position)); } @@ -133,7 +136,7 @@ Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step){ // Planning lets the walker recover a stopped open leg: it plans back to // the leg's end, then walks the rest as given. From a set of tiles // the start isn't known: the walker reads the facing. - const RoutePlanning planning{.maps = &story_maps(), .costs = default_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; + const RoutePlanning planning{.maps = &maps, .costs = story_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; walk_overworld_route_with_recovery(env.console, env.context, *route, planning, facing, interrupts, move_speed(step)); log_arrival(env, step.name, target->map, target->tile); const std::optional end_facing = route->empty() ? facing : std::optional(route->back().facing_after); @@ -157,7 +160,7 @@ Outcome walk_legs(StoryEnv& env, RunState& state, const StoryStep& step){ const OverworldInterruptSet interrupts = story_interrupts(env.supplies, step.fight_wild, env.checks, env.language); // Planning lets the walker recover a stopped open leg: it plans back to // the leg's end, then walks the rest as given. - const RoutePlanning planning{.maps = &story_maps(), .costs = default_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; + const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = start, .label = step.name, .wall_check = wall_check(env)}; walk_overworld_route_with_recovery(env.console, env.context, *route, planning, facing, interrupts, move_speed(step)); log_arrival(env, step.name, route->back().end.map, route->back().end.point); return Pokemon::outcome_ok(to_pose(StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after})); @@ -172,6 +175,10 @@ Outcome take_step(StoryEnv& env, RunState& state, const StoryStep& step){ } env.console.log(std::format("Story: {}: a trainer saw the player after {} tile(s).", step.name, *stepped)); tiles_stepped = *stepped; + }else if (step.variant == TRAINER_WAIT_VARIANT){ + if (!step_into_line(env.console, env.context, step.direction, facing_of(state.pose), 1, TRAINER_WAIT_WATCH)){ + return Pokemon::outcome_failed("no_trainer", std::format("{} s on the tile {}", TRAINER_WAIT_WATCH.count() / 1000, direction_name(step.direction))); + } }else{ step_once(env.context, step.direction, facing_of(state.pose)); } @@ -193,14 +200,14 @@ std::optional refuse_battle(StoryEnv& env, const StoryStep& step, s if (step.may_lose || supplies.risk != BattleRisk::HIGH){ return std::nullopt; } - const SimItems items{.potions = supplies.potions, .awakenings = supplies.awakenings}; + const SimItems items{.potions = supplies.potions, .awakenings = supplies.awakenings, .antidotes = supplies.antidotes}; const std::optional estimate = story_win_chance(supplies.lead, items, supplies.hp_fill, trainer); if (!estimate){ return std::nullopt; } env.console.log(std::format( - "Story: {} against {}: the sim wins {} of {} ({:.0f}%) with {}.", - step.name, trainer, estimate->wins, estimate->battles, 100 * estimate->chance, estimate->inputs + "Story: {} against {}: the sim wins {} of {} ({:.0f}%) with {}; {}.", + step.name, trainer, estimate->wins, estimate->battles, 100 * estimate->chance, estimate->inputs, loss_text(*estimate) )); return win_chance_refusal(*estimate); } @@ -326,8 +333,22 @@ std::optional> viridian_mart_orders(const std::vector& story_maps(){ - static const std::vector maps = overworld_maps(map_flag_set(overworld_maps(), "after_hall_of_fame")); + static const std::vector maps = []{ + const std::vector all = overworld_maps(); + std::set flags = map_flag_set(all, "after_hall_of_fame"); + flags.merge(map_flag_set(all, "new_game")); + return overworld_maps(flags); + }(); return maps; } @@ -360,6 +381,7 @@ std::string save_card_place(const std::string& map){ {"CeruleanCity", "cerulean-city"}, {"Route24", "route-24"}, {"Route25", "route-25"}, + {"VermilionCity", "vermilion-city"}, }; for (const auto& [prefix, place] : PLACES){ if (map == prefix || map.rfind(prefix + "_", 0) == 0){ @@ -471,7 +493,7 @@ std::optional> plan_story_route( if (!holds->empty()){ facing = holds->back().facing_after; } - std::optional> route = plan_overworld_route(maps, from, goal, default_route_costs(), facing); + std::optional> route = plan_overworld_route(maps, from, goal, story_route_costs(), facing); if (route){ route->insert(route->begin(), holds->begin(), holds->end()); } @@ -488,96 +510,6 @@ std::optional wall_beside(const MapData& map, const TilePos& tile){ return std::nullopt; } -namespace{ - -// How the console does an action that needs only the env. -using ConsoleAction = Outcome (*)(StoryEnv& env, RunState& state, const StoryStep& step); - -ConsoleAction console_action_of(StoryActionId id){ - switch (id){ - case StoryActionId::NEW_GAME: - return [](StoryEnv& env, RunState&, const StoryStep&){ - play_new_game_intro(env.console, env.context, env.player_name); - return Pokemon::outcome_ok(); - }; - case StoryActionId::ROUTE: - return [](StoryEnv& env, RunState& state, const StoryStep& step){ return walk_route(env, state, step); }; - case StoryActionId::LEGS: - return walk_legs; - case StoryActionId::STEP: - return take_step; - case StoryActionId::TALK: - return [](StoryEnv& env, RunState&, const StoryStep&){ - pbf_press_button(env.context, BUTTON_A, 160ms, 800ms); - return Pokemon::outcome_ok(); - }; - case StoryActionId::ANSWER: - return [](StoryEnv& env, RunState&, const StoryStep& step){ - answer_yes_no(env.console, env.context, step.yes, BUTTON_B, YES_NO_TIMEOUT); - return Pokemon::outcome_ok(); - }; - case StoryActionId::MASH: - return [](StoryEnv& env, RunState&, const StoryStep&){ - mash_until_overworld(env.console, env.context, SCENE_TIMEOUT); - return Pokemon::outcome_ok(); - }; - case StoryActionId::BATTLE: - return [](StoryEnv& env, RunState&, const StoryStep& step){ return battle(env, step); }; - case StoryActionId::HEAL: - return [](StoryEnv& env, RunState&, const StoryStep&){ - heal_at_center(env.console, env.context); - healed_by_nurse(env.supplies); - return Pokemon::outcome_ok(); - }; - case StoryActionId::SHOP: - return [](StoryEnv& env, RunState&, const StoryStep& step){ return shop(env, step); }; - case StoryActionId::READY: - return get_ready; - case StoryActionId::OPTIONS: - case StoryActionId::GAME: - break; - } - throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::string("No console action for ") + story_action_name(id)); -} - -} // namespace - - -StoryExecutor::StoryExecutor(StoryEnv& env) - : m_env(env) - , m_guard(env.console.logger()) - , m_table(make_story_action_table([this](StoryActionId id){ return console_action(id); })) - , m_executor(m_table, m_guard) - , m_look(env.console, env.context) - , m_interrupts(step_interrupts( - [this](bool fresh){ return m_look.look(fresh); }, - console_interrupt_actions(env.console, env.context, [&env]{ - return story_interrupts(env.supplies, false, env.checks, env.language); - }) - )) -{ - m_executor.set_interrupts(&m_interrupts); -} - -Outcome StoryExecutor::run(RunState& state, const StoryStep& step){ - return m_executor.run(state, step); -} - -Pokemon::StoryAction StoryExecutor::console_action(StoryActionId id){ - if (id == StoryActionId::OPTIONS){ - return {.act = [this](RunState&, const StoryStep&){ - set_options(m_env.console, m_env.context, m_menu, m_env.language); - return Pokemon::outcome_ok(); - }, .needs_console = true}; - } - if (id == StoryActionId::GAME){ - return {.act = [this](RunState& state, const StoryStep& step){ return game(state, step); }, .needs_console = true}; - } - const ConsoleAction action = console_action_of(id); - return {.act = [this, action](RunState& state, const StoryStep& step){ return action(m_env, state, step); }, .needs_console = true}; -} - - namespace{ bool walked(StoryEnv& env, RunState& state, const StoryStep& step){ @@ -654,28 +586,98 @@ std::optional run_abilities(StoryEnv& env, RunState& state, const Story ); ConsoleChoiceActions choice(env.console, env.context); StoryAbilities abilities{console_member_reader(env.console, env.context, env.language), mart, teach, grind, choice}; + if (const std::optional plan = parse_grind_check_variant(step.variant)){ + if (grind_reached_already(env.console.logger(), grind, abilities.members, *plan)){ + state.flags.insert(step.name); + } + return Pokemon::outcome_unchanged(); + } return run_ability_game_step(env.console.logger(), &env.console, step, abilities); } -} // namespace +// CUT_TREE_VARIANT: the tree `step.direction` of the player, cut, and a +// step onto its tile. +Outcome cut_tree(StoryEnv& env, RunState& state, const StoryStep& step){ + const std::optional here = story_pose(state.pose); + if (!here){ + return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); + } + if (here->facing != step.direction){ + // The tree is in the way: a tap only turns the player to it. + face_wall(env.context, step.direction); + } + pbf_press_button(env.context, BUTTON_A, 160ms, 800ms); + answer_yes_no(env.console, env.context, true, BUTTON_B, YES_NO_TIMEOUT); + mash_until_overworld(env.console, env.context, SCENE_TIMEOUT); + step_once(env.context, step.direction, step.direction); + return Pokemon::outcome_ok(to_pose(StoryPose{here->map, here->tile.next(step.direction), step.direction})); +} + +// Vermilion Gym's trash cans on the console: a walk to each can's stand +// (a ROUTE planned on trash_can_maps()), A, and its pages counted. +class ConsoleTrashCans{ +public: + ConsoleTrashCans(StoryEnv& env, RunState& state, const StoryStep& step) + : m_env(env), m_state(state), m_step(step) + {} + + CanPages check(int can){ + const TrashCanStand stand = trash_can_stand(can); + const std::optional here = story_pose(m_state.pose); + const Pokemon::Pose target = to_pose(StoryPose{m_state.pose.map, stand.tile, stand.facing}); + if (!here || !(here->tile == stand.tile) || here->facing != stand.facing){ + StoryStep walk = m_step; + walk.kind = StoryStepKind::ROUTE; + walk.name = m_step.name + "_can" + std::to_string(can); + walk.variant.clear(); + walk.target = target; + static const std::vector MAPS = trash_can_maps(story_maps()); + const Outcome outcome = walk_route(m_env, m_state, walk, MAPS); + if (!outcome.succeeded()){ + return CanPages::FAILED; + } + if (outcome.pose){ + m_state.pose = *outcome.pose; + } + } + const std::optional pages = talk_and_count_pages(m_env.console, m_env.context); + if (!pages){ + return CanPages::FAILED; + } + m_env.console.log(std::format("Story: {}: can {}, {} page(s).", m_step.name, can, *pages)); + return *pages == 1 ? CanPages::ONE : CanPages::TWO; + } -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(); +private: + StoryEnv& m_env; + RunState& m_state; + const StoryStep& m_step; +}; + +// TRASH_CANS_VARIANT: the search for Vermilion Gym's switches. +Outcome open_gym_door(StoryEnv& env, RunState& state, const StoryStep& step){ + if (!story_pose(state.pose)){ + return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); } - if (step.variant == LEAVE_GIFT_SUMMARY){ - leave_gift_summary(m_env.console, m_env.context); - return Pokemon::outcome_ok(); + ConsoleTrashCans cans(env, state, step); + const TrashCanResult result = run_trash_cans(env.console, [&](int can){ return cans.check(can); }); + if (result.end != TrashCanEnd::OPENED){ + return Pokemon::outcome_failed(std::string("trash_cans_") + trash_can_end_name(result.end), std::format("{} checks", result.checks)); } + return Pokemon::outcome_ok(state.pose); +} + +// The GAME steps without a row of their own: a capture or a PC deposit or +// withdrawal, a MART, TEACH, LEVEL, GRIND or fossil CHOICE. +Outcome other_game_step(StoryEnv& env, RunState& state, const StoryStep& step){ const Pokemon::Pose patch = state.pose; - ConsoleCaptureActions capture(m_env.console, m_env.context, m_env.language, [&]{ - pace_trainer_battle(m_env, state, step, patch); + ConsoleCaptureActions capture(env.console, env.context, env.language, [&]{ + pace_trainer_battle(env, state, step, patch); }); - ConsolePcStorageActions pc(m_env.console, m_env.context); - std::optional done = run_storage_game_step(m_env.console.logger(), &m_env.console, step, capture, pc); + ConsolePcStorageActions pc(env.console, env.context); + std::optional done = run_storage_game_step(env.console.logger(), &env.console, step, capture, pc); if (!done){ - done = run_abilities(m_env, state, step); + done = run_abilities(env, state, step); } if (done && done->succeeded() && !done->pose){ // Pacing leaves the player on either of its tiles. @@ -687,6 +689,116 @@ Outcome StoryExecutor::game(RunState& state, const StoryStep& step){ return Pokemon::unsupported_step(step); } +// How the console does an action that needs only the env. +using ConsoleAction = Outcome (*)(StoryEnv& env, RunState& state, const StoryStep& step); + +// The console's GAME actions; nullptr for another id. +ConsoleAction game_console_action(StoryActionId id){ + switch (id){ + case StoryActionId::CLOSE_TRAINER_CARD: + return [](StoryEnv& env, RunState&, const StoryStep&){ + close_trainer_card(env.console, env.context); + return Pokemon::outcome_ok(); + }; + case StoryActionId::LEAVE_GIFT_SUMMARY: + return [](StoryEnv& env, RunState&, const StoryStep&){ + leave_gift_summary(env.console, env.context); + return Pokemon::outcome_ok(); + }; + case StoryActionId::CUT_TREE: + return cut_tree; + case StoryActionId::TRASH_CANS: + return open_gym_door; + case StoryActionId::GAME: + return other_game_step; + default: + return nullptr; + } +} + +ConsoleAction console_action_of(StoryActionId id){ + if (const ConsoleAction game = game_console_action(id)){ + return game; + } + switch (id){ + case StoryActionId::NEW_GAME: + return [](StoryEnv& env, RunState&, const StoryStep&){ + play_new_game_intro(env.console, env.context, env.player_name); + return Pokemon::outcome_ok(); + }; + case StoryActionId::ROUTE: + return [](StoryEnv& env, RunState& state, const StoryStep& step){ return walk_route(env, state, step); }; + case StoryActionId::LEGS: + return walk_legs; + case StoryActionId::STEP: + return take_step; + case StoryActionId::TALK: + return [](StoryEnv& env, RunState&, const StoryStep&){ + pbf_press_button(env.context, BUTTON_A, 160ms, 800ms); + return Pokemon::outcome_ok(); + }; + case StoryActionId::ANSWER: + return [](StoryEnv& env, RunState&, const StoryStep& step){ + answer_yes_no(env.console, env.context, step.yes, BUTTON_B, YES_NO_TIMEOUT); + return Pokemon::outcome_ok(); + }; + case StoryActionId::MASH: + return [](StoryEnv& env, RunState&, const StoryStep&){ + mash_until_overworld(env.console, env.context, SCENE_TIMEOUT); + return Pokemon::outcome_ok(); + }; + case StoryActionId::BATTLE: + return [](StoryEnv& env, RunState&, const StoryStep& step){ return battle(env, step); }; + case StoryActionId::HEAL: + return [](StoryEnv& env, RunState&, const StoryStep&){ + heal_at_center(env.console, env.context); + healed_by_nurse(env.supplies); + return Pokemon::outcome_ok(); + }; + case StoryActionId::SHOP: + return [](StoryEnv& env, RunState&, const StoryStep& step){ return shop(env, step); }; + case StoryActionId::READY: + return get_ready; + default: + break; + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::string("No console action for ") + story_action_name(id)); +} + +} // namespace + + +StoryExecutor::StoryExecutor(StoryEnv& env) + : m_env(env) + , m_guard(env.console.logger()) + , m_table(make_story_action_table([this](StoryActionId id){ return console_action(id); })) + , m_executor(m_table, m_guard) + , m_look(env.console, env.context) + , m_interrupts(step_interrupts( + [this](bool fresh){ return m_look.look(fresh); }, + console_interrupt_actions(env.console, env.context, [&env]{ + return story_interrupts(env.supplies, false, env.checks, env.language); + }) + )) +{ + m_executor.set_interrupts(&m_interrupts); +} + +Outcome StoryExecutor::run(RunState& state, const StoryStep& step){ + return m_executor.run(state, step); +} + +Pokemon::StoryAction StoryExecutor::console_action(StoryActionId id){ + if (id == StoryActionId::OPTIONS){ + return {.act = [this](RunState&, const StoryStep&){ + set_options(m_env.console, m_env.context, m_menu, m_env.language); + return Pokemon::outcome_ok(); + }, .needs_console = true}; + } + const ConsoleAction action = console_action_of(id); + return {.act = [this, action](RunState& state, const StoryStep& step){ return action(m_env, state, step); }, .needs_console = true}; +} + namespace{ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index 53a6a3b7b1..9e089b767c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -72,6 +72,28 @@ inline const std::string TRAINER_LINE_VARIANT = "trainer_line"; // Maximum tiles to step into a trainer's line of sight. inline constexpr int TRAINER_LINE_TILES = 3; +// The variant of a STEP into the line of a trainer who looks around +// (MOVEMENT_TYPE_LOOK_AROUND): one tile, then a wait of up to +// TRAINER_WAIT_WATCH for the trainer to turn to the player. Indoors, where +// the player can't run, such a trainer turns a random way every 32 to 128 +// frames (pokefirered event_object_movement.c, MovementType_LookAround; +// it turns to the player only when the player runs), so they face the +// player within 30 s all but about 1 time in 500; and the game checks +// their sight every frame (ProcessPlayerFieldInput()), not only after a +// step. Stepped into from outside their sight onto the second tile of +// their line, the player is always seen there, and the trainer walks up +// one tile. +inline const std::string TRAINER_WAIT_VARIANT = "trainer_wait"; + +// The GAME step that cuts the tree the player faces, `direction` +// (pokefirered data/scripts/field_moves.inc, EventScript_CutTree): A, +// "This tree looks like it can be CUT down! Would you like to CUT it?" +// YES, " used CUT!", the tree falls; then a step onto its tile. With +// no Pokemon that knows Cut there is no YES/NO, and the step fails. The +// tree grows back when the player leaves the map (its hide flag is a +// FLAG_TEMP), so the walk back cuts it again. +inline const std::string CUT_TREE_VARIANT = "cut_tree"; + // The GAME step that closes the Trainer Card SID Helper leaves open. inline const std::string CLOSE_TRAINER_CARD = "close_trainer_card"; @@ -146,8 +168,13 @@ StoryRunPlan plan_story_run( // The maps the story plans on: every map to Route 4's Pokemon Center, with // the objects the game hides after the Hall of Fame removed, as the lab's -// frlg_route does. +// frlg_route does, and those a new game hides until their scene (the +// S.S. Anne's rival on the Captain's door). const std::vector& story_maps(); +// default_route_costs() without Surf, which the story hasn't got yet: +// routes stay on land, even where water is the shorter way (Vermilion +// City's harbor). +const RouteCosts& story_route_costs(); // Converts a StoryPose to a general navigation Pose. Pokemon::Pose to_pose(const StoryPose& pose); @@ -197,9 +224,10 @@ std::optional> fixed_route( // other step, or a `patch` not on one tile. Pokemon::Pose paced_pose(const Pokemon::Pose& patch, const StoryStep& step); -// A ROUTE's legs from `pose` to `goal`: from one tile, as planned; from -// a set of tiles (paced_pose()), first the holds to walls that bring them -// all to one tile (holds_to_one_tile()), then the route from there. +// A ROUTE's legs from `pose` to `goal`, on story_route_costs(): from +// one tile, as planned; from a set of tiles (paced_pose()), first the +// holds to walls that bring them all to one tile (holds_to_one_tile()), +// then the route from there. // Nothing if either can't be planned. std::optional> plan_story_route( const std::vector& maps, const Pokemon::Pose& pose, @@ -294,16 +322,6 @@ class StoryExecutor : public Pokemon::StoryStepExecutor{ // The step's action from the table. virtual Pokemon::Outcome run(Pokemon::RunState& state, const StoryStep& step) override; -protected: - // CLOSE_TRAINER_CARD: back from SID Helper (close_trainer_card()). - // LEAVE_GIFT_SUMMARY: back from Gift RNG (leave_gift_summary()). - // A capture or a PC deposit or withdrawal (capture_step(), pc_step() - // in PokemonFRLG_AutoStoryCapture.h); a shiny met in a capture stops - // the program without reloading. A MART, TEACH, LEVEL, GRIND or - // fossil CHOICE (PokemonFRLG_AutoStoryAbilities.h); a GRIND heals by - // routing to its target, the nurse, and back. - virtual Pokemon::Outcome game(Pokemon::RunState& state, const StoryStep& step) override; - private: // How the console does `id`. Pokemon::StoryAction console_action(StoryActionId id); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.h index 54d1f5a449..47895755ac 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFoeTracker.h @@ -54,6 +54,9 @@ class TrainerFoeTracker{ // The party slot out; none when the trainer isn't known. std::optional out() const; + // The party slots seen fainted. + const std::set& fainted() const{ return m_fainted; } + private: std::vector m_party; std::vector m_species; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp index 9abb8f03a2..38f4afba33 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp @@ -120,6 +120,22 @@ Pokemon::StoryStep grind_step(std::string name, std::string why, const GrindPlan return step; } +std::optional parse_grind_check_variant(std::string_view variant){ + if (!variant.starts_with(GRIND_CHECK_VARIANT_PREFIX)){ + return std::nullopt; + } + return parse_grind_variant(std::string(GRIND_VARIANT_PREFIX) + std::string(variant.substr(GRIND_CHECK_VARIANT_PREFIX.size()))); +} + +Pokemon::StoryStep grind_check_step(std::string name, std::string why, const GrindPlan& plan){ + Pokemon::StoryStep step; + step.kind = Pokemon::StoryStepKind::GAME; + step.name = std::move(name); + step.why = std::move(why); + step.variant = std::string(GRIND_CHECK_VARIANT_PREFIX) + grind_variant(plan).substr(GRIND_VARIANT_PREFIX.size()); + return step; +} + namespace{ @@ -161,6 +177,10 @@ class GrindRun{ } } } + // The member, read, has what the plan grinds for. Nothing fought. + bool reached_already(){ + return m_plan.member == 0 && read_level() && over() && m_result.end == GrindEnd::REACHED; + } private: GrindResult end(GrindEnd end, std::string detail){ @@ -274,6 +294,12 @@ GrindResult run_grind(Logger& logger, GrindActions& actions, const MemberReader& return result; } +bool grind_reached_already(Logger& logger, GrindActions& actions, const MemberReader& reader, const GrindPlan& plan){ + const bool reached = GrindRun(logger, actions, reader, plan).reached_already(); + logger.log(reached ? "Grind: reached already; no walk out to the patch." : "Grind: not reached yet; out to the patch."); + return reached; +} + std::string grind_message(const GrindPlan& plan, const GrindResult& result){ const std::string level = result.level ? std::to_string(*result.level) : "?"; const std::string chance = plan.trainer.empty() ? "" : std::format( @@ -355,7 +381,7 @@ bool ConsoleGrindActions::needs_heal(){ std::optional ConsoleGrindActions::win_chance(const MemberInfo& member, std::string_view trainer){ m_supplies.lead = lead_as_read(member, m_supplies.lead); - return healed_win_chance(m_supplies.lead, SimItems{.potions = m_supplies.potions, .awakenings = m_supplies.awakenings}, trainer); + return healed_win_chance(m_supplies.lead, SimItems{.potions = m_supplies.potions, .awakenings = m_supplies.awakenings, .antidotes = m_supplies.antidotes}, trainer); } bool ConsoleGrindActions::heal_trip(){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h index 09cd66cc08..34953a85f2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h @@ -86,6 +86,18 @@ std::optional parse_grind_variant(std::string_view variant); // The table's GRIND step: `nurse` is where the player faces the nurse. Pokemon::StoryStep grind_step(std::string name, std::string why, const GrindPlan& plan, Pokemon::Pose nurse); +// The prefix of a GAME step's variant that checks a GRIND step's plan +// before the walk out to its patch, e.g. "grind_check slot=1 level=32 +// ...", the rest as grind_variant()'s. +inline constexpr std::string_view GRIND_CHECK_VARIANT_PREFIX = "grind_check "; +// Parses a grind check variant string into its GrindPlan. +std::optional parse_grind_check_variant(std::string_view variant); + +// The table's check before the walk out to a GRIND step's patch: when the +// lead already has what `plan` grinds for, the step sets a run flag named +// `name`, which the walk out, the GRIND step and the way back skip_if. +Pokemon::StoryStep grind_check_step(std::string name, std::string why, const GrindPlan& plan); + // The outcome of an individual grinding battle. enum class GrindFight{ @@ -156,7 +168,13 @@ struct GrindResult{ // From the overworld in the patch: grinds as `plan` says. GrindResult run_grind(Logger& logger, GrindActions& actions, const MemberReader& reader, const GrindPlan& plan); -// Formats a log message summarizing the grind result. +// Before the walk out to the patch: whether the member `plan` grinds, as +// read, already has its level, or with a trainer its win chance. Nothing +// is fought. 00.24 walked to Route 6's grass and back at Lv29 with the +// chance against Lt. Surge already 79% (console B, 2026-11-03). +bool grind_reached_already(Logger& logger, GrindActions& actions, const MemberReader& reader, const GrindPlan& plan); + +// Formats a log message summarizing the grind result. std::string grind_message(const GrindPlan& plan, const GrindResult& result); // The story's lead as `read` shows it (species, level, moves, stats), diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp index e50325e6a0..963f819dfd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp @@ -1,134 +1,134 @@ -/* 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; -}; - - +/* 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; +}; + + struct AutoStoryGrindInput{ std::string name; std::function body; @@ -142,337 +142,362 @@ struct AutoStoryGrindCase{ AutoStoryGrindExpectation expectation; }; -class Test_AutoStoryGrind : public UnitTest{ -public: +class Test_AutoStoryGrind : public UnitTest{ +public: explicit Test_AutoStoryGrind(AutoStoryGrindCase test_case) : UnitTest("PokemonFRLG::AutoStoryGrind - " + test_case.input.name) , m_case(std::move(test_case)) - {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - try{ + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ return m_case.input.body(); - }catch (Exception& e){ - return std::string(e.name()) + ": " + e.message(); - } - } - -private: + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: AutoStoryGrindCase m_case; -}; - - -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 50%, Lv`cap` at most. -GrindPlan beat_rival(unsigned cap){ - GrindPlan plan = squirtle_to(cap); - plan.trainer = std::string(RIVAL); - return plan; -} - -// 30% 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.30 + 0.05 * (level - 16); - }; -} -void never_winnable(FakePatch& patch){ - patch.chance_at = [](unsigned) -> std::optional{ return 0.40; }; -} - -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 50% 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 40% of 50%" - " 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 52% at Lv22 (42% when the sim's Potion was sized -// on the foe's biggest hit) and more at Lv25: a GRIND to 50% stops at -// Lv22. -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{}); - double previous = 0; - for (uint8_t level = 22; level <= 25; level += 3){ - PartyMember lead = at22; - lead.moves_through_level = level; - const std::optional estimate = healed_win_chance(lead, SimItems{.potions = 10}, RIVAL); - if (!estimate || estimate->chance < MIN_WIN_CHANCE || estimate->chance <= previous){ - return std::format( - "Lv{}: {:.1f}%; expected {:.0f}% or more, rising with the level.", - level, estimate ? 100 * estimate->chance : 0.0, 100 * MIN_WIN_CHANCE - ); - } - previous = estimate->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=50"){ - 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; -} - -} // namespace - - +}; + + +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 50%, Lv`cap` at most. +GrindPlan beat_rival(unsigned cap){ + GrindPlan plan = squirtle_to(cap); + plan.trainer = std::string(RIVAL); + return plan; +} + +// 30% 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.30 + 0.05 * (level - 16); + }; +} +void never_winnable(FakePatch& patch){ + patch.chance_at = [](unsigned) -> std::optional{ return 0.40; }; +} + +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 50% 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 40% of 50%" + " 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 52% at Lv22 (42% when the sim's Potion was sized +// on the foe's biggest hit) and more at Lv25: a GRIND to 50% stops at +// Lv22. +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{}); + double previous = 0; + for (uint8_t level = 22; level <= 25; level += 3){ + PartyMember lead = at22; + lead.moves_through_level = level; + const std::optional estimate = healed_win_chance(lead, SimItems{.potions = 10}, RIVAL); + if (!estimate || estimate->chance < MIN_WIN_CHANCE || estimate->chance <= previous){ + return std::format( + "Lv{}: {:.1f}%; expected {:.0f}% or more, rising with the level.", + level, estimate ? 100 * estimate->chance : 0.0, 100 * MIN_WIN_CHANCE + ); + } + previous = estimate->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=50"){ + 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; +} + +// The check in the Center before the walk out: a lead that already has +// the chance needs no trip (00.24 at Lv29, 79% against Lt. Surge, console +// B, 2026-11-03); one short of it does. Nothing is fought either way. +UnitTestResult test_grind_reached_already(){ + QuietLogger logger; + for (const unsigned level : {16u, 19u}){ + FakePatch patch; + patch.level = level; + winnable_from_19(patch); + const bool reached = grind_reached_already(logger, patch, [&patch](size_t slot){ + return patch.read_member(slot); + }, beat_rival(30)); + if (reached != (level >= 19) || patch.encounters != 0 || patch.heals != 0 || !patch.error.empty()){ + return std::format("Lv{}: reached {}, {} encounters, {} heals {}.", level, reached, patch.encounters, patch.heals, patch.error); + } + } + const Pokemon::StoryStep check = grind_check_step("rival_winnable", "", beat_rival(30)); + const std::optional back = parse_grind_check_variant(check.variant); + if (!back || back->trainer != RIVAL || back->level != 30 || parse_grind_variant(check.variant)){ + return "\"" + check.variant + "\" didn't read back as its plan, or read as a GRIND step."; + } + return true; +} + +} // namespace + + + const AutoStoryGrindCase AUTO_STORY_GRIND_CASES[] = { { .input = {"grinding on a fake patch", test_grind}, @@ -494,16 +519,17 @@ const AutoStoryGrindCase AUTO_STORY_GRIND_CASES[] = { .input = {"console B's party is fought at Lv22 against the Cerulean rival", test_console_party}, .expectation = {}, }, + {.input = {"reached already: no walk out to the patch", test_grind_reached_already}, .expectation = {}}, }; -void add_tests_AutoStoryGrind(UnitTestDatabase& database){ +void add_tests_AutoStoryGrind(UnitTestDatabase& database){ for (const auto& item : AUTO_STORY_GRIND_CASES){ database.add(item); } -} - - - +} + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation 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 79da386b61..8a141637d0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp @@ -26,6 +26,8 @@ #include "CommonFramework/Language.h" #include "Tests/ImageUnitTest.h" #include "Tests/TestUtils.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleHpBarReader.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleScreenDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" @@ -371,6 +373,52 @@ class Test_BattleShows : public ImageUnitTest{ std::optional m_expected; }; +// battle_yes_no_shows() on console B's captures named +// _.png. +class Test_BattleYesNo : public ImageUnitTest{ +public: + Test_BattleYesNo(const std::string& image) + : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + bool expected = false; + const std::vector words = parse_words(Filesystem::Path(image_paths()[0]).stem().string()); + if (words.empty() || !parse_bool(words.back(), expected)){ + return "Error: filename must be _."; + } + return battle_yes_no_shows(images[0]) == expected; + } +}; + +// read_opponent_hp_fill() on console B's captures named +// _FoeHp_of48.png: the bar's filled share within a pixel. +class Test_FoeHp : public ImageUnitTest{ +public: + Test_FoeHp(const std::string& image) + : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + const std::vector words = parse_words(Filesystem::Path(image_paths()[0]).stem().string()); + int pixels = -1; + if (words.empty() || words.back().find("of48") == std::string::npos || !parse_int(words.back().substr(0, words.back().find("of48")), pixels)){ + return "Error: filename must be _FoeHp_of48."; + } + const std::optional fill = read_opponent_hp_fill(images[0]); + if (!fill || std::abs(*fill - pixels / 48.0) > 1.0 / 48){ + return std::format("Expected {} of 48 pixels, got {:.1f}.", pixels, fill ? 48 * *fill : -1.0); + } + return true; + } +}; + // read_party_status() on console B's captures named // _Hp_of[_].png: the bar's color, // and its colored share within a pixel of the 48-pixel bar of hp / max hp. @@ -479,6 +527,20 @@ void add_tests_AutoStoryItems(UnitTestDatabase& database){ }){ database.add(std::string("PokemonFRLG/AutoStoryBattle/") + image); } + // The YES/NO to "Delete a move to make room for BITE?" (Miguel), and the + // S.S. Anne rival's and Misty's send-outs, which the move prompt's + // detector alone took for one (console B, 2026-11-03). + for (const char* image : { + "Miguel_DeleteMoveForBite_YesNo_True.png", + "SSAnneRival_SentOutRaticate_YesNo_False.png", + "SSAnneRival_SentOutKadabra_YesNo_False.png", + "Misty_SentOutStarmie_YesNo_False.png", + }){ + database.add(std::string("PokemonFRLG/AutoStoryBattle/") + image); + } + // The S.S. Anne rival's Pidgeotto at full HP, its Raticate low. + database.add("PokemonFRLG/AutoStoryBattle/SSAnneRival_Pidgeotto_FoeHp_48of48.png"); + database.add("PokemonFRLG/AutoStoryBattle/SSAnneRival_Raticate_FoeHp_7of48.png"); // The battle's choices, registered here to keep add_tests() within its size. add_tests_AutoStoryParty(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp index 5dbe86d9c6..0219b7b703 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include "Common/Cpp/Exceptions.h" @@ -653,6 +654,37 @@ UnitTestResult test_end_watch_timing(Logger&){ return true; } +// Console B, 2026-11-03, after losing to Lt. Surge: no battle screen and +// no START menu, and the loop pressed + and B for 8 minutes. It gives up +// 60 s after the last battle screen, once START has looked; a battle +// screen in between starts that time over. +UnitTestResult test_end_watch_gives_up(Logger&){ + const WallClock start = current_time(); + const auto at = [&](int ms){ return start + std::chrono::milliseconds(ms); }; + BattleEndWatch watch; + if (watch.overworld_lost(at(120000))){ + return "Gave up before any battle screen showed."; + } + watch.seen(BattleSeen::BATTLE, at(0)); + std::optional gave_up; + for (int ms = 300; ms <= 600000 && !gave_up; ms += 300){ + watch.seen(BattleSeen::NONE, at(ms)); + watch.check_overworld(at(ms)); + if (ms == 30000){ + watch.seen(BattleSeen::BATTLE, at(ms)); + } + if (watch.overworld_lost(at(ms))){ + gave_up = ms; + } + } + if (!gave_up || *gave_up < 90000 || *gave_up > 95000){ + return gave_up + ? std::format("Expected to give up 60 s after the battle screen at 30 s, at {} ms.", *gave_up) + : std::string("Never gave up on the overworld in 10 minutes."); + } + return true; +} + } // namespace @@ -724,6 +756,56 @@ UnitTestResult test_disabled_move(Logger&){ return true; } +// Regression, console B 2026-11-03, m68, the S.S. Anne rival: "no PP left +// for that move" read 37 s after the battle's last move, as it ended, and +// the story took the move's PP as 0. Read once, a move is left alone as a +// Disable (Kadabra's) would have it, its PP kept; read again for the same +// move, its PP is 0. +UnitTestResult test_no_pp_read_once(Logger&){ + Supplies supplies; + supplies.lead = squirtle_before_miguel(); + supplies.foes = {"grimer", "voltorb", "koffing"}; + BattleDecisions decisions(supplies, BattleKind::TRAINER); + if (decisions.turn(GREEN).slot != TOP_LEFT_MOVE){ + return "Expected Water Gun (slot 0) at turn 1."; + } + decisions.turn_done(TurnChoice::MOVE, TurnResult::NOT_DONE); + if (supplies.lead.moves[TOP_LEFT_MOVE].pp != 25){ + return std::format("Expected Water Gun's 25 PP kept after one \"no PP\" read, got {}.", supplies.lead.moves[TOP_LEFT_MOVE].pp); + } + for (int c = 0; c < 5; c++){ + if (decisions.turn(GREEN).slot == TOP_LEFT_MOVE){ + return std::format("Expected Water Gun left alone at turn {} after the read, as a Disable.", c + 1); + } + } + if (decisions.turn(GREEN).slot != TOP_LEFT_MOVE){ + return "Expected Water Gun again once a Disable would be over."; + } + decisions.turn_done(TurnChoice::MOVE, TurnResult::NOT_DONE); + if (supplies.lead.moves[TOP_LEFT_MOVE].pp != 0){ + return std::format("Expected Water Gun at 0 PP after a second read, got {}.", supplies.lead.moves[TOP_LEFT_MOVE].pp); + } + return true; +} + +// "no PP left" counts only as soon after the choice as a refusal's menu +// comes: m68's read 37 s after the battle's last move doesn't. +UnitTestResult test_no_pp_due(Logger&){ + const WallClock start = current_time(); + MoveRefusalWatch watch; + if (watch.no_pp_due(start)){ + return "Expected no \"no PP\" read counted with no move chosen."; + } + watch.move_chosen(start); + if (!watch.no_pp_due(start + 4000ms)){ + return "Expected a \"no PP\" read 4 s after the choice counted."; + } + if (watch.no_pp_due(start + 37000ms)){ + return "Expected a \"no PP\" read 37 s after the choice not counted."; + } + return true; +} + // The loop's view of a move turned down (console B, 2026-11-03): the move // list back (no battle screen) and the battle menu 5.7 s after the choice. // Not a turn (8.3 s and up, with no move list), nor a menu long after one @@ -831,86 +913,29 @@ UnitTestResult test_nurse_before_battle(Logger&){ } const AutoStoryPartyTestCase AUTO_STORY_PARTY_CASES[] = { - { - .input = {.name = "movesets the game gives and the story plays", .body = test_movesets}, - .expectation = {.description = "verifies movesets progression through early game"}, - }, - { - .input = {.name = "move offered and slot forgotten", .body = test_learn_prompts}, - .expectation = {.description = "determines move learning replacements and prompts"}, - }, - { - .input = {.name = "Mega Kick kept over later level-up moves", .body = test_mega_kick_kept}, - .expectation = {.description = "keeps Mega Kick over later level-up moves"}, - }, - { - .input = {.name = "YES/NO answers to move prompts", .body = test_prompt_answers}, - .expectation = {.description = "answers prompts when deciding to learn new moves"}, - }, - { - .input = {.name = "fainted lead runs or sends the next member", .body = test_fainted_lead}, - .expectation = {.description = "switches to next alive member when lead faints"}, - }, - { - .input = {.name = "PP: preferred attack, then Tackle; nurse restores", .body = test_pp}, - .expectation = {.description = "tracks PP depletion and replenishment"}, - }, - { - .input = {.name = "moves read off the summary, Water Gun over Tackle", .body = test_moves_from_summary}, - .expectation = {.description = "reads move list from summary screen"}, - }, - { - .input = {.name = "summary members: fewer moves, unread PP, strongest attack", .body = test_summary_members}, - .expectation = {.description = "handles party members with partial move data"}, - }, - { - .input = {.name = "no B during an evolution", .body = test_no_b_during_evolution}, - .expectation = {.description = "prevents cancelling evolution sequence"}, - }, - { - .input = {.name = "RUN, then a dark cave: no evolution wait, START soon", .body = test_run_then_overworld}, - .expectation = {.description = "handles post-battle overworld transition in dark cave"}, - }, - { - .input = {.name = "battle end: START after the battle is gone, A and B for 30 s at most", .body = test_end_watch_timing}, - .expectation = {.description = "verifies timing constraints on battle end loop"}, - }, - { - .input = {.name = "attacks by matchup: Bite on the rival's Bulbasaur and Misty", .body = test_attack_by_matchup}, - .expectation = {.description = "selects optimal attack move based on type matchup"}, - }, - { - .input = {.name = "attacks against an unknown foe: the plan's order", .body = test_attack_unknown_foe}, - .expectation = {.description = "falls back to default move preference order against unknown foe"}, - }, - { - .input = {.name = "Bite learned over Tail Whip or Withdraw", .body = test_bite_learned}, - .expectation = {.description = "prioritizes learning offensive moves over status moves"}, - }, - { - .input = {.name = "a YES/NO with no move list after it changes no move", .body = test_yes_no_without_move_list}, - .expectation = {.description = "maintains moveset when prompt completes without move selection"}, - }, - { - .input = {.name = "a disabled move: left alone while Disable lasts, PP back", .body = test_disabled_move}, - .expectation = {.description = "avoids selecting disabled moves"}, - }, - { - .input = {.name = "a move turned down: the move list, then the menu sooner than a turn", .body = test_move_refusal_watch}, - .expectation = {.description = "handles refusing a new move cleanly"}, - }, - { - .input = {.name = "the move list closed with nothing forgotten: YES to \"Stop learning?\"", .body = test_move_list_closed}, - .expectation = {.description = "confirms stopping learning when cancelling move list"}, - }, - { - .input = {.name = "move prompts after a turn: a plain YES after the first few", .body = test_move_prompts_bounded}, - .expectation = {.description = "bounds prompt handling attempts after turns"}, - }, - { - .input = {.name = "the nurse before a battle for HP not green with no Potion", .body = test_nurse_before_battle}, - .expectation = {.description = "triggers pokemon center visit when health is low and without potions"}, - }, + {.input = {.name = "movesets the game gives and the story plays", .body = test_movesets}, .expectation = {.description = "verifies movesets progression through early game"},}, + {.input = {.name = "move offered and slot forgotten", .body = test_learn_prompts}, .expectation = {.description = "determines move learning replacements and prompts"},}, + {.input = {.name = "Mega Kick kept over later level-up moves", .body = test_mega_kick_kept}, .expectation = {.description = "keeps Mega Kick over later level-up moves"},}, + {.input = {.name = "YES/NO answers to move prompts", .body = test_prompt_answers}, .expectation = {.description = "answers prompts when deciding to learn new moves"},}, + {.input = {.name = "fainted lead runs or sends the next member", .body = test_fainted_lead}, .expectation = {.description = "switches to next alive member when lead faints"},}, + {.input = {.name = "PP: preferred attack, then Tackle; nurse restores", .body = test_pp}, .expectation = {.description = "tracks PP depletion and replenishment"},}, + {.input = {.name = "moves read off the summary, Water Gun over Tackle", .body = test_moves_from_summary}, .expectation = {.description = "reads move list from summary screen"},}, + {.input = {.name = "summary members: fewer moves, unread PP, strongest attack", .body = test_summary_members}, .expectation = {.description = "handles party members with partial move data"},}, + {.input = {.name = "no B during an evolution", .body = test_no_b_during_evolution}, .expectation = {.description = "prevents cancelling evolution sequence"},}, + {.input = {.name = "RUN, then a dark cave: no evolution wait, START soon", .body = test_run_then_overworld}, .expectation = {.description = "handles post-battle overworld transition in dark cave"},}, + {.input = {.name = "battle end: START after the battle is gone, A and B for 30 s at most", .body = test_end_watch_timing}, .expectation = {.description = "verifies timing constraints on battle end loop"},}, + {.input = {.name = "attacks by matchup: Bite on the rival's Bulbasaur and Misty", .body = test_attack_by_matchup}, .expectation = {.description = "selects optimal attack move based on type matchup"},}, + {.input = {.name = "attacks against an unknown foe: the plan's order", .body = test_attack_unknown_foe}, .expectation = {.description = "falls back to default move preference order against unknown foe"},}, + {.input = {.name = "Bite learned over Tail Whip or Withdraw", .body = test_bite_learned}, .expectation = {.description = "prioritizes learning offensive moves over status moves"},}, + {.input = {.name = "a YES/NO with no move list after it changes no move", .body = test_yes_no_without_move_list}, .expectation = {.description = "maintains moveset when prompt completes without move selection"},}, + {.input = {.name = "a disabled move: left alone while Disable lasts, PP back", .body = test_disabled_move}, .expectation = {.description = "avoids selecting disabled moves"},}, + {.input = {.name = "a move turned down: the move list, then the menu sooner than a turn", .body = test_move_refusal_watch}, .expectation = {.description = "handles refusing a new move cleanly"},}, + {.input = {.name = "the move list closed with nothing forgotten: YES to \"Stop learning?\"", .body = test_move_list_closed}, .expectation = {.description = "confirms stopping learning when cancelling move list"},}, + {.input = {.name = "move prompts after a turn: a plain YES after the first few", .body = test_move_prompts_bounded}, .expectation = {.description = "bounds prompt handling attempts after turns"},}, + {.input = {.name = "the nurse before a battle for HP not green with no Potion", .body = test_nurse_before_battle}, .expectation = {.description = "triggers pokemon center visit when health is low and without potions"},}, + {.input = {.name = "battle end: no overworld 60 s after the battle, a stop", .body = test_end_watch_gives_up}, .expectation = {.description = "stops battle watch loop when overworld does not reappear within 60s"},}, + {.input = {.name = "\"no PP\" read once: left alone as a Disable; twice: PP 0", .body = test_no_pp_read_once}, .expectation = {.description = "treats first no PP message as disable and second as zero PP"},}, + {.input = {.name = "\"no PP\" counts only soon after the move chosen", .body = test_no_pp_due}, .expectation = {.description = "only attributes no PP warning to recently selected move"},}, }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp new file mode 100644 index 0000000000..c6d23e3371 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -0,0 +1,457 @@ +/* AutoStory Row Sim + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Pokemon/AutoStory/Pokemon_TilePose.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryRowSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using Pokemon::Outcome; +using Pokemon::RunState; + + +void SimStubs::begin_row(std::string row){ + m_rows.push_back({std::move(row), {}}); +} +void SimStubs::record(const StoryStep& step){ + if (m_rows.empty()){ + m_rows.push_back({"", {}}); + } + m_rows.back().steps.push_back(step.name); +} +Pokemon::StoryAction SimStubs::stub(Pokemon::StoryActionFunction then){ + return { + .act = [this, then = std::move(then)](RunState& state, const StoryStep& step){ + record(step); + return then ? then(state, step) : Pokemon::outcome_ok(); + }, + .needs_console = true, + }; +} + +std::string stub_report(const std::vector& rows){ + std::string out; + for (const RowStubs& row : rows){ + out += std::format("{}: {} stubbed", row.row, row.steps.size()); + for (size_t i = 0; i < row.steps.size(); i++){ + out += (i == 0 ? ": " : ", ") + row.steps[i]; + } + out += "\n"; + } + return out; +} + + +namespace{ + +const MapData* map_named(const std::string& name){ + for (const MapData& map : story_maps()){ + if (map.name == name){ + return ↦ + } + } + return nullptr; +} + +// Whether the player, at the end of a WALK `step`, stops there holding on: +// the map's edge, a wall, or water to Surf onto, but no warp of any kind, +// even one to a map that isn't loaded. +bool stops_past_end(const RouteStep& step){ + const MapData& map = *map_named(step.end.map); + const TilePos here = step.end.point; + const TilePos ahead = here.next(step.direction); + if (!in_bounds(map, ahead)){ + return true; + } + if (warps_on_step(map, ahead) || exit_mat(map, here) == step.direction + || (step.direction == Direction::NORTH && is_door(map, ahead)) + ){ + return false; + } + const StepKind kind = classify_step(map, PlayerState{here, elevation_at(map, here), step.surfing}, step.direction); + return kind == StepKind::BLOCKED || kind == StepKind::START_SURF; +} + +// The first leg of `route` to a wall that holding on would carry past its +// end, or nothing (console B, 2026-11-02: one rode Route 4's Pokemon +// Center escalator). +std::optional overrun_wall_leg(const std::vector& route){ + for (const RouteStep& step : route){ + if (step.to_wall && !stops_past_end(step)){ + return to_string(step); + } + } + return std::nullopt; +} + +// Where the run stands, with a facing: a wall beside it when unknown. +struct Standing{ + MapPosition position; + Direction facing = Direction::NORTH; + std::string error; +}; +Standing standing(const Pokemon::Pose& pose){ + const std::optional at = story_pose(pose); + if (!at){ + return {{}, Direction::NORTH, "no one-tile pose: " + Pokemon::to_string(pose)}; + } + std::optional facing = at->facing; + if (!facing){ + const MapData* map = map_named(at->map); + facing = map ? wall_beside(*map, at->tile) : std::nullopt; + } + if (!facing){ + return {{}, Direction::NORTH, "facing unknown and no wall beside " + to_string(MapPosition{at->map, at->tile})}; + } + return {{at->map, at->tile}, *facing, ""}; +} + +// An action this simulation plays. +Pokemon::StoryAction played(Pokemon::StoryActionFunction act){ + return {.act = std::move(act), .needs_console = false}; +} + +} // namespace + + +StoryMapsSim::StoryMapsSim(StorySimKnobs knobs) + : Pokemon::TableExecutor(m_table, m_guard) + , m_table(make_story_action_table([this](StoryActionId id){ return action(id); })) + , m_knobs(std::move(knobs)) + , m_interrupts(step_interrupts([this](bool){ return look(); }, sim_interrupt_actions())) +{ + if (m_knobs.clear_interrupts){ + set_interrupts(&m_interrupts); + } +} + +void StoryMapsSim::begin_attempt(const Pokemon::StoryCheckpoint& data){ + m_stubs.begin_row(data.id); + m_row = data.id; + // A reload starts the row on the quiet overworld. + m_dialog_up = m_dialog_rose = false; +} + +Outcome StoryMapsSim::run(RunState& state, const StoryStep& step){ + if (m_knobs.dialog_before && m_knobs.dialog_before(m_row, step.name)){ + m_dialog_up = m_dialog_rose = true; + m_dialog_notes.push_back(m_row + "/" + step.name + ": a dialog pops up before it."); + } + return Pokemon::TableExecutor::run(state, step); +} + +Outcome StoryMapsSim::run_action(RunState& state, const StoryStep& step){ + if (m_dialog_up){ + if (!presses_through_text(step.kind)){ + return Pokemon::outcome_failed("dialog", step.name + ": a dialog that popped up before it is still up"); + } + m_dialog_up = false; + } + return Pokemon::TableExecutor::run_action(state, step); +} + +Pokemon::ScreenLook StoryMapsSim::look(){ + const WallClock start = WallClock() + std::chrono::hours(1); + Pokemon::ScreenLook out; + out.log.record({}, start); + if (std::exchange(m_dialog_rose, false)){ + out.log.record({Pokemon::ScreenEvent::DIALOG_BOX}, start + Milliseconds(33)); + } + out.window = {.start = start, .end = start + BETWEEN_STEPS_LOOK}; + return out; +} + +// Every kind closes what is up and notes it: the simulation shows only +// the dialogs it pops up. +StepInterruptActions StoryMapsSim::sim_interrupt_actions(){ + const auto cleared = [this](const char* what){ + return [this, what](RunState&, const Pokemon::InterruptVerdict&){ + m_dialog_notes.push_back(std::string(what) + " cleared before the next step."); + m_dialog_up = false; + return Pokemon::outcome_ok(); + }; + }; + return StepInterruptActions{ + .wild = cleared("a wild battle"), + .trainer = cleared("a trainer's battle"), + .dialog = cleared("a dialog"), + .warp = cleared("a warp"), + }; +} + +Pokemon::StoryAction StoryMapsSim::action(StoryActionId id){ + switch (id){ + case StoryActionId::ROUTE: + return played([this](RunState& state, const StoryStep& step){ return walk_to(state.pose, step); }); + case StoryActionId::LEGS: + return played([this](RunState& state, const StoryStep& step){ return legs(state.pose, step); }); + case StoryActionId::STEP: + return played([this](RunState& state, const StoryStep& step){ return step_ahead(state.pose, step); }); + // The nurse's way must plan too, but the run goes on from where it stands. + case StoryActionId::READY: + return played([this](RunState& state, const StoryStep& step){ + const Outcome to_nurse = walk_to(state.pose, step); + return to_nurse.succeeded() ? Pokemon::outcome_ok() : to_nurse; + }); + case StoryActionId::SHOP: + return played([this](RunState&, const StoryStep& step){ return shop(step); }); + // Pacing leaves the player on either of its tiles, as on the console. + case StoryActionId::CLOSE_TRAINER_CARD: + case StoryActionId::LEAVE_GIFT_SUMMARY: + case StoryActionId::CUT_TREE: + case StoryActionId::TRASH_CANS: + case StoryActionId::GAME: + return m_stubs.stub([](RunState& state, const StoryStep& step){ + return Pokemon::outcome_ok(paced_pose(state.pose, step)); + }); + case StoryActionId::NEW_GAME: + case StoryActionId::OPTIONS: + case StoryActionId::TALK: + case StoryActionId::ANSWER: + case StoryActionId::MASH: + case StoryActionId::BATTLE: + case StoryActionId::HEAL: + return m_stubs.stub(); + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown story action."); +} + +Outcome StoryMapsSim::legs(const Pokemon::Pose& pose, const StoryStep& step) const{ + const Standing at = standing(pose); + if (!at.error.empty()){ + return Pokemon::outcome_failed("no_pose", at.error); + } + const std::optional> route = fixed_route(story_maps(), at.position, step.legs); + if (!route || route->empty()){ + return Pokemon::outcome_failed("bad_legs", to_string(at.position)); + } + return Pokemon::outcome_ok(to_pose(StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after})); +} + +// A step into a trainer's line goes on until a trainer can see the +// player, as step_until_seen() does on the console. +Outcome StoryMapsSim::step_ahead(const Pokemon::Pose& pose, const StoryStep& step) const{ + const std::optional at = story_pose(pose); + if (!at){ + return Pokemon::outcome_failed("no_pose", Pokemon::to_string(pose)); + } + const MapData* map = map_named(at->map); + if (step.variant != TRAINER_LINE_VARIANT || map == nullptr){ + return Pokemon::outcome_ok(to_pose(StoryPose{at->map, at->tile.next(step.direction), step.direction})); + } + TilePos onto = at->tile; + const std::optional stepped = step_until_seen( + TRAINER_LINE_TILES, + [&]{ onto = onto.next(step.direction); }, + [&]{ + return std::any_of(map->objects.begin(), map->objects.end(), [&](const MapObject& object){ + return object.trainer && trainer_may_see(*map, object, onto); + }); + } + ); + if (!stepped){ + return Pokemon::outcome_failed("no_trainer", to_string(MapPosition{at->map, onto})); + } + return Pokemon::outcome_ok(to_pose(StoryPose{at->map, onto, step.direction})); +} + +// The Viridian Mart's list as the game shows it. After a purchase the +// cursor stays on the row bought (console B, 2026-11-01), so each +// order moves it from there, as buy_items() does since ad5d40885. +Outcome StoryMapsSim::shop(const StoryStep& step){ + static const std::vector LIST{"poke_ball", "potion", "antidote", "parlyz_heal", "cancel"}; + const std::optional> orders = viridian_mart_orders(step.items); + if (!orders){ + return Pokemon::outcome_failed("unknown_item", step.name); + } + int cursor = 0; + for (size_t c = 0; c < orders->size(); c++){ + const MartOrder& order = (*orders)[c]; + // The presses: down or up from where the cursor is. + cursor += order.row - cursor; + if (cursor < 0 || cursor >= (int)LIST.size() || LIST[cursor] == "cancel"){ + return Pokemon::outcome_failed("mart_cancel", std::format("order {} ends on row {}", c + 1, cursor + 1)); + } + if (LIST[cursor] != step.items[c].item){ + return Pokemon::outcome_failed("wrong_item", std::format("{} instead of {}", LIST[cursor], step.items[c].item)); + } + m_bag[LIST[cursor]] += order.quantity; + } + return Pokemon::outcome_ok(); +} + +Outcome StoryMapsSim::walk_to(const Pokemon::Pose& pose, const StoryStep& step) const{ + if (Pokemon::tiles_of(pose).size() > 1){ + // From the tiles a pacing step leaves: the route must plan, and + // test_routes_after_pacing() walks it on the sim from each. + const std::optional target = story_pose(*step.target); + const RouteGoal goal{{target->map, target->tile}, target->facing}; + const std::optional> route = plan_story_route(story_maps(), pose, std::nullopt, goal); + if (!route || route->empty()){ + return Pokemon::outcome_failed("no_route", Pokemon::to_string(pose) + " to " + to_string(goal.position)); + } + return Pokemon::outcome_ok(to_pose(StoryPose{target->map, target->tile, route->back().facing_after})); + } + const Standing at = standing(pose); + if (!at.error.empty()){ + return Pokemon::outcome_failed("no_pose", at.error); + } + const std::optional target = story_pose(*step.target); + const RouteGoal goal{{target->map, target->tile}, target->facing}; + const std::optional> route = plan_overworld_route( + story_maps(), at.position, goal, story_route_costs(), at.facing + ); + if (!route){ + return Pokemon::outcome_failed("no_route", to_string(at.position) + " to " + to_string(goal.position)); + } + const MapPosition end = follow_overworld_route(story_maps(), at.position, *route); + if (!(end == goal.position)){ + return Pokemon::outcome_failed("wrong_end", to_string(end)); + } + if (const std::optional leg = overrun_wall_leg(*route)){ + return Pokemon::outcome_failed("wall_overrun", *leg); + } + const Direction facing = route->empty() ? at.facing : route->back().facing_after; + return Pokemon::outcome_ok(to_pose(StoryPose{target->map, target->tile, facing})); +} + + +// The route the program walks for `step`, a ROUTE or LEGS, from `at`: +// planned, or the legs laid out. +std::optional> walked_route(const StoryPose& at, const StoryStep& step){ + const MapPosition start{at.map, at.tile}; + if (step.kind == StoryStepKind::LEGS){ + return fixed_route(story_maps(), start, step.legs); + } + const std::optional target = story_pose(*step.target); + const RouteGoal goal{{target->map, target->tile}, target->facing}; + return plan_overworld_route(story_maps(), start, goal, story_route_costs(), at.facing); +} + + +OverworldStepSim::OverworldStepSim(OverworldSim& sim, std::string row, StoryPose at) + : Pokemon::TableExecutor(m_table, m_guard) + , m_sim(sim) + , m_at(std::move(at)) + , m_table(make_story_action_table([this](StoryActionId id){ return action(id); })) +{ + m_stubs.begin_row(std::move(row)); +} + +Pokemon::StoryAction OverworldStepSim::action(StoryActionId id){ + using Body = std::string (OverworldStepSim::*)(const StoryStep&); + Body body = nullptr; + switch (id){ + case StoryActionId::ROUTE: + case StoryActionId::LEGS: + body = &OverworldStepSim::walk; + break; + case StoryActionId::STEP: + body = &OverworldStepSim::step_ahead; + break; + case StoryActionId::CUT_TREE: + body = &OverworldStepSim::cut; + break; + case StoryActionId::BATTLE: + body = &OverworldStepSim::battle; + break; + default: + return m_stubs.stub(); + } + return played([this, body](RunState&, const StoryStep& step){ + const std::string failure = (this->*body)(step); + return failure.empty() ? Pokemon::outcome_ok() : Pokemon::outcome_failed("sim_failure", failure); + }); +} + +// A ROUTE or LEGS by the sim's walker, fighting a trainer who sees the +// player on the way. +std::string OverworldStepSim::walk(const StoryStep& step){ + const std::optional> route = walked_route(m_at, step); + if (route && route->empty()){ + // Already there. + return ""; + } + const RoutePlanning planning{.maps = &story_maps(), .costs = story_route_costs(), .start = MapPosition{m_at.map, m_at.tile}, .label = step.name}; + const std::string failure = route ? OverworldSimWalker(m_sim).run(*route, planning) : "no legs."; + m_at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : m_at; + return failure; +} + +// A step one tap at a time, or for a step into a trainer's line by +// step_until_seen(), each watched for the trainer. +std::string OverworldStepSim::step_ahead(const StoryStep& step){ + const auto tap = [this, &step]{ + if (m_sim.state().facing != step.direction){ + m_sim.tap(step.direction); + } + m_sim.tap(step.direction); + }; + const auto seen = [this]{ + return m_sim.watch({SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, TRAINER_LINE_WATCH).has_value(); + }; + const int tiles = step.variant == TRAINER_LINE_VARIANT ? TRAINER_LINE_TILES : 1; + return step_until_seen(tiles, tap, seen) ? "" : "no trainer saw the player."; +} + +// A CUT_TREE_VARIANT step: turned to the tree, cut, and a step onto its +// tile. +std::string OverworldStepSim::cut(const StoryStep& step){ + if (m_sim.state().facing != step.direction){ + m_sim.tap(step.direction); + } + if (!m_sim.cut_tree()){ + return "no tree to cut."; + } + m_sim.tap(step.direction); + m_at = StoryPose{m_at.map, m_at.tile.next(step.direction), step.direction}; + if (!(m_sim.state().position == MapPosition{m_at.map, m_at.tile})){ + return "expected to step onto the tree's tile " + to_string(MapPosition{m_at.map, m_at.tile}) + "."; + } + return ""; +} + +// A battle from the trainer's message, which must be up, ending where the +// table says. +std::string OverworldStepSim::battle(const StoryStep& step){ + if (!m_sim.close_message() || m_sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ + return "no_battle."; + } + m_at = *story_pose(*step.pose_after); + if (!(m_sim.state().position == MapPosition{m_at.map, m_at.tile})){ + return "expected " + to_string(MapPosition{m_at.map, m_at.tile}) + "."; + } + return ""; +} + + +std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id){ + OverworldStepSim executor(sim, id, at); + RunState state; + const Outcome outcome = executor.run(state, step); + at = executor.at(); + if (outcome.succeeded()){ + return ""; + } + return id + "/" + step.name + ": " + outcome.detail + " On " + to_string(sim.state().position) + "."; +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h new file mode 100644 index 0000000000..b8e5526496 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h @@ -0,0 +1,201 @@ +/* AutoStory Row Sim + * + * From: https://github.com/PokemonAutomation/ + * + * FRLG's story on the simulation, through the same action table as the + * console (make_story_action_table()): each binding plays the actions it + * can and stubs the rest, which need the console. StoryMapsSim runs the + * whole story on the story maps; OverworldStepSim walks a row's steps on + * the OverworldSim, for the simulation tests of each stage's rows. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryRowSim_H +#define PokemonAutomation_PokemonFRLG_AutoStoryRowSim_H + +#include +#include +#include +#include +#include +#include "Pokemon/AutoStory/Pokemon_StoryActions.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryStepInterrupts.h" +#include "PokemonFRLG_AutoStoryStepTable.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The steps of one attempt at a row that a simulation did not play. +struct RowStubs{ + std::string row; + std::vector steps; +}; + +// The steps a simulation stubs, by row: the stand-in it binds to each +// action it cannot play. +class SimStubs{ +public: + // An attempt at `row` begins; its stubs are listed apart from the last. + void begin_row(std::string row); + // Needs the console; adds the step to the row's stubs, then does + // `then` (what the simulation keeps of the step: where pacing leaves + // the player), or passes with the pose as it was. + Pokemon::StoryAction stub(Pokemon::StoryActionFunction then = {}); + const std::vector& rows() const{ + return m_rows; + } + +private: + void record(const StoryStep& step); + + std::vector m_rows; +}; + +// Each attempt's stubs, a line a row: "00.03: 2 stubbed: oak, rival". +std::string stub_report(const std::vector& rows); + +// A failed step fails the run as it threw: a simulation has no save to +// reload. +class SimStepGuard : public Pokemon::StepGuard{ +public: + virtual bool stops_on_failure(const Pokemon::StoryStep&) const override{ + return true; + } +}; + + +// What happens on the story maps besides the story's steps. +struct StorySimKnobs{ + // Whether a dialog pops up right before row `row`'s step `step`: + // someone walks up and talks between two steps. Until it is pressed + // through, it stops a step that walks or talks, as on the console. + std::function dialog_before = nullptr; + // Whether the executor clears what came up between steps + // (step_interrupts()), as the console does; off, as before it did. + bool clear_interrupts = true; +}; + +// The whole story on the story maps, without a walker: each ROUTE planned +// and followed from where the run stands (turning to a wall when the +// facing is unknown, as the program does), each LEGS laid out, a step +// into a trainer's line taken until a trainer can see the player, a +// READY's way to the nurse planned, the Viridian Mart's orders checked +// against its list, and a GAME leaving the player where pacing would +// (paced_pose()). The rest is stubbed. The dialogs `knobs` pops up are +// the only interrupts between steps it shows. +class StoryMapsSim : public Pokemon::TableExecutor{ +public: + // The table and guard are members, built after the base: it only keeps + // references to them until a step runs. + explicit StoryMapsSim(StorySimKnobs knobs = {}); + StoryMapsSim(const StoryMapsSim&) = delete; + void operator=(const StoryMapsSim&) = delete; + + virtual void begin_attempt(const Pokemon::StoryCheckpoint& data) override; + // The step, after a dialog pops up before it if the knobs say so. + virtual Pokemon::Outcome run(Pokemon::RunState& state, const StoryStep& step) override; + // The story's actions as this simulation binds them. + const Pokemon::StoryActionTable& actions() const{ + return m_table; + } + const SimStubs& stubs() const{ + return m_stubs; + } + // What the Mart sold, by its list's names. + const std::map& bag() const{ + return m_bag; + } + // "00.03/oak: a dialog pops up before it.", and how it was cleared. + const std::vector& dialog_notes() const{ + return m_dialog_notes; + } + +protected: + // A dialog that popped up and is still up stops a step that walks or + // talks; a step that presses through text closes it. + virtual Pokemon::Outcome run_action(Pokemon::RunState& state, const StoryStep& step) override; + +private: + Pokemon::StoryAction action(StoryActionId id); + Pokemon::Outcome walk_to(const Pokemon::Pose& pose, const StoryStep& step) const; + Pokemon::Outcome legs(const Pokemon::Pose& pose, const StoryStep& step) const; + Pokemon::Outcome step_ahead(const Pokemon::Pose& pose, const StoryStep& step) const; + Pokemon::Outcome shop(const StoryStep& step); + // The screen between steps: the dialog that popped up, if one did. + Pokemon::ScreenLook look(); + StepInterruptActions sim_interrupt_actions(); + + SimStepGuard m_guard; + SimStubs m_stubs; + std::map m_bag; + Pokemon::StoryActionTable m_table; + StorySimKnobs m_knobs; + Pokemon::StoryInterrupts m_interrupts; + std::string m_row; + // A dialog popped up (StorySimKnobs::dialog_before) and is still up; + // it rose since the last look. + bool m_dialog_up = false; + bool m_dialog_rose = false; + std::vector m_dialog_notes; +}; + + +// The route the program walks for `step`, a ROUTE or LEGS, from `at`: +// planned, or the legs laid out. +std::optional> walked_route(const StoryPose& at, const StoryStep& step); + +// A row's steps on the OverworldSim as the program does them: routes and +// legs by the sim's walker from where the table says the player stands, +// fighting a trainer who sees the player on the way; a step one tap at a +// time, or for a step into a trainer's line by step_until_seen(), each +// watched for the trainer; a battle from the trainer's message, which +// must be up; a cut tree cut and stepped onto. The rest is stubbed. +class OverworldStepSim : public Pokemon::TableExecutor{ +public: + // `sim` must outlive the executor. + OverworldStepSim(OverworldSim& sim, std::string row, StoryPose at); + OverworldStepSim(const OverworldStepSim&) = delete; + void operator=(const OverworldStepSim&) = delete; + + // Where the table says the player stands: moved to where it says each + // step ends. + const StoryPose& at() const{ + return m_at; + } + const Pokemon::StoryActionTable& actions() const{ + return m_table; + } + const SimStubs& stubs() const{ + return m_stubs; + } + +private: + Pokemon::StoryAction action(StoryActionId id); + std::string walk(const StoryStep& step); + std::string step_ahead(const StoryStep& step); + std::string cut(const StoryStep& step); + std::string battle(const StoryStep& step); + + OverworldSim& m_sim; + StoryPose m_at; + SimStepGuard m_guard; + SimStubs m_stubs; + Pokemon::StoryActionTable m_table; +}; + +// Runs `step` of row `id` on `sim` through OverworldStepSim, from `at`, +// and moves `at` to where the table says the step ends. Returns the +// failure, "/: On .", or empty. +std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp index 976aa9d792..ffbd519592 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -138,7 +138,7 @@ class Test_RunPlan : public UnitTest{ const RunPlanScenario m_scenario; }; -const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"; +const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10 00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25"; const RunPlanScenario RUN_PLAN_CASES[] = { { @@ -154,8 +154,7 @@ const RunPlanScenario RUN_PLAN_CASES[] = { .expectation = {.expected_ids = "00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16"}, }, { - .input = { - .name = "SID Helper: 00.01 stops on the keyboard", + .input = { .name = "SID Helper: 00.01 stops on the keyboard", .first = "00.01", .handoffs = {.sid_helper = true}, }, @@ -230,15 +229,14 @@ const RunPlanScenario RUN_PLAN_CASES[] = { }, { .input = {.name = "Gift RNG off: 00.12b is passed over", .first = "00.11"}, - .expectation = {.expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"}, + .expectation = {.expected_ids = "00.11 00.12 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25"}, }, { .input = {.name = "Gift RNG off: 00.12b runs when started at", .first = "00.12b"}, - .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"}, + .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25"}, }, { - .input = { - .name = "Gift RNG: started at 00.12b, from the save beside the salesman", + .input = { .name = "Gift RNG: started at 00.12b, from the save beside the salesman", .first = "00.12b", .handoffs = {.gift_rng = true}, }, @@ -260,11 +258,10 @@ const RunPlanScenario RUN_PLAN_CASES[] = { }, { .input = {.name = "Starter RNG alone: 00.12b runs when started at", .first = "00.12b", .handoffs = {.starter_rng = true}}, - .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20"}, + .expectation = {.expected_ids = "00.12b 00.12c 00.13 00.14 00.14b 00.14c 00.15 00.15b 00.16 00.17 00.18 00.19 00.20 00.21 00.22 00.23 00.24 00.25"}, }, { - .input = { - .name = "Starter RNG first, with both on", + .input = { .name = "Starter RNG first, with both on", .first = "00.03", .handoffs = {.starter_rng = true, .gift_rng = true}, }, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp index 5d2f8b41bb..faf96e34c5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -5,11 +5,11 @@ * A run of the whole story table, new game to the Boulder Badge, without * the console: story_checkpoints() built into engine checkpoints by the * shared builder, saving at each end as the program does, run by the - * shared runner with the program's retry policy, against a fake game. - * The fake plans each ROUTE on the story maps from where the run's pose - * says we stand (turning to a wall when the facing is unknown, as the - * program does), walks each LEGS on them, steps onto triggers, and passes - * every other step unless told to fail it. + * shared runner with the program's retry policy, on the simulation of the + * story maps (StoryMapsSim): the story's action table as the console's, + * bound to the simulation, which plans each ROUTE on the story maps from + * where the run's pose says we stand, walks each LEGS on them, steps onto + * triggers and stubs what needs the console, unless told to fail a step. * */ @@ -17,7 +17,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -32,6 +34,7 @@ #include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryPaceSim.h" +#include "PokemonFRLG_AutoStoryRowSim.h" #include "PokemonFRLG_AutoStoryStepTable.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -53,70 +56,14 @@ std::string joined(const std::vector& lines){ return text; } -const MapData* map_named(const std::string& name){ - for (const MapData& map : story_maps()){ - if (map.name == name){ - return ↦ - } - } - return nullptr; -} - -// Whether the player, at the end of a WALK `step`, stops there holding on: -// the map's edge, a wall, or water to Surf onto, but no warp of any kind, -// even one to a map that isn't loaded. -bool stops_past_end(const RouteStep& step){ - const MapData& map = *map_named(step.end.map); - const TilePos here = step.end.point; - const TilePos ahead = here.next(step.direction); - if (!in_bounds(map, ahead)){ - return true; - } - if (warps_on_step(map, ahead) || exit_mat(map, here) == step.direction - || (step.direction == Direction::NORTH && is_door(map, ahead)) - ){ - return false; - } - const StepKind kind = classify_step(map, PlayerState{here, elevation_at(map, here), step.surfing}, step.direction); - return kind == StepKind::BLOCKED || kind == StepKind::START_SURF; -} - -// The first leg of `route` to a wall that holding on would carry past its -// end, or nothing (console B, 2026-11-02: one rode Route 4's Pokemon -// Center escalator). -std::optional overrun_wall_leg(const std::vector& route){ - for (const RouteStep& step : route){ - if (step.to_wall && !stops_past_end(step)){ - return to_string(step); - } - } - return std::nullopt; -} - -// Where the run stands, with a facing: a wall beside it when unknown. -struct Standing{ - MapPosition position; - Direction facing = Direction::NORTH; - std::string error; -}; -Standing standing(const Pokemon::Pose& pose){ - const std::optional at = story_pose(pose); - if (!at){ - return {{}, Direction::NORTH, "no one-tile pose: " + Pokemon::to_string(pose)}; - } - std::optional facing = at->facing; - if (!facing){ - const MapData* map = map_named(at->map); - facing = map ? wall_beside(*map, at->tile) : std::nullopt; - } - if (!facing){ - return {{}, Direction::NORTH, "facing unknown and no wall beside " + to_string(MapPosition{at->map, at->tile})}; - } - return {{at->map, at->tile}, *facing, ""}; -} - -class FakeGame : public Pokemon::StoryStepExecutor{ +// The story on the story maps (StoryMapsSim), with a trace of what ran +// and steps told to fail. +class FakeGame : public StoryMapsSim{ public: + explicit FakeGame(StorySimKnobs knobs = {}) + : StoryMapsSim(std::move(knobs)) + {} + struct Failure{ std::string failure; size_t times = 0; @@ -126,10 +73,9 @@ class FakeGame : public Pokemon::StoryStepExecutor{ std::map failures; // Where the save loaded stands, when not at its checkpoint's start. std::optional save_stands; - // What the Mart sold, by its list's names. - std::map bag; virtual void begin_attempt(const Pokemon::StoryCheckpoint& data) override{ + StoryMapsSim::begin_attempt(data); m_row = data.id; trace.push_back("begin " + data.id); } @@ -141,126 +87,10 @@ class FakeGame : public Pokemon::StoryStepExecutor{ failure->second.times--; return Pokemon::outcome_failed(failure->second.failure, key); } - return Pokemon::StoryStepExecutor::run(state, step); - } -protected: - virtual Outcome route(RunState& state, const StoryStep& step) override{ - return walk_to(state.pose, step); - } - virtual Outcome legs(RunState& state, const StoryStep& step) override{ - const Standing at = standing(state.pose); - if (!at.error.empty()){ - return Pokemon::outcome_failed("no_pose", at.error); - } - const std::optional> route = fixed_route(story_maps(), at.position, step.legs); - if (!route || route->empty()){ - return Pokemon::outcome_failed("bad_legs", to_string(at.position)); - } - return Pokemon::outcome_ok(to_pose(StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after})); - } - // A step into a trainer's line goes on until a trainer can see the - // player, as step_until_seen() does on the console. - virtual Outcome step(RunState& state, const StoryStep& step) override{ - const std::optional at = story_pose(state.pose); - if (!at){ - return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); - } - const MapData* map = map_named(at->map); - if (step.variant != TRAINER_LINE_VARIANT || map == nullptr){ - return Pokemon::outcome_ok(to_pose(StoryPose{at->map, at->tile.next(step.direction), step.direction})); - } - TilePos onto = at->tile; - const std::optional stepped = step_until_seen( - TRAINER_LINE_TILES, - [&]{ onto = onto.next(step.direction); }, - [&]{ - return std::any_of(map->objects.begin(), map->objects.end(), [&](const MapObject& object){ - return object.trainer && trainer_may_see(*map, object, onto); - }); - } - ); - if (!stepped){ - return Pokemon::outcome_failed("no_trainer", to_string(MapPosition{at->map, onto})); - } - return Pokemon::outcome_ok(to_pose(StoryPose{at->map, onto, step.direction})); - } - // The nurse's way must plan too, but the run goes on from where it stands. - virtual Outcome ready(RunState& state, const StoryStep& step) override{ - const Outcome to_nurse = walk_to(state.pose, step); - return to_nurse.succeeded() ? Pokemon::outcome_ok() : to_nurse; - } - virtual Outcome new_game(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome options(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome talk(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome answer(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome mash(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome battle(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - virtual Outcome heal(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - // Pacing leaves the player on either of its tiles, as on the console. - virtual Outcome game(RunState& state, const StoryStep& step) override{ - return Pokemon::outcome_ok(paced_pose(state.pose, step)); - } - // The Viridian Mart's list as the game shows it. After a purchase the - // cursor stays on the row bought (console B, 2026-11-01), so each - // order moves it from there, as buy_items() does since ad5d40885. - virtual Outcome shop(RunState&, const StoryStep& step) override{ - static const std::vector LIST{"poke_ball", "potion", "antidote", "parlyz_heal", "cancel"}; - const std::optional> orders = viridian_mart_orders(step.items); - if (!orders){ - return Pokemon::outcome_failed("unknown_item", step.name); - } - int cursor = 0; - for (size_t c = 0; c < orders->size(); c++){ - const MartOrder& order = (*orders)[c]; - // The presses: down or up from where the cursor is. - cursor += order.row - cursor; - if (cursor < 0 || cursor >= (int)LIST.size() || LIST[cursor] == "cancel"){ - return Pokemon::outcome_failed("mart_cancel", std::format("order {} ends on row {}", c + 1, cursor + 1)); - } - if (LIST[cursor] != step.items[c].item){ - return Pokemon::outcome_failed("wrong_item", std::format("{} instead of {}", LIST[cursor], step.items[c].item)); - } - bag[LIST[cursor]] += order.quantity; - } - return Pokemon::outcome_ok(); + return StoryMapsSim::run(state, step); } private: - Outcome walk_to(const Pokemon::Pose& pose, const StoryStep& step){ - if (Pokemon::tiles_of(pose).size() > 1){ - // From the tiles a pacing step leaves: the route must plan, and - // test_routes_after_pacing() walks it on the sim from each. - const std::optional target = story_pose(*step.target); - const RouteGoal goal{{target->map, target->tile}, target->facing}; - const std::optional> route = plan_story_route(story_maps(), pose, std::nullopt, goal); - if (!route || route->empty()){ - return Pokemon::outcome_failed("no_route", Pokemon::to_string(pose) + " to " + to_string(goal.position)); - } - return Pokemon::outcome_ok(to_pose(StoryPose{target->map, target->tile, route->back().facing_after})); - } - const Standing at = standing(pose); - if (!at.error.empty()){ - return Pokemon::outcome_failed("no_pose", at.error); - } - const std::optional target = story_pose(*step.target); - const RouteGoal goal{{target->map, target->tile}, target->facing}; - const std::optional> route = plan_overworld_route( - story_maps(), at.position, goal, default_route_costs(), at.facing - ); - if (!route){ - return Pokemon::outcome_failed("no_route", to_string(at.position) + " to " + to_string(goal.position)); - } - const MapPosition end = follow_overworld_route(story_maps(), at.position, *route); - if (!(end == goal.position)){ - return Pokemon::outcome_failed("wrong_end", to_string(end)); - } - if (const std::optional leg = overrun_wall_leg(*route)){ - return Pokemon::outcome_failed("wall_overrun", *leg); - } - const Direction facing = route->empty() ? at.facing : route->back().facing_after; - return Pokemon::outcome_ok(to_pose(StoryPose{target->map, target->tile, facing})); - } - std::string m_row; }; @@ -269,6 +99,10 @@ class FakeGame : public Pokemon::StoryStepExecutor{ // run takes with every handoff off, each saving at its end (hooks.save // stands for save_game()). struct Simulation{ + explicit Simulation(StorySimKnobs knobs = {}) + : game(std::move(knobs)) + {} + std::vector rows = plan_story_run(story_checkpoints(), 0, story_checkpoints().size() - 1, StoryHandoffs{}).rows; FakeGame game; std::vector lines; @@ -331,6 +165,8 @@ struct Simulation{ struct AutoStorySimulationInput{ std::string name; UnitTestResult (*body)(); + // A body that logs, run instead of `body` if given. + UnitTestResult (*logged)(Logger&) = {}; }; struct AutoStorySimulationExpectation{ @@ -348,9 +184,9 @@ class Test_AutoStorySimulation : public UnitTest{ : UnitTest("PokemonFRLG::AutoStory - Simulation: " + scenario.input.name) , m_scenario(std::move(scenario)) {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ try{ - return m_scenario.input.body(); + return m_scenario.input.logged ? m_scenario.input.logged(logger) : m_scenario.input.body(); }catch (Exception& e){ return std::string(e.name()) + ": " + e.message(); } @@ -391,9 +227,9 @@ UnitTestResult test_whole_story(){ } // 00.07's shopping, with the cursor staying on each row bought. const std::map bought{{"potion", 5}, {"antidote", 3}, {"parlyz_heal", 1}}; - if (sim.game.bag != bought){ + if (sim.game.bag() != bought){ std::string text; - for (const auto& item : sim.game.bag){ + for (const auto& item : sim.game.bag()){ text += std::format(" {} {}", item.second, item.first); } return "Expected 5 Potions, 3 Antidotes and 1 Parlyz Heal; bought" + text + "."; @@ -401,6 +237,32 @@ UnitTestResult test_whole_story(){ return true; } +// The whole story through the table as the simulation binds it, each +// row's stubs logged: a step is stubbed exactly when its action needs the +// console, so no step the simulation plays is passed over. +UnitTestResult test_stubbed_steps_need_the_console(Logger& logger){ + Simulation sim; + if (!sim.run({}).completed){ + return "Expected the whole story through:" + joined(sim.lines); + } + logger.log("Steps the simulation does not play, by row:\n" + stub_report(sim.game.stubs().rows())); + for (const RowStubs& stubs : sim.game.stubs().rows()){ + const auto row = std::find_if(sim.rows.begin(), sim.rows.end(), [&](const StoryCheckpoint& data){ return data.id == stubs.row; }); + if (row == sim.rows.end()){ + return "Stubs for no row: " + stubs.row; + } + for (const StoryStep& step : row->steps){ + const Pokemon::StoryAction* action = sim.game.actions().find(step); + const bool stubbed = std::ranges::find(stubs.steps, step.name) != stubs.steps.end(); + if (action == nullptr || stubbed != action->needs_console){ + return std::format("{} {}: {}, but its action {} the console.", row->id, step.name, + stubbed ? "stubbed" : "played", action != nullptr && action->needs_console ? "needs" : "does not need"); + } + } + } + return true; +} + UnitTestResult test_start_and_end(){ Simulation sim; const Pokemon::SegmentReport report = sim.run({.from = "00.05", .to = "00.07"}); @@ -457,18 +319,6 @@ UnitTestResult test_gives_up_after_attempts(){ return true; } -// The route the program walks for `step`, a ROUTE or LEGS, from `at`: -// planned, or the legs laid out. -std::optional> walked_route(const StoryPose& at, const StoryStep& step){ - const MapPosition start{at.map, at.tile}; - if (step.kind == StoryStepKind::LEGS){ - return fixed_route(story_maps(), start, step.legs); - } - const std::optional target = story_pose(*step.target); - const RouteGoal goal{{target->map, target->tile}, target->facing}; - return plan_overworld_route(story_maps(), start, goal, default_route_costs(), at.facing); -} - // Walks `step` of 00.14 on `sim` from `at`, where the table says the // player stands, and moves `at` to where it says the step ends. Returns // the failure, or empty. @@ -535,48 +385,6 @@ UnitTestResult test_cerulean_entry_short(){ return true; } -// Runs `step` of row `id` on `sim` as the program does: routes and legs -// by the sim's walker from `at`, where the table says the player stands, -// fighting a trainer who sees the player on the way; a step one tap at a -// time, or for a step into a trainer's line by step_until_seen(), each -// watched for the trainer; a battle from the trainer's message, which -// must be up. Moves `at` to where the table says the step ends. Returns -// the failure, or empty. -std::string row_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id){ - const std::string where = id + "/" + step.name + ": "; - const auto on = [&sim]{ return " On " + to_string(sim.state().position) + "."; }; - if (step.kind == StoryStepKind::LEGS || step.kind == StoryStepKind::ROUTE){ - const std::optional> route = walked_route(at, step); - const RoutePlanning planning{.maps = &story_maps(), .start = MapPosition{at.map, at.tile}, .label = step.name}; - const std::string failure = route ? OverworldSimWalker(sim).run(*route, planning) : "no legs."; - at = route ? StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after} : at; - return failure.empty() ? "" : where + failure + on(); - } - if (step.kind == StoryStepKind::STEP){ - const auto tap = [&sim, &step]{ - if (sim.state().facing != step.direction){ - sim.tap(step.direction); - } - sim.tap(step.direction); - }; - const auto seen = [&sim]{ - return sim.watch({SimWatcher::MESSAGE, SimWatcher::BATTLE_SCREEN}, TRAINER_LINE_WATCH).has_value(); - }; - const int tiles = step.variant == TRAINER_LINE_VARIANT ? TRAINER_LINE_TILES : 1; - return step_until_seen(tiles, tap, seen) ? "" : where + "no trainer saw the player." + on(); - } - if (step.kind == StoryStepKind::BATTLE){ - if (!sim.close_message() || sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ - return where + "no_battle." + on(); - } - at = *story_pose(*step.pose_after); - if (!(sim.state().position == MapPosition{at.map, at.tile})){ - return where + "expected " + to_string(MapPosition{at.map, at.tile}) + "." + on(); - } - } - return ""; -} - // Console B, 2026-11-03 (row 00.16), twice: the open leg north from // Cerulean City onto Nugget Bridge ended on (11, 33), a tile short of // (11, 32), and the one step onto Cale's row reached only (11, 32), out @@ -964,8 +772,59 @@ UnitTestResult test_out_of_bill_cottage(){ } +// Someone talks to the player before every step (StorySimKnobs:: +// dialog_before): cleared between steps, the whole story still passes; +// left up, as before the executor looked, a walk fails on it. +UnitTestResult test_dialog_before_every_step(){ + const auto always = [](const std::string&, const std::string&){ + return true; + }; + Simulation cleared({.dialog_before = always}); + if (!cleared.run({}).completed){ + return "Expected the story through, a dialog before every step cleared:" + joined(cleared.lines); + } + Simulation left({.dialog_before = always, .clear_interrupts = false}); + const bool completed = left.run({}).completed; + const auto stopped = [](const std::string& line){ + return line.find("is still up") != std::string::npos; + }; + if (completed || std::ranges::none_of(left.lines, stopped)){ + return "Expected a dialog left up to stop a walk:" + joined(left.lines); + } + return true; +} + +// Dialogs pop up at random before one step in five: how many whole runs +// pass with them cleared between steps, and with them left up. +UnitTestResult test_dialogs_at_random(Logger& logger){ + constexpr uint32_t SEEDS = 5; + size_t passes[2] = {0, 0}; + for (uint32_t seed = 1; seed <= SEEDS; seed++){ + for (bool clear : {false, true}){ + const auto rng = std::make_shared(seed); + Simulation sim({ + .dialog_before = [rng](const std::string&, const std::string&){ + return std::bernoulli_distribution(0.2)(*rng); + }, + .clear_interrupts = clear, + }); + passes[clear] += sim.run({}).completed ? 1 : 0; + } + } + logger.log(std::format( + "Dialogs before one step in five, {} seeds: {} passed left up, {} cleared between steps.", + SEEDS, passes[0], passes[1] + )); + if (passes[1] != SEEDS){ + return std::format("Expected every run through with the dialogs cleared, {} of {}.", passes[1], SEEDS); + } + return true; +} + + const AutoStorySimulationTestCase AUTO_STORY_SIMULATION_CASES[] = { {.input = {.name = "new game to Brock, in order, each start where the last save stood", .body = test_whole_story}, .expectation = {.description = "runs full story progression from new game through Brock gym"}}, + {.input = {.name = "the steps it stubs need the console", .body = nullptr, .logged = test_stubbed_steps_need_the_console}, .expectation = {.description = "stubs each needs-console step of the whole story and plays the rest"}}, {.input = {.name = "start and end checkpoints", .body = test_start_and_end}, .expectation = {.description = "executes segment bounded by start and end checkpoints"}}, {.input = {.name = "a failed walk reloads its checkpoint's save", .body = test_retry_from_save}, .expectation = {.description = "reloads checkpoint save state on simulated movement failure"}}, {.input = {.name = "a battle lost every time stops after 3 attempts", .body = test_gives_up_after_attempts}, .expectation = {.description = "aborts after three consecutive battle blackouts"}}, @@ -979,6 +838,8 @@ const AutoStorySimulationTestCase AUTO_STORY_SIMULATION_CASES[] = { {.input = {.name = "00.20 onto the grass out of Shane's sight, the leg west a tile short", .body = test_oddish_patch_west_short}, .expectation = {.description = "recovers when approaching grass patch with short western leg"}}, {.input = {.name = "the routes after pacing, from either of its tiles", .body = test_routes_after_pacing}, .expectation = {.description = "plans valid routes regardless of which pacing tile character ends on"}}, {.input = {.name = "a save off the start pose stops before any step", .body = test_save_off_start_pose}, .expectation = {.description = "fails the start check without walking or saving"}}, + {.input = {.name = "a dialog pops up before every step", .body = test_dialog_before_every_step}, .expectation = {.description = "cleared between steps, the story passes; left up, a walk fails on it"}}, + {.input = {.name = "dialogs pop up before one step in five, over 5 seeds", .body = nullptr, .logged = test_dialogs_at_random}, .expectation = {.description = "every run passes with them cleared"}}, }; } // namespace diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp index 2869e1c299..3781630bd9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.cpp @@ -5,7 +5,9 @@ */ #include "Common/Cpp/Exceptions.h" +#include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryStepTable.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -28,6 +30,10 @@ const char* story_action_name(StoryActionId id){ case StoryActionId::HEAL: return "heal"; case StoryActionId::SHOP: return "shop"; case StoryActionId::READY: return "ready"; + case StoryActionId::CLOSE_TRAINER_CARD: return "close_trainer_card"; + case StoryActionId::LEAVE_GIFT_SUMMARY: return "leave_gift_summary"; + case StoryActionId::CUT_TREE: return "cut_tree"; + case StoryActionId::TRASH_CANS: return "trash_cans"; case StoryActionId::GAME: return "game"; } throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown story action."); @@ -47,6 +53,10 @@ const std::vector& story_action_rows(){ {StoryStepKind::HEAL, "", StoryActionId::HEAL}, {StoryStepKind::SHOP, "", StoryActionId::SHOP}, {StoryStepKind::READY, "", StoryActionId::READY}, + {StoryStepKind::GAME, CLOSE_TRAINER_CARD, StoryActionId::CLOSE_TRAINER_CARD}, + {StoryStepKind::GAME, LEAVE_GIFT_SUMMARY, StoryActionId::LEAVE_GIFT_SUMMARY}, + {StoryStepKind::GAME, CUT_TREE_VARIANT, StoryActionId::CUT_TREE}, + {StoryStepKind::GAME, TRASH_CANS_VARIANT, StoryActionId::TRASH_CANS}, {StoryStepKind::GAME, "", StoryActionId::GAME}, }; return ROWS; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.h index 002e749cb3..515bc5c3ce 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable.h @@ -39,7 +39,20 @@ enum class StoryActionId{ HEAL, SHOP, READY, - // A GAME step: one only FRLG has. + // CLOSE_TRAINER_CARD: back from SID Helper (close_trainer_card()). + CLOSE_TRAINER_CARD, + // LEAVE_GIFT_SUMMARY: back from Gift RNG (leave_gift_summary()). + LEAVE_GIFT_SUMMARY, + // CUT_TREE_VARIANT: the tree ahead cut, and a step onto its tile. + CUT_TREE, + // TRASH_CANS_VARIANT: the search for Vermilion Gym's switches. + TRASH_CANS, + // Any other GAME step: a capture or a PC deposit or withdrawal + // (capture_step(), pc_step() in PokemonFRLG_AutoStoryCapture.h); a + // shiny met in a capture stops the program without reloading. A MART, + // TEACH, LEVEL, GRIND or fossil CHOICE + // (PokemonFRLG_AutoStoryAbilities.h); a GRIND heals by routing to its + // target, the nurse, and back. GAME, }; // "route", "game": the action's name in logs and test traces. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp index ef2de6078e..33302b794b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp @@ -18,7 +18,9 @@ #include "Pokemon/AutoStory/Pokemon_StoryActions.h" #include "PokemonFRLG_AutoStoryStepInterrupts.h" #include "PokemonFRLG_AutoStoryStepTable.h" +#include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -36,6 +38,23 @@ const StoryStepKind ALL_KINDS[] = { StoryStepKind::SAVE, StoryStepKind::READY, StoryStepKind::GAME, }; +// The action StoryExecutor::game() ran for a GAME `step` before the table. +StoryActionId old_game_dispatch(const StoryStep& step){ + if (step.variant == CLOSE_TRAINER_CARD){ + return StoryActionId::CLOSE_TRAINER_CARD; + } + if (step.variant == LEAVE_GIFT_SUMMARY){ + return StoryActionId::LEAVE_GIFT_SUMMARY; + } + if (step.variant == CUT_TREE_VARIANT){ + return StoryActionId::CUT_TREE; + } + if (step.variant == TRASH_CANS_VARIANT){ + return StoryActionId::TRASH_CANS; + } + return StoryActionId::GAME; +} + // The action StoryExecutor ran for `step` before the table: the shared // per-kind dispatch (StoryStepExecutor::run()) into the kinds it // overrode. Nothing for the kinds it left to the shared executor. @@ -53,7 +72,7 @@ std::optional old_dispatch(const StoryStep& step){ case StoryStepKind::HEAL: return StoryActionId::HEAL; case StoryStepKind::SHOP: return StoryActionId::SHOP; case StoryStepKind::READY: return StoryActionId::READY; - case StoryStepKind::GAME: return StoryActionId::GAME; + case StoryStepKind::GAME: return old_game_dispatch(step); case StoryStepKind::TRAIN: case StoryStepKind::SAVE: return std::nullopt; @@ -113,6 +132,9 @@ std::string parity_error(FakeGame& game, const StoryStep& step){ } // Every kind, with no variant, an unknown one, and each one a row names. +// The table finds a row by the variant's first word, where game() compared +// the whole variant; the rows' variants are one word, as every authored +// step's is (test_parity_story_steps()). std::vector every_kind_and_variant(){ std::vector variants{"", "no_such_variant", "no_such_variant key=1"}; for (const StoryActionRow& row : story_action_rows()){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp index f6fe9b27cf..e61a81ab2a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp @@ -129,7 +129,9 @@ std::optional read_party_summary( if (read){ std::string moves; for (const SummaryMove& move : read->moves){ - moves += std::format("{}{} {}/{}", moves.empty() ? "" : ", ", move.move, move.pp, move.max_pp); + moves += std::format( + "{}{} {}/{}", moves.empty() ? "" : ", ", move.move.empty() ? "?" : move.move, move.pp, move.max_pp + ); } const std::string stats = read->stats ? std::format( diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 68fa3d2b41..d426f8d08e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -10,6 +10,7 @@ #include "PokemonFRLG_AutoStoryMart.h" #include "PokemonFRLG_AutoStoryPcStorage.h" #include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" #include "PokemonFRLG_AutoStoryTeach.h" namespace PokemonAutomation{ @@ -19,180 +20,7 @@ namespace PokemonFRLG{ namespace{ -constexpr Direction NORTH = Direction::NORTH; -constexpr Direction SOUTH = Direction::SOUTH; -constexpr Direction EAST = Direction::EAST; -constexpr Direction WEST = Direction::WEST; - -const char* const BEDROOM = "PalletTown_PlayersHouse_2F"; -const char* const PALLET = "PalletTown"; -const char* const LAB = "PalletTown_ProfessorOaksLab"; -const char* const VIRIDIAN = "ViridianCity"; -const char* const VIRIDIAN_MART = "ViridianCity_Mart"; -const char* const VIRIDIAN_CENTER = "ViridianCity_PokemonCenter_1F"; -const char* const FOREST = "ViridianForest"; -const char* const FOREST_GATE = "Route2_ViridianForest_NorthEntrance"; -const char* const PEWTER = "PewterCity"; -const char* const PEWTER_CENTER = "PewterCity_PokemonCenter_1F"; -const char* const GYM = "PewterCity_Gym"; -const char* const ROUTE3 = "Route3"; -const char* const ROUTE4 = "Route4"; -const char* const ROUTE4_CENTER = "Route4_PokemonCenter_1F"; -const char* const MT_MOON_1F = "MtMoon_1F"; -const char* const MT_MOON_B2F = "MtMoon_B2F"; -const char* const CERULEAN = "CeruleanCity"; -const char* const CERULEAN_CENTER = "CeruleanCity_PokemonCenter_1F"; -const char* const CERULEAN_MART = "CeruleanCity_Mart"; -const char* const CERULEAN_GYM = "CeruleanCity_Gym"; -const char* const ROUTE24 = "Route24"; -const char* const ROUTE25 = "Route25"; -const char* const SEA_COTTAGE = "Route25_SeaCottage"; - -Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt){ - return to_pose(StoryPose{map, {x, y}, facing}); -} - -StoryCheckpoint row( - std::string id, std::string title, std::optional start, - Supplies supplies, StartMenu menu, std::vector steps -){ - StoryCheckpoint checkpoint; - checkpoint.id = std::move(id); - checkpoint.title = std::move(title); - checkpoint.start = std::move(start); - checkpoint.steps = std::move(steps); - checkpoint.supplies = supplies; - checkpoint.menu = menu; - return checkpoint; -} - -// At the rows after Brock: no items counted, the starter with the moves -// the story gave it by Lv13. A run that loads a save reads the moves off -// the lead's summary instead (read_lead_moves()), so a save whose moves -// differ from these (console B, 2026-11-02: Water Gun where Tackle was) -// is fought with the moves it has. -Supplies after_brock(){ - Supplies supplies; - supplies.lead = story_lead_after_brock(); - return supplies; -} - -// What a walk does about wild Pokemon: runs, or fights them for EXP. -enum class WildBattles{ - RUN, - FIGHT, -}; - -StoryStep make(StoryStepKind kind, std::string name, std::string why){ - StoryStep step; - step.kind = kind; - step.name = std::move(name); - step.why = std::move(why); - return step; -} - -StoryStep route(std::string name, Pokemon::Pose target, std::string why, WildBattles wild = WildBattles::RUN){ - StoryStep step = make(StoryStepKind::ROUTE, std::move(name), std::move(why)); - step.target = std::move(target); - step.fight_wild = wild == WildBattles::FIGHT; - return step; -} - -StoryStep step_into(std::string name, Direction direction, std::string why){ - StoryStep step = make(StoryStepKind::STEP, std::move(name), std::move(why)); - step.direction = direction; - return step; -} - -// A step the way a ledge faces, from the tile before it: the player hops -// it and lands on `landing`, two tiles on. Planned routes never hop a -// ledge (they hold into walls, and a held ledge hops), so where the only -// way on is down a ledge the table takes it as this one step. -// A step `direction` into a trainer's line, a tile at a time until the -// trainer sees the player (TRAINER_LINE_VARIANT): the battle after it -// starts whether the walk before ended on its tile or a tile short. -StoryStep into_line(std::string name, Direction direction, std::string why){ - StoryStep step = step_into(std::move(name), direction, std::move(why)); - step.variant = TRAINER_LINE_VARIANT; - return step; -} - -StoryStep hop(std::string name, Direction direction, Pokemon::Pose landing, std::string why){ - StoryStep step = step_into(std::move(name), direction, std::move(why)); - step.pose_after = std::move(landing); - return step; -} - -StoryStep answer(std::string name, bool yes, std::string why){ - StoryStep step = make(StoryStepKind::ANSWER, std::move(name), std::move(why)); - step.yes = yes; - return step; -} - -StoryStep scene(std::string name, std::optional pose_after, std::string why){ - StoryStep step = make(StoryStepKind::MASH, std::move(name), std::move(why)); - step.pose_after = std::move(pose_after); - return step; -} - -StoryStep battle(std::string name, Pokemon::Pose pose_after, bool may_lose, std::string why){ - StoryStep step = make(StoryStepKind::BATTLE, std::move(name), std::move(why)); - step.pose_after = std::move(pose_after); - step.may_lose = may_lose; - return step; -} - -StoryStep legs(std::string name, std::vector legs, std::string why, WildBattles wild = WildBattles::RUN){ - StoryStep step = make(StoryStepKind::LEGS, std::move(name), std::move(why)); - step.legs = std::move(legs); - step.fight_wild = wild == WildBattles::FIGHT; - return step; -} - -// `step`, a ROUTE or LEGS, run with B held: from the Running Shoes on. -// Indoors, where the game doesn't let the player run, it walks. -StoryStep running(StoryStep step){ - step.variant = RUN_VARIANT; - return step; -} - -StoryStep new_game_intro(Pokemon::Pose pose_after, std::string why){ - StoryStep step = make(StoryStepKind::NEW_GAME, "intro", std::move(why)); - step.pose_after = std::move(pose_after); - return step; -} - -StoryStep talk(std::string name, std::string why){ - return make(StoryStepKind::TALK, std::move(name), std::move(why)); -} - -StoryStep ready(std::string name, Pokemon::Pose nurse, std::string why){ - StoryStep step = make(StoryStepKind::READY, std::move(name), std::move(why)); - step.target = std::move(nurse); - return step; -} - -StoryStep heal(std::string name){ - return make(StoryStepKind::HEAL, std::move(name), "full HP"); -} - -StoryStep shop(std::string name, std::vector items, std::string why){ - StoryStep step = make(StoryStepKind::SHOP, std::move(name), std::move(why)); - step.items = std::move(items); - return step; -} - - -StoryStep game_step(std::string variant, Pokemon::Pose pose_after, std::string why){ - StoryStep step = make(StoryStepKind::GAME, variant, std::move(why)); - step.variant = std::move(variant); - step.pose_after = std::move(pose_after); - return step; -} - -StoryStep options_step(){ - return make(StoryStepKind::OPTIONS, "options", "fast text; no switch prompt between a trainer's Pokemon"); -} +using namespace StoryTable; StoryCheckpoint new_game(){ @@ -875,15 +703,97 @@ StoryCheckpoint oddish(){ }); } +// Cerulean's only way south without Cut (CeruleanCity map.json, +// scripts.inc) is through the burgled house (30, 11), whose door the +// policeman leaves once the S.S. TICKET is had, out of the hole in its +// back wall into the yard (31, 8). The Rocket there (33, 6) stops the +// player on (33, 5) or (33, 7) (CeruleanCity_EventScript_GruntTrigger*): +// from (32, 7), the step east onto (33, 7) turns the player to him; +// Machop and Drowzee, Lv17, then TM28 Dig, and he is gone. Then east +// round the yard, down to the city's south edge by the path along its +// east side (39..40, 20..31), down Route 5's east side to the +// Underground Path's north house (31, 31), through its tunnel and out of +// the south house onto Route 6, below its door (19, 14). Route 6: +// - Bug Catcher Keigo (3, 16) faces east, sight 5, over the middle path +// (4..8, 16); Bug Catcher Elijah (20, 25) faces west, sight 3, over +// the east path (17..19, 25). The way goes down column 15, through the +// grass to the wall (15, 33), out of both lines; the open leg west to +// it, a tile short, leaves the player on column 16, out of them too. +// - Camper Ricky and Picnicker Nancy (12..13, 21) battle only when +// talked to. +// - Picnicker Isabelle (13, 32) and Camper Jeff (13, 33) face west, sight +// 3, over (10..12, 32) and (10..12, 33); the only way south, (10..12, +// 34), is from Jeff's row. Up to (15, 31), west to the wall (7, 31), +// down to the wall (7, 33), east: Jeff is fought on the step onto +// (10, 33) and walks up to (11, 33). Isabelle's row is kept off. +// Then down into Vermilion City (offset 12) to its Pokemon Center. +StoryCheckpoint to_vermilion(){ + return row("00.21", "The Cerulean Rocket, Route 5, the Underground Path and Route 6 to Vermilion's Pokemon Center: Jeff", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("west_of_rocket", at(CERULEAN, 32, 7, EAST), "through the burgled house and out of the hole in its back wall, west of the Rocket (33, 6)")), + step_into("rocket_trigger", EAST, "onto (33, 7), below the Rocket"), + battle("rocket_grunt", at(CERULEAN, 33, 7, NORTH), false, "Machop and Drowzee, Lv17; TM28 Dig"), + running(route("route6_column15", at(ROUTE6, 15, 14), "round the yard and down Cerulean's east side, Route 5 into the Underground Path, through it onto Route 6, west to column 15")), + running(legs("round_keigo_and_elijah", { + {SOUTH, 19}, {NORTH, 1}, {NORTH, 1}, {WEST, 8}, {SOUTH, 2}, {EAST, 2}, + }, "down column 15 to the wall (15, 33); up to (15, 31), west to the wall (7, 31), down to the wall (7, 33), east to (9, 33)")), + into_line("jeff_line", EAST, "onto (10, 33), in Jeff's line"), + battle("camper_jeff", at(ROUTE6, 10, 33, EAST), false, "Spearow and Raticate, Lv16; Jeff stops at (11, 33)"), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "down Route 6's south path into Vermilion City, to the Pokemon Center")), + heal("heal_vermilion"), + }); +} + +// The S.S. Anne (VermilionCity, SSAnne_* scripts.inc). The step onto +// (23, 33) (VermilionCity_EventScript_CheckTicket) has the sailor (24, 33) +// ask for a ticket; the S.S. TICKET (00.18) is shown and the player turns +// east to him. Then onto the pier (23, 34), along the gangway, through +// 1F, by legs off its cabins' doormats on row 10, which planned routes +// cross, and up its stairs (3, 8) to 2F. The step onto (30, 6), below the +// Captain's door (30, 2) (SSAnne_2F_Corridor_EventScript_RivalTriggerLeft), +// brings the rival out and down to (30, 5); the player keeps facing +// north, and after the battle he walks round the player and off. For a +// player who took Squirtle: Pidgeotto Lv19, Raticate Lv16, Kadabra Lv18, +// Ivysaur Lv20. The ship's trainers are in its cabins and on its deck, +// off the way. The Captain (5, 4) faces up; talked to from (5, 5) he is +// rubbed on the back and gives HM01 (VAR_MAP_SCENE_VERMILION_CITY 1). +// Back out through 1F's door (19, 1): on the gangway the ship sails +// (SSAnne_Exterior_ExitSSAnne), the player is put on (23, 34) and walked +// up to (23, 32) (VermilionCity_EventScript_ExitSSAnne). Cut is taught +// later, to the Oddish. +StoryCheckpoint ss_anne(){ + return row("00.22", "The S.S. Anne: the rival, HM01 Cut from the Captain, back to Vermilion's Pokemon Center", at(VERMILION_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("above_ticket_check", at(VERMILION, 23, 32, SOUTH), "out of the Center down to the harbor, above the ticket check (23, 33)")), + step_into("ticket_check", SOUTH, "onto (23, 33): \"Do you have a ticket?\""), + scene("show_ss_ticket", at(VERMILION, 23, 33, EAST), "the S.S. TICKET; the player turns to the sailor"), + running(route("aboard", at(SS_ANNE_1F, 19, 2, SOUTH), "onto the pier, along the gangway and in by 1F's door, a step below it")), + running(legs("past_the_cabins", {{SOUTH, 8}, {NORTH, 1}, {WEST, 6}, {NORTH, 1}}, "to the wall (19, 10), up a step, west to the worker (12, 9), up a step to (13, 8): off the cabins' mats on row 10")), + running(route("below_rival_trigger", at(SS_ANNE_2F, 30, 7, NORTH), "west along 1F, up the stairs (3, 8) and along 2F below the Captain's door")), + step_into("rival_trigger", NORTH, "onto (30, 6): the rival comes out of the Captain's door"), + battle("rival_ss_anne", at(SS_ANNE_2F, 30, 6, NORTH), false, "Pidgeotto, Raticate, Kadabra and Ivysaur"), + running(route("behind_captain", at(CAPTAINS_OFFICE, 5, 5, NORTH), "into the Captain's office, behind the Captain (5, 4)")), + talk("captain", "\"Ooargh... I feel hideous... Urrp! Seasick...\""), + scene("hm01_cut", at(CAPTAINS_OFFICE, 5, 5, NORTH), "the player rubs his back; HM01, and what Cut does"), + running(route("off_2f", at(SS_ANNE_1F, 13, 8), "back along 2F, down the stairs and east along 1F's row 8")), + running(legs("round_the_sailor", {{EAST, 4}, {SOUTH, 1}, {EAST, 2}}, "to the sailor (18, 8), down a step, east to (19, 9): off the cabins' mats on row 10")), + running(route("below_ship_door", at(SS_ANNE_1F, 19, 2, NORTH), "up below the door to the gangway")), + step_into("off_the_ship", NORTH, "out onto the gangway"), + scene("ss_anne_sails", at(VERMILION, 23, 32, NORTH), "the ship sails; the player is walked off the pier to (23, 32)"), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "back to the Pokemon Center")), + heal("heal_vermilion"), + }); +} + } // namespace + 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(), route3(), magikarp_salesman(), after_gift_rng(), magikarp_to_box(), mt_moon(), route4_to_cerulean(), cerulean_gym(), misty(), cerulean_grind(), cerulean_rival(), nugget_bridge(), - route25(), bill(), back_to_cerulean(), oddish(), + route25(), bill(), back_to_cerulean(), oddish(), to_vermilion(), ss_anne(), + vermilion_gym(), to_lt_surge(), lt_surge(), }; return checkpoints; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h index 545a81afe4..1e12574fe5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h @@ -4,9 +4,10 @@ * * FireRed from a new game to the Boulder Badge, the Running Shoes, Route * 3 to the Magikarp salesman in Route 4's Pokemon Center, through Mt. - * Moon to Route 4 east of it, and on to Cerulean City, Misty, its rival - * and Nugget Bridge, as story checkpoints 00.01 to 00.16, each saved at its - * end. After 00.10 every route and leg runs. Taken from a by-hand run on + * Moon to Route 4 east of it, and on to Cerulean City, Misty, its rival, + * Nugget Bridge and Bill, then Route 6 to Vermilion City, the S.S. + * Anne's HM01 and Lt. Surge, as story checkpoints 00.01 to 00.25, each + * saved at its end. After 00.10 every route and leg runs. Taken from a by-hand run on * console B (2026-11-01) and the pokefirered map scripts. * */ @@ -23,7 +24,7 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -// Checkpoints 00.01 to 00.16, in order, with 00.01b, the way back in after +// Checkpoints 00.01 to 00.25, in order, with 00.01b, the way back in after // SID Helper, after 00.01, 00.04 split in two at Squirtle's ball for // Starter RNG: 00.04 and 00.04b, 00.12b, the way back in after Gift RNG, // after 00.12, 00.12c, the Magikarp put in BOX 1, after it, 00.14b and @@ -39,6 +40,10 @@ void add_tests_AutoStory(UnitTestDatabase& database); void add_tests_AutoStoryRunPlan(UnitTestDatabase& database); // The table run end to end against a fake game (PokemonFRLG_AutoStorySimulation_Tests.cpp). void add_tests_AutoStorySimulation(UnitTestDatabase& database); +// Rows 00.21 and 00.22 on the overworld sim (PokemonFRLG_AutoStoryVermilion_Tests.cpp). +void add_tests_AutoStoryVermilion(UnitTestDatabase& database); +// Rows 00.23 to 00.25 on the overworld sim (PokemonFRLG_AutoStoryVermilionGym_Tests.cpp). +void add_tests_AutoStoryVermilionGym(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp new file mode 100644 index 0000000000..5ea62165bc --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp @@ -0,0 +1,158 @@ +/* AutoStory Table Steps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonFRLG_AutoStoryTableSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryTable{ + + +Pokemon::Pose at(const char* map, int x, int y, std::optional facing){ + return to_pose(StoryPose{map, {x, y}, facing}); +} + +StoryCheckpoint row( + std::string id, std::string title, std::optional start, + Supplies supplies, StartMenu menu, std::vector steps +){ + StoryCheckpoint checkpoint; + checkpoint.id = std::move(id); + checkpoint.title = std::move(title); + checkpoint.start = std::move(start); + checkpoint.steps = std::move(steps); + checkpoint.supplies = supplies; + checkpoint.menu = menu; + return checkpoint; +} + +Supplies after_brock(){ + Supplies supplies; + supplies.lead = story_lead_after_brock(); + return supplies; +} + +StoryStep make(StoryStepKind kind, std::string name, std::string why){ + StoryStep step; + step.kind = kind; + step.name = std::move(name); + step.why = std::move(why); + return step; +} + +StoryStep route(std::string name, Pokemon::Pose target, std::string why, WildBattles wild){ + StoryStep step = make(StoryStepKind::ROUTE, std::move(name), std::move(why)); + step.target = std::move(target); + step.fight_wild = wild == WildBattles::FIGHT; + return step; +} + +StoryStep step_into(std::string name, Direction direction, std::string why){ + StoryStep step = make(StoryStepKind::STEP, std::move(name), std::move(why)); + step.direction = direction; + return step; +} + +StoryStep into_line(std::string name, Direction direction, std::string why){ + StoryStep step = step_into(std::move(name), direction, std::move(why)); + step.variant = TRAINER_LINE_VARIANT; + return step; +} + +StoryStep hop(std::string name, Direction direction, Pokemon::Pose landing, std::string why){ + StoryStep step = step_into(std::move(name), direction, std::move(why)); + step.pose_after = std::move(landing); + return step; +} + +StoryStep answer(std::string name, bool yes, std::string why){ + StoryStep step = make(StoryStepKind::ANSWER, std::move(name), std::move(why)); + step.yes = yes; + return step; +} + +StoryStep scene(std::string name, std::optional pose_after, std::string why){ + StoryStep step = make(StoryStepKind::MASH, std::move(name), std::move(why)); + step.pose_after = std::move(pose_after); + return step; +} + +StoryStep battle(std::string name, Pokemon::Pose pose_after, bool may_lose, std::string why){ + StoryStep step = make(StoryStepKind::BATTLE, std::move(name), std::move(why)); + step.pose_after = std::move(pose_after); + step.may_lose = may_lose; + return step; +} + +StoryStep legs(std::string name, std::vector legs, std::string why, WildBattles wild){ + StoryStep step = make(StoryStepKind::LEGS, std::move(name), std::move(why)); + step.legs = std::move(legs); + step.fight_wild = wild == WildBattles::FIGHT; + return step; +} + +StoryStep running(StoryStep step){ + step.variant = RUN_VARIANT; + return step; +} + +StoryStep new_game_intro(Pokemon::Pose pose_after, std::string why){ + StoryStep step = make(StoryStepKind::NEW_GAME, "intro", std::move(why)); + step.pose_after = std::move(pose_after); + return step; +} + +StoryStep talk(std::string name, std::string why){ + return make(StoryStepKind::TALK, std::move(name), std::move(why)); +} + +StoryStep ready(std::string name, Pokemon::Pose nurse, std::string why){ + StoryStep step = make(StoryStepKind::READY, std::move(name), std::move(why)); + step.target = std::move(nurse); + return step; +} + +StoryStep heal(std::string name){ + return make(StoryStepKind::HEAL, std::move(name), "full HP"); +} + +StoryStep shop(std::string name, std::vector items, std::string why){ + StoryStep step = make(StoryStepKind::SHOP, std::move(name), std::move(why)); + step.items = std::move(items); + return step; +} + +StoryStep game_step(std::string variant, Pokemon::Pose pose_after, std::string why){ + StoryStep step = make(StoryStepKind::GAME, variant, std::move(why)); + step.variant = std::move(variant); + step.pose_after = std::move(pose_after); + return step; +} + +StoryStep options_step(){ + return make(StoryStepKind::OPTIONS, "options", "fast text; no switch prompt between a trainer's Pokemon"); +} +StoryStep into_sight(std::string name, Direction direction, std::string why){ + StoryStep step = step_into(std::move(name), direction, std::move(why)); + step.variant = TRAINER_WAIT_VARIANT; + return step; +} + +StoryStep cut_tree(std::string name, Pokemon::Pose tree, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = CUT_TREE_VARIANT; + step.direction = facing_of(tree).value_or(Direction::NORTH); + step.pose_after = std::move(tree); + return step; +} + + + +} // namespace StoryTable +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h new file mode 100644 index 0000000000..dd038a4704 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h @@ -0,0 +1,179 @@ +/* AutoStory Table Steps + * + * From: https://github.com/PokemonAutomation/ + * + * What the story table (PokemonFRLG_AutoStoryTable.h) is written in: the + * maps' names and the functions that make its rows and steps, shared by + * the files its rows are in. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTableSteps_H +#define PokemonAutomation_PokemonFRLG_AutoStoryTableSteps_H + +#include +#include +#include +#include "PokemonFRLG_AutoStoryEvents.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryTable{ + + +// Cardinal direction: North. +inline constexpr Direction NORTH = Direction::NORTH; +// Cardinal direction: South. +inline constexpr Direction SOUTH = Direction::SOUTH; +// Cardinal direction: East. +inline constexpr Direction EAST = Direction::EAST; +// Cardinal direction: West. +inline constexpr Direction WEST = Direction::WEST; + +// Pallet Town player's bedroom map. +inline constexpr const char* BEDROOM = "PalletTown_PlayersHouse_2F"; +// Pallet Town overworld map. +inline constexpr const char* PALLET = "PalletTown"; +// Professor Oak's lab map. +inline constexpr const char* LAB = "PalletTown_ProfessorOaksLab"; +// Viridian City overworld map. +inline constexpr const char* VIRIDIAN = "ViridianCity"; +// Viridian City Mart map. +inline constexpr const char* VIRIDIAN_MART = "ViridianCity_Mart"; +// Viridian City Pokemon Center map. +inline constexpr const char* VIRIDIAN_CENTER = "ViridianCity_PokemonCenter_1F"; +// Viridian Forest overworld map. +inline constexpr const char* FOREST = "ViridianForest"; +// Viridian Forest North Entrance gate map. +inline constexpr const char* FOREST_GATE = "Route2_ViridianForest_NorthEntrance"; +// Pewter City overworld map. +inline constexpr const char* PEWTER = "PewterCity"; +// Pewter City Pokemon Center map. +inline constexpr const char* PEWTER_CENTER = "PewterCity_PokemonCenter_1F"; +// Pewter City Gym map. +inline constexpr const char* GYM = "PewterCity_Gym"; +// Route 3 overworld map. +inline constexpr const char* ROUTE3 = "Route3"; +// Route 4 overworld map. +inline constexpr const char* ROUTE4 = "Route4"; +// Route 4 Pokemon Center map. +inline constexpr const char* ROUTE4_CENTER = "Route4_PokemonCenter_1F"; +// Mt. Moon 1F map. +inline constexpr const char* MT_MOON_1F = "MtMoon_1F"; +// Mt. Moon B2F map. +inline constexpr const char* MT_MOON_B2F = "MtMoon_B2F"; +// Cerulean City overworld map. +inline constexpr const char* CERULEAN = "CeruleanCity"; +// Cerulean City Pokemon Center map. +inline constexpr const char* CERULEAN_CENTER = "CeruleanCity_PokemonCenter_1F"; +// Cerulean City Mart map. +inline constexpr const char* CERULEAN_MART = "CeruleanCity_Mart"; +// Cerulean City Gym map. +inline constexpr const char* CERULEAN_GYM = "CeruleanCity_Gym"; +// Route 24 overworld map. +inline constexpr const char* ROUTE24 = "Route24"; +// Route 25 overworld map. +inline constexpr const char* ROUTE25 = "Route25"; +// Bill's Sea Cottage map. +inline constexpr const char* SEA_COTTAGE = "Route25_SeaCottage"; +// Route 6 overworld map. +inline constexpr const char* ROUTE6 = "Route6"; +// Vermilion City overworld map. +inline constexpr const char* VERMILION = "VermilionCity"; +// Vermilion City Pokemon Center map. +inline constexpr const char* VERMILION_CENTER = "VermilionCity_PokemonCenter_1F"; +// Vermilion City Gym map. +inline constexpr const char* VERMILION_GYM = "VermilionCity_Gym"; +// S.S. Anne 1F corridor map. +inline constexpr const char* SS_ANNE_1F = "SSAnne_1F_Corridor"; +// S.S. Anne 2F corridor map. +inline constexpr const char* SS_ANNE_2F = "SSAnne_2F_Corridor"; +// Captain's Office map. +inline constexpr const char* CAPTAINS_OFFICE = "SSAnne_CaptainsOffice"; + +// Constructs pose at map coordinates. +Pokemon::Pose at(const char* map, int x, int y, std::optional facing = std::nullopt); + +// Constructs story checkpoint row. +StoryCheckpoint row( + std::string id, std::string title, std::optional start, + Supplies supplies, StartMenu menu, std::vector steps +); + +// At the rows after Brock: no items counted, the starter with the moves +// the story gave it by Lv13. A run that loads a save reads the moves off +// the lead's summary instead (read_lead_moves()), so a save whose moves +// differ from these (console B, 2026-11-02: Water Gun where Tackle was) +// is fought with the moves it has. +Supplies after_brock(); + +// What a walk does about wild Pokemon: runs, or fights them for EXP. +enum class WildBattles{ + RUN, + FIGHT, +}; + +// Constructs a story step of given kind. +StoryStep make(StoryStepKind kind, std::string name, std::string why); +// Constructs a route navigation step. +StoryStep route(std::string name, Pokemon::Pose target, std::string why, WildBattles wild = WildBattles::RUN); +// Constructs a single directional movement step. +StoryStep step_into(std::string name, Direction direction, std::string why); +// A step `direction` into a trainer's line, a tile at a time until the +// trainer sees the player (TRAINER_LINE_VARIANT): the battle after it +// starts whether the walk before ended on its tile or a tile short. +StoryStep into_line(std::string name, Direction direction, std::string why); +// A step `direction` into the line of a trainer who looks around +// (TRAINER_WAIT_VARIANT), then a wait until they turn to the player: the +// battle after it starts on that tile. +StoryStep into_sight(std::string name, Direction direction, std::string why); +// A step the way a ledge faces, from the tile before it: the player hops +// it and lands on `landing`, two tiles on. Planned routes never hop a +// ledge (they hold into walls, and a held ledge hops), so where the only +// way on is down a ledge the table takes it as this one step. +StoryStep hop(std::string name, Direction direction, Pokemon::Pose landing, std::string why); +// Constructs a dialog answer step. +StoryStep answer(std::string name, bool yes, std::string why); +// Constructs an in-game cutscene or dialog interaction step. +StoryStep scene(std::string name, std::optional pose_after, std::string why); +// Constructs a trainer or boss battle step. +StoryStep battle(std::string name, Pokemon::Pose pose_after, bool may_lose, std::string why); +// Constructs a fixed multi-leg route step. +StoryStep legs(std::string name, std::vector legs, std::string why, WildBattles wild = WildBattles::RUN); +// `step`, a ROUTE or LEGS, run with B held: from the Running Shoes on. +// Indoors, where the game doesn't let the player run, it walks. +StoryStep running(StoryStep step); +// Constructs new game introduction sequence step. +StoryStep new_game_intro(Pokemon::Pose pose_after, std::string why); +// Constructs an NPC dialog interaction step. +StoryStep talk(std::string name, std::string why); +// Constructs checkpoint preparation and recovery step. +StoryStep ready(std::string name, Pokemon::Pose nurse, std::string why); +// Constructs a Pokemon Center healing sequence step. +StoryStep heal(std::string name); +// Constructs a Poke Mart shopping step. +StoryStep shop(std::string name, std::vector items, std::string why); +// Constructs an internal engine gameplay automation step. +StoryStep game_step(std::string variant, Pokemon::Pose pose_after, std::string why); +// Constructs game settings configuration step. +StoryStep options_step(); +// Cuts the tree the player faces (CUT_TREE_VARIANT) and steps onto its +// tile, `tree`, facing the way the step goes. +StoryStep cut_tree(std::string name, Pokemon::Pose tree, std::string why); + + +// Rows in files of their own (PokemonFRLG_AutoStoryTableVermilionGym.cpp). +StoryCheckpoint vermilion_gym(); +// Route navigation step towards Lt. Surge battle. +StoryCheckpoint to_lt_surge(); +// Lt. Surge gym leader battle checkpoint. +StoryCheckpoint lt_surge(); + + + +} // namespace StoryTable +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp new file mode 100644 index 0000000000..6c8f3d653f --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp @@ -0,0 +1,158 @@ +/* AutoStory Table: Vermilion Gym + * + * From: https://github.com/PokemonAutomation/ + * + * Rows 00.23 to 00.25: HM01 Cut to the Oddish, the tree before Vermilion + * Gym, its three trainers and its trash cans, then Lt. Surge from a save + * in front of him. + * + * The gym (VermilionCity_Gym map.json, scripts.inc), 11 x 20, entered on + * (5, 19): the cans on rows 10, 12 and 14 at x 1, 3, 5, 7, 9 + * (PokemonFRLG_AutoStoryTrashCans.h), the beams across rows 6 and 7 that + * the two switches turn off, Lt. Surge (5, 2) facing down above them. Its + * trainers, sight 3: + * - Sailor Dwayne (8, 13) and Engineer Baily (2, 11) look around + * (MOVEMENT_TYPE_LOOK_AROUND): between them they see across rows 11 and + * 13 and down columns 2 and 8, so no way from the door to the cans + * keeps out of their sight. Each is fought from the second tile of his + * line, stepped onto from beside it, out of sight, and waited on there + * until he turns to the player (TRAINER_WAIT_VARIANT): Dwayne from + * (8, 15), stepped onto from (7, 15), Baily from (2, 13), from (3, 13). + * Each walks up a tile, Dwayne to (8, 14), Baily to (2, 12), and stays + * there while the player is in the gym. + * - Gentleman Tucker (7, 8) faces west over (4..6, 8): fought on the step + * up from (5, 9) onto (5, 8); he walks up to (6, 8). + * The cans' stands, on rows 9, 11 and 13 above each, are clear of where + * the three stand after their battles. Leaving the gym puts them back + * where the map has them, beaten, and grows the tree back. + * + */ + +#include "PokemonFRLG_AutoStoryBattleData.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" +#include "PokemonFRLG_AutoStoryTeach.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryTable{ + + +namespace{ + +// The search for the switches (PokemonFRLG_AutoStoryTrashCans.h). It +// ends at the last can's stand, which the step's Outcome says. +StoryStep trash_cans(std::string name, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = TRASH_CANS_VARIANT; + return step; +} + +// `step`, skipped when the run flag `flag` is set. +StoryStep unless(const char* flag, StoryStep step){ + step.skip_if.emplace_back(flag); + return step; +} + +} // namespace + + +// HM01 Cut, from the S.S. Anne (00.22), to the Oddish (00.20), second in +// the party: a wild Oddish of Lv12 to 14 knows 2 or 3 moves (Absorb, +// Sweet Scent, Poison Powder at 14), so it has room. TM28 Dig, from the +// Cerulean Rocket (00.21), to the lead in place of Withdraw: Ground, twice +// as strong on Lt. Surge's three, it takes the sim's chance against him +// for a Lv28 Wartortle with 14 Potions from 6% to 47%. Vermilion's tree +// (19, 24) (EventScript_CutTree) is the only way into the gym's yard, +// row 24 being walls but for it; the gym's door is (14, 25). In the gym, +// the trainers as above, then the trash cans until the door opens; out, +// the tree cut again from below, and back to the nurse. +StoryCheckpoint vermilion_gym(){ + const TeachPlan cut{.item = "hm01", .move = "cut", .member = 1}; + const TeachPlan dig{.item = "tm28", .move = "dig", .member = 0, .forget_move = "withdraw"}; + return row("00.23", "Vermilion Gym: Cut and Dig, the tree, Dwayne, Baily and Tucker, the trash cans", at(VERMILION_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + teach_step("teach_cut", "HM01 Cut to the Oddish, second in the party, unless it knows it", cut), + teach_step("teach_dig", "TM28 Dig in place of Withdraw, for Lt. Surge, unless the lead knows it or there is no TM28", dig), + running(route("above_gym_tree", at(VERMILION, 19, 23, SOUTH), "out of the Center, south through the city to above the tree (19, 24)")), + cut_tree("cut_gym_tree", at(VERMILION, 19, 24, SOUTH), "the Oddish cuts the tree; a step onto its tile"), + running(route("gym_door", at(VERMILION_GYM, 5, 19, NORTH), "down into the yard and in by the gym's door (14, 25)")), + running(legs("west_of_dwayne_line", {{NORTH, 4}, {EAST, 2}}, "up column 5 to the can (5, 14), east to (7, 15): out of Dwayne's and Baily's sight")), + into_sight("dwayne_sight", EAST, "onto (8, 15), two below Dwayne (8, 13): he turns to the player and walks up to (8, 14)"), + battle("sailor_dwayne", at(VERMILION_GYM, 8, 15, NORTH), false, "two Pikachu, Lv21"), + running(legs("east_of_baily_line", {{WEST, 4}, {NORTH, 2}, {WEST, 1}}, "west along row 15 to (4, 15), up to (4, 13), west to (3, 13): out of Baily's sight")), + into_sight("baily_sight", WEST, "onto (2, 13), two below Baily (2, 11): he turns to the player and walks up to (2, 12)"), + battle("engineer_baily", at(VERMILION_GYM, 2, 13, NORTH), false, "Voltorb and Magnemite, Lv21"), + running(legs("below_tucker_line", {{EAST, 2}, {NORTH, 4}, {EAST, 1}}, "east to (4, 13), up column 4 past Baily's row to (4, 9), east to (5, 9): below Tucker's line, not on it")), + into_line("tucker_line", NORTH, "onto (5, 8), in Tucker's line: he walks up to (6, 8)"), + battle("gentleman_tucker", at(VERMILION_GYM, 5, 8, EAST), false, "Pikachu, Lv23"), + trash_cans("trash_cans", "the cans until both locks open; a wrong second can resets them"), + running(route("below_gym_tree", at(VERMILION, 19, 25, NORTH), "out of the gym, east to below the tree, grown back")), + cut_tree("cut_tree_out", at(VERMILION, 19, 24, NORTH), "the tree again; a step onto its tile"), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "north through the city to the Pokemon Center")), + heal("heal_vermilion"), + }); +} + +// Wild battles on Route 6 until the sim beats Lt. Surge half the time: +// with Dig, 47% at Lv28 and 51% at Lv29, so a lead the gym's trainers +// have brought to Lv29 passes at once, and a Lv28 one after a level. The grass nearest the city is Route 6's +// south patch (13..16, 30..33); Picnicker Isabelle (13, 32) faces west +// over (10..12, 32), on the way north from the city, so the walk goes +// along Jeff's row 33 (he is beaten) to the wall (7, 33), up to row 31 +// and east along it onto the patch's west edge, (13, 31), above +// Isabelle, out of her line. Back to the nurse the same way. +// All that is skipped when the lead, read in the Center, passes already. +// Then the tree, the gym, whose trainers stand where the map has them, +// beaten, and whose beams stay off (FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES): +// up column 5 to below Lt. Surge, and saved there. +StoryCheckpoint to_lt_surge(){ + constexpr const char* SURGE_WINNABLE = "surge_winnable"; + const GrindPlan for_surge{ + .member = 0, .level = 32, .max_encounters = 150, .pace = EAST, .heal_every = 0, + .trainer = std::string(story_trainer(VERMILION_GYM, "lt_surge").value_or("")), .min_chance = MIN_WIN_CHANCE, + }; + return row("00.24", "Route 6's grass until Lt. Surge is winnable, then through the open beams to him", at(VERMILION_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + grind_check_step(SURGE_WINNABLE, "the lead as it is: if the sim beats Lt. Surge 50% or more already, no trip to Route 6", for_surge), + unless(SURGE_WINNABLE, running(route("route6_south_path", at(ROUTE6, 11, 34, NORTH), "out of the Center, north out of the city onto Route 6's path, below Jeff's row"))), + unless(SURGE_WINNABLE, running(legs("round_isabelle_line", {{NORTH, 1}, {WEST, 4}, {NORTH, 2}, {EAST, 5}, {EAST, 1}}, "onto Jeff's row (11, 33), west to the wall (7, 33), up to (7, 31), east along row 31 to (12, 31) and a step onto the grass, (13, 31): out of Isabelle's line"))), + unless(SURGE_WINNABLE, grind_step("grind_for_surge", "wild battles until the sim beats Lt. Surge 50% or more, Lv32 at most; the nurse when hurt or low on PP", for_surge, at(VERMILION_CENTER, 7, 4, NORTH))), + unless(SURGE_WINNABLE, running(route("west_on_row_31", at(ROUTE6, 7, 31), "west along row 31, out of the grass, to the wall (7, 31)"))), + unless(SURGE_WINNABLE, running(legs("down_to_jeff_row", {{SOUTH, 2}, {EAST, 4}}, "down to the wall (7, 33), east along Jeff's row to (11, 33): out of Isabelle's line"))), + unless(SURGE_WINNABLE, running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "south into the city to the Pokemon Center"))), + unless(SURGE_WINNABLE, heal("heal_vermilion")), + running(route("above_gym_tree", at(VERMILION, 19, 23, SOUTH), "out of the Center, south through the city to above the tree (19, 24)")), + cut_tree("cut_gym_tree", at(VERMILION, 19, 24, SOUTH), "the tree; a step onto its tile"), + running(route("below_lt_surge", at(VERMILION_GYM, 5, 3, NORTH), "into the gym, up round the cans and through the beams, off, to below Surge (5, 2)")), + }); +} + +// Lt. Surge (5, 2), talked to from below, from the save in front of him: +// Voltorb Lv21, Pikachu Lv18 and Raichu Lv24, Shock Wave and Thunder +// Wave, a Super Potion and a Full Heal (sParty_LeaderLtSurge); then the +// THUNDERBADGE and TM34, read to the overworld with the battle's text. +// Out, the tree, and the nurse. Surge's win also puts Oak's aide on +// Vermilion City's (25, 7) (VermilionCity_Gym_EventScript_ShowOaksAide), +// where he stays until talked to; the story's maps don't have him. The +// way to the nurse along row 7 runs into him (console B, 2026-11-03: the +// leg west stopped beside him and the step into the Center's door went +// into a house), so it goes along row 10 to the water instead, clear of +// him, and up column 14 from there. +StoryCheckpoint lt_surge(){ + return row("00.25", "Vermilion Gym: Lt. Surge, the THUNDERBADGE, then the Center", at(VERMILION_GYM, 5, 3, NORTH), after_brock(), StartMenu::FULL, { + talk("challenge_lt_surge", "\"Hey, kid! What do you think you're doing here?\""), + battle("lt_surge", at(VERMILION_GYM, 5, 3, NORTH), false, "Voltorb, Pikachu and Raichu; THUNDERBADGE and TM34"), + running(route("below_gym_tree", at(VERMILION, 19, 25, NORTH), "down through the beams, out of the gym, east to below the tree, grown back")), + cut_tree("cut_tree_out", at(VERMILION, 19, 24, NORTH), "the tree; a step onto its tile"), + running(route("below_oaks_aide", at(VERMILION, 14, 10), "north through the city and west along row 10 to the water, (14, 10): off Oak's aide's row 7")), + running(route("vermilion_nurse", at(VERMILION_CENTER, 7, 4, NORTH), "up to the Pokemon Center")), + heal("heal_vermilion"), + }); +} + + +} // namespace StoryTable +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h index 53a2b1f37d..614a3c725d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h @@ -10,11 +10,12 @@ * "Teach which POKEMON?", each member ABLE or NOT ABLE. A on a member * that knows the move says so; one NOT ABLE: " and are not * compatible.". - * - " wants to learn the move ."; with fewer than four moves - * it learns. - * - With four: "However, already knows four moves.", "Should a - * move be deleted and replaced with ?" YES/NO. YES opens the - * moves page; A on a move forgets it and it learns. An HM move can't be + * - With fewer than four moves it learns, in the TM's scene ("Machine + * set!", " learned !"). + * - With four: " wants to learn the move .", "However, + * already knows four moves.", "Should a move be deleted and replaced + * with ?" YES/NO. YES opens the moves page; A on a move forgets + * it and it learns, in the same scene. An HM move can't be * forgotten (pokefirered: "HM moves can't be forgotten now.") and the * moves page shows again. B there, then "Stop trying to teach ?" * YES, leaves it unlearned. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp index 41bb533259..2cc2ea5c4e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp @@ -171,6 +171,7 @@ TeachPrompt ScreenTeachActions::member_answer(){ bool incompatible = false; bool texts = false; size_t prompts = 0; + size_t unplaced = 0; for (size_t c = 0; c < MAX_LOOKS; c++){ const TeachView view = m_screens.look(); switch (view.screen){ @@ -182,11 +183,18 @@ TeachPrompt ScreenTeachActions::member_answer(){ texts = true; press(TeachPress::A); break; + case TeachScreen::TM_SCENE: + return through_scene() ? TeachPrompt::LEARNED : TeachPrompt::FAILED; case TeachScreen::OTHER: // A tutor's party menu closes onto the overworld once learned. if (m_tutor && texts){ return TeachPrompt::LEARNED; } + // A TM's: the scene, not placed (the first look may be the + // party menu closing). + if (!m_tutor && ++unplaced > 1){ + return through_scene() ? TeachPrompt::LEARNED : TeachPrompt::FAILED; + } break; case TeachScreen::PARTY_YES_NO: return view.text == TeachText::STOP_QUESTION ? TeachPrompt::FAILED : TeachPrompt::DELETE_PROMPT; @@ -210,6 +218,38 @@ TeachPrompt ScreenTeachActions::member_answer(){ return TeachPrompt::FAILED; } +bool ScreenTeachActions::through_scene(){ + bool learned = false; + for (size_t c = 0; c < MAX_LOOKS; c++){ + const TeachView view = m_screens.look(); + switch (view.screen){ + case TeachScreen::TM_SCENE: + case TeachScreen::OTHER: + // A on every look, "learned" too: the forget's "Poof!" has + // read as "learned" (console B, 2026-11-03) and waits for A. + // The wobble and the real "learned" run on their own: an A + // then is lost, or opens the TM's menu once the TM CASE is + // back, which leave() closes. + learned |= view.text == TeachText::LEARNED; + press(TeachPress::A); + break; + case TeachScreen::TM_CASE: + case TeachScreen::ITEM_MENU: + case TeachScreen::BAG: + return true; + default: + m_logger.log(std::format("Teach: {} after the TM's scene: not learned.", teach_screen_name(view.screen)), COLOR_RED); + return false; + } + } + if (learned){ + m_logger.log("Teach: the TM CASE didn't show after \"learned\"; reading the moves will tell.", COLOR_ORANGE); + return true; + } + m_logger.log("Teach: the TM's scene didn't end.", COLOR_RED); + return false; +} + void ScreenTeachActions::answer_to_forget(const TeachView& view){ // YES to delete a move; NO to stop trying, back to the moves. if (view.text == TeachText::STOP_QUESTION){ @@ -254,10 +294,10 @@ ForgetEnd ScreenTeachActions::forget(size_t slot){ if (picked && m_tutor){ return ForgetEnd::LEARNED; } - // After the pick, its messages: B pages through them, once the - // moves page has closed (the first look). - if (picked && ++unplaced > 1){ - press(TeachPress::B); + // A TM's scene; one not placed once the moves page has closed + // (the first look). + if (picked && (view.screen == TeachScreen::TM_SCENE || ++unplaced > 1)){ + return through_scene() ? ForgetEnd::LEARNED : ForgetEnd::FAILED; } break; } @@ -300,6 +340,7 @@ void ScreenTeachActions::dump_screen(std::string_view label){ } bool ScreenTeachActions::leave(){ + bool scene_seen = false; for (size_t c = 0; c < MAX_LOOKS; c++){ const TeachView view = m_screens.look(); switch (view.screen){ @@ -314,6 +355,16 @@ bool ScreenTeachActions::leave(){ case TeachScreen::KNOWN_MOVES: stop_teaching(); break; + case TeachScreen::TM_SCENE: + // B would cancel it: to its end, then out. Once: a scene + // that A didn't end is stuck. + if (scene_seen){ + m_logger.log("Teach: the TM's scene is still up.", COLOR_RED); + return false; + } + scene_seen = true; + through_scene(); + break; case TeachScreen::OTHER: return m_screens.to_overworld(); } @@ -401,6 +452,7 @@ TeachView ConsoleTeachScreens::read_view(const ImageViewRGB32& image, TeachScree case TeachScreen::PARTY_PROMPT: case TeachScreen::PARTY_TEXT: case TeachScreen::PARTY_YES_NO: + case TeachScreen::TM_SCENE: view.text = read_teach_text(logger, m_language, image); break; default: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h index d90618b306..a6b36fbdf0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h @@ -13,16 +13,21 @@ * WATER PULSE x1", "No39 ROCK TOMB x1", CLOSE). * - A on a TM: USE/GIVE/EXIT, USE on top; A: the party menu, "Teach which * POKEMON?", each member ABLE or NOT ABLE, the cursor on the lead. - * - A on a member: " wants to learn the move ."; with room it - * learns. With four: "However, already knows four moves.", - * "Should a move be deleted and replaced with ?" YES/NO (YES on - * top), YES: "Which move should be forgotten?", then KNOWN MOVES with - * the four moves and the new one fifth, the red cursor on the first. A - * on a move forgets it: "1, 2 and... ... Poof!", " forgot .", - * "And... learned !", which the reader doesn't place (read - * "other"; B pages through them), then the TM CASE's list. B instead: "Stop trying to teach ?" - * YES/NO, YES: " did not learn the move ." and the TM CASE's - * list again, the TM kept. + * - A on a member with room: the TM's scene (pokefirered + * pokemon_special_anim.c), the member on pale blue diamonds: "Machine + * set!", which waits for A (B there cancels the teach), the disc's + * wobble, " learned !", then the TM CASE's list (console B, + * 00.23: HM01 to an Oddish with two free slots). With four: " + * wants to learn the move .", "However, already knows four + * moves.", "Should a move be deleted and replaced with ?" YES/NO + * (YES on top), YES: "Which move should be forgotten?", then KNOWN + * MOVES with the four moves and the new one fifth, the red cursor on + * the first. A on a move forgets it in the TM's scene: "1, 2 and... + * ... Poof!", " forgot .", "And...", each waiting for A, + * then "Machine set!" and the rest as with room. A pages through it + * all, until the TM CASE: "Poof!" has read as "learned". B on the + * moves instead: "Stop trying to teach ?" YES/NO, YES: " did not learn the move + * ." and the TM CASE's list again, the TM kept. * - From the TM CASE's list, B closes it to the bag, and B the bag. * - A move tutor (not seen on console B yet; pokefirered * move_tutors.inc): its lines and two YES/NOs in the overworld's @@ -128,6 +133,10 @@ class ScreenTeachActions : public TeachActions{ ItemOpen open_row(TeachScreen screen, std::string_view name); // After A on a member: what it showed. TeachPrompt member_answer(); + // In the TM's scene: A on each look, never B, until the TM CASE is + // back. True if it came back or "learned" showed (the moves + // read after decide); false if the party menu came back instead. + bool through_scene(); // On a YES/NO while forgetting: the answer that goes on to the moves. void answer_to_forget(const TeachView& view); void press(TeachPress press); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp index ce0ec98291..6a4e0ff1e6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp @@ -8,12 +8,20 @@ * tm03 from the TM Case": the console's steps had no readers and stopped * before the bag. * + * A TM or HM taught goes through the TM's scene (pokefirered + * pokemon_special_anim.c, Task_UseTM_NoForget / Task_ForgetMove, then + * Task_MachineSet): "Machine set!" waits for A and B cancels the teach; + * the wobble and " learned !" run on their own. Console B, + * 00.23 (2026-11-03): HM01 to an Oddish with two free slots stopped on + * "Machine set!", read as "other", and gave up. + * */ #include #include #include #include +#include #include #include #include @@ -57,6 +65,17 @@ enum class FakeScreen{ KNOWN_MOVES, // A tutor's last line in the overworld, after its party menu. TUTOR_TEXT, + // The TM's scene. + SCENE, +}; + +// A look at the TM's scene: what it shows, and whether it waits for A; +// the others pass with the look. +struct SceneStep{ + TeachText text = TeachText::UNKNOWN; + bool waits_for_a = false; + // The disc's wobble and zoom: no message box. + bool box = true; }; // The bag, TM CASE and party menu as console B showed them. @@ -80,8 +99,22 @@ struct FakeTeachGame{ FakeScreen after_texts = FakeScreen::OVERWORLD; // The messages after a move is forgotten ("1, 2 and... Poof!", "... // forgot TAIL WHIP.", "And... learned WATER PULSE!") read as "other", - // as on console B (2026-11-03). + // as on console B (2026-11-03): a tutor's over the party menu, a TM's + // whole scene. bool forget_texts_unread = false; + // The TM's scene left to show, and the move it teaches: into + // `scene_slot`, or a free slot. + std::deque scene = {}; + std::string scene_move = {}; + std::optional scene_slot = std::nullopt; + // The TM's scene reads as "other". + bool scene_unread = false; + // "1, 2, and... Poof!" reads as "learned", as on console B (TM28 to + // the Wartortle, 2026-11-03). + bool poof_reads_learned = false; + // A doesn't page the scene's boxes: it never ends. + bool scene_stuck = false; + size_t looks = 0; // The TM in use, by row. size_t tm = 0; std::vector presses = {}; @@ -127,10 +160,11 @@ std::string tm_move(const FakeTeachGame& game){ } void use_up(FakeTeachGame& game){ - if (game.tutoring){ + const std::string move = tm_move(game); + // An HM is kept. + if (game.tutoring || is_hm_move(move)){ return; } - const std::string move = tm_move(game); for (FakeTm& tm : game.tms){ if (tm.move == move && tm.count > 0){ tm.count--; @@ -139,17 +173,70 @@ void use_up(FakeTeachGame& game){ } } +// The TM's scene, after the forget's lines if any: "Machine set!", the +// wobble, " learned !", then the TM CASE. +void start_scene(FakeTeachGame& game, std::optional slot){ + game.scene.clear(); + // After a forget (pokefirered Task_ForgetMove): "1, 2, and... Poof!", + // " forgot .", "And...", each ending in \p: they wait for A. + if (slot){ + game.scene.push_back({}); + game.scene.push_back({ + .text = game.poof_reads_learned ? TeachText::LEARNED : TeachText::UNKNOWN, + .waits_for_a = true, + }); + game.scene.push_back({.waits_for_a = true}); + game.scene.push_back({.waits_for_a = true}); + } + game.scene.push_back({.text = TeachText::MACHINE_SET, .waits_for_a = true}); + game.scene.push_back({.box = false}); + game.scene.push_back({.box = false}); + game.scene.push_back({.text = TeachText::LEARNED}); + game.scene.push_back({.text = TeachText::LEARNED}); + game.scene_move = tm_move(game); + game.scene_slot = slot; + game.scene_unread |= slot && game.forget_texts_unread; + game.screen = FakeScreen::SCENE; +} + +// The scene's step passed: at its end the move is learned. +void next_scene_step(FakeTeachGame& game){ + game.scene.pop_front(); + if (!game.scene.empty()){ + return; + } + if (game.scene_slot){ + game.moves[*game.scene_slot] = game.scene_move; + }else{ + game.moves.push_back(game.scene_move); + } + use_up(game); + game.screen = FakeScreen::TM_CASE; + game.cursor = game.tm; +} + +void press_scene(FakeTeachGame& game, TeachPress press){ + if (press == TeachPress::B){ + game.error = "B cancelled the TM's scene"; + }else if (press == TeachPress::A && game.scene.front().waits_for_a && !game.scene_stuck){ + next_scene_step(game); + } +} + void choose_member(FakeTeachGame& game){ const std::string move = tm_move(game); if (!game.compatible.contains(move)){ show_texts(game, {TeachText::NOT_COMPATIBLE}, FakeScreen::PROMPT); return; } + if (game.moves.size() < MOVE_SLOTS && !game.tutoring){ + start_scene(game, std::nullopt); + return; + } if (game.moves.size() < MOVE_SLOTS){ game.moves.push_back(move); - use_up(game); game.cursor = 0; - show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::UNKNOWN}, after_party(game)); + show_texts(game, {TeachText::LEARNED}, after_party(game)); return; } show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::KNOWS_FOUR}, FakeScreen::DELETE_YES_NO); @@ -246,6 +333,10 @@ void press_known_moves(FakeTeachGame& game, TeachPress press){ if (is_hm_move(game.moves[game.cursor])){ return; } + if (!game.tutoring){ + start_scene(game, game.cursor); + return; + } game.moves[game.cursor] = tm_move(game); use_up(game); show_texts(game, {TeachText::UNKNOWN, TeachText::UNKNOWN, TeachText::UNKNOWN}, after_party(game)); @@ -271,6 +362,7 @@ void press_game(FakeTeachGame& game, TeachPress press){ case FakeScreen::DELETE_YES_NO: case FakeScreen::STOP_YES_NO: press_yes_no(game, press); return; case FakeScreen::KNOWN_MOVES: press_known_moves(game, press); return; + case FakeScreen::SCENE: press_scene(game, press); return; case FakeScreen::PROMPT: if (press == TeachPress::A){ choose_member(game); @@ -319,6 +411,13 @@ TeachView view_of(const FakeTeachGame& game){ return {.screen = TeachScreen::PARTY_YES_NO, .cursor = game.cursor, .text = TeachText::STOP_QUESTION}; case FakeScreen::KNOWN_MOVES: return {.screen = TeachScreen::KNOWN_MOVES, .cursor = game.cursor}; + case FakeScreen::SCENE:{ + const SceneStep& step = game.scene.front(); + if (game.scene_unread || !step.box){ + return {}; + } + return {.screen = TeachScreen::TM_SCENE, .text = step.text}; + } } return {}; } @@ -349,7 +448,14 @@ class FakeTeachScreens : public TeachScreens{ return true; } virtual TeachView look() override{ - return view_of(m_game); + if (++m_game.looks > 1000){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "more than 1000 looks"); + } + const TeachView view = view_of(m_game); + if (m_game.screen == FakeScreen::SCENE && !m_game.scene.front().waits_for_a){ + next_scene_step(m_game); + } + return view; } virtual void press(TeachPress press) override{ m_game.presses.emplace_back(teach_press_name(press)); @@ -396,9 +502,31 @@ void left_on_poke_balls(FakeTeachGame& game){ void three_moves(FakeTeachGame& game){ game.moves = {"bite", "bubble", "withdraw"}; } +void three_moves_scene_unread(FakeTeachGame& game){ + three_moves(game); + game.scene_unread = true; +} +// 00.23 on console B: HM01 to the Oddish (slot 2), ABSORB and SWEET +// SCENT. +void oddish_cut(FakeTeachGame& game){ + game.tms = {{"water-pulse", 1}, {"cut", 1}}; + game.moves = {"absorb", "sweet-scent"}; + game.compatible.insert("cut"); +} +// Next in the row: TM28 DIG over a move of the Wartortle's four. +void wartortle_dig(FakeTeachGame& game){ + game.tms = {{"water-pulse", 1}, {"dig", 1}}; + game.compatible.insert("dig"); +} void no_tm03(FakeTeachGame& game){ game.tms = {{"rock-tomb", 1}}; } +// Console B, 2026-11-03: the step stopped pressing on "Poof!" read as +// "learned", and waited 5 minutes. +void wartortle_dig_poof_learned(FakeTeachGame& game){ + wartortle_dig(game); + game.poof_reads_learned = true; +} void forget_texts_unread(FakeTeachGame& game){ game.forget_texts_unread = true; } @@ -458,6 +586,39 @@ std::vector screens_cases(){ }; } +// The TM's scene, with a free slot and after a forget, for a TM and an HM. +std::vector scene_screens_cases(){ + return { + { + .name = "a free slot, the TM's scene read as other", + .setup = three_moves_scene_unread, + .plan = {.item = "tm03", .move = "water-pulse", .member = 0}, + .moves = {"bite", "bubble", "withdraw", "water-pulse"}, + }, + { + .name = "console B 00.23: HM01 to the Oddish, two free slots", + .setup = oddish_cut, + .plan = {.item = "hm01", .move = "cut", .member = 1}, + .moves = {"absorb", "sweet-scent", "cut"}, + .tm03 = 1, + }, + { + .name = "TM28 DIG over WITHDRAW", + .setup = wartortle_dig, + .plan = {.item = "tm28", .move = "dig", .member = 0, .forget_move = "withdraw"}, + .moves = {"bite", "tail-whip", "bubble", "dig"}, + .tm03 = 1, + }, + { + .name = "console B: TM28 DIG over WITHDRAW, \"Poof!\" read as \"learned\"", + .setup = wartortle_dig_poof_learned, + .plan = {.item = "tm28", .move = "dig", .member = 0, .forget_move = "withdraw"}, + .moves = {"bite", "tail-whip", "bubble", "dig"}, + .tm03 = 1, + }, + }; +} + // Route 4's Mega Kick tutor: the TM03 still in the TM CASE after each. std::vector tutor_screens_cases(){ return { @@ -531,8 +692,32 @@ UnitTestResult check_screens_case(const ScreensCase& item){ return true; } +// A TM's scene that A doesn't page: the step gives up within a few +// scenes' looks, not one per leave() look. +UnitTestResult test_stuck_scene(){ + QuietLogger logger; + FakeTeachGame game; + wartortle_dig(game); + game.scene_stuck = true; + FakeTeachScreens screens(game); + ScreenTeachActions actions(logger, screens); + const MemberReader reader = [&game](size_t) -> std::optional{ + return MemberInfo{.species = "wartortle", .level = 22, .moves = game.moves}; + }; + const TeachPlan plan{.item = "tm28", .move = "dig", .member = 0, .forget_move = "withdraw"}; + const TeachResult result = run_teach(logger, actions, reader, plan); + if (result.end != TeachEnd::FAILED){ + return std::format("a stuck scene ended {} ({}).", teach_end_name(result.end), result.detail); + } + // The scene's looks: once in forget(), once in leave(). + if (game.looks > 150){ + return std::format("a stuck scene took {} looks.", game.looks); + } + return true; +} + UnitTestResult test_screens(){ - for (const std::vector& cases : {screens_cases(), tutor_screens_cases()}){ + for (const std::vector& cases : {screens_cases(), scene_screens_cases(), tutor_screens_cases()}){ for (const ScreensCase& item : cases){ const UnitTestResult result = check_screens_case(item); if (result.result != UnitTestResult::PASSED){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.cpp new file mode 100644 index 0000000000..75c32c05c1 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.cpp @@ -0,0 +1,154 @@ +/* AutoStory Trash Cans + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "PokemonFRLG_AutoStoryTrashCans.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using Pokemon::Direction; +using Pokemon::TilePos; + + +TilePos trash_can_tile(int can){ + return TilePos{1 + 2 * ((can - 1) % 5), 10 + 2 * ((can - 1) / 5)}; +} + +std::vector trash_can_neighbors(int can){ + const int column = (can - 1) % 5; + std::vector cans; + if (can > 5){ + cans.push_back(can - 5); + } + if (column < 4){ + cans.push_back(can + 1); + } + if (can <= 10){ + cans.push_back(can + 5); + } + if (column > 0){ + cans.push_back(can - 1); + } + return cans; +} + +TrashCanStand trash_can_stand(int can){ + const TilePos tile = trash_can_tile(can); + return TrashCanStand{TilePos{tile.x, tile.y - 1}, Direction::SOUTH}; +} + +const std::vector& trash_can_order(){ + static const std::vector ORDER{1, 2, 3, 4, 5, 10, 9, 8, 7, 6, 11, 12, 13, 14, 15}; + return ORDER; +} + +std::vector trash_can_maps(const std::vector& maps){ + struct Moved{ + const char* name; + TilePos tile; + }; + static constexpr Moved MOVED[] = { + {"sailor", {8, 14}}, + {"balding_man", {2, 12}}, + {"gentleman", {6, 8}}, + }; + std::vector moved = maps; + for (Pokemon::MapData& map : moved){ + if (map.name != "VermilionCity_Gym"){ + continue; + } + for (Pokemon::MapObject& object : map.objects){ + for (const Moved& trainer : MOVED){ + if (object.name == trainer.name){ + object.x = trainer.tile.x; + object.y = trainer.tile.y; + object.trainer.reset(); + } + } + } + } + return moved; +} + +const char* trash_can_end_name(TrashCanEnd end){ + switch (end){ + case TrashCanEnd::OPENED: return "opened"; + case TrashCanEnd::GAVE_UP: return "gave_up"; + case TrashCanEnd::FAILED: return "failed"; + } + return "?"; +} + + +namespace{ + +// The guess beside `first`: the next can in the search order, as the +// player is likely to walk that way anyway, else the first beside it. +int guess_beside(int first){ + const std::vector beside = trash_can_neighbors(first); + const std::vector& order = trash_can_order(); + const size_t at = std::find(order.begin(), order.end(), first) - order.begin(); + for (size_t c = 1; c < order.size(); c++){ + const int next = order[(at + c) % order.size()]; + if (std::find(beside.begin(), beside.end(), next) != beside.end()){ + return next; + } + } + return beside[0]; +} + +} // namespace + + +TrashCanResult run_trash_cans(Logger& logger, const CheckCan& check, size_t max_checks){ + TrashCanResult result; + const std::vector& order = trash_can_order(); + size_t next = 0; + std::optional first; + while (result.checks < max_checks){ + const int can = first ? guess_beside(*first) : order[next]; + const CanPages pages = check(can); + result.checks++; + result.last_can = can; + if (pages == CanPages::FAILED){ + logger.log(std::format("Trash cans: checking can {} failed.", can), COLOR_RED); + result.end = TrashCanEnd::FAILED; + return result; + } + if (!first){ + if (pages == CanPages::TWO){ + logger.log(std::format("Trash cans: the first switch is under can {}; the second may be under {} of the cans beside it.", can, trash_can_neighbors(can).size())); + first = can; + }else{ + next = (next + 1) % order.size(); + } + continue; + } + if (pages == CanPages::ONE){ + logger.log(std::format("Trash cans: the second switch is under can {}: the door is open, after {} checks and {} resets.", can, result.checks, result.resets)); + result.end = TrashCanEnd::OPENED; + return result; + } + // Reset: the new first switch is as likely under this can as any. + result.resets++; + logger.log(std::format("Trash cans: not under can {}; the locks reset ({}).", can, result.resets)); + first.reset(); + next = std::find(order.begin(), order.end(), can) - order.begin(); + } + logger.log(std::format("Trash cans: the door is still shut after {} checks.", result.checks), COLOR_RED); + result.end = TrashCanEnd::GAVE_UP; + return result; +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h new file mode 100644 index 0000000000..3de89130a4 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h @@ -0,0 +1,143 @@ +/* AutoStory Trash Cans + * + * From: https://github.com/PokemonAutomation/ + * + * Vermilion Gym's door switches (pokefirered data/maps/VermilionCity_Gym/ + * scripts.inc, src/field_specials.c SetVermilionTrashCans()). Fifteen + * cans stand in three rows of five, numbered 1-5 on row 10, 6-10 on row + * 12 and 11-15 on row 14, at x 1, 3, 5, 7, 9. Each time the player + * enters the gym the first switch goes under a can drawn at random, and + * the second under one of the cans beside it in the rows (left, right, + * above, below), each as likely. A can answers A from any side: + * + * - Before the first switch: "Nope! There's only trash here." (one page), + * or "Hey! There's a switch under the trash! Turn it on!" and "The first + * electric lock opened!" (two pages). + * - After it: "The second electric lock opened! The motorized door + * opened!" (one page): the beams across rows 6 and 7 go off for good + * (FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES). Or "Nope! There's only + * trash here." and "Hey! The electric locks were reset!" (two pages): + * both switches are drawn again, the first anywhere. The can that had + * the first switch counts as a wrong one. + * + * So in either state the number of pages tells the two answers apart, + * with no text read. Leaving the gym draws the switches again too; a + * battle doesn't (the first switch is a FLAG_TEMP, kept until the map + * changes). + * + * The search: the cans in a snake along the rows until one has two + * pages; then one guess beside it. A wrong guess draws both switches + * again, so the search goes on from the can the player stands at, which + * is as likely as any to have the new first switch. A guess is right + * 1 time in 2 to 4 (about 36% over the 15 cans), so the gym opens after + * about 3 rounds of about 9 cans; TRASH_CAN_MAX_CHECKS gives up after + * about 20 rounds (left 1 time in 10000). + * + * The decisions take their game step as a CheckCan, so they run in tests + * against a fake gym; on the console the step walks to the can + * and counts its pages (PokemonFRLG_AutoStoryEvents.cpp). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTrashCans_H +#define PokemonAutomation_PokemonFRLG_AutoStoryTrashCans_H + +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "Pokemon/Navigation/Pokemon_MapData.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The GAME step that opens Vermilion Gym's door. +inline const std::string TRASH_CANS_VARIANT = "trash_cans"; + +// Total number of trash cans in Vermilion Gym. +inline constexpr int TRASH_CAN_COUNT = 15; +// Maximum number of trash can inspections before aborting. +inline constexpr size_t TRASH_CAN_MAX_CHECKS = 200; + +// Can `can`'s tile (1-15). +Pokemon::TilePos trash_can_tile(int can); + +// The cans the second switch may be under when the first is under `can` +// (SetVermilionTrashCans()'s cases). +std::vector trash_can_neighbors(int can); + +// Where the player stands to check `can`, facing it: the row above it. +// Rows 9, 11 and 13 run the gym's width, clear of the trainers where +// their battles leave them (PokemonFRLG_AutoStoryTableVermilionGym.cpp). +struct TrashCanStand{ + Pokemon::TilePos tile; + Pokemon::Direction facing = Pokemon::Direction::SOUTH; +}; +// Position and facing to inspect a trash can. +TrashCanStand trash_can_stand(int can); + +// `maps` with Vermilion Gym's trainers where their battles in 00.23 leave +// them, beaten (PokemonFRLG_AutoStoryTableVermilionGym.cpp): Sailor +// Dwayne on (8, 14), Engineer Baily on (2, 12), Gentleman Tucker on +// (6, 8). The search plans its walks between the cans on these, not on +// the maps' tiles for them, which are free once they have walked up. +std::vector trash_can_maps(const std::vector& maps); + +// The order the search checks the cans: along row 10 east, row 12 west, +// row 14 east. +const std::vector& trash_can_order(); + + +// What a can's message came to. +enum class CanPages{ + ONE, + TWO, + // The walk to the can or its message failed. + FAILED, +}; + +// The game step the search takes, injected so its decisions run in +// tests: from the overworld in the gym, to `can`'s stand, A on the can, +// its message to the overworld. +using CheckCan = std::function; + +// Termination reason for trash can search. +enum class TrashCanEnd{ + // The second lock opened. + OPENED, + // TRASH_CAN_MAX_CHECKS checks without it. + GAVE_UP, + FAILED, +}; +// String name for trash can search termination reason. +const char* trash_can_end_name(TrashCanEnd end); + +// Result summary of trash can search run. +struct TrashCanResult{ + TrashCanEnd end = TrashCanEnd::FAILED; + size_t checks = 0; + // Wrong guesses: the locks reset. + size_t resets = 0; + // The can checked last, where the player stands. + int last_can = 0; +}; + +// Searches the cans as above until the second lock opens, from the +// overworld in the gym with neither switch found since the player came +// in. +TrashCanResult run_trash_cans(Logger& logger, const CheckCan& check, size_t max_checks = TRASH_CAN_MAX_CHECKS); + +// Registers Vermilion Gym trash can puzzle unit tests. +void add_tests_AutoStoryTrashCans(UnitTestDatabase& database); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans_Tests.cpp new file mode 100644 index 0000000000..58122423af --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans_Tests.cpp @@ -0,0 +1,305 @@ +/* AutoStory Trash Cans Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The trash can search against a fake Vermilion Gym that draws its + * switches as SetVermilionTrashCans() does and answers each can with the + * pages its message has (PokemonFRLG_AutoStoryTrashCans.h). + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +struct AutoStoryTrashCansInput{ + std::string name; + std::function body; +}; + +struct AutoStoryTrashCansExpectation{ +}; + +struct AutoStoryTrashCansCase{ + AutoStoryTrashCansInput input; + AutoStoryTrashCansExpectation expectation; +}; + +class Test_AutoStoryTrashCans : public UnitTest{ +public: + explicit Test_AutoStoryTrashCans(AutoStoryTrashCansCase test_case) + : UnitTest("PokemonFRLG::AutoStoryTrashCans - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_case.input.body(); + } + +private: + AutoStoryTrashCansCase m_case; +}; + +using Switches = std::pair; + +// SetVermilionTrashCans()'s second switch for each first one, as its +// cases add to the first: 1 +1 or +5; 2-4 +1, +5 or -1; 5 +5 or -1; 6 -5, +// +1 or +5; 7-9 -5, +1, +5 or -1; 10 -5, +5 or -1; 11 -5 or +1; 12-14 -5, +// +1 or -1; 15 -5 or -1. +std::vector decomp_offsets(int first){ + switch (first){ + case 1: return {+1, +5}; + case 2: case 3: case 4: return {+1, +5, -1}; + case 5: return {+5, -1}; + case 6: return {-5, +1, +5}; + case 7: case 8: case 9: return {-5, +1, +5, -1}; + case 10: return {-5, +5, -1}; + case 11: return {-5, +1}; + case 12: case 13: case 14: return {-5, +1, -1}; + default: return {-5, -1}; + } +} + +Switches draw(std::mt19937& rng){ + const int first = 1 + static_cast(rng() % 15); + const std::vector offsets = decomp_offsets(first); + return {first, first + offsets[rng() % offsets.size()]}; +} + +// The gym's cans as its scripts answer them. +class FakeGym{ +public: + explicit FakeGym(std::function draw) + : m_draw(std::move(draw)) + , m_switches(m_draw()) + {} + + CanPages check(int can){ + checked.push_back(can); + if (can == fail_at){ + return CanPages::FAILED; + } + if (open){ + return CanPages::ONE; + } + if (!m_first_found){ + m_first_found = can == m_switches.first; + return m_first_found ? CanPages::TWO : CanPages::ONE; + } + if (can == m_switches.second){ + open = true; + return CanPages::ONE; + } + m_first_found = false; + m_switches = m_draw(); + return CanPages::TWO; + } + + std::vector checked; + bool open = false; + int fail_at = 0; + +private: + std::function m_draw; + Switches m_switches; + bool m_first_found = false; +}; + +// Draws `script` in order, then the game's draws. +std::function scripted(std::vector script, uint32_t seed = 1){ + auto rng = std::make_shared(seed); + auto next = std::make_shared(0); + return [script = std::move(script), rng, next]{ + return *next < script.size() ? script[(*next)++] : draw(*rng); + }; +} + +UnitTestResult test_cans_and_neighbors(){ + for (int can = 1; can <= TRASH_CAN_COUNT; can++){ + std::set expected; + for (int offset : decomp_offsets(can)){ + expected.insert(can + offset); + } + const std::vector beside = trash_can_neighbors(can); + if (std::set(beside.begin(), beside.end()) != expected){ + return std::format("Can {}: the cans beside it differ from SetVermilionTrashCans().", can); + } + // Beside: one tile apart on the map but for the can between. + const Pokemon::TilePos tile = trash_can_tile(can); + for (int other : beside){ + const Pokemon::TilePos there = trash_can_tile(other); + if (std::abs(there.x - tile.x) + std::abs(there.y - tile.y) != 2){ + return std::format("Cans {} and {} aren't two tiles apart.", can, other); + } + } + const TrashCanStand stand = trash_can_stand(can); + if (stand.tile.next(stand.facing) != tile){ + return std::format("Can {}'s stand doesn't face it.", can); + } + } + if (!(trash_can_tile(1) == Pokemon::TilePos{1, 10}) || !(trash_can_tile(15) == Pokemon::TilePos{9, 14})){ + return "Cans 1 and 15 aren't at (1, 10) and (9, 14), the gym's TrashCan1 and TrashCan15."; + } + std::vector order = trash_can_order(); + std::sort(order.begin(), order.end()); + for (int can = 1; can <= TRASH_CAN_COUNT; can++){ + if (order[can - 1] != can){ + return "The search order isn't every can once."; + } + } + return true; +} + +// The guess beside the first switch is right: the search ends there. +UnitTestResult test_right_guess(){ + QuietLogger logger; + FakeGym gym(scripted({{3, 4}})); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + const std::vector expected{1, 2, 3, 4}; + if (result.end != TrashCanEnd::OPENED || gym.checked != expected || result.resets != 0 || result.last_can != 4 || !gym.open){ + return std::format("Expected 1, 2, 3 then 4, open, got {} after {} checks.", trash_can_end_name(result.end), result.checks); + } + return true; +} + +// The guess is wrong: the locks reset and both switches are drawn again; +// the search goes on from the can the player stands at, finds the new +// first switch and guesses again. +UnitTestResult test_wrong_guess_resets(){ + QuietLogger logger; + // First under 3, second under 8: the guess, 4, is wrong. Then first + // under 4 (where the player stands), second under 5. + FakeGym gym(scripted({{3, 8}, {4, 5}})); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + const std::vector expected{1, 2, 3, 4, 4, 5}; + if (result.end != TrashCanEnd::OPENED || gym.checked != expected || result.resets != 1){ + std::string text; + for (int can : gym.checked){ + text += " " + std::to_string(can); + } + return std::format("Expected 1 2 3 4 4 5 with a reset, got{} ({}).", text, trash_can_end_name(result.end)); + } + // The new first switch further on: the search walks on from there. + FakeGym further(scripted({{3, 8}, {13, 14}})); + const TrashCanResult on = run_trash_cans(logger, [&](int can){ return further.check(can); }); + const std::vector walked{1, 2, 3, 4, 4, 5, 10, 9, 8, 7, 6, 11, 12, 13, 14}; + if (on.end != TrashCanEnd::OPENED || further.checked != walked){ + return "Expected the search to go on from can 4 to 13, then 14."; + } + return true; +} + +// Every first switch, with every second beside it: the door opens, the +// wrong guesses' draws as the game makes them. +UnitTestResult test_every_pair_opens(){ + QuietLogger logger; + uint32_t seed = 1; + for (int first = 1; first <= TRASH_CAN_COUNT; first++){ + for (int offset : decomp_offsets(first)){ + FakeGym gym(scripted({{first, first + offset}}, seed++)); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + if (result.end != TrashCanEnd::OPENED || !gym.open){ + return std::format("First {}, second {}: {} after {} checks.", first, first + offset, trash_can_end_name(result.end), result.checks); + } + } + } + return true; +} + +// 10000 gyms as the game draws them: the checks it takes, and how often +// TRASH_CAN_MAX_CHECKS isn't enough. +UnitTestResult test_many_gyms(){ + QuietLogger logger; + std::mt19937 rng(7); + size_t total = 0; + size_t gave_up = 0; + size_t most = 0; + constexpr size_t GYMS = 10000; + for (size_t c = 0; c < GYMS; c++){ + FakeGym gym([&rng]{ return draw(rng); }); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + gave_up += result.end != TrashCanEnd::OPENED; + total += result.checks; + most = std::max(most, result.checks); + } + const double mean = static_cast(total) / GYMS; + if (gave_up > 2 || mean < 15 || mean > 35){ + return std::format("Expected about 25 checks a gym and next to no give-ups, got {:.1f} checks, {} give-ups, at most {}.", mean, gave_up, most); + } + return true; +} + +UnitTestResult test_failed_check_stops(){ + QuietLogger logger; + FakeGym gym(scripted({{9, 14}})); + gym.fail_at = 3; + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + if (result.end != TrashCanEnd::FAILED || result.checks != 3 || gym.checked.back() != 3){ + return "Expected the search to stop at the failed check of can 3."; + } + return true; +} + +} // namespace + + +const AutoStoryTrashCansCase AUTO_STORY_TRASH_CANS_CASES[] = { + { + .input = {"cans, the cans beside each and their stands match the gym", test_cans_and_neighbors}, + .expectation = {}, + }, + { + .input = {"a right guess opens the door", test_right_guess}, + .expectation = {}, + }, + { + .input = {"a wrong guess resets, the search goes on from there", test_wrong_guess_resets}, + .expectation = {}, + }, + { + .input = {"every first and second switch opens the door", test_every_pair_opens}, + .expectation = {}, + }, + { + .input = {"10000 gyms as the game draws them", test_many_gyms}, + .expectation = {}, + }, + { + .input = {"a failed check stops the search", test_failed_check_stops}, + .expectation = {}, + }, +}; + +void add_tests_AutoStoryTrashCans(UnitTestDatabase& database){ + for (const auto& item : AUTO_STORY_TRASH_CANS_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp new file mode 100644 index 0000000000..b59faf6d75 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -0,0 +1,574 @@ +/* AutoStory Vermilion Gym Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Rows 00.23 to 00.25 (PokemonFRLG_AutoStoryTableVermilionGym.cpp) on the + * overworld sim: the tree cut on the way in and out, the gym's three + * trainers fought where the table says, the trash can search walking + * between the cans clear of where the trainers stand after their battles + * (the switches found at once, and after a wrong can's reset), and Lt. + * Surge reached through the beams. And the sim's chance against Surge. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryDamage.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryParty.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" +#include "PokemonFRLG_AutoStoryTrashCans.h" +#include "PokemonFRLG_AutoStoryWinChance.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +struct AutoStoryVermilionGymInput{ + std::string name; + std::function body; +}; + +struct AutoStoryVermilionGymExpectation{ +}; + +struct AutoStoryVermilionGymCase{ + AutoStoryVermilionGymInput input; + AutoStoryVermilionGymExpectation expectation; +}; + +class Test_AutoStoryVermilionGym : public UnitTest{ +public: + explicit Test_AutoStoryVermilionGym(AutoStoryVermilionGymCase test_case) + : UnitTest("PokemonFRLG::AutoStory - Simulation: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_case.input.body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + AutoStoryVermilionGymCase m_case; +}; + +const char* const GYM = StoryTable::VERMILION_GYM; + +const StoryCheckpoint& checkpoint(std::string_view id){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [&](const StoryCheckpoint& data){ return data.id == id; }); + if (row == rows.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::format("No checkpoint {}.", id)); + } + return *row; +} + +size_t step_index(const StoryCheckpoint& row, const std::string& name){ + const auto step = std::find_if(row.steps.begin(), row.steps.end(), [&](const StoryStep& data){ return data.name == name; }); + if (step == row.steps.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No step " + row.id + "/" + name + "."); + } + return step - row.steps.begin(); +} + +std::string beaten_list(const OverworldSim& sim){ + std::string text; + for (const std::string& name : sim.state().trainers_beaten){ + text += " " + name; + } + return text.empty() ? " none" : text; +} + +MapData& map_named(std::vector& maps, const std::string& name){ + for (MapData& map : maps){ + if (map.name == name){ + return map; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No map " + name + "."); +} + +// `maps` with the trainers beaten that rows before 00.24 fought: Route +// 6's Jeff (00.21) and Vermilion Gym's three (00.23). +std::vector maps_after_gym(std::vector maps = story_maps()){ + for (MapObject& object : map_named(maps, GYM).objects){ + object.trainer.reset(); + } + for (MapObject& object : map_named(maps, StoryTable::ROUTE6).objects){ + if (object.name == "camper" && object.trainer){ + object.trainer.reset(); + } + } + return maps; +} + +// maps_after_gym() with Oak's aide on Vermilion City's (25, 7): Lt. +// Surge's win shows him (VermilionCity_Gym_EventScript_ShowOaksAide), +// and he stays until talked to (VermilionCity_EventScript_HideOaksAide). +std::vector maps_after_surge(){ + const std::vector all = overworld_maps(); + std::set flags = map_flag_set(all, "after_hall_of_fame"); + flags.merge(map_flag_set(all, "new_game")); + flags.erase("FLAG_HIDE_VERMILION_CITY_OAKS_AIDE"); + return maps_after_gym(overworld_maps(flags)); +} + +// The walk to `can`'s stand on `sim` as the trash can step plans it, on +// trash_can_maps(): ends there facing the can, or says why not. +std::string walk_to_stand(OverworldSim& sim, StoryPose& at, int can){ + static const std::vector MAPS = trash_can_maps(story_maps()); + const TrashCanStand stand = trash_can_stand(can); + const MapPosition start{at.map, at.tile}; + const std::optional> route = plan_overworld_route( + MAPS, start, RouteGoal{{GYM, stand.tile}, stand.facing}, story_route_costs(), at.facing + ); + if (!route){ + return std::format("no route from {} to can {}.", to_string(start), can); + } + if (!route->empty()){ + const RoutePlanning planning{.maps = &MAPS, .costs = story_route_costs(), .start = start, .label = "can"}; + if (const std::string failure = OverworldSimWalker(sim).run(*route, planning); !failure.empty()){ + return std::format("to can {}: ", can) + failure; + } + } + at = StoryPose{GYM, stand.tile, stand.facing}; + if (!(sim.state().position == MapPosition{GYM, stand.tile}) || sim.state().facing != stand.facing){ + return std::format("not at can {}'s stand facing it: on {}.", can, to_string(sim.state().position)); + } + if (!sim.state().violations.empty()){ + return std::format("to can {}: {}", can, sim.state().violations.back()); + } + return ""; +} + +// A step of a row on the sim: row_step_on_sim(), but Surge's battle, +// which a talk starts, leaves the player where the table says. The TEACH, +// the GRIND and the talks the sim hasn't got pass. +std::string gym_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step, const std::string& id){ + if (step.kind == StoryStepKind::BATTLE && step.name == "lt_surge"){ + at = *story_pose(*step.pose_after); + return ""; + } + return row_step_on_sim(sim, at, step, id); +} + +using Switches = std::pair; + +// The gym's cans as its scripts answer them, each checked from its stand, +// walked to on the sim as the program plans it. +class SimGym{ +public: + SimGym(OverworldSim& sim, StoryPose& at, std::vector draws) + : m_sim(sim), m_at(at), m_draws(std::move(draws)) + { + next_draw(); + } + + CanPages check(int can){ + error = walk_to_stand(m_sim, m_at, can); + if (!error.empty()){ + return CanPages::FAILED; + } + if (open){ + return CanPages::ONE; + } + if (!m_first_found){ + m_first_found = can == m_switches.first; + return m_first_found ? CanPages::TWO : CanPages::ONE; + } + if (can == m_switches.second){ + open = true; + return CanPages::ONE; + } + m_first_found = false; + next_draw(); + return CanPages::TWO; + } + + std::string error; + bool open = false; + +private: + void next_draw(){ + // Past the script: one the search's guess finds. + m_switches = m_drawn < m_draws.size() ? m_draws[m_drawn++] : Switches{8, 7}; + } + + OverworldSim& m_sim; + StoryPose& m_at; + std::vector m_draws; + size_t m_drawn = 0; + Switches m_switches; + bool m_first_found = false; +}; + +// Row 00.23 from steps `first` to `last` (exclusive) on `sim`, the trash +// cans drawn as `draws` say. +std::string run_gym_steps(OverworldSim& sim, StoryPose& at, size_t first, size_t last, const std::vector& draws){ + const StoryCheckpoint& row = checkpoint("00.23"); + for (size_t c = first; c < last; c++){ + const StoryStep& step = row.steps[c]; + if (step.variant == TRASH_CANS_VARIANT){ + QuietLogger logger; + SimGym gym(sim, at, draws); + const TrashCanResult result = run_trash_cans(logger, [&](int can){ return gym.check(can); }); + if (result.end != TrashCanEnd::OPENED || !gym.open){ + return "00.23/trash_cans: " + std::string(trash_can_end_name(result.end)) + " " + gym.error; + } + continue; + } + if (const std::string failure = gym_step_on_sim(sim, at, step, row.id); !failure.empty()){ + return failure; + } + } + return ""; +} + +// 00.23 on the sim from the nurse to the nurse: the tree cut, Dwayne, +// Baily and Tucker fought in that order where the table says and no one +// else, the cans searched, out past the tree grown back. The switches +// found at once (under 3 and 4), and after a wrong guess's reset (under +// 3 and 8, then 13 and 14). +UnitTestResult test_gym_row(){ + const StoryCheckpoint& row = checkpoint("00.23"); + for (const std::vector& draws : {std::vector{{3, 4}}, std::vector{{3, 8}, {13, 14}}}){ + StoryPose at = *story_pose(*row.start); + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + if (const std::string failure = run_gym_steps(sim, at, 0, row.steps.size(), draws); !failure.empty()){ + return failure + " Beat" + beaten_list(sim) + "."; + } + const std::vector expected{"sailor", "balding_man", "gentleman"}; + if (sim.state().trainers_beaten != expected || !sim.state().violations.empty()){ + return "00.23: expected Dwayne, Baily and Tucker beaten and no violation; beat" + beaten_list(sim) + "."; + } + if (!(sim.state().position == MapPosition{StoryTable::VERMILION_CENTER, {7, 4}})){ + return "00.23: expected to end at Vermilion's nurse, not " + to_string(sim.state().position) + "."; + } + } + return true; +} + +// From where Tucker's battle leaves the player, with the three where +// their battles leave them: every can's stand reached from (5, 8) and from +// every other stand, and the way out to the nurse from every stand. +UnitTestResult test_cans_from_everywhere(){ + const StoryCheckpoint& row = checkpoint("00.23"); + StoryPose at = *story_pose(*row.start); + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + const size_t cans = step_index(row, "trash_cans"); + if (const std::string failure = run_gym_steps(sim, at, 0, cans, {}); !failure.empty()){ + return failure; + } + for (int from = 1; from <= TRASH_CAN_COUNT; from++){ + OverworldSim there = sim; + StoryPose here = at; + if (const std::string failure = walk_to_stand(there, here, from); !failure.empty()){ + return "From Tucker's battle: " + failure; + } + for (int to = 1; to <= TRASH_CAN_COUNT; to++){ + OverworldSim walked = there; + StoryPose pose = here; + if (const std::string failure = walk_to_stand(walked, pose, to); !failure.empty()){ + return std::format("From can {}: ", from) + failure; + } + if (walked.state().trainers_beaten.size() != 3){ + return std::format("Can {} to can {}: a battle on the way.", from, to); + } + } + OverworldSim out = there; + if (const std::string failure = run_gym_steps(out, here, cans + 1, row.steps.size(), {}); !failure.empty()){ + return std::format("Out from can {}: ", from) + failure; + } + if (!(out.state().position == MapPosition{StoryTable::VERMILION_CENTER, {7, 4}}) || !out.state().violations.empty()){ + return std::format("Out from can {}: expected the nurse and no violation, on {}.", from, to_string(out.state().position)); + } + } + return true; +} + +// The tree shuts the gym's yard: no way from above it to the door. +UnitTestResult test_tree_shuts_the_yard(){ + const RouteGoal door{{GYM, {5, 19}}, std::nullopt}; + if (plan_overworld_route(story_maps(), MapPosition{StoryTable::VERMILION, {19, 23}}, door, story_route_costs(), Direction::SOUTH)){ + return "Planned a way into the gym past the uncut tree (19, 24)."; + } + if (!plan_overworld_route(story_maps(), MapPosition{StoryTable::VERMILION, {19, 24}}, door, story_route_costs(), Direction::SOUTH)){ + return "No way from the tree's tile to the gym."; + } + // The sim: no step onto the tree; cut, it's gone; a warp grows it back. + OverworldSim sim(story_maps(), MapPosition{StoryTable::VERMILION, {19, 23}}, Direction::SOUTH); + sim.tap(Direction::SOUTH); + if (!(sim.state().position == MapPosition{StoryTable::VERMILION, {19, 23}}) || !sim.cut_tree()){ + return "Expected the tree to stop the player, then be cut."; + } + sim.tap(Direction::SOUTH); + if (!(sim.state().position == MapPosition{StoryTable::VERMILION, {19, 24}})){ + return "Expected a step onto the cut tree's tile."; + } + return true; +} + +// 00.24 and 00.25 on the sim, the gym as 00.23 leaves it: to Route 6's +// grass round Isabelle's line and back, to below Surge through the beams, +// and from him back out past the tree to the nurse, meeting no one. +UnitTestResult test_to_surge_and_out(){ + for (const char* id : {"00.24", "00.25"}){ + const StoryCheckpoint& row = checkpoint(id); + StoryPose at = *story_pose(*row.start); + OverworldSim sim(maps_after_gym(), MapPosition{at.map, at.tile}, *at.facing); + for (const StoryStep& step : row.steps){ + if (const std::string failure = gym_step_on_sim(sim, at, step, row.id); !failure.empty()){ + return failure; + } + } + if (!sim.state().trainers_beaten.empty() || !sim.state().violations.empty()){ + return std::string(id) + ": a battle or a violation on the way."; + } + const MapPosition end = std::string(id) == "00.24" ? MapPosition{GYM, {5, 3}} : MapPosition{StoryTable::VERMILION_CENTER, {7, 4}}; + if (!(sim.state().position == end)){ + return std::string(id) + ": ended on " + to_string(sim.state().position) + ", not " + to_string(end) + "."; + } + } + const StoryPose next = *story_pose(*checkpoint("00.25").start); + if (!(next.tile == TilePos{5, 3}) || next.map != GYM){ + return "00.25 doesn't start where 00.24 ends."; + } + return true; +} + +// Console B, 00.25 (2026-11-03 18:17): the walk to the nurse went up +// column 27 to row 7 and west along it, into Oak's aide on (25, 7), there +// since Surge's win; the leg was taken as walked and the step into the +// Center's door went into a house's wall. 00.25 from Surge to the nurse +// with the aide there, meeting no one. +UnitTestResult test_out_past_oaks_aide(){ + const StoryCheckpoint& row = checkpoint("00.25"); + const std::vector maps = maps_after_surge(); + const MapData& city = *std::find_if(maps.begin(), maps.end(), [](const MapData& map){ return map.name == StoryTable::VERMILION; }); + if (object_at(city, {25, 7}) == nullptr){ + return "Expected Oak's aide on (25, 7) after Lt. Surge."; + } + StoryPose at = *story_pose(*row.start); + OverworldSim sim(maps, MapPosition{at.map, at.tile}, *at.facing); + for (const StoryStep& step : row.steps){ + if (const std::string failure = gym_step_on_sim(sim, at, step, row.id); !failure.empty()){ + return failure; + } + } + const MapPosition nurse{StoryTable::VERMILION_CENTER, {7, 4}}; + if (!sim.state().violations.empty() || !(sim.state().position == nurse)){ + return "Expected the nurse reached past Oak's aide; on " + to_string(sim.state().position) + (sim.state().violations.empty() ? "." : ", " + sim.state().violations.back()); + } + return true; +} + +// The story's lead before Surge: Wartortle at `level` with the S.S. +// Anne's moves (00.22), stats at OWN_GENES. +PartyMember surge_lead(uint8_t level){ + const Stats stats = combatant("wartortle", level, OWN_GENES)->stats; + std::vector moves; + for (const char* move : {"bite", "water-pulse", "bubble", "withdraw"}){ + moves.push_back(SummaryMove{.move = move, .pp = max_pp(move), .max_pp = max_pp(move)}); + } + return member_from_summary("wartortle", level, moves, stats); +} + +// Lt. Surge against the lead with 14 Potions: Raichu's Shock Wave, +// Thunder Wave and Double Team beat Water Pulse and Bite (3% at Lv28); +// TM28 Dig in place of Withdraw takes it to 27% at Lv28 and 61% at Lv30, +// past the GRIND's gate (00.23, 00.24). The battles still lost are to the +// Raichu. The gym's three trainers are no risk. +UnitTestResult test_surge_chance(){ + const SimTrainer surge = *sim_trainer("TRAINER_LEADER_LT_SURGE"); + const SimItems potions{.potions = 14}; + const auto chance = [&](uint8_t level, bool dig){ + SimBattler own = *own_battler(surge_lead(level)); + if (dig){ + own.moves[3] = SimMove{.move = "dig", .pp = 10}; + } + return estimate_win_chance(own, potions, surge); + }; + const WinEstimate plain = chance(28, false); + const WinEstimate dug = chance(28, true); + const WinEstimate dug30 = chance(30, true); + if (plain.chance > 0.15 || dug.chance < 0.2 || dug.chance > 0.4 || dug30.chance < 0.5){ + return std::format( + "Expected under 15% at Lv28, 20-40% with Dig, 50% or more at Lv30; got {:.1f}%, {:.1f}% ({}), {:.1f}%.", + 100 * plain.chance, 100 * dug.chance, loss_text(dug), 100 * dug30.chance + ); + } + const auto& by_foe = dug.losses.by_foe; + if (!by_foe.contains("raichu") || 10 * by_foe.at("raichu") < 9 * (dug.battles - dug.wins)){ + return "Expected the battles lost with Dig lost to the Raichu, got \"" + loss_text(dug) + "\"."; + } + for (const char* trainer : {"TRAINER_SAILOR_DWAYNE", "TRAINER_ENGINEER_BAILY", "TRAINER_GENTLEMAN_TUCKER"}){ + if (estimate_win_chance(*own_battler(surge_lead(28)), potions, *sim_trainer(trainer)).chance < 0.9){ + return std::string("Expected the lead to beat ") + trainer + " 90% of the time or more."; + } + } + return true; +} + +// Console B, m73 (2026-11-03): the Raichu stayed at full HP for six +// turns while the Dig missed. pret AI_CV_EvasionUp gives Double Team +3 +// at 90% HP or more, 156 times in 256, over Shock Wave and Thunder Wave +// (100 each); AI_CBM_EvasionUp takes 10 off at +6 evasion. +UnitTestResult test_surge_raichu_double_team(){ + const SimTrainer surge = *sim_trainer("TRAINER_LEADER_LT_SURGE"); + const SimBattler& raichu = surge.party[2]; + const SimBattler lead = *own_battler(surge_lead(29)); + if (raichu.moves[2].move != "double-team"){ + return "Expected the Raichu's third move to be Double Team, got " + raichu.moves[2].move + "."; + } + const double full = ai_move_chances(surge, raichu, lead)[2]; + SimBattler dodging = raichu; + dodging.body.stages[static_cast(Stage::EVASION)] = 6; + const double maxed = ai_move_chances(surge, dodging, lead)[2]; + if (full < 0.65 || maxed > 0){ + return std::format("Expected Double Team 65% of the time or more at full HP, never at +6; got {:.0f}% and {:.0f}%.", 100 * full, 100 * maxed); + } + return true; +} + +// m73's lead as its summary showed it before Lt. Surge, `levels` more. +PartyMember m73_lead(uint8_t levels = 0){ + std::vector moves; + for (const char* move : {"bite", "water-pulse", "bubble", "dig"}){ + moves.push_back(SummaryMove{.move = move, .pp = max_pp(move), .max_pp = max_pp(move)}); + } + PartyMember lead = member_from_summary("wartortle", 29, moves, Stats{83, 44, 59, 51, 64, 49}); + lead.moves_through_level = static_cast(29 + levels); + return lead; +} + +// m73: each Dig took two turns (18 s from menu to menu, Bite 10 s), and +// the Raichu's Double Team lands while the lead is under ground. The sim +// played Dig as one turn and gave Double Team no CHECK_VIABILITY: 79% for +// m73's lead and 14 Potions, lost on the console. Now 58%, all lost to +// the Raichu; at Lv31 the lead outspeeds it (82%). +UnitTestResult test_surge_dig_two_turns(){ + const SimItems potions{.potions = 14}; + const WinEstimate m73 = *story_win_chance(m73_lead(), potions, 1.0, "TRAINER_LEADER_LT_SURGE"); + const WinEstimate lv31 = *story_win_chance(m73_lead(2), potions, 1.0, "TRAINER_LEADER_LT_SURGE"); + const auto& by_foe = m73.losses.by_foe; + const bool raichu = by_foe.contains("raichu") && by_foe.at("raichu") == m73.battles - m73.wins; + if (m73.chance < 0.45 || m73.chance > 0.7 || !raichu || lv31.chance < 0.75){ + return std::format( + "Expected m73's lead at 45-70%, every loss to the Raichu, 75% or more at Lv31; got {:.1f}% ({}), {:.1f}%.", + 100 * m73.chance, loss_text(m73), 100 * lv31.chance + ); + } + return true; +} + +// 00.24 with a lead that beats Lt. Surge already: the check in the Center +// comes first, and with its flag set the walk to Route 6's grass, the +// grind and the way back are skipped; the rest goes from the nurse to +// below Surge on the sim. Console B walked out and back at Lv29, 79% +// (2026-11-03). +UnitTestResult test_surge_winnable_skips_grind(){ + const StoryCheckpoint& row = checkpoint("00.24"); + const StoryStep& check = row.steps.front(); + if (!parse_grind_check_variant(check.variant)){ + return "Expected 00.24 to start with the grind's check, not " + check.name + "."; + } + StoryPose at = *story_pose(*row.start); + OverworldSim sim(maps_after_gym(), MapPosition{at.map, at.tile}, *at.facing); + std::vector skipped; + for (const StoryStep& step : row.steps){ + if (step.skip_if == std::vector{check.name}){ + skipped.push_back(step.name); + continue; + } + if (const std::string failure = gym_step_on_sim(sim, at, step, row.id); !failure.empty()){ + return failure; + } + } + const std::vector expected{ + "route6_south_path", "round_isabelle_line", "grind_for_surge", "west_on_row_31", "down_to_jeff_row", "vermilion_nurse", "heal_vermilion", + }; + if (skipped != expected || !(sim.state().position == MapPosition{GYM, {5, 3}})){ + return std::format("Expected the trip to Route 6 skipped and below Surge reached; {} skipped, on {}.", skipped.size(), to_string(sim.state().position)); + } + return true; +} +} // namespace + + +const AutoStoryVermilionGymCase AUTO_STORY_VERMILION_GYM_CASES[] = { + { + .input = {"00.23 the tree shuts the gym's yard until cut", test_tree_shuts_the_yard}, + .expectation = {}, + }, + { + .input = {"00.23 Vermilion Gym: Dwayne, Baily and Tucker, the cans at once or after a reset", test_gym_row}, + .expectation = {}, + }, + { + .input = {"00.23 every can's stand from Tucker's tile and from every other, and out", test_cans_from_everywhere}, + .expectation = {}, + }, + { + .input = {"00.24 and 00.25 through the beams to Lt. Surge and out", test_to_surge_and_out}, + .expectation = {}, + }, + { + .input = {"00.25 out to the nurse past Oak's aide on (25, 7)", test_out_past_oaks_aide}, + .expectation = {}, + }, + { + .input = {"Lt. Surge: Dig over Withdraw, the sim's chance", test_surge_chance}, + .expectation = {}, + }, + { + .input = {"Lt. Surge: the Raichu's Double Team at full HP", test_surge_raichu_double_team}, + .expectation = {}, + }, + { + .input = {"Lt. Surge: Dig takes two turns, m73's lead under 79%", test_surge_dig_two_turns}, + .expectation = {}, + }, + { + .input = {"00.24 with Lt. Surge winnable already: no trip to Route 6", test_surge_winnable_skips_grind}, + .expectation = {}, + }, +}; + +void add_tests_AutoStoryVermilionGym(UnitTestDatabase& database){ + for (const auto& item : AUTO_STORY_VERMILION_GYM_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilion_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilion_Tests.cpp new file mode 100644 index 0000000000..14ca17705a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilion_Tests.cpp @@ -0,0 +1,237 @@ +/* AutoStory Vermilion Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Rows 00.21 (Cerulean to Vermilion City) and 00.22 (the S.S. Anne) on + * the overworld sim: their walks reach their ends, and meet only the + * trainers the table fights. + * + */ + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTable.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct AutoStoryVermilionInput{ + std::string name; + std::function body; +}; + +struct AutoStoryVermilionExpectation{ +}; + +struct AutoStoryVermilionCase{ + AutoStoryVermilionInput input; + AutoStoryVermilionExpectation expectation; +}; + +class Test_AutoStoryVermilion : public UnitTest{ +public: + explicit Test_AutoStoryVermilion(AutoStoryVermilionCase test_case) + : UnitTest("PokemonFRLG::AutoStory - Simulation: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_case.input.body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + AutoStoryVermilionCase m_case; +}; + +const StoryCheckpoint* checkpoint(const std::string& id){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [&](const StoryCheckpoint& data){ return data.id == id; }); + return row == rows.end() ? nullptr : &*row; +} + +std::string beaten_list(const OverworldSim& sim){ + std::string text; + for (const std::string& name : sim.state().trainers_beaten){ + text += " " + name; + } + return text.empty() ? " none" : text; +} + +size_t step_index(const StoryCheckpoint& row, const std::string& name){ + const auto step = std::find_if(row.steps.begin(), row.steps.end(), [&](const StoryStep& data){ return data.name == name; }); + if (step == row.steps.end()){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No step " + row.id + "/" + name + "."); + } + return step - row.steps.begin(); +} + +// Runs row 00.21's steps from `first` to its end on `sim` from `at`, and +// checks that only Jeff ("camper") was fought and the walk ended at +// Vermilion's nurse. +std::string run_to_vermilion(OverworldSim& sim, StoryPose at, const StoryCheckpoint& row, size_t first){ + for (size_t c = first; c < row.steps.size(); c++){ + const std::string failure = row_step_on_sim(sim, at, row.steps[c], row.id); + if (!failure.empty()){ + return failure; + } + } + if (sim.state().trainers_beaten != std::vector{"camper"} || !sim.state().violations.empty()){ + return "00.21: expected Jeff alone beaten and no violation; beat" + beaten_list(sim) + "."; + } + if (!(sim.state().position == MapPosition{"VermilionCity_PokemonCenter_1F", {7, 4}})){ + return "00.21: expected to end at Vermilion's nurse, not " + to_string(sim.state().position) + "."; + } + return ""; +} + +// 00.21 on the sim, from Cerulean's Pokemon Center through the burgled +// house to (32, 7), west of the Rocket, meeting no one; his trigger and +// battle aren't the sim's, so from where his battle leaves the player: +// Route 5, the Underground Path and Route 6, down column 15 out of +// Keigo's and Elijah's lines, round Isabelle's row to Jeff, who is fought +// on the step onto (10, 33), and on to Vermilion's nurse. Then from the +// walk onto Route 6's column 15 ended a tile short, on column 16: the +// same. +UnitTestResult test_route6_jeff_only(){ + const StoryCheckpoint* row = checkpoint("00.21"); + if (row == nullptr){ + return "No checkpoint 00.21."; + } + StoryPose at = *story_pose(*row->start); + OverworldSim house(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + const size_t rocket = step_index(*row, "rocket_trigger"); + for (size_t c = 0; c < rocket; c++){ + if (const std::string failure = row_step_on_sim(house, at, row->steps[c], row->id); !failure.empty()){ + return failure; + } + } + if (!(house.state().position == MapPosition{"CeruleanCity", {32, 7}}) || !house.state().trainers_beaten.empty()){ + return "00.21: expected to reach (32, 7) west of the Rocket, meeting no one, not " + to_string(house.state().position) + "."; + } + const size_t battle = step_index(*row, "rocket_grunt"); + const StoryPose after_rocket = *story_pose(*row->steps[battle].pose_after); + OverworldSim whole(story_maps(), MapPosition{after_rocket.map, after_rocket.tile}, *after_rocket.facing); + if (const std::string failure = run_to_vermilion(whole, after_rocket, *row, battle + 1); !failure.empty()){ + return "From the Rocket: " + failure; + } + const size_t walk = step_index(*row, "route6_column15"); + const StoryPose column = *story_pose(*row->steps[walk].target); + OverworldSim sim(story_maps(), MapPosition{column.map, {column.tile.x + 1, column.tile.y}}, Direction::WEST); + if (const std::string failure = run_to_vermilion(sim, column, *row, walk + 1); !failure.empty()){ + return "A tile short: " + failure; + } + return true; +} + +// Whether `route` from `start` steps onto any of `tiles` of `map`. +bool steps_onto(const std::vector& route, MapPosition start, const std::string& map, const std::vector& tiles){ + for (const RouteStep& step : route){ + const std::vector passed = step.action == RouteAction::WALK ? leg_tiles(story_maps(), start, step) : std::vector{}; + for (size_t c = 1; c < passed.size(); c++){ + if (passed[c].map == map && std::find(tiles.begin(), tiles.end(), passed[c].point) != tiles.end()){ + return true; + } + } + start = step.end; + } + return false; +} + +// Where the table says the player stands after `step` from `at`. +StoryPose pose_after(const StoryPose& at, const StoryStep& step){ + if (step.pose_after){ + return *story_pose(*step.pose_after); + } + if (step.kind == StoryStepKind::STEP){ + return StoryPose{at.map, at.tile.next(step.direction), step.direction}; + } + return at; +} + +// 00.22's walks on the sim, each from where the table says it starts: to +// the harbor short of the ticket check, aboard and along 2F short of the +// rival's trigger row (30..32, 6), into the Captain's office behind him, +// back to 1F's door, and from the pier to the nurse. None meets a +// trainer: the ship's are in its cabins and on its deck. +UnitTestResult test_ss_anne_walks(){ + const StoryCheckpoint* row = checkpoint("00.22"); + if (row == nullptr){ + return "No checkpoint 00.22."; + } + StoryPose at = *story_pose(*row->start); + bool before_rival = true; + for (const StoryStep& step : row->steps){ + if (step.kind != StoryStepKind::ROUTE && step.kind != StoryStepKind::LEGS){ + before_rival &= step.kind != StoryStepKind::BATTLE; + at = pose_after(at, step); + continue; + } + const std::optional> route = walked_route(at, step); + if (!route || route->empty()){ + return "00.22/" + step.name + ": no route."; + } + const MapPosition start{at.map, at.tile}; + if (steps_onto(*route, start, "VermilionCity", {{22, 33}, {23, 33}}) + || (before_rival && steps_onto(*route, start, "SSAnne_2F_Corridor", {{30, 6}, {31, 6}, {32, 6}})) + ){ + return "00.22/" + step.name + ": crosses a trigger before its step."; + } + OverworldSim sim(story_maps(), start, *at.facing); + if (const std::string failure = row_step_on_sim(sim, at, step, row->id); !failure.empty()){ + return failure; + } + if (!(sim.state().position == MapPosition{at.map, at.tile})){ + return "00.22/" + step.name + ": ended on " + to_string(sim.state().position) + ", not " + to_string(MapPosition{at.map, at.tile}) + "."; + } + if (!sim.state().trainers_beaten.empty() || !sim.state().violations.empty()){ + return "00.22/" + step.name + ": a trainer's battle or a violation on the way."; + } + } + if (!(MapPosition{at.map, at.tile} == MapPosition{"VermilionCity_PokemonCenter_1F", {7, 4}})){ + return "00.22: expected to end at Vermilion's nurse."; + } + return true; +} + +} // namespace + + +const AutoStoryVermilionCase AUTO_STORY_VERMILION_CASES[] = { + { + .input = {"00.21 to Vermilion, only Jeff on Route 6, the walk onto column 15 whole or a tile short", test_route6_jeff_only}, + .expectation = {}, + }, + { + .input = {"00.22 the S.S. Anne's walks, short of the ticket check and the rival's trigger", test_ss_anne_walks}, + .expectation = {}, + }, +}; + +void add_tests_AutoStoryVermilion(UnitTestDatabase& database){ + for (const auto& item : AUTO_STORY_VERMILION_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp index 4d1767a18d..21cd717f1d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "PokemonFRLG_AutoStoryWinChance.h" namespace PokemonAutomation{ @@ -105,6 +107,18 @@ class SimBattle{ return false; } + // After a lost battle: the foe out and the lead's state. + void tally_loss(LossTally& tally){ + tally.by_foe[foe().species]++; + tally.seeded += m_own.seeded ? 1 : 0; + tally.poisoned += m_own.status == SimStatus::POISON ? 1 : 0; + tally.asleep += m_own.status == SimStatus::SLEEP ? 1 : 0; + tally.paralyzed += m_own.status == SimStatus::PARALYSIS ? 1 : 0; + tally.accuracy_down += stage(m_own, Stage::ACCURACY) < 0 ? 1 : 0; + tally.no_potions += m_items.potions == 0 ? 1 : 0; + tally.turn_cap += m_own.hp > 0 ? 1 : 0; + } + private: SimBattler& foe(){ return m_trainer.party[m_out]; @@ -116,6 +130,17 @@ class SimBattle{ })); } + // The trainer's Pokemon not out and not fainted. + std::vector to_come() const{ + std::vector rest; + for (size_t slot = 0; slot < m_trainer.party.size(); slot++){ + if (slot != m_out && m_trainer.party[slot].hp > 0){ + rest.push_back(m_trainer.party[slot]); + } + } + return rest; + } + bool send_next(){ if (foes_left() == 0){ return false; @@ -146,9 +171,14 @@ class SimBattle{ void play_turn(){ const size_t left = foes_left(); - const OwnTurn own = own_turn(m_own, m_items, foe(), m_trainer, left); - const std::optional item = ai_item_choice(foe(), m_trainer.items, left); - const std::optional foe_slot = item ? std::nullopt : foe_move(); + m_psn_turns = m_own.status == SimStatus::POISON ? m_psn_turns + 1 : 0; + // A charged two-turn move strikes: no choice this turn. + const bool own_locked = !m_own.charging.empty(); + const bool foe_locked = !foe().charging.empty(); + const OwnTurn own = own_locked ? OwnTurn{} : own_turn(m_own, m_items, foe(), m_trainer, left, to_come()); + const bool antidote = !own_locked && !own.potion && !own.awakening && antidote_now(); + const std::optional item = foe_locked ? std::nullopt : ai_item_choice(foe(), m_trainer.items, left); + const std::optional foe_slot = item || foe_locked ? std::nullopt : foe_move(); if (own.potion){ gain_hp(m_own, POTION_HP); m_items.potions--; @@ -158,17 +188,30 @@ class SimBattle{ m_own.sleep = 0; m_items.awakenings--; } + if (antidote){ + m_own.status = SimStatus::NONE; + m_items.antidotes--; + m_antidote_used = true; + } if (item){ gain_hp(foe(), ai_item_hp(m_trainer.items[*item])); m_trainer.items[*item].clear(); } - const bool item_used = own.potion || own.awakening; - const std::string own_move = item_used ? "" : own.slot ? m_own.moves[*own.slot].move : "struggle"; - const std::string foe_used = item ? "" : foe_slot ? foe().moves[*foe_slot].move : "struggle"; + const bool item_used = own.potion || own.awakening || antidote; + const std::string own_move = own_locked ? m_own.charging + : item_used ? "" : own.slot ? m_own.moves[*own.slot].move : "struggle"; + const std::string foe_used = foe_locked ? foe().charging + : item ? "" : foe_slot ? foe().moves[*foe_slot].move : "struggle"; play_moves(own_move, own.slot, foe_used, foe_slot); end_of_turn(); } + // As the battle loop: once a battle, PSN_TURNS_BEFORE_ANTIDOTE turns + // poisoned in a row. + bool antidote_now() const{ + return m_items.antidotes > 0 && !m_antidote_used && m_psn_turns >= PSN_TURNS_BEFORE_ANTIDOTE; + } + bool own_first(const std::string& own_move, const std::string& foe_used){ const int own_priority = data_of(own_move).priority; const int foe_priority = data_of(foe_used).priority; @@ -216,13 +259,25 @@ class SimBattle{ } void use(SimBattler& user, SimBattler& target, const std::string& move_name, std::optional slot, bool moves_first){ - if (move_name.empty() || user.hp == 0 || target.hp == 0 || !can_move(user, moves_first)){ + if (move_name.empty() || user.hp == 0 || target.hp == 0){ + return; + } + if (!can_move(user, moves_first)){ + user.charging.clear(); return; } if (slot && user.moves[*slot].pp > 0){ user.moves[*slot].pp--; } const MoveData& move = data_of(move_name); + if (two_turn(move) && user.charging.empty()){ + user.charging = move_name; + return; + } + user.charging.clear(); + if (semi_invulnerable(target) && !on_user(move)){ + return; + } if (!hits(user, target, move)){ return; } @@ -233,6 +288,18 @@ class SimBattle{ } } + // A move on its user only: a stat raise, Recover. + static bool on_user(const MoveData& move){ + if (move.power > 0){ + return false; + } + if (move.effect == "restore-hp" || move.effect == "softboiled"){ + return true; + } + const std::optional change = stage_change(move.effect); + return change && change->on_user; + } + uint32_t hit_count(const MoveData& move){ if (move.effect == "double-hit" || move.effect == "twineedle"){ return 2; @@ -339,6 +406,8 @@ class SimBattle{ std::mt19937& m_rng; size_t m_out = 0; bool m_flinched = false; + int m_psn_turns = 0; + bool m_antidote_used = false; }; } // namespace @@ -349,12 +418,38 @@ WinEstimate estimate_win_chance(const SimBattler& own, const SimItems& items, co WinEstimate estimate{.battles = battles, .wins = 0, .chance = 0}; for (size_t i = 0; i < battles; i++){ SimBattle battle(own, items, trainer, rng); - estimate.wins += battle.won() ? 1 : 0; + if (battle.won()){ + estimate.wins++; + }else{ + battle.tally_loss(estimate.losses); + } } estimate.chance = battles == 0 ? 0 : static_cast(estimate.wins) / battles; return estimate; } +std::string loss_text(const WinEstimate& estimate){ + const LossTally& t = estimate.losses; + std::string text = std::format("lost {}:", estimate.battles - estimate.wins); + std::vector> foes(t.by_foe.begin(), t.by_foe.end()); + std::ranges::sort(foes, [](const auto& a, const auto& b){ return a.second > b.second; }); + for (size_t i = 0; i < foes.size(); i++){ + text += std::format("{} {} {}", i == 0 ? "" : ",", foes[i].first, foes[i].second); + } + const std::pair states[] = { + {"seeded", t.seeded}, {"poisoned", t.poisoned}, {"asleep", t.asleep}, {"paralyzed", t.paralyzed}, + {"accuracy down", t.accuracy_down}, {"no Potions", t.no_potions}, {"turn cap", t.turn_cap}, + }; + const char* separator = ";"; + for (const auto& [name, count] : states){ + if (count > 0){ + text += std::format("{} {} {}", separator, name, count); + separator = ","; + } + } + return text; +} + std::optional story_win_chance(const PartyMember& lead, const SimItems& items, double hp_share, std::string_view trainer){ const std::optional own = own_battler(lead, hp_share); const std::optional sim = sim_trainer(trainer); @@ -372,6 +467,9 @@ std::optional story_win_chance(const PartyMember& lead, const SimIt if (items.awakenings > 0){ estimate.inputs += std::format(", {} Awakenings", items.awakenings); } + if (items.antidotes > 0){ + estimate.inputs += std::format(", {} Antidotes", items.antidotes); + } return estimate; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h index f041c98d76..2807d5b092 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h @@ -10,13 +10,18 @@ * AI's ties. Too low a chance stops the story before the fight, for a * GRIND or the nurse first. * - * Each turn: the Potion or Awakening and the AI's item first, then the moves by - * priority and speed (a quarter with PARALYSIS). Sleep (2 to 5 on the + * Each turn: the Potion, Awakening or Antidote (as the battle loop: once a + * battle, after PSN_TURNS_BEFORE_ANTIDOTE turns poisoned) and the AI's + * item first, then the moves by priority and speed (a quarter with + * PARALYSIS). Sleep (2 to 5 on the * game's counter), poison (1/8 HP a turn), paralysis (1/4 no move), Leech * Seed (1/8 a turn to the other side), flinch, one- and two-stage stat * changes, multi-hit moves (2 to 5 as the game draws them), drain (half) - * and Recover (half) are played; burn, freeze, confusion, recoil, - * recharge, two-turn moves, trapping, abilities and held items are not. + * and Recover (half) are played, and two-turn moves (two_turn()): the + * charge turn, PP paid then and no choice the next, cut short by a turn + * the user can't move; under ground or in the air every move at it but + * its own stat raises and heals misses. Burn, freeze, confusion, recoil, + * recharge, trapping, abilities and held items are not. * The lead is never switched out: a fainted lead is a lost battle. * */ @@ -26,6 +31,7 @@ #include #include +#include #include #include #include @@ -36,6 +42,21 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ +// How the battles were lost: the foe out when the lead fainted, and the +// lead's state then. A battle can count under more than one state. +struct LossTally{ + std::map by_foe = {}; + size_t seeded = 0; + size_t poisoned = 0; + size_t asleep = 0; + size_t paralyzed = 0; + // The lead's accuracy below 0. + size_t accuracy_down = 0; + size_t no_potions = 0; + // Still going at the turn cap. + size_t turn_cap = 0; +}; + // Outcome count and win probability estimate from Monte Carlo simulations. struct WinEstimate{ size_t battles = 0; @@ -45,8 +66,13 @@ struct WinEstimate{ // The lead and items the battles were played with, for the log: // "wartortle Lv20 60/60 HP: bite 25/25, ...; 5 Potions, 2 Awakenings". std::string inputs = {}; + LossTally losses = {}; }; +// The losses for the log: "lost 604: ivysaur 410, kadabra 150; seeded +// 380, poisoned 300, asleep 120, accuracy down 200, no Potions 50". +std::string loss_text(const WinEstimate& estimate); + // `own` with `items` against `trainer`, `battles` times, from a fixed // `seed` so the same inputs give the same estimate. WinEstimate estimate_win_chance(const SimBattler& own, const SimItems& items, const SimTrainer& trainer, size_t battles = 1000, uint32_t seed = 1); 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 f423c1e54d..315c85a120 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -230,8 +230,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 27 || checkpoints[0].start){ - return "Expected 27 checkpoints, the first from a new game."; + if (checkpoints.size() != 32 || checkpoints[0].start){ + return "Expected 32 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -382,11 +382,11 @@ bool looks_around(const MapObject& object){ } // The trainer on `map` who would see the player on `tile`, other than the -// `beaten`. Wanderers and those who look around are left out: no route -// can plan around one (see wanderer_stops() and held_past_lookers()). -const MapObject* trainer_seeing(const MapData& map, const TilePos& tile, const std::set& beaten){ +// `beaten`. Wanderers and those who look around are left out (no route can plan round one: wanderer_stops(), +// held_past_lookers()); with `lookers`, only those who look around, met by a wait (TRAINER_WAIT_VARIANT). +const MapObject* trainer_seeing(const MapData& map, const TilePos& tile, const std::set& beaten, bool lookers){ for (const MapObject& object : map.objects){ - if (object.trainer && !object.wander && !looks_around(object) && !beaten.contains(&object) && trainer_may_see(map, object, tile)){ + if (object.trainer && !object.wander && looks_around(object) == lookers && !beaten.contains(&object) && trainer_may_see(map, object, tile)){ return &object; } } @@ -406,7 +406,7 @@ TilePos step_end(const StoryPose& pose, const StoryStep& step, const std::set legs_walked(const StoryPose& pose, const StoryStep& step) const MapData* map = find_map_named(pose.map); const std::optional facing = pose.facing ? pose.facing : map ? wall_beside(*map, pose.tile) : std::nullopt; const StoryPose target = one_tile(*step.target); - route = plan_overworld_route(story_maps(), start, RouteGoal{{target.map, target.tile}, target.facing}, default_route_costs(), facing); + route = plan_overworld_route(story_maps(), start, RouteGoal{{target.map, target.tile}, target.facing}, story_route_costs(), facing); } return route ? *route : std::vector{}; } @@ -479,15 +479,15 @@ std::vector> wanderer_stops(const StoryPose& pose, co // the player's way, they walk up beside the leg, and the walker holds it // on to its wall after the battle. Or the leg is one tile: the trainer // sees the player when the step is done, on its end. Empty if so, else -// the leg that doesn't. -std::string held_past_lookers(const StoryPose& pose, const StoryStep& step){ +// the leg that doesn't. Trainers in `beaten` see no one. +std::string held_past_lookers(const StoryPose& pose, const StoryStep& step, const std::set& beaten){ MapPosition from{pose.map, pose.tile}; for (const RouteStep& leg : legs_walked(pose, step)){ const MapData* map = find_map_named(from.map); bool crosses = false; - for (const MapPosition& tile : leg.action == RouteAction::WALK ? leg_tiles(story_maps(), from, leg) : std::vector{}){ - for (const MapObject& object : map ? map->objects : std::vector{}){ - crosses |= looks_around(object) && trainer_may_see(*map, object, tile.point); + for (const MapPosition& tile : leg.action == RouteAction::WALK && map ? leg_tiles(story_maps(), from, leg) : std::vector{}){ + for (const MapObject& object : map->objects){ + crosses |= looks_around(object) && !beaten.contains(&object) && trainer_may_see(*map, object, tile.point); } } if (crosses && !leg.to_wall && leg.tiles != 1){ @@ -527,13 +527,19 @@ std::string tile_problem(const MapPosition& tile, const std::setto){ return "onto a warp to a map not loaded at " + to_string(tile); } - const MapObject* trainer = trainer_seeing(*map, tile.point, beaten); + const MapObject* trainer = trainer_seeing(*map, tile.point, beaten, false); if (trainer != nullptr){ return "in the line of the trainer at " + to_string(TilePos{trainer->x, trainer->y}) + " at " + to_string(tile); } return ""; } +// LEGS whose legs through grass end on walls or are one tile (Route 6). +bool grass_held(const StoryPose& pose, const StoryStep& step){ + const std::vector legs = legs_walked(pose, step); + return step.kind == StoryStepKind::LEGS && std::all_of(legs.begin(), legs.end(), [](const RouteStep& leg){ return !leg.wild || leg.to_wall || leg.tiles == 1; }); +} + // Moves `pose` past `step`, as check_checkpoint() does. UnitTestResult advance(StoryPose& pose, const StoryStep& step){ if (step.kind == StoryStepKind::ROUTE){ @@ -565,7 +571,7 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set{}){ const StoryPose end = to_patch ? one_tile(*step.target) : StoryPose{}; const bool patch = to_patch && tile.map == end.map && tile.point == end.tile; - if (const std::string problem = tile_problem(tile, beaten); !problem.empty() && !(patch && problem.starts_with("in the grass"))){ + if (const std::string problem = tile_problem(tile, beaten); !problem.empty() && !((patch || grass_held(pose, step)) && problem.starts_with("in the grass"))){ return where + problem; } } @@ -573,11 +579,11 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set 0){ return where + "crosses a wandering trainer's sight where the walk can't tell where they'd stop the player."; } - if (const std::string problem = walks ? held_past_lookers(pose, step) : ""; !problem.empty()){ + if (const std::string problem = walks ? held_past_lookers(pose, step, beaten) : ""; !problem.empty()){ return where + problem; } const MapData* map = find_map_named(pose.map); - const MapObject* trainer = step.kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, step, beaten), beaten) : nullptr; + const MapObject* trainer = step.kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, step, beaten), beaten, step.variant == TRAINER_WAIT_VARIANT) : nullptr; if (trainer != nullptr && (c + 1 == checkpoint.steps.size() || checkpoint.steps[c + 1].kind != StoryStepKind::BATTLE)){ return where + "steps into a trainer's line with no battle after it."; } @@ -592,9 +598,9 @@ UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::setstart); for (size_t c = 0; c < steps.size(); c++){ const MapData* map = find_map_named(pose.map); - const MapObject* trainer = steps[c].kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, steps[c], {}), {}) : nullptr; + const MapObject* trainer = steps[c].kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, steps[c], {}), {}, false) : nullptr; if (trainer != nullptr && (c + 1 == steps.size() || steps[c + 1].kind != StoryStepKind::BATTLE)){ return "00.16/" + steps[c].name + ": no battle right after the step."; } diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index d2dc208485..85f66492bd 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1942,6 +1942,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp @@ -1961,14 +1963,22 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableVermilionGym.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVariantWords.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilion_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryWinChance.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp diff --git a/devtools/maps/frlg_maps.py b/devtools/maps/frlg_maps.py index e7bc65e726..6af5fd6f06 100644 --- a/devtools/maps/frlg_maps.py +++ b/devtools/maps/frlg_maps.py @@ -94,6 +94,7 @@ 'CeruleanCave_1F': {'entrance': (33, 21)}, # Mewtwo's tile. Its battle starts by talking to it from the south. 'CeruleanCave_B1F': {'mewtwo': (7, 12)}, + 'VermilionCity_PokemonCenter_1F': {'nurse_front': (7, 4)}, } FLAG_SETS = { # Set once the player has entered the Hall of Fame. They clear the way @@ -103,6 +104,23 @@ 'FLAG_HIDE_CERULEAN_RIVAL', 'FLAG_HIDE_CERULEAN_ROCKET', ]}, + # Set at every new game (EventScript_ResetAllMapFlags in + # data/event_scripts.s): people who are on the map only in a scene. + # The S.S. Anne's rival stands on the Captain's door until his scene + # brings him out. + 'SSAnne_2F_Corridor': {'new_game': ['FLAG_HIDE_SS_ANNE_RIVAL']}, + 'VermilionCity': {'new_game': ['FLAG_HIDE_VERMILION_CITY_OAKS_AIDE']}, +} + +# Tiles a script changes for good once the story is past it, written as +# the story finds them after: Vermilion Gym's beams across rows 6 and 7 +# go off once both switches are found +# (VermilionCity_Gym_EventScript_SetBeamsOff: (4..6, 6..7) floor, +# FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES). No route of the story's goes +# north of them before that, and one after can't end a leg on them as on +# a wall. +OPENED_TILES = { + 'VermilionCity_Gym': [(x, y) for y in (6, 7) for x in (4, 5, 6)], } @@ -132,9 +150,18 @@ def behaviors(decomp): return names -def tileset_attributes(decomp, symbol, kind): - name = re.sub(r'([a-z])([A-Z0-9])', r'\1_\2', symbol.replace('gTileset_', '')).lower() - data = decomp.read(f'data/tilesets/{kind}/{name}/metatile_attributes.bin') +def tileset_attributes(decomp, symbol): + """The metatile attributes of tileset `symbol` (gTileset_), from the + file src/data/tilesets/metatiles.h names for gMetatileAttributes_: + folder names don't always follow the symbol (gTileset_SSAnne is ss_anne).""" + attributes = symbol.replace('gTileset_', 'gMetatileAttributes_') + match = re.search( + r'\b' + attributes + r'\[\]\s*=\s*INCBIN_U32\("([^"]+)"\)', + decomp.read('src/data/tilesets/metatiles.h').decode(), + ) + if not match: + sys.exit(f'No {attributes} in src/data/tilesets/metatiles.h.') + data = decomp.read(match.group(1)) return [struct.unpack_from('