diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp index 4f2351b9e0..29e1f14054 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.cpp @@ -71,6 +71,8 @@ class PlaceNameMatcher : public OCR::SmallDictionaryMatcher{ {"route-3", "Route 3"}, {"route-4", "Route 4"}, {"cerulean-city", "Cerulean City"}, + {"route-24", "Route 24"}, + {"route-25", "Route 25"}, }){ 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 ef01053b76..0bf34fd1fb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h @@ -4,9 +4,10 @@ * * Reads where the game would be saved: the place name in blue at the top * of the card the START menu's SAVE shows ("PALLET TOWN" over PLAYER, - * BADGES and TIME). It knows the places from a new game to Route 4, and - * Cerulean City, in English only so far: in another language nothing is - * read. Route 3, Route 4 and Cerulean City have no test capture yet. + * BADGES and TIME). It knows the places from a new game to Route 4, + * Cerulean City, Route 24 and Route 25 (Bill's Sea Cottage is on it), in + * English only so far: in another language nothing is read. Route 3, + * Route 4, Cerulean City, Route 24 and Route 25 have no test capture yet. * */ @@ -31,8 +32,9 @@ namespace PokemonFRLG{ 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"; empty if it -// can't be read, as in a language the reader has no names for. +// "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. std::string read_save_card_location(Logger& logger, Language language, const ImageViewRGB32& screen); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp index 923d2b84a3..7763b32f4a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.cpp @@ -489,22 +489,23 @@ class Test_TeachScreen : public ImageUnitTest{ void add_tests_TeachScreenReader(UnitTestDatabase& database){ // FireRed on console B, 2026-11-03: TM03 taught to the lead from the - // bag, B on the move to forget, and YES to stop. + // bag, B on the move to forget, and YES to stop. Listed in the order + // the screens came up. for (const char* image : { - "01_BagItems_eng.png", - "02_BagKeyItems_eng.png", - "03_KeyItemsTmCase_eng.png", - "04_TmCaseMenu_eng.png", - "05_TmCaseList_eng.png", - "06_Tm03Menu_eng.png", - "07_TeachWhich_eng.png", - "08_WantsToLearn_eng.png", - "09_KnowsFour_eng.png", - "10_DeleteQuestion_eng.png", - "11_WhichMove_eng.png", - "12_KnownMoves_eng.png", - "13_StopQuestion_eng.png", - "14_DidNotLearn_eng.png", + "BagItems_eng.png", + "BagKeyItems_eng.png", + "KeyItemsTmCase_eng.png", + "TmCaseMenu_eng.png", + "TmCaseList_eng.png", + "Tm03Menu_eng.png", + "TeachWhich_eng.png", + "WantsToLearn_eng.png", + "KnowsFour_eng.png", + "DeleteQuestion_eng.png", + "WhichMove_eng.png", + "KnownMoves_eng.png", + "StopQuestion_eng.png", + "DidNotLearn_eng.png", }){ database.add(std::string("PokemonFRLG/TeachScreenReader/") + image); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp index 9952e1eaba..1816a97ff6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.cpp @@ -368,6 +368,23 @@ std::optional plan_reanchor( return std::nullopt; } +std::optional> holds_to_one_tile( + const std::vector& maps, const std::vector& tiles +){ + std::vector positions; + for (const MapPosition& tile : tiles){ + add_unique(positions, Hypothesis{0, tile}); + } + if (positions.empty()){ + return std::nullopt; + } + const std::vector found = find_player({maps}, positions, false); + if (found.empty()){ + return std::nullopt; + } + return found[0].holds; +} + } // namespace PokemonFRLG diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h index c4aaa0ba4f..51029bcf8e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h @@ -106,6 +106,15 @@ std::optional plan_reanchor( const RouteCosts& costs = default_route_costs() ); +// Holds to walls that bring the player to one tile from every one of +// `tiles`, found as plan_reanchor() finds them, with no trainer about: for +// a player known to be on one of a few tiles without a stop (a wild +// battle's pacing). Empty if `tiles` are one tile; the last hold ends on +// it otherwise. Nothing if no holds bring them together. +std::optional> holds_to_one_tile( + const std::vector& maps, const std::vector& tiles +); + // Registers overworld reanchor unit tests. void add_tests_OverworldReanchor(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp index f4524b64f7..bd24190fa1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.cpp @@ -37,6 +37,7 @@ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ leg.tile_time = tile_time(step, speed); leg.stop_on_interrupt = !step.to_wall; leg.stop_if_blocked = step.to_wall; + leg.stalled_steps = step.connection_steps; return leg; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 357d053788..b362f41941 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/TestRunners/UnitTest.h" @@ -360,6 +361,10 @@ RouteStep to_route_step(const std::vector& maps, const Pokemon::Leg tiles = leg_tiles(maps, MapPosition{maps[leg.start.map].name, leg.start.state.position}, step); + for (int c : std::views::iota(1, (int)tiles.size()) | std::views::filter([&](int n){ return tiles[n].map != tiles[n - 1].map; })){ + step.connection_steps.push_back(c); + } return step; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index 221586666a..a7503a77ae 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -68,6 +68,11 @@ struct RouteStep{ Direction facing_after = Direction::NORTH; // Where the player is after the step. MapPosition end; + // WALK: the steps (from 1) onto a connected map. The game holds the + // player up a tile before each while it loads the map: a hold timed + // 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 = {}; }; // Formats a RouteStep as a string. std::string to_string(const RouteStep& step); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 8ae6bfa390..1eda6c4a9e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -371,6 +371,15 @@ void OverworldSim::walk_hold(Direction direction, Milliseconds duration, int sho bump(direction, pressed); return; } + if (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). + pressed += tile; + if (pressed >= end){ + return; + } + } if (step->walked_out){ m_state.facing = Direction::SOUTH; } @@ -610,6 +619,9 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, leg.tile_time = TILE_TIME; leg.stop_on_interrupt = !step.to_wall; leg.stop_if_blocked = step.to_wall && m_rules.stop_if_blocked; + if (m_rules.hold_over_connections){ + leg.stalled_steps = step.connection_steps; + } Pokemon::LegCalls calls; calls.hold_watching = [&](Milliseconds hold) -> std::optional{ const std::optional seen = hold_watching(step, hold, ints); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index f58aeabbef..392af86e97 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -1,351 +1,359 @@ -/* Overworld Sim - * - * From: https://github.com/PokemonAutomation/ - * - * A whole-tile model of FireRed's overworld, for running the walker - * (run_route(), Pokemon::walk_leg_with()) without a console. The player - * moves a tile per 16 frames while the D-pad is held, by the maps' step - * rules (classify_step()), and also hops ledges the way they face, takes - * warps with a fade, reads signs walked into moving north or south, and is - * seen by trainers along their sight. Wild battles start on the steps a - * test chooses. Each of these shows a screen at a time after the step's - * press, which the walker's watchers see or miss as the console's do. - * - * Every console failure of the walker becomes a test here (red first). - * OverworldSimWalker is the console side of run_route() (RouteCalls) on - * the sim, step for step as walk_overworld_route_with_recovery() does it, - * with the walker's rules as switches, so a test can show a failure with - * the rule that was missing and its absence with the rule. - * - * Not modelled: Surf, bikes, running, HP and status. Wandering people - * stand on their tile. - * - */ - -#ifndef PokemonAutomation_PokemonFRLG_OverworldSim_H -#define PokemonAutomation_PokemonFRLG_OverworldSim_H - -#include -#include -#include -#include -#include -#include "Common/Cpp/Time.h" -#include "Common/Cpp/TestRunners/UnitTestDatabase.h" -#include "PokemonFRLG_OverworldRecovery.h" -#include "PokemonFRLG_OverworldRoute.h" -#include "PokemonFRLG_OverworldWalk.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -// What the screen shows, as the walker's detectors see it. -enum class SimScreen{ - OVERWORLD, - MESSAGE, // a white message box: a sign, a trainer - START_MENU, - BATTLE_FLASH, // a wild battle's opening flashes - BATTLE_WIPE, // black, as a wild battle's wipe ends, before its text box - BATTLE_OPENING, // a battle's text box, empty, before the HP boxes - BATTLE, // a battle's text box with the HP boxes - FADE, // a warp's fade out and in -}; +/* Overworld Sim + * + * From: https://github.com/PokemonAutomation/ + * + * A whole-tile model of FireRed's overworld, for running the walker + * (run_route(), Pokemon::walk_leg_with()) without a console. The player + * moves a tile per 16 frames while the D-pad is held, by the maps' step + * rules (classify_step()), and also hops ledges the way they face, takes + * warps with a fade, reads signs walked into moving north or south, and is + * seen by trainers along their sight. Wild battles start on the steps a + * test chooses. Each of these shows a screen at a time after the step's + * press, which the walker's watchers see or miss as the console's do. + * + * Every console failure of the walker becomes a test here (red first). + * OverworldSimWalker is the console side of run_route() (RouteCalls) on + * the sim, step for step as walk_overworld_route_with_recovery() does it, + * with the walker's rules as switches, so a test can show a failure with + * the rule that was missing and its absence with the rule. + * + * A step onto a connected map waits a tile first, as the console does + * while the game loads the map. + * + * Not modelled: Surf, bikes, running, HP and status. Wandering people + * stand on their tile. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldSim_H +#define PokemonAutomation_PokemonFRLG_OverworldSim_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_OverworldRecovery.h" +#include "PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_OverworldWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// What the screen shows, as the walker's detectors see it. +enum class SimScreen{ + OVERWORLD, + MESSAGE, // a white message box: a sign, a trainer + START_MENU, + BATTLE_FLASH, // a wild battle's opening flashes + BATTLE_WIPE, // black, as a wild battle's wipe ends, before its text box + BATTLE_OPENING, // a battle's text box, empty, before the HP boxes + BATTLE, // a battle's text box with the HP boxes + FADE, // a warp's fade out and in +}; // The human-readable name of a simulated screen. -const char* sim_screen_name(SimScreen screen); - -// What the walker watches the screen with. -enum class SimWatcher{ - FLASH, // BattleFlashWatcher: a battle's flashes, and a warp's fade - BATTLE_SCREEN, // BattleScreenWatcher: the battle menu or an HP box - // BattleDialogDetector, which the walker used before BattleScreenWatcher: - // the battle text box alone, which black below a small room passes for. - BATTLE_TEXT_BOX, - MESSAGE, // WhiteDialogWatcher - FADE, // BlackScreenWatcher: a warp's fade -}; - -// A wild battle on the player's `grass_step`th step onto tall grass (from -// 1), or onto any tile of a map in SimScript::wild_floor_maps. Seen late: -// its flashes are missed, and only the battle screen shows. -struct SimEncounter{ - int grass_step = 1; - bool seen_late = false; -}; - -// How long after a step's press each thing shows. The walker's own -// detection delays (OverworldInterrupt::detection_delay()) are its guesses -// at these. -struct SimTiming{ - Milliseconds tile{267}; - Milliseconds flash{300}; - Milliseconds battle_screen{3700}; - // A wild battle's text box, before the HP boxes (battle_screen). Not - // set: not modelled, the battle screen shows at once. - std::optional battle_opening; - // Black, from the end of a wild battle's wipe until its text box - // (battle_opening) or HP boxes. Not set: not modelled. - std::optional battle_wipe; - Milliseconds message{400}; - Milliseconds fade{1000}; // from the step's end to the new map - Milliseconds trainer_notice{1000}; // "!" over the trainer, before they walk up - Milliseconds trainer_battle{2000}; // after the trainer's message closes -}; - -// What a test sets up beyond the maps. -struct SimScript{ - std::vector encounters; - // The START menu is open at the start: the D-pad moves its cursor. - bool start_menu_open = false; - // The hold numbered by the key (from 1) stops this many tiles short, - // with nothing on screen (the forest drift, console B, 2026-11-01). - std::map short_holds; - // Maps where every step can start a wild battle, also on tiles the maps - // don't mark as grass: Mt. Moon B2F's tiles closed on one side ('8') - // are cave floor too (console B, 2026-11-03). - std::set wild_floor_maps; - SimTiming timing; -}; - -// The player and the screen, and what went wrong that the game would show. -struct SimState{ - MapPosition position; - Direction facing = Direction::SOUTH; - SimScreen screen = SimScreen::OVERWORLD; - // Whether black shows below the map: the player stands near the bottom - // of a map with nothing joined to it (a small room or a gate). - bool black_below = false; - int steps = 0; - int warps = 0; - int signs_read = 0; - std::vector trainers_beaten; - // Things the walker did that the game wouldn't let pass: D-pad holds - // under a battle, a trainer's battle fled, a battle fought that wasn't - // there. - std::vector violations; - std::vector log; -}; - -// A watcher that fired: which (an index into the watchers asked for), and -// how long after the hold or watch started. -struct SimSeen{ - size_t watcher = 0; - Milliseconds elapsed{0}; -}; - -// How the walker handles the battle on screen. -enum class SimBattleChoice{ - RUN, - FIGHT, -}; +const char* sim_screen_name(SimScreen screen); + +// What the walker watches the screen with. +enum class SimWatcher{ + FLASH, // BattleFlashWatcher: a battle's flashes, and a warp's fade + BATTLE_SCREEN, // BattleScreenWatcher: the battle menu or an HP box + // BattleDialogDetector, which the walker used before BattleScreenWatcher: + // the battle text box alone, which black below a small room passes for. + BATTLE_TEXT_BOX, + MESSAGE, // WhiteDialogWatcher + FADE, // BlackScreenWatcher: a warp's fade +}; + +// A wild battle on the player's `grass_step`th step onto tall grass (from +// 1), or onto any tile of a map in SimScript::wild_floor_maps. Seen late: +// its flashes are missed, and only the battle screen shows. +struct SimEncounter{ + int grass_step = 1; + bool seen_late = false; +}; + +// How long after a step's press each thing shows. The walker's own +// detection delays (OverworldInterrupt::detection_delay()) are its guesses +// at these. +struct SimTiming{ + Milliseconds tile{267}; + Milliseconds flash{300}; + Milliseconds battle_screen{3700}; + // A wild battle's text box, before the HP boxes (battle_screen). Not + // set: not modelled, the battle screen shows at once. + std::optional battle_opening; + // Black, from the end of a wild battle's wipe until its text box + // (battle_opening) or HP boxes. Not set: not modelled. + std::optional battle_wipe; + Milliseconds message{400}; + Milliseconds fade{1000}; // from the step's end to the new map + Milliseconds trainer_notice{1000}; // "!" over the trainer, before they walk up + Milliseconds trainer_battle{2000}; // after the trainer's message closes +}; + +// What a test sets up beyond the maps. +struct SimScript{ + std::vector encounters; + // The START menu is open at the start: the D-pad moves its cursor. + bool start_menu_open = false; + // The hold numbered by the key (from 1) stops this many tiles short, + // with nothing on screen (the forest drift, console B, 2026-11-01). + std::map short_holds; + // Maps where every step can start a wild battle, also on tiles the maps + // don't mark as grass: Mt. Moon B2F's tiles closed on one side ('8') + // are cave floor too (console B, 2026-11-03). + std::set wild_floor_maps; + SimTiming timing; +}; + +// The player and the screen, and what went wrong that the game would show. +struct SimState{ + MapPosition position; + Direction facing = Direction::SOUTH; + SimScreen screen = SimScreen::OVERWORLD; + // Whether black shows below the map: the player stands near the bottom + // of a map with nothing joined to it (a small room or a gate). + bool black_below = false; + int steps = 0; + int warps = 0; + int signs_read = 0; + std::vector trainers_beaten; + // Things the walker did that the game wouldn't let pass: D-pad holds + // under a battle, a trainer's battle fled, a battle fought that wasn't + // there. + std::vector violations; + std::vector log; +}; + +// A watcher that fired: which (an index into the watchers asked for), and +// how long after the hold or watch started. +struct SimSeen{ + size_t watcher = 0; + Milliseconds elapsed{0}; +}; + +// How the walker handles the battle on screen. +enum class SimBattleChoice{ + RUN, + FIGHT, +}; // How a simulated battle concluded. -enum class SimBattleEnd{ - ENDED, - CANT_RUN, // a trainer's battle: "No! There's no running from a TRAINER battle!" - NO_BATTLE, // there was none, on screen or about to show -}; - +enum class SimBattleEnd{ + ENDED, + CANT_RUN, // a trainer's battle: "No! There's no running from a TRAINER battle!" + NO_BATTLE, // there was none, on screen or about to show +}; + // Simulates overworld movement, encounters and dialogs. -class OverworldSim{ -public: - OverworldSim(std::vector maps, MapPosition start, Direction facing, SimScript script = {}); - - const SimState& state() const{ return m_state; } - - // Holds `direction` for `duration` then lets go for `release`, while - // `watchers` watch until `watch` (at least the hold and release) from - // the start. Returns the first that saw its screen, as run_until(). - std::optional hold( - Direction direction, Milliseconds duration, Milliseconds release, Milliseconds watch, - const std::vector& watchers - ); - - // A short press of `direction`: turns the player, or steps if they - // already face that way. - void tap(Direction direction); - - // Watches `watchers` for `duration`, as wait_until(). - std::optional watch(const std::vector& watchers, Milliseconds duration); - - // Runs from or fights the battle on screen, back to the overworld. A - // trainer's battle can't be run from; a battle that isn't there is - // waited out (the story's battle loop gives up after 15 s). - SimBattleEnd handle_battle(SimBattleChoice choice); - - // Closes the message on screen with B. Returns whether a trainer's - // battle follows it. - bool close_message(); - -private: - // A screen that shows at `at`; `unfreeze`: the player can move again. - struct Pending{ - Milliseconds at; - SimScreen screen; - bool unfreeze; - }; - // Where one press of the D-pad takes the player. - struct Step{ - int map = 0; - PlayerState player; - bool warped = false; - bool walked_out = false; // the warp ended with the game walking the player a tile south - bool ledge = false; - }; - - void advance_to(Milliseconds time); - void schedule(Milliseconds at, SimScreen screen, bool unfreeze); - bool sees(SimWatcher watcher, SimScreen screen) const; - std::optional first_seen(SimWatcher watcher, Milliseconds from, Milliseconds to) const; - std::optional watch_until(const std::vector& watchers, Milliseconds from, Milliseconds to); - void walk_hold(Direction direction, Milliseconds duration, int short_by); - void stuck_hold(Direction direction); - void bump(Direction direction, Milliseconds pressed); - std::optional press(Direction direction) const; - std::optional take_warp(int map_index, const TilePos& tile) const; - std::optional cross_edge(Direction direction) const; - bool after_step(const Step& step, Milliseconds pressed); - bool wild_battle(Milliseconds pressed); - bool trainer_sees(Milliseconds pressed); - void update_view(); - void note(const std::string& line); - void violation(const std::string& line); - const MapData& map() const{ return m_maps[m_map]; } - - std::vector m_maps; - SimScript m_script; - int m_map = 0; - PlayerState m_player; - Milliseconds m_now{0}; - std::vector m_pending; // in time order - // The game has stopped the player: a battle, a message, a warp. - bool m_frozen = false; - // A battle is on screen or about to show. - bool m_battle_pending = false; - // The trainer (an object of the player's map) whose message is up or - // about to show, and whose battle follows it. - std::optional m_trainer_speaking; - // The trainer whose battle is on screen or about to show. - std::optional m_trainer; - int m_grass_steps = 0; - int m_holds = 0; - SimState m_state; -}; - - -// The walker's rules that console failures added, each on unless a test -// turns it off to show the failure it fixed. -struct SimWalkerRules{ - // Fail before a step unless the overworld is up (START menu left open, - // console B, 2026-11-01, Viridian Forest). - bool check_overworld = true; - // A battle needs an HP box or the battle menu: black below a small room - // isn't one (console B, 2026-11-01, Viridian Forest's north gate). - bool battle_needs_hp_box = true; - // A trainer's battle after their message is fought, not taken for a - // wild one and run from (console B, 2026-11-01, Viridian Forest). - bool fight_trainer_after_message = true; - // How long the walker watches after a leg through grass for a battle - // whose flashes it missed. It watched 500 ms, and walked the next leg - // into the battle (console B, 2026-11-01, Route 1, run 7 and r14). - Milliseconds after_wild_leg_watch = OverworldWalkSteps::AFTER_WILD_LEG_WATCH; - // A leg to a wall held again after an interrupt stops when it is - // interrupted again right away: by a sign it is held into. Without it - // the walker read the sign 6 times (console B, 2026-11-01, r16). - bool stop_if_blocked = true; - // How long a hold's watch goes on past the hold and its release. 0 as - // now; the tile-at-a-time build watched each step for 900 ms, and saw - // the warp's fade on the step onto the forest's north gate as a battle. - Milliseconds watch_past_hold{0}; - // A leg onto a warp whose fade was a wild battle's (its wipe passes for - // the fade) stops before the warp, and the player is found again on it. - // Without it the walker stopped as off the route (console B, - // 2026-11-03, Mt. Moon 1F, row 00.13). - bool find_player_after_warp_battle = true; - // After a leg's watch saw nothing, a battle's text box on screen (its - // HP boxes still to come) is watched on for, and the leg taken as - // stopped part-way. Without it the walker took "south 15 (to wall)" - // as walked and the next leg into the battle (console B, 2026-11-03, - // Mt. Moon B1F, row 00.13). - bool watch_on_for_unseen_battle = true; - // The same after a leg the maps see no wild tiles on (RouteStep::wild), - // which the leg and its watch don't watch for battles: on Mt. Moon - // B2F's tiles closed on one side, which the maps don't mark as wild, a - // battle on "east 7 (to wall)" left black after the leg, the leg was - // taken as walked and the next one held from where the battle was - // (console B, 2026-11-03, row 00.13). - bool watch_on_after_unmarked_leg = true; - // Before a step, a battle on its way (its text box or wipe) is watched - // on for, and the step before it taken as stopped part-way, not the - // step held from where the battle left the player. - bool battle_before_step = true; -}; - -// The console side of run_route() on an OverworldSim, as -// walk_overworld_route_with_recovery() does it, with the story's -// interrupts: a wild battle (flashes, or seen late) is fought or run from, -// a message is closed and a trainer's battle after it fought. -class OverworldSimWalker{ -public: - // `interrupts`: whose detection delays place a stopped leg, the - // walker's own or a caller's (story_interrupts()). Each set lists a - // wild battle, one seen late and a message for `wild`, and a message - // for `other`, in that order. - OverworldSimWalker( - OverworldSim& sim, SimWalkerRules rules = {}, - const OverworldInterruptSet& interrupts = default_overworld_interrupts() - ); - - // RouteCalls whose `fail` throws OperationFailedException, as the - // console's does. - RouteCalls calls(); - - // run_route() from the sim's facing, planned with `planning`. Returns - // the failure's message, or empty if the route was walked. - std::string run(const std::vector& route, const RoutePlanning& planning); - - const std::vector& log() const{ return m_log; } - -private: - // One of the story's interrupts (story_interrupts()), with the - // walker's detection delay for it. - struct Interrupt{ - std::string name; - SimWatcher watcher; - Milliseconds delay; - bool battle; - }; - StepOutcome walk(const RouteStep& step, std::optional facing); - std::optional walk_step(const RouteStep& step, std::optional facing); - bool after_warp(const RouteStep& step, int warps_before); - [[noreturn]] void fail_off_route(const RouteStep& step); - std::optional hold_watching(const RouteStep& step, Milliseconds hold, const std::vector& interrupts); - size_t clear_late(const std::vector& interrupts, Milliseconds watch, size_t max); - size_t clear_unseen_battle(const RouteStep& step); - bool battle_on_its_way() const; - void clear(const Interrupt& interrupt); - void hold_to_warp(const RouteStep& step); - StepEnd watch_for_trainer(const RouteStep& step, int approach_tiles); - bool moved_past_wall(const RouteStep& step); - void check_overworld(const RouteStep& step); - const std::vector& interrupts(const RouteStep& step) const; - std::vector watchers(const std::vector& interrupts) const; - [[noreturn]] void fail(const std::string& message); - - OverworldSim& m_sim; - SimWalkerRules m_rules; - std::vector m_wild; - std::vector m_other; - std::vector m_log; -}; - - +class OverworldSim{ +public: + OverworldSim(std::vector maps, MapPosition start, Direction facing, SimScript script = {}); + + const SimState& state() const{ return m_state; } + + // Holds `direction` for `duration` then lets go for `release`, while + // `watchers` watch until `watch` (at least the hold and release) from + // the start. Returns the first that saw its screen, as run_until(). + std::optional hold( + Direction direction, Milliseconds duration, Milliseconds release, Milliseconds watch, + const std::vector& watchers + ); + + // A short press of `direction`: turns the player, or steps if they + // already face that way. + void tap(Direction direction); + + // Watches `watchers` for `duration`, as wait_until(). + std::optional watch(const std::vector& watchers, Milliseconds duration); + + // Runs from or fights the battle on screen, back to the overworld. A + // trainer's battle can't be run from; a battle that isn't there is + // waited out (the story's battle loop gives up after 15 s). + SimBattleEnd handle_battle(SimBattleChoice choice); + + // Closes the message on screen with B. Returns whether a trainer's + // battle follows it. + bool close_message(); + +private: + // A screen that shows at `at`; `unfreeze`: the player can move again. + struct Pending{ + Milliseconds at; + SimScreen screen; + bool unfreeze; + }; + // Where one press of the D-pad takes the player. + struct Step{ + int map = 0; + PlayerState player; + bool warped = false; + bool walked_out = false; // the warp ended with the game walking the player a tile south + bool ledge = false; + }; + + void advance_to(Milliseconds time); + void schedule(Milliseconds at, SimScreen screen, bool unfreeze); + bool sees(SimWatcher watcher, SimScreen screen) const; + std::optional first_seen(SimWatcher watcher, Milliseconds from, Milliseconds to) const; + std::optional watch_until(const std::vector& watchers, Milliseconds from, Milliseconds to); + void walk_hold(Direction direction, Milliseconds duration, int short_by); + void stuck_hold(Direction direction); + void bump(Direction direction, Milliseconds pressed); + std::optional press(Direction direction) const; + std::optional take_warp(int map_index, const TilePos& tile) const; + std::optional cross_edge(Direction direction) const; + bool after_step(const Step& step, Milliseconds pressed); + bool wild_battle(Milliseconds pressed); + bool trainer_sees(Milliseconds pressed); + void update_view(); + void note(const std::string& line); + void violation(const std::string& line); + const MapData& map() const{ return m_maps[m_map]; } + + std::vector m_maps; + SimScript m_script; + int m_map = 0; + PlayerState m_player; + Milliseconds m_now{0}; + std::vector m_pending; // in time order + // The game has stopped the player: a battle, a message, a warp. + bool m_frozen = false; + // A battle is on screen or about to show. + bool m_battle_pending = false; + // The trainer (an object of the player's map) whose message is up or + // about to show, and whose battle follows it. + std::optional m_trainer_speaking; + // The trainer whose battle is on screen or about to show. + std::optional m_trainer; + int m_grass_steps = 0; + int m_holds = 0; + SimState m_state; +}; + + +// The walker's rules that console failures added, each on unless a test +// turns it off to show the failure it fixed. +struct SimWalkerRules{ + // Fail before a step unless the overworld is up (START menu left open, + // console B, 2026-11-01, Viridian Forest). + bool check_overworld = true; + // A battle needs an HP box or the battle menu: black below a small room + // isn't one (console B, 2026-11-01, Viridian Forest's north gate). + bool battle_needs_hp_box = true; + // A trainer's battle after their message is fought, not taken for a + // wild one and run from (console B, 2026-11-01, Viridian Forest). + bool fight_trainer_after_message = true; + // How long the walker watches after a leg through grass for a battle + // whose flashes it missed. It watched 500 ms, and walked the next leg + // into the battle (console B, 2026-11-01, Route 1, run 7 and r14). + Milliseconds after_wild_leg_watch = OverworldWalkSteps::AFTER_WILD_LEG_WATCH; + // A leg to a wall held again after an interrupt stops when it is + // interrupted again right away: by a sign it is held into. Without it + // the walker read the sign 6 times (console B, 2026-11-01, r16). + bool stop_if_blocked = true; + // How long a hold's watch goes on past the hold and its release. 0 as + // now; the tile-at-a-time build watched each step for 900 ms, and saw + // the warp's fade on the step onto the forest's north gate as a battle. + Milliseconds watch_past_hold{0}; + // A leg onto a warp whose fade was a wild battle's (its wipe passes for + // the fade) stops before the warp, and the player is found again on it. + // Without it the walker stopped as off the route (console B, + // 2026-11-03, Mt. Moon 1F, row 00.13). + bool find_player_after_warp_battle = true; + // After a leg's watch saw nothing, a battle's text box on screen (its + // HP boxes still to come) is watched on for, and the leg taken as + // stopped part-way. Without it the walker took "south 15 (to wall)" + // as walked and the next leg into the battle (console B, 2026-11-03, + // Mt. Moon B1F, row 00.13). + bool watch_on_for_unseen_battle = true; + // The same after a leg the maps see no wild tiles on (RouteStep::wild), + // which the leg and its watch don't watch for battles: on Mt. Moon + // B2F's tiles closed on one side, which the maps don't mark as wild, a + // battle on "east 7 (to wall)" left black after the leg, the leg was + // taken as walked and the next one held from where the battle was + // (console B, 2026-11-03, row 00.13). + bool watch_on_after_unmarked_leg = true; + // Before a step, a battle on its way (its text box or wipe) is watched + // on for, and the step before it taken as stopped part-way, not the + // step held from where the battle left the player. + bool battle_before_step = true; + // A hold over a step onto a connected map is a tile longer for it + // (RouteStep::connection_steps). Without it the open leg north from + // Cerulean City onto Nugget Bridge ended a tile short (console B, + // 2026-11-03, rows 00.16 and 00.20). + bool hold_over_connections = true; +}; + +// The console side of run_route() on an OverworldSim, as +// walk_overworld_route_with_recovery() does it, with the story's +// interrupts: a wild battle (flashes, or seen late) is fought or run from, +// a message is closed and a trainer's battle after it fought. +class OverworldSimWalker{ +public: + // `interrupts`: whose detection delays place a stopped leg, the + // walker's own or a caller's (story_interrupts()). Each set lists a + // wild battle, one seen late and a message for `wild`, and a message + // for `other`, in that order. + OverworldSimWalker( + OverworldSim& sim, SimWalkerRules rules = {}, + const OverworldInterruptSet& interrupts = default_overworld_interrupts() + ); + + // RouteCalls whose `fail` throws OperationFailedException, as the + // console's does. + RouteCalls calls(); + + // run_route() from the sim's facing, planned with `planning`. Returns + // the failure's message, or empty if the route was walked. + std::string run(const std::vector& route, const RoutePlanning& planning); + + const std::vector& log() const{ return m_log; } + +private: + // One of the story's interrupts (story_interrupts()), with the + // walker's detection delay for it. + struct Interrupt{ + std::string name; + SimWatcher watcher; + Milliseconds delay; + bool battle; + }; + StepOutcome walk(const RouteStep& step, std::optional facing); + std::optional walk_step(const RouteStep& step, std::optional facing); + bool after_warp(const RouteStep& step, int warps_before); + [[noreturn]] void fail_off_route(const RouteStep& step); + std::optional hold_watching(const RouteStep& step, Milliseconds hold, const std::vector& interrupts); + size_t clear_late(const std::vector& interrupts, Milliseconds watch, size_t max); + size_t clear_unseen_battle(const RouteStep& step); + bool battle_on_its_way() const; + void clear(const Interrupt& interrupt); + void hold_to_warp(const RouteStep& step); + StepEnd watch_for_trainer(const RouteStep& step, int approach_tiles); + bool moved_past_wall(const RouteStep& step); + void check_overworld(const RouteStep& step); + const std::vector& interrupts(const RouteStep& step) const; + std::vector watchers(const std::vector& interrupts) const; + [[noreturn]] void fail(const std::string& message); + + OverworldSim& m_sim; + SimWalkerRules m_rules; + std::vector m_wild; + std::vector m_other; + std::vector m_log; +}; + + // Register overworld simulation unit tests. -void add_tests_OverworldSim(UnitTestDatabase& database); - - - +void add_tests_OverworldSim(UnitTestDatabase& database); + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation -#endif +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index be846b6447..2f9848360a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -147,7 +147,9 @@ class RepelWoreOffInterrupt : public MessageInterrupt{ Pokemon::LegSpec leg_spec(const RouteStep& step, MoveSpeed speed){ - return Pokemon::LegSpec{step.direction, step.tiles, step.to_wall, tile_time(step, speed)}; + Pokemon::LegSpec leg{step.direction, step.tiles, step.to_wall, tile_time(step, speed)}; + leg.stalled_steps = step.connection_steps; + return leg; } } // namespace diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index b88283db90..15459736b4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -48,6 +48,7 @@ #include "Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h" +#include "Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryTable.h" @@ -518,7 +519,6 @@ void add_tests_NamingCursor(UnitTestDatabase& database){ } } - struct NamingPlanInput{ std::string name = ""; std::string text = ""; @@ -693,7 +693,6 @@ void add_tests_NamingPlan(UnitTestDatabase& database){ } } - struct NamingKeysInput{ std::string name = ""; std::string text = ""; @@ -763,7 +762,6 @@ void add_tests_NamingKeys(UnitTestDatabase& database){ } } - struct NamingTextInput{ std::string name = ""; std::string text = ""; @@ -895,6 +893,7 @@ static void add_tests_AutoStoryReaders(UnitTestDatabase& database){ add_tests_SaveCardReader(database); add_tests_SummaryMovesReader(database); add_tests_TeachScreenReader(database); + add_tests_AutoStoryScreenEvents(database); } void add_tests(UnitTestDatabase& database){ @@ -935,6 +934,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStory(database); add_tests_AutoStoryExport(database); add_tests_AutoStorySimulation(database); + add_tests_AutoStoryPaceSim(database); add_tests_AutoStoryItems(database); add_tests_AutoStoryRngLoop(database); add_tests_AutoStoryCapture(database); @@ -945,7 +945,6 @@ void add_tests(UnitTestDatabase& database){ add_tests_NamingKeys(database); add_tests_NamingText(database); add_tests_RngManipulation(database); - add_tests_AutoStoryScreenEvents(database); } UnitTestResult check_against_golden_file( diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp index 00fe213550..52283f5166 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.cpp @@ -769,13 +769,7 @@ class StoryMessage : public OverworldInterrupt{ return 400ms; } virtual void clear(ConsoleHandle& console, ProControllerContext& context) const override{ - if (advance_text(console, context, AFTER_BATTLE_TEXT, true) == TextEnd::OVERWORLD){ - return; - } - console.log("Story: a trainer's battle after a message on the overworld."); - fail_on_blackout(console, fight_battle(console, context, m_supplies, BattleKind::TRAINER, m_checks, m_language)); - mash_until_overworld(console, context, AFTER_BATTLE_TEXT); - recover_after_battle(console, context, m_supplies, m_checks, m_language); + clear_overworld_message(console, context, m_supplies, m_checks, m_language); } private: @@ -978,6 +972,16 @@ BattleEnd fight_battle( return BattleLoop(console, context, supplies, kind, checks, language).run(); } +void clear_overworld_message(ConsoleHandle& console, ProControllerContext& context, Supplies& supplies, StoryChecks checks, Language language){ + if (advance_text(console, context, AFTER_BATTLE_TEXT, true) == TextEnd::OVERWORLD){ + return; + } + console.log("Story: a trainer's battle after a message on the overworld."); + fail_on_blackout(console, fight_battle(console, context, supplies, BattleKind::TRAINER, checks, language)); + mash_until_overworld(console, context, AFTER_BATTLE_TEXT); + recover_after_battle(console, context, supplies, checks, language); +} + OverworldInterruptSet story_interrupts(Supplies& supplies, bool fight_wild, StoryChecks checks, Language language){ // Battles first: their screens are checked before the message box's. OverworldInterruptSet ret; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h index 58d6a5caa1..a07e0d6a08 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h @@ -200,6 +200,16 @@ void recover_after_battle( ); +// From a message on the overworld: advances it until the overworld is back +// and, when a battle starts after it (a trainer who saw the player), +// fights it and recovers after it. A battle lost (a blackout) throws +// OperationFailedException. +void clear_overworld_message( + ConsoleHandle& console, ProControllerContext& context, + Supplies& supplies, StoryChecks checks, Language language +); + + // The interrupts for walking story routes. Wild battles are run from, or // fought if `fight_wild`. A message is advanced until the overworld is // back; a battle that starts after it (a trainer who saw the player) is diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index 186f737d0f..20febd96d3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -54,6 +54,12 @@ constexpr StoryTrainer STORY_TRAINERS[] = { // The Nugget Bridge Rocket, Route24_EventScript_Rocket. {.map = "Route24", .battle = "rocket_grunt", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_6"}, {.map = "CeruleanCity_Gym", .battle = "misty", .trainer = "TRAINER_LEADER_MISTY"}, + // Route 25's four the way can't walk around (Route25 scripts.inc): + // Franklin (11, 4), Kelsey (22, 8), Nob (27, 9) and Haley (42, 5). + {.map = "Route25", .battle = "hiker_franklin", .trainer = "TRAINER_HIKER_FRANKLIN"}, + {.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"}, }; using JsonTable = std::map>; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp index 6897734183..860a78a259 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.cpp @@ -12,6 +12,7 @@ #include "CommonTools/Async/InferenceRoutines.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_BattleDialogs.h" +#include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_BagDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h" #include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h" @@ -448,6 +449,13 @@ bool pace_until_battle(Logger& logger, PatchSteps& steps, size_t max_steps){ logger.log(std::format("Pacing: a battle shows after step {}; its flash was missed.", c + 1), COLOR_ORANGE); return true; } + // Console B, 2026-11-03 (m63): Camper Shane on Route 24 saw the + // player pace in his sight and walked up; his message stayed up + // while the rest of the steps paced in vain. + if (steps.message_shows()){ + const std::string trainer = steps.fight_trainer(); + logger.log(std::format("Pacing: trainer battle during pacing: {}, after step {}.", trainer, c + 1), COLOR_ORANGE); + } } if (steps.overworld_shows()){ return false; @@ -462,11 +470,15 @@ bool pace_until_battle(Logger& logger, PatchSteps& steps, size_t max_steps){ } -ConsolePatchSteps::ConsolePatchSteps(ConsoleHandle& console, ProControllerContext& context, Direction pace, PaceSpot& spot) +ConsolePatchSteps::ConsolePatchSteps( + ConsoleHandle& console, ProControllerContext& context, Direction pace, PaceSpot& spot, + const PaceTrainerBattle& trainer_battle +) : m_console(console) , m_context(context) , m_pace(pace) , m_spot(spot) + , m_trainer_battle(trainer_battle) {} bool ConsolePatchSteps::step(){ @@ -493,21 +505,38 @@ bool ConsolePatchSteps::overworld_shows(){ return PokemonFRLG::overworld_shows(m_console, m_context); } +bool ConsolePatchSteps::message_shows(){ + m_context.wait_for_all_requests(); + VideoSnapshot screen = m_console.video().snapshot(); + return screen && WhiteDialogDetector(COLOR_RED).detect(screen); +} + +std::string ConsolePatchSteps::fight_trainer(){ + m_trainer_battle(); + // Back on the patch's tile, facing as the walk back left it. + m_spot = {}; + return "a trainer"; +} + void ConsolePatchSteps::dump_screen(std::string_view label){ m_context.wait_for_all_requests(); dump_debug_image(m_console, "AutoStory/pace", std::string(label), m_console.video().snapshot()); } -ConsoleCaptureActions::ConsoleCaptureActions(ConsoleHandle& console, ProControllerContext& context, Language language) +ConsoleCaptureActions::ConsoleCaptureActions( + ConsoleHandle& console, ProControllerContext& context, Language language, + PaceTrainerBattle trainer_battle +) : m_console(console) , m_context(context) , m_language(language) + , m_trainer_battle(std::move(trainer_battle)) {} bool ConsoleCaptureActions::find_encounter(Direction pace){ PaceSpot spot; - ConsolePatchSteps steps(m_console, m_context, pace, spot); + ConsolePatchSteps steps(m_console, m_context, pace, spot, m_trainer_battle); return pace_until_battle(m_console.logger(), steps, PACE_MAX_STEPS); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h index 7617b44be7..26b99c5df5 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h @@ -1,293 +1,322 @@ -/* AutoStory Capture - * - * From: https://github.com/PokemonAutomation/ - * - * A story step that catches a named species where the player stands, in - * grass or a cave: step back and forth until a wild battle starts, read - * its species, run from any other, weaken the target only with a move the - * table names and only while its HP bar is green, and throw Poke Balls - * until it is caught. A catch goes to the party, or to the PC when the - * party is full. The step is bounded: it stops after so many encounters - * or so many balls thrown, or when the bag has none left. - * - * A shiny, of any species, stops the step where it is: nothing is pressed - * after the shiny is seen, not even RUN, so a person decides what to do - * with it. The program stops without reloading. - * - * The decisions take their game steps from a CaptureActions, so they run - * in tests against a fake game; ConsoleCaptureActions does them on the - * console with the battle detectors the shiny hunts use. - * - */ - -#ifndef PokemonAutomation_PokemonFRLG_AutoStoryCapture_H -#define PokemonAutomation_PokemonFRLG_AutoStoryCapture_H - -#include -#include -#include -#include -#include -#include "Common/Cpp/Logging/AbstractLogger.h" -#include "Common/Cpp/TestRunners/UnitTestDatabase.h" -#include "CommonFramework/Language.h" -#include "CommonFramework/Tools/VideoStream.h" -#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" -#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" -#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" -#include "Pokemon/AutoStory/Pokemon_StorySteps.h" -#include "Pokemon/Navigation/Pokemon_Grid.h" -#include "PokemonFRLG_AutoStoryPcStorage.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -// What a capture step catches, and its bounds. -struct CapturePlan{ - // The species' slug, e.g. "rattata". - std::string species = {}; - // How many to catch. - size_t catches = 1; - // Wild battles, of any species, before the step gives up. - size_t max_encounters = 20; - // Poke Balls thrown before the step gives up. - size_t max_balls = 5; - // The way the player steps to look for a battle, then back: the table - // stands the player in the patch with a free tile that way. - Pokemon::Direction pace = Pokemon::Direction::EAST; - // The FIGHT list's move (0-3) to weaken the target with: one that - // can't knock it out from green HP. None: only balls. - std::optional weaken_move = std::nullopt; - // The most turns spent weakening each target. - size_t max_weaken_turns = 2; -}; - -// The prefix of a GAME step's variant that names a capture step. -inline constexpr std::string_view CAPTURE_VARIANT_PREFIX = "capture "; - -// `plan` as a GAME step's variant, e.g. "capture rattata catches=1 -// encounters=20 balls=5 pace=east", with " move=1 turns=2" when it -// weakens (the move counted from 1). -std::string capture_variant(const CapturePlan& plan); -// A variant from capture_variant(), whose keys after the species may come -// in any order or be left out (their defaults); nothing if `variant` isn't -// one or has a key or value it doesn't know. -std::optional parse_capture_variant(std::string_view variant); - -// The table's step that runs `plan`. -Pokemon::StoryStep capture_step(std::string name, std::string why, const CapturePlan& plan); -// The table's step that runs `transfer` at the PC the player faces. -Pokemon::StoryStep pc_step(std::string name, std::string why, const PcTransfer& transfer); - - -// What a wild battle's opening showed. -struct EncounterSeen{ - // The species' slug; empty if it couldn't be read. - std::string species = {}; - bool shiny = false; -}; - -// The wild Pokemon's HP bar. -enum class FoeHp{ - UNKNOWN, - GREEN, - YELLOW, - RED, -}; - +/* AutoStory Capture + * + * From: https://github.com/PokemonAutomation/ + * + * A story step that catches a named species where the player stands, in + * grass or a cave: step back and forth until a wild battle starts, read + * its species, run from any other, weaken the target only with a move the + * table names and only while its HP bar is green, and throw Poke Balls + * until it is caught. A catch goes to the party, or to the PC when the + * party is full. The step is bounded: it stops after so many encounters + * or so many balls thrown, or when the bag has none left. + * + * A shiny, of any species, stops the step where it is: nothing is pressed + * after the shiny is seen, not even RUN, so a person decides what to do + * with it. The program stops without reloading. + * + * The decisions take their game steps from a CaptureActions, so they run + * in tests against a fake game; ConsoleCaptureActions does them on the + * console with the battle detectors the shiny hunts use. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryCapture_H +#define PokemonAutomation_PokemonFRLG_AutoStoryCapture_H + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Language.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "PokemonFRLG_AutoStoryPcStorage.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// What a capture step catches, and its bounds. +struct CapturePlan{ + // The species' slug, e.g. "rattata". + std::string species = {}; + // How many to catch. + size_t catches = 1; + // Wild battles, of any species, before the step gives up. + size_t max_encounters = 20; + // Poke Balls thrown before the step gives up. + size_t max_balls = 5; + // The way the player steps to look for a battle, then back: the table + // stands the player in the patch with a free tile that way. + Pokemon::Direction pace = Pokemon::Direction::EAST; + // The FIGHT list's move (0-3) to weaken the target with: one that + // can't knock it out from green HP. None: only balls. + std::optional weaken_move = std::nullopt; + // The most turns spent weakening each target. + size_t max_weaken_turns = 2; +}; + +// The prefix of a GAME step's variant that names a capture step. +inline constexpr std::string_view CAPTURE_VARIANT_PREFIX = "capture "; + +// `plan` as a GAME step's variant, e.g. "capture rattata catches=1 +// encounters=20 balls=5 pace=east", with " move=1 turns=2" when it +// weakens (the move counted from 1). +std::string capture_variant(const CapturePlan& plan); +// A variant from capture_variant(), whose keys after the species may come +// in any order or be left out (their defaults); nothing if `variant` isn't +// one or has a key or value it doesn't know. +std::optional parse_capture_variant(std::string_view variant); + +// The table's step that runs `plan`. +Pokemon::StoryStep capture_step(std::string name, std::string why, const CapturePlan& plan); +// The table's step that runs `transfer` at the PC the player faces. +Pokemon::StoryStep pc_step(std::string name, std::string why, const PcTransfer& transfer); + + +// What a wild battle's opening showed. +struct EncounterSeen{ + // The species' slug; empty if it couldn't be read. + std::string species = {}; + bool shiny = false; +}; + +// The wild Pokemon's HP bar. +enum class FoeHp{ + UNKNOWN, + GREEN, + YELLOW, + RED, +}; + // How an attack turn during a capture concluded. -enum class TurnEnd{ - // The battle menu is back. - MENU, - // The wild Pokemon fainted; the battle is over and the overworld back. - FOE_FAINTED, - // The player's Pokemon fainted. - PLAYER_FAINTED, - FAILED, -}; - +enum class TurnEnd{ + // The battle menu is back. + MENU, + // The wild Pokemon fainted; the battle is over and the overworld back. + FOE_FAINTED, + // The player's Pokemon fainted. + PLAYER_FAINTED, + FAILED, +}; + // The result of throwing a ball during a capture attempt. -enum class ThrowEnd{ - // Caught: its first message shows. - CAUGHT, - // It broke free; the battle menu is back. - BROKE_FREE, - // The bag has no Poke Ball; the battle menu is back. - NO_BALLS, - PLAYER_FAINTED, - FAILED, -}; - +enum class ThrowEnd{ + // Caught: its first message shows. + CAUGHT, + // It broke free; the battle menu is back. + BROKE_FREE, + // The bag has no Poke Ball; the battle menu is back. + NO_BALLS, + PLAYER_FAINTED, + FAILED, +}; + // Where a captured Pokemon was sent. -enum class CatchDestination{ - PARTY, - PC, -}; +enum class CatchDestination{ + PARTY, + PC, +}; // The human-readable name of a catch destination. -const char* destination_name(CatchDestination destination); - -// The game steps a capture takes, injected so its decisions run in tests. -class CaptureActions{ -public: - virtual ~CaptureActions() = default; - - // From the overworld, in the patch: steps `pace` and back until a wild - // battle starts. False if none did within its bound. - virtual bool find_encounter(Pokemon::Direction pace) = 0; - // From a wild battle's start: whether it is shiny and, if not, its - // species, read at the battle menu. A shiny is left as it shows. - virtual EncounterSeen read_encounter() = 0; - // From the battle menu: RUN, until the overworld is back. False if it - // didn't get away. - virtual bool flee() = 0; - // At the battle menu: the wild Pokemon's HP bar. - virtual FoeHp read_foe_hp() = 0; - // From the battle menu: FIGHT, the move `move` (0-3), the turn. - virtual TurnEnd attack(size_t move) = 0; - // From the battle menu: BAG, a Poke Ball, the throw. - virtual ThrowEnd throw_ball() = 0; - // From the catch's first message: no nickname, and the messages to the - // overworld, the last saying it went to `destination`. False if the - // overworld didn't come back. - virtual bool finish_catch(CatchDestination destination) = 0; -}; - +const char* destination_name(CatchDestination destination); + +// The game steps a capture takes, injected so its decisions run in tests. +class CaptureActions{ +public: + virtual ~CaptureActions() = default; + + // From the overworld, in the patch: steps `pace` and back until a wild + // battle starts. False if none did within its bound. + virtual bool find_encounter(Pokemon::Direction pace) = 0; + // From a wild battle's start: whether it is shiny and, if not, its + // species, read at the battle menu. A shiny is left as it shows. + virtual EncounterSeen read_encounter() = 0; + // From the battle menu: RUN, until the overworld is back. False if it + // didn't get away. + virtual bool flee() = 0; + // At the battle menu: the wild Pokemon's HP bar. + virtual FoeHp read_foe_hp() = 0; + // From the battle menu: FIGHT, the move `move` (0-3), the turn. + virtual TurnEnd attack(size_t move) = 0; + // From the battle menu: BAG, a Poke Ball, the throw. + virtual ThrowEnd throw_ball() = 0; + // From the catch's first message: no nickname, and the messages to the + // overworld, the last saying it went to `destination`. False if the + // overworld didn't come back. + virtual bool finish_catch(CatchDestination destination) = 0; +}; + // How an encounter capture attempt concluded. -enum class CaptureEnd{ - CAUGHT, - OUT_OF_ENCOUNTERS, - OUT_OF_BALLS, - NO_ENCOUNTER, - // A shiny showed: stopped in its battle, nothing pressed. - SHINY, - PLAYER_FAINTED, - FAILED, -}; +enum class CaptureEnd{ + CAUGHT, + OUT_OF_ENCOUNTERS, + OUT_OF_BALLS, + NO_ENCOUNTER, + // A shiny showed: stopped in its battle, nothing pressed. + SHINY, + PLAYER_FAINTED, + FAILED, +}; // The human-readable name of a capture end outcome. -const char* capture_end_name(CaptureEnd end); - +const char* capture_end_name(CaptureEnd end); + // The summary result of a capture attempt. -struct CaptureResult{ - CaptureEnd end = CaptureEnd::FAILED; - size_t encounters = 0; - size_t balls_thrown = 0; - // Where each catch went, in order. - std::vector caught = {}; - // SHINY: its species, if read. FAILED: what failed. - std::string detail = {}; -}; - -// Turns of one target's battle before the step gives up on it. -constexpr size_t CAPTURE_MAX_TURNS = 20; - -// From the overworld in the patch, with `party_size` Pokemon in the party: -// catches as `plan` says. -CaptureResult run_capture(Logger& logger, CaptureActions& actions, const CapturePlan& plan, size_t party_size); - -// What the run says when a capture of `plan` ends with `result`. -std::string capture_message(const CapturePlan& plan, const CaptureResult& result); - - -// A capture or PC GAME step (see capture_step(), pc_step()) with these -// actions, the party read from `pc`: nothing for another variant. A capture that ends on a shiny -// throws ProgramFinishedException, so the segment stops without -// reloading, with a screenshot from `stream` if given; any other end but -// CAUGHT is a failed Outcome, as is a PC step that fails or is refused. -std::optional run_storage_game_step( - Logger& logger, VideoStream* stream, const Pokemon::StoryStep& step, - CaptureActions& capture, PcStorageActions& pc -); - - -// Where pacing has left the player: on the patch's tile, or one tile -// `pace` from it, as the last step taken left it. -struct PaceSpot{ - bool displaced = false; - // The way the player faces after its last step; nothing before one. - std::optional facing = std::nullopt; -}; - -// The game steps of pacing in a patch for a wild battle, injected so the -// search runs in tests. -class PatchSteps{ -public: - virtual ~PatchSteps() = default; - - // One step, `pace` or back as the last left the player: whether a - // battle's flash showed. - virtual bool step() = 0; - // Whether a battle shows: its text box, with an HP box or the menu. - virtual bool battle_shows() = 0; - // Whether the overworld is up, with no text or menu open. - virtual bool overworld_shows() = 0; - // Saves the screen as a debug image, named `label`. - virtual void dump_screen(std::string_view label) = 0; -}; - -// From the overworld, in the patch: steps until a wild battle starts, -// `max_steps` at most. True once one has. -bool pace_until_battle(Logger& logger, PatchSteps& steps, size_t max_steps); - -// Steps in a patch before pacing gives up: about 10 % of steps in grass -// start a battle at Route 4's rates. -constexpr size_t PACE_MAX_STEPS = 200; - -// The console's PatchSteps: `pace` and back from `spot`, which each step -// updates. -class ConsolePatchSteps : public PatchSteps{ -public: - ConsolePatchSteps(ConsoleHandle& console, ProControllerContext& context, Pokemon::Direction pace, PaceSpot& spot); - - virtual bool step() override; - virtual bool battle_shows() override; - virtual bool overworld_shows() override; - virtual void dump_screen(std::string_view label) override; - -private: - ConsoleHandle& m_console; - ProControllerContext& m_context; - Pokemon::Direction m_pace; - PaceSpot& m_spot; -}; - - -// The console's capture steps. Encounters are found as the overworld -// shiny hunt finds them, a shiny heard as the shiny hunts hear it -// (handle_encounter()), and runs as they run (flee_battle()). The wild -// Pokemon's HP bar has no reader yet, so it reads UNKNOWN and nothing is -// weakened on the console. -class ConsoleCaptureActions : public CaptureActions{ -public: - ConsoleCaptureActions(ConsoleHandle& console, ProControllerContext& context, Language language); - - virtual bool find_encounter(Pokemon::Direction pace) override; - virtual EncounterSeen read_encounter() override; - virtual bool flee() override; - virtual FoeHp read_foe_hp() override; - virtual TurnEnd attack(size_t move) override; - virtual ThrowEnd throw_ball() override; - virtual bool finish_catch(CatchDestination destination) override; - -private: - // In the bag: to the Poke Balls pocket. False if it wasn't found. - bool open_ball_pocket(); - // After the throw: caught, broke free, or neither in time. - ThrowEnd wait_for_throw(); - - ConsoleHandle& m_console; - ProControllerContext& m_context; - Language m_language; -}; - - +struct CaptureResult{ + CaptureEnd end = CaptureEnd::FAILED; + size_t encounters = 0; + size_t balls_thrown = 0; + // Where each catch went, in order. + std::vector caught = {}; + // SHINY: its species, if read. FAILED: what failed. + std::string detail = {}; +}; + +// Turns of one target's battle before the step gives up on it. +constexpr size_t CAPTURE_MAX_TURNS = 20; + +// From the overworld in the patch, with `party_size` Pokemon in the party: +// catches as `plan` says. +CaptureResult run_capture(Logger& logger, CaptureActions& actions, const CapturePlan& plan, size_t party_size); + +// What the run says when a capture of `plan` ends with `result`. +std::string capture_message(const CapturePlan& plan, const CaptureResult& result); + + +// A capture or PC GAME step (see capture_step(), pc_step()) with these +// actions, the party read from `pc`: nothing for another variant. A capture that ends on a shiny +// throws ProgramFinishedException, so the segment stops without +// reloading, with a screenshot from `stream` if given; any other end but +// CAUGHT is a failed Outcome, as is a PC step that fails or is refused. +std::optional run_storage_game_step( + Logger& logger, VideoStream* stream, const Pokemon::StoryStep& step, + CaptureActions& capture, PcStorageActions& pc +); + + +// Where pacing has left the player: on the patch's tile, or one tile +// `pace` from it, as the last step taken left it. +struct PaceSpot{ + bool displaced = false; + // The way the player faces after its last step; nothing before one. + std::optional facing = std::nullopt; +}; + +// The game steps of pacing in a patch for a wild battle, injected so the +// search runs in tests. +class PatchSteps{ +public: + virtual ~PatchSteps() = default; + + // One step, `pace` or back as the last left the player: whether a + // battle's flash showed. + virtual bool step() = 0; + // Whether a battle shows: its text box, with an HP box or the menu. + virtual bool battle_shows() = 0; + // Whether the overworld is up, with no text or menu open. + virtual bool overworld_shows() = 0; + // Whether a message shows on the overworld: a trainer who saw the + // player walked up and speaks. + virtual bool message_shows() = 0; + // From that message: closes it, fights the trainer's battle after it, + // and stands the player back on the patch's tile, from either tile + // pacing may have left them on. Returns who was fought, for the log. + virtual std::string fight_trainer() = 0; + // Saves the screen as a debug image, named `label`. + virtual void dump_screen(std::string_view label) = 0; +}; + +// From the overworld, in the patch: steps until a wild battle starts, +// `max_steps` at most. True once one has. A trainer who sees the player +// meanwhile is fought (PatchSteps::fight_trainer()) and pacing goes on; +// their battle isn't a wild one. +bool pace_until_battle(Logger& logger, PatchSteps& steps, size_t max_steps); + +// Steps in a patch before pacing gives up: about 10 % of steps in grass +// start a battle at Route 4's rates. +constexpr size_t PACE_MAX_STEPS = 200; + +// On the console, from a trainer's message during pacing: closes it, +// fights the battle after it and walks the player back onto the patch's +// tile, planned from both of pacing's tiles. While a trainer walks up the +// player can't move, so the steps pacing took meanwhile went nowhere and +// the spot pacing kept can be a tile off. A battle lost throws, as on a +// route. +using PaceTrainerBattle = std::function; + +// The console's PatchSteps: `pace` and back from `spot`, which each step +// updates; a trainer is fought by `trainer_battle`. +class ConsolePatchSteps : public PatchSteps{ +public: + ConsolePatchSteps( + ConsoleHandle& console, ProControllerContext& context, Pokemon::Direction pace, PaceSpot& spot, + const PaceTrainerBattle& trainer_battle + ); + + virtual bool step() override; + virtual bool battle_shows() override; + virtual bool overworld_shows() override; + virtual bool message_shows() override; + virtual std::string fight_trainer() override; + virtual void dump_screen(std::string_view label) override; + +private: + ConsoleHandle& m_console; + ProControllerContext& m_context; + Pokemon::Direction m_pace; + PaceSpot& m_spot; + const PaceTrainerBattle& m_trainer_battle; +}; + + +// The console's capture steps. Encounters are found as the overworld +// shiny hunt finds them, a shiny heard as the shiny hunts hear it +// (handle_encounter()), and runs as they run (flee_battle()). The wild +// Pokemon's HP bar has no reader yet, so it reads UNKNOWN and nothing is +// weakened on the console. A trainer who sees the player while pacing is +// fought by `trainer_battle`. +class ConsoleCaptureActions : public CaptureActions{ +public: + ConsoleCaptureActions( + ConsoleHandle& console, ProControllerContext& context, Language language, + PaceTrainerBattle trainer_battle + ); + + virtual bool find_encounter(Pokemon::Direction pace) override; + virtual EncounterSeen read_encounter() override; + virtual bool flee() override; + virtual FoeHp read_foe_hp() override; + virtual TurnEnd attack(size_t move) override; + virtual ThrowEnd throw_ball() override; + virtual bool finish_catch(CatchDestination destination) override; + +private: + // In the bag: to the Poke Balls pocket. False if it wasn't found. + bool open_ball_pocket(); + // After the throw: caught, broke free, or neither in time. + ThrowEnd wait_for_throw(); + + ConsoleHandle& m_console; + ProControllerContext& m_context; + Language m_language; + PaceTrainerBattle m_trainer_battle; +}; + + // Register auto-story capture unit tests. -void add_tests_AutoStoryCapture(UnitTestDatabase& database); - - - +void add_tests_AutoStoryCapture(UnitTestDatabase& database); + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation -#endif +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp index 29eef4bada..f3fa9e4161 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryCapture_Tests.cpp @@ -1,174 +1,174 @@ -/* AutoStory Capture Tests - * - * From: https://github.com/PokemonAutomation/ - * - * The capture step's decisions against a fake game: which encounters are - * run from, when balls or encounters run out, where a catch goes, what a - * shiny stops; its table variant; and a capture and a PC step run by the - * shared segment runner, as the program runs them, to show which failures - * reload the save and which stop the program. - * - */ - -#include -#include -#include -#include -#include -#include -#include "Common/Cpp/Exceptions.h" -#include "Common/Cpp/TestRunners/UnitTest.h" -#include "CommonFramework/Exceptions/ProgramFinishedException.h" -#include "Pokemon/AutoStory/Pokemon_StorySegments.h" -#include "Pokemon/AutoStory/Pokemon_StorySteps.h" -#include "PokemonFRLG_AutoStoryCapture.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -namespace{ - -using Pokemon::Direction; -using Pokemon::StoryStep; - -// Keeps the lines logged, for the messages a test checks. -class LineLogger : public Logger{ -public: - using Logger::log; - virtual void log(const std::string& msg, Color) override{ - lines.push_back(msg); - } - std::vector lines; -}; - -std::string joined(const std::vector& items){ - std::string text; - for (const std::string& item : items){ - text += (text.empty() ? "" : ", ") + item; - } - return text; -} - - -// A wild Pokemon the fake game sends, in order. -struct FakeEncounter{ - std::string species = {}; - bool shiny = false; - // Throws it breaks free from before one catches it. - size_t breaks = 0; - // Its HP bar after each attack, the last repeated; GREEN before any. - std::vector hp_after = {}; - bool faints_on_attack = false; - bool flee_fails = false; -}; - -// The game a capture plays, as a trace of what it did. -class FakeCaptureGame : public CaptureActions{ -public: - size_t party = 2; - size_t bag_balls = 10; - std::vector encounters; - std::vector trace; - // Set by a step taken where the game wouldn't take it. - std::string error; - - virtual bool find_encounter(Direction pace) override{ - if (pace != Direction::EAST && pace != Direction::NORTH){ - error = "paced " + std::string(direction_name(pace)); - } - if (m_next >= encounters.size()){ - trace.push_back("none"); - return false; - } - m_current = encounters[m_next++]; - m_hp = FoeHp::GREEN; - m_attacks = 0; - m_in_battle = true; - trace.push_back((m_current.shiny ? "shiny " : "") + (m_current.species.empty() ? "?" : m_current.species)); - return true; - } - // A shiny's species isn't read: the console hears it before the menu. - virtual EncounterSeen read_encounter() override{ - return {m_current.shiny ? "" : m_current.species, m_current.shiny}; - } - virtual bool flee() override{ - check_in_battle("run"); - trace.push_back("run"); - if (m_current.flee_fails){ - return false; - } - m_in_battle = false; - return true; - } - virtual FoeHp read_foe_hp() override{ - return m_hp; - } - virtual TurnEnd attack(size_t move) override{ - check_in_battle("attack"); - if (m_hp != FoeHp::GREEN){ - error = "attacked at a HP bar that wasn't green"; - } - trace.push_back(std::format("move {}", move + 1)); - if (m_current.faints_on_attack){ - m_in_battle = false; - return TurnEnd::FOE_FAINTED; - } - if (!m_current.hp_after.empty()){ - m_hp = m_current.hp_after[std::min(m_attacks, m_current.hp_after.size() - 1)]; - } - m_attacks++; - return TurnEnd::MENU; - } - virtual ThrowEnd throw_ball() override{ - check_in_battle("throw"); - if (bag_balls == 0){ - trace.push_back("no ball"); - return ThrowEnd::NO_BALLS; - } - bag_balls--; - trace.push_back("ball"); - if (m_current.breaks > 0){ - m_current.breaks--; - return ThrowEnd::BROKE_FREE; - } - m_caught = true; - return ThrowEnd::CAUGHT; - } - virtual bool finish_catch(CatchDestination destination) override{ - if (!m_caught){ - error = "finished a catch that wasn't made"; - } - const CatchDestination where = party < 6 ? CatchDestination::PARTY : CatchDestination::PC; - if (destination != where){ - error = std::string("expected the catch to go to the ") + destination_name(where); - } - if (where == CatchDestination::PARTY){ - party++; - } - trace.push_back(std::string("to ") + destination_name(where)); - m_caught = false; - m_in_battle = false; - return true; - } - -private: - void check_in_battle(const char* step){ - if (!m_in_battle){ - error = std::string(step) + " outside a battle"; - } - } - - size_t m_next = 0; - FakeEncounter m_current; - FoeHp m_hp = FoeHp::GREEN; - size_t m_attacks = 0; - bool m_in_battle = false; - bool m_caught = false; -}; - - +/* AutoStory Capture Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The capture step's decisions against a fake game: which encounters are + * run from, when balls or encounters run out, where a catch goes, what a + * shiny stops; its table variant; and a capture and a PC step run by the + * shared segment runner, as the program runs them, to show which failures + * reload the save and which stop the program. + * + */ + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/Exceptions/ProgramFinishedException.h" +#include "Pokemon/AutoStory/Pokemon_StorySegments.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "PokemonFRLG_AutoStoryCapture.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using Pokemon::Direction; +using Pokemon::StoryStep; + +// Keeps the lines logged, for the messages a test checks. +class LineLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string& msg, Color) override{ + lines.push_back(msg); + } + std::vector lines; +}; + +std::string joined(const std::vector& items){ + std::string text; + for (const std::string& item : items){ + text += (text.empty() ? "" : ", ") + item; + } + return text; +} + + +// A wild Pokemon the fake game sends, in order. +struct FakeEncounter{ + std::string species = {}; + bool shiny = false; + // Throws it breaks free from before one catches it. + size_t breaks = 0; + // Its HP bar after each attack, the last repeated; GREEN before any. + std::vector hp_after = {}; + bool faints_on_attack = false; + bool flee_fails = false; +}; + +// The game a capture plays, as a trace of what it did. +class FakeCaptureGame : public CaptureActions{ +public: + size_t party = 2; + size_t bag_balls = 10; + std::vector encounters; + std::vector trace; + // Set by a step taken where the game wouldn't take it. + std::string error; + + virtual bool find_encounter(Direction pace) override{ + if (pace != Direction::EAST && pace != Direction::NORTH){ + error = "paced " + std::string(direction_name(pace)); + } + if (m_next >= encounters.size()){ + trace.push_back("none"); + return false; + } + m_current = encounters[m_next++]; + m_hp = FoeHp::GREEN; + m_attacks = 0; + m_in_battle = true; + trace.push_back((m_current.shiny ? "shiny " : "") + (m_current.species.empty() ? "?" : m_current.species)); + return true; + } + // A shiny's species isn't read: the console hears it before the menu. + virtual EncounterSeen read_encounter() override{ + return {m_current.shiny ? "" : m_current.species, m_current.shiny}; + } + virtual bool flee() override{ + check_in_battle("run"); + trace.push_back("run"); + if (m_current.flee_fails){ + return false; + } + m_in_battle = false; + return true; + } + virtual FoeHp read_foe_hp() override{ + return m_hp; + } + virtual TurnEnd attack(size_t move) override{ + check_in_battle("attack"); + if (m_hp != FoeHp::GREEN){ + error = "attacked at a HP bar that wasn't green"; + } + trace.push_back(std::format("move {}", move + 1)); + if (m_current.faints_on_attack){ + m_in_battle = false; + return TurnEnd::FOE_FAINTED; + } + if (!m_current.hp_after.empty()){ + m_hp = m_current.hp_after[std::min(m_attacks, m_current.hp_after.size() - 1)]; + } + m_attacks++; + return TurnEnd::MENU; + } + virtual ThrowEnd throw_ball() override{ + check_in_battle("throw"); + if (bag_balls == 0){ + trace.push_back("no ball"); + return ThrowEnd::NO_BALLS; + } + bag_balls--; + trace.push_back("ball"); + if (m_current.breaks > 0){ + m_current.breaks--; + return ThrowEnd::BROKE_FREE; + } + m_caught = true; + return ThrowEnd::CAUGHT; + } + virtual bool finish_catch(CatchDestination destination) override{ + if (!m_caught){ + error = "finished a catch that wasn't made"; + } + const CatchDestination where = party < 6 ? CatchDestination::PARTY : CatchDestination::PC; + if (destination != where){ + error = std::string("expected the catch to go to the ") + destination_name(where); + } + if (where == CatchDestination::PARTY){ + party++; + } + trace.push_back(std::string("to ") + destination_name(where)); + m_caught = false; + m_in_battle = false; + return true; + } + +private: + void check_in_battle(const char* step){ + if (!m_in_battle){ + error = std::string(step) + " outside a battle"; + } + } + + size_t m_next = 0; + FakeEncounter m_current; + FoeHp m_hp = FoeHp::GREEN; + size_t m_attacks = 0; + bool m_in_battle = false; + bool m_caught = false; +}; + + struct AutoStoryCaptureInput{ std::string name; std::function body; @@ -182,434 +182,440 @@ struct AutoStoryCaptureCase{ AutoStoryCaptureExpectation expectation; }; -class Test_AutoStoryCapture : public UnitTest{ -public: +class Test_AutoStoryCapture : public UnitTest{ +public: explicit Test_AutoStoryCapture(AutoStoryCaptureCase test_case) : UnitTest("PokemonFRLG::AutoStoryCapture - " + 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: AutoStoryCaptureCase m_case; -}; - - -const CapturePlan RATTATA{.species = "rattata", .max_encounters = 6, .max_balls = 4}; - - -// The capture loop's decisions, by what the game sends. -struct CaptureCase{ - std::string name = {}; - CapturePlan plan = RATTATA; - size_t party = 2; - size_t bag_balls = 10; - std::vector encounters = {}; - CaptureEnd end = CaptureEnd::FAILED; - std::string trace = {}; - size_t caught = 0; -}; -const std::vector CAPTURE_CASES = { - { - .name = "the target on the first encounter, caught to the party", - .encounters = {{.species = "rattata"}}, - .end = CaptureEnd::CAUGHT, - .trace = "rattata, ball, to party", - .caught = 1, - }, - { - .name = "other species and an unread one run from", - .encounters = {{.species = "spearow"}, {.species = ""}, {.species = "rattata", .breaks = 1}}, - .end = CaptureEnd::CAUGHT, - .trace = "spearow, run, ?, run, rattata, ball, ball, to party", - .caught = 1, - }, - { - .name = "a full party sends the catch to the PC", - .party = 6, - .encounters = {{.species = "rattata"}}, - .end = CaptureEnd::CAUGHT, - .trace = "rattata, ball, to PC", - .caught = 1, - }, - { - .name = "two catches with a party of 5: the first to the party, the second to the PC", - .plan = {.species = "rattata", .catches = 2, .max_encounters = 6, .max_balls = 4}, - .party = 5, - .encounters = {{.species = "rattata"}, {.species = "rattata"}}, - .end = CaptureEnd::CAUGHT, - .trace = "rattata, ball, to party, rattata, ball, to PC", - .caught = 2, - }, - { - .name = "the step's ball cap: run once it is reached", - .encounters = {{.species = "rattata", .breaks = 10}}, - .end = CaptureEnd::OUT_OF_BALLS, - .trace = "rattata, ball, ball, ball, ball, run", - }, - { - .name = "the bag runs out first", - .bag_balls = 2, - .encounters = {{.species = "rattata", .breaks = 10}}, - .end = CaptureEnd::OUT_OF_BALLS, - .trace = "rattata, ball, ball, no ball, run", - }, - { - .name = "the encounter cap", - .plan = {.species = "rattata", .max_encounters = 3}, - .encounters = {{.species = "spearow"}, {.species = "ekans"}, {.species = "spearow"}, {.species = "rattata"}}, - .end = CaptureEnd::OUT_OF_ENCOUNTERS, - .trace = "spearow, run, ekans, run, spearow, run", - }, - { - .name = "no wild battle starts", - .encounters = {{.species = "spearow"}}, - .end = CaptureEnd::NO_ENCOUNTER, - .trace = "spearow, run, none", - }, - { - .name = "a shiny of another species: nothing pressed after it", - .encounters = {{.species = "spearow", .shiny = true}, {.species = "rattata"}}, - .end = CaptureEnd::SHINY, - .trace = "shiny spearow", - }, - { - .name = "a shiny target: no ball thrown either", - .encounters = {{.species = "rattata", .shiny = true}}, - .end = CaptureEnd::SHINY, - .trace = "shiny rattata", - }, - { - .name = "a run that fails", - .encounters = {{.species = "spearow", .flee_fails = true}}, - .end = CaptureEnd::FAILED, - .trace = "spearow, run", - }, - { - .name = "weakened from green only, then balls", - .plan = {.species = "rattata", .max_balls = 4, .weaken_move = 0, .max_weaken_turns = 3}, - .encounters = {{.species = "rattata", .breaks = 1, .hp_after = {FoeHp::GREEN, FoeHp::YELLOW}}}, - .end = CaptureEnd::CAUGHT, - .trace = "rattata, move 1, move 1, ball, ball, to party", - .caught = 1, - }, - { - .name = "weakening stops at its turn cap", - .plan = {.species = "rattata", .max_balls = 4, .weaken_move = 2, .max_weaken_turns = 1}, - .encounters = {{.species = "rattata", .hp_after = {FoeHp::GREEN}}}, - .end = CaptureEnd::CAUGHT, - .trace = "rattata, move 3, ball, to party", - .caught = 1, - }, - { - .name = "a target knocked out: on to the next encounter", - .plan = {.species = "rattata", .max_balls = 4, .weaken_move = 0}, - .encounters = {{.species = "rattata", .faints_on_attack = true}, {.species = "rattata", .hp_after = {FoeHp::RED}}}, - .end = CaptureEnd::CAUGHT, - .trace = "rattata, move 1, rattata, move 1, ball, to party", - .caught = 1, - }, -}; - -// Each decision of the capture loop, by what the game sends. -UnitTestResult test_capture_decisions(){ - for (const CaptureCase& item : CAPTURE_CASES){ - LineLogger logger; - FakeCaptureGame game; - game.party = item.party; - game.bag_balls = item.bag_balls; - game.encounters = item.encounters; - const CaptureResult result = run_capture(logger, game, item.plan, item.party); - if (!game.error.empty()){ - return item.name + ": " + game.error + ". Trace: " + joined(game.trace); - } - if (result.end != item.end || joined(game.trace) != item.trace || result.caught.size() != item.caught){ - return std::format( - "{}: expected {} with {} caught after \"{}\"; got {} with {} caught after \"{}\".", - item.name, capture_end_name(item.end), item.caught, item.trace, - capture_end_name(result.end), result.caught.size(), joined(game.trace) - ); - } - } - return true; -} - - -// The table's variant, both ways, and the variants it refuses. -UnitTestResult test_capture_variant(){ - const std::vector plans = { - RATTATA, - {.species = "pidgey", .catches = 2, .max_encounters = 30, .max_balls = 8, .pace = Direction::NORTH, - .weaken_move = 3, .max_weaken_turns = 1}, - }; - for (const CapturePlan& plan : plans){ - const std::string variant = capture_variant(plan); - const std::optional back = parse_capture_variant(variant); - if (!back || back->species != plan.species || back->catches != plan.catches - || back->max_encounters != plan.max_encounters || back->max_balls != plan.max_balls - || back->pace != plan.pace || back->weaken_move != plan.weaken_move - || (plan.weaken_move && back->max_weaken_turns != plan.max_weaken_turns) - ){ - return "\"" + variant + "\" didn't read back as the plan it was made from."; - } - } - const std::optional short_form = parse_capture_variant("capture rattata"); - if (!short_form || short_form->species != "rattata" || short_form->max_balls != 5 || short_form->weaken_move){ - return "Expected \"capture rattata\" to read with the defaults."; - } - for (std::string_view bad : { - "capture", "capture ", "capture balls=3", "capture rattata balls=0", "capture rattata move=5", - "capture rattata pace=up", "capture rattata colour=red", "capture rattata balls", "pc deposit slot=2 box=1", - }){ - if (parse_capture_variant(bad)){ - return "Expected \"" + std::string(bad) + "\" refused."; - } - } - const Pokemon::StoryStep step = capture_step("catch_rattata", "Cut user", RATTATA); - if (step.kind != Pokemon::StoryStepKind::GAME || !parse_capture_variant(step.variant)){ - return "Expected capture_step() to be a GAME step with a capture variant."; - } - return true; -} - - -// What the run says at each end. -UnitTestResult test_capture_messages(){ - struct Case{ - CaptureResult result; - std::string expected; - }; - const std::vector cases = { - { - .result = {.end = CaptureEnd::CAUGHT, .encounters = 3, .balls_thrown = 2, .caught = {CatchDestination::PC}}, - .expected = "Capture: caught 1 rattata (PC): 3 encounters, 2 balls thrown, 1 of 1 caught.", - }, - { - .result = {.end = CaptureEnd::SHINY, .encounters = 4, .detail = ""}, - .expected = "Capture: a shiny Pokemon in encounter 4. Stopped in its battle without running, for you to " - "decide: catch it or run by hand, then save.", - }, - { - .result = {.end = CaptureEnd::OUT_OF_BALLS, .encounters = 1, .balls_thrown = 2, .detail = "the bag has no Poke Ball"}, - .expected = "Capture of rattata: out of balls (the bag has no Poke Ball): 1 encounters, 2 balls thrown, 0 of 1 caught.", - }, - { - .result = {.end = CaptureEnd::OUT_OF_ENCOUNTERS, .encounters = 6}, - .expected = "Capture of rattata: out of encounters: 6 encounters, 0 balls thrown, 0 of 1 caught.", - }, - }; - for (const Case& item : cases){ - const std::string got = capture_message(RATTATA, item.result); - if (got != item.expected){ - return "Expected \"" + item.expected + "\", got \"" + got + "\"."; - } - } - return true; -} - - -// A PC that does nothing: the segment tests below only use its party. -class IdlePc : public PcStorageActions{ -public: - std::vector party = {{.species = "squirtle"}, {.species = "magikarp"}}; - size_t views = 0; - virtual std::vector read_party() override{ - return party; - } - virtual PcView read_view() override{ - views++; - return {}; - } - virtual void press(PcPress) override{} -}; - -// Runs capture and PC GAME steps as the program's executor does. -class StorageExecutor : public Pokemon::StoryStepExecutor{ -public: - LineLogger logger; - FakeCaptureGame capture; - IdlePc pc; - -protected: - virtual Pokemon::Outcome game(Pokemon::RunState&, const StoryStep& step) override{ - const std::optional outcome = run_storage_game_step(logger, nullptr, step, capture, pc); - return outcome ? *outcome : Pokemon::unsupported_step(step); - } -}; - -struct SegmentRun{ - Pokemon::SegmentReport report; - size_t reloads = 0; - // The program stopped: ProgramFinishedException. - bool finished = false; -}; - -// One checkpoint of `steps`, run by the shared runner with the program's -// retry policy. -SegmentRun run_steps(StorageExecutor& executor, const std::vector& steps){ - const Pokemon::StoryCheckpoint data{.id = "test.01", .title = "capture", .start = std::nullopt, .steps = steps}; - Pokemon::Segment segment; - segment.id = "test"; - segment.checkpoints.push_back(Pokemon::CheckpointSpec{ - .id = data.id, .title = data.title, .saves = false, - .build = [&]{ return Pokemon::build_story_checkpoint(data, executor); }, - }); - SegmentRun run; - Pokemon::SegmentHooks hooks; - hooks.save = [](Pokemon::RunState&){ return Pokemon::outcome_ok(); }; - hooks.reload = [&](Pokemon::RunState&){ - run.reloads++; - return Pokemon::outcome_ok(); - }; - hooks.log = [](const std::string&){}; - Pokemon::RunState state; - try{ - run.report = Pokemon::run_segment(state, segment, {}, Pokemon::SegmentPolicy{}, hooks); - }catch (ProgramFinishedException&){ - run.finished = true; - } - return run; -} - -// A capture that runs out reloads the save and tries again; a shiny stops -// the program where it is, without a reload; a refused deposit fails -// the checkpoint before the PC is touched. -UnitTestResult test_capture_in_segment(){ - { - StorageExecutor executor; - executor.capture.encounters = {{.species = "spearow"}, {.species = "rattata"}}; - const SegmentRun run = run_steps(executor, {capture_step("catch_rattata", "", RATTATA)}); - if (!run.report.completed || run.reloads != 0 || joined(executor.capture.trace) != "spearow, run, rattata, ball, to party"){ - return "Expected the catch in one attempt; got: " + joined(executor.capture.trace); - } - } - { - StorageExecutor executor; - executor.capture.bag_balls = 0; - executor.capture.encounters = {{.species = "rattata"}, {.species = "rattata"}, {.species = "rattata"}}; - const SegmentRun run = run_steps(executor, {capture_step("catch_rattata", "", RATTATA)}); - if (run.report.completed || run.reloads != 2 || run.report.failure.failure != "capture_out_of_balls"){ - return std::format( - "Expected 3 attempts out of balls, reloading between; got {} reloads, failure {}.", - run.reloads, run.report.failure.failure - ); - } - } - { - StorageExecutor executor; - executor.capture.encounters = {{.species = "pidgey", .shiny = true}, {.species = "rattata"}}; - const SegmentRun run = run_steps(executor, {capture_step("catch_rattata", "", RATTATA)}); - const bool told = std::find_if(executor.logger.lines.begin(), executor.logger.lines.end(), [](const std::string& line){ - return line.starts_with("Capture: a shiny Pokemon in encounter 1."); - }) != executor.logger.lines.end(); - if (!run.finished || !told || run.reloads != 0 || joined(executor.capture.trace) != "shiny pidgey"){ - return "Expected the program stopped at the shiny with no reload; got: " + joined(executor.capture.trace) - + std::format(" ({} reloads)", run.reloads); - } - } - { - StorageExecutor executor; - executor.pc.party = {{.species = "squirtle", .fainted = true}, {.species = "magikarp"}}; - const PcTransfer deposit{.action = PcAction::DEPOSIT, .party_slot = 1}; - const SegmentRun run = run_steps(executor, {pc_step("deposit_magikarp", "", deposit)}); - if (run.report.completed || run.report.failure.failure != "pc_refused" || executor.pc.views != 0){ - return "Expected the deposit of the last Pokemon that can fight refused before the PC was read."; - } - } - return true; -} - - - -// A patch where the battle starts on step `battle_step` (from 1): the -// flash shows unless missed; its screen shows from `screen_after` steps -// later on (0: right after the step), or never. -struct FakePatch : public PatchSteps{ - size_t battle_step = 0; - bool flash_missed = false; - std::optional screen_after = 0; - // Whether the overworld shows when no battle does. - bool overworld = true; - - size_t steps = 0; - size_t moved = 0; - std::vector dumps = {}; - - bool battle_started() const{ - return battle_step != 0 && steps >= battle_step; - } - virtual bool step() override{ - if (battle_started()){ - // The D-pad does nothing in a battle. - steps++; - return false; - } - steps++; - moved++; - return steps == battle_step && !flash_missed; - } - virtual bool battle_shows() override{ - return battle_started() && screen_after && steps >= battle_step + *screen_after; - } - virtual bool overworld_shows() override{ - return !battle_started() && overworld; - } - virtual void dump_screen(std::string_view label) override{ - dumps.emplace_back(label); - } -}; - -struct PaceCase{ - std::string name; - FakePatch patch; - bool found; - // Steps the player took on the overworld: none in a battle. - size_t moved; - size_t dumps = 0; -}; - -UnitTestResult test_pace_until_battle(){ - const auto patch = [](size_t battle_step, bool flash_missed, std::optional screen_after, bool overworld){ - FakePatch fake; - fake.battle_step = battle_step; - fake.flash_missed = flash_missed; - fake.screen_after = screen_after; - fake.overworld = overworld; - return fake; - }; - std::vector cases{ - {"the flash on step 5", patch(5, false, 0, true), true, 5}, - // Console B, 2026-11-03: "Wild RATTATA appeared!" waited for A - // while 193 more steps paced in vain. - {"the flash missed, the text box right after", patch(5, true, 0, true), true, 5}, - {"the flash missed, the text box a step later", patch(5, true, 1, true), true, 5}, - {"the flash missed, the text box only after the last step", patch(PACE_MAX_STEPS, true, 0, true), true, PACE_MAX_STEPS}, - {"no battle: the overworld, no image", patch(0, false, 0, true), false, PACE_MAX_STEPS}, - {"no battle, not the overworld: an image", patch(0, false, 0, false), false, PACE_MAX_STEPS, 1}, - }; - for (PaceCase& item : cases){ - LineLogger logger; - const bool found = pace_until_battle(logger, item.patch, PACE_MAX_STEPS); - if (found != item.found || item.patch.moved != item.moved || item.patch.dumps.size() != item.dumps){ - return std::format( - "{}: expected found {}, {} steps moved, {} images; got {}, {}, {} ({}).", - item.name, item.found, item.moved, item.dumps, - found, item.patch.moved, item.patch.dumps.size(), joined(logger.lines) - ); - } - } - return true; -} - +}; + + +const CapturePlan RATTATA{.species = "rattata", .max_encounters = 6, .max_balls = 4}; + + +// The capture loop's decisions, by what the game sends. +struct CaptureCase{ + std::string name = {}; + CapturePlan plan = RATTATA; + size_t party = 2; + size_t bag_balls = 10; + std::vector encounters = {}; + CaptureEnd end = CaptureEnd::FAILED; + std::string trace = {}; + size_t caught = 0; +}; +const std::vector CAPTURE_CASES = { + { + .name = "the target on the first encounter, caught to the party", + .encounters = {{.species = "rattata"}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, ball, to party", + .caught = 1, + }, + { + .name = "other species and an unread one run from", + .encounters = {{.species = "spearow"}, {.species = ""}, {.species = "rattata", .breaks = 1}}, + .end = CaptureEnd::CAUGHT, + .trace = "spearow, run, ?, run, rattata, ball, ball, to party", + .caught = 1, + }, + { + .name = "a full party sends the catch to the PC", + .party = 6, + .encounters = {{.species = "rattata"}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, ball, to PC", + .caught = 1, + }, + { + .name = "two catches with a party of 5: the first to the party, the second to the PC", + .plan = {.species = "rattata", .catches = 2, .max_encounters = 6, .max_balls = 4}, + .party = 5, + .encounters = {{.species = "rattata"}, {.species = "rattata"}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, ball, to party, rattata, ball, to PC", + .caught = 2, + }, + { + .name = "the step's ball cap: run once it is reached", + .encounters = {{.species = "rattata", .breaks = 10}}, + .end = CaptureEnd::OUT_OF_BALLS, + .trace = "rattata, ball, ball, ball, ball, run", + }, + { + .name = "the bag runs out first", + .bag_balls = 2, + .encounters = {{.species = "rattata", .breaks = 10}}, + .end = CaptureEnd::OUT_OF_BALLS, + .trace = "rattata, ball, ball, no ball, run", + }, + { + .name = "the encounter cap", + .plan = {.species = "rattata", .max_encounters = 3}, + .encounters = {{.species = "spearow"}, {.species = "ekans"}, {.species = "spearow"}, {.species = "rattata"}}, + .end = CaptureEnd::OUT_OF_ENCOUNTERS, + .trace = "spearow, run, ekans, run, spearow, run", + }, + { + .name = "no wild battle starts", + .encounters = {{.species = "spearow"}}, + .end = CaptureEnd::NO_ENCOUNTER, + .trace = "spearow, run, none", + }, + { + .name = "a shiny of another species: nothing pressed after it", + .encounters = {{.species = "spearow", .shiny = true}, {.species = "rattata"}}, + .end = CaptureEnd::SHINY, + .trace = "shiny spearow", + }, + { + .name = "a shiny target: no ball thrown either", + .encounters = {{.species = "rattata", .shiny = true}}, + .end = CaptureEnd::SHINY, + .trace = "shiny rattata", + }, + { + .name = "a run that fails", + .encounters = {{.species = "spearow", .flee_fails = true}}, + .end = CaptureEnd::FAILED, + .trace = "spearow, run", + }, + { + .name = "weakened from green only, then balls", + .plan = {.species = "rattata", .max_balls = 4, .weaken_move = 0, .max_weaken_turns = 3}, + .encounters = {{.species = "rattata", .breaks = 1, .hp_after = {FoeHp::GREEN, FoeHp::YELLOW}}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, move 1, move 1, ball, ball, to party", + .caught = 1, + }, + { + .name = "weakening stops at its turn cap", + .plan = {.species = "rattata", .max_balls = 4, .weaken_move = 2, .max_weaken_turns = 1}, + .encounters = {{.species = "rattata", .hp_after = {FoeHp::GREEN}}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, move 3, ball, to party", + .caught = 1, + }, + { + .name = "a target knocked out: on to the next encounter", + .plan = {.species = "rattata", .max_balls = 4, .weaken_move = 0}, + .encounters = {{.species = "rattata", .faints_on_attack = true}, {.species = "rattata", .hp_after = {FoeHp::RED}}}, + .end = CaptureEnd::CAUGHT, + .trace = "rattata, move 1, rattata, move 1, ball, to party", + .caught = 1, + }, +}; + +// Each decision of the capture loop, by what the game sends. +UnitTestResult test_capture_decisions(){ + for (const CaptureCase& item : CAPTURE_CASES){ + LineLogger logger; + FakeCaptureGame game; + game.party = item.party; + game.bag_balls = item.bag_balls; + game.encounters = item.encounters; + const CaptureResult result = run_capture(logger, game, item.plan, item.party); + if (!game.error.empty()){ + return item.name + ": " + game.error + ". Trace: " + joined(game.trace); + } + if (result.end != item.end || joined(game.trace) != item.trace || result.caught.size() != item.caught){ + return std::format( + "{}: expected {} with {} caught after \"{}\"; got {} with {} caught after \"{}\".", + item.name, capture_end_name(item.end), item.caught, item.trace, + capture_end_name(result.end), result.caught.size(), joined(game.trace) + ); + } + } + return true; +} + + +// The table's variant, both ways, and the variants it refuses. +UnitTestResult test_capture_variant(){ + const std::vector plans = { + RATTATA, + {.species = "pidgey", .catches = 2, .max_encounters = 30, .max_balls = 8, .pace = Direction::NORTH, + .weaken_move = 3, .max_weaken_turns = 1}, + }; + for (const CapturePlan& plan : plans){ + const std::string variant = capture_variant(plan); + const std::optional back = parse_capture_variant(variant); + if (!back || back->species != plan.species || back->catches != plan.catches + || back->max_encounters != plan.max_encounters || back->max_balls != plan.max_balls + || back->pace != plan.pace || back->weaken_move != plan.weaken_move + || (plan.weaken_move && back->max_weaken_turns != plan.max_weaken_turns) + ){ + return "\"" + variant + "\" didn't read back as the plan it was made from."; + } + } + const std::optional short_form = parse_capture_variant("capture rattata"); + if (!short_form || short_form->species != "rattata" || short_form->max_balls != 5 || short_form->weaken_move){ + return "Expected \"capture rattata\" to read with the defaults."; + } + for (std::string_view bad : { + "capture", "capture ", "capture balls=3", "capture rattata balls=0", "capture rattata move=5", + "capture rattata pace=up", "capture rattata colour=red", "capture rattata balls", "pc deposit slot=2 box=1", + }){ + if (parse_capture_variant(bad)){ + return "Expected \"" + std::string(bad) + "\" refused."; + } + } + const Pokemon::StoryStep step = capture_step("catch_rattata", "Cut user", RATTATA); + if (step.kind != Pokemon::StoryStepKind::GAME || !parse_capture_variant(step.variant)){ + return "Expected capture_step() to be a GAME step with a capture variant."; + } + return true; +} + + +// What the run says at each end. +UnitTestResult test_capture_messages(){ + struct Case{ + CaptureResult result; + std::string expected; + }; + const std::vector cases = { + { + .result = {.end = CaptureEnd::CAUGHT, .encounters = 3, .balls_thrown = 2, .caught = {CatchDestination::PC}}, + .expected = "Capture: caught 1 rattata (PC): 3 encounters, 2 balls thrown, 1 of 1 caught.", + }, + { + .result = {.end = CaptureEnd::SHINY, .encounters = 4, .detail = ""}, + .expected = "Capture: a shiny Pokemon in encounter 4. Stopped in its battle without running, for you to " + "decide: catch it or run by hand, then save.", + }, + { + .result = {.end = CaptureEnd::OUT_OF_BALLS, .encounters = 1, .balls_thrown = 2, .detail = "the bag has no Poke Ball"}, + .expected = "Capture of rattata: out of balls (the bag has no Poke Ball): 1 encounters, 2 balls thrown, 0 of 1 caught.", + }, + { + .result = {.end = CaptureEnd::OUT_OF_ENCOUNTERS, .encounters = 6}, + .expected = "Capture of rattata: out of encounters: 6 encounters, 0 balls thrown, 0 of 1 caught.", + }, + }; + for (const Case& item : cases){ + const std::string got = capture_message(RATTATA, item.result); + if (got != item.expected){ + return "Expected \"" + item.expected + "\", got \"" + got + "\"."; + } + } + return true; +} + + +// A PC that does nothing: the segment tests below only use its party. +class IdlePc : public PcStorageActions{ +public: + std::vector party = {{.species = "squirtle"}, {.species = "magikarp"}}; + size_t views = 0; + virtual std::vector read_party() override{ + return party; + } + virtual PcView read_view() override{ + views++; + return {}; + } + virtual void press(PcPress) override{} +}; + +// Runs capture and PC GAME steps as the program's executor does. +class StorageExecutor : public Pokemon::StoryStepExecutor{ +public: + LineLogger logger; + FakeCaptureGame capture; + IdlePc pc; + +protected: + virtual Pokemon::Outcome game(Pokemon::RunState&, const StoryStep& step) override{ + const std::optional outcome = run_storage_game_step(logger, nullptr, step, capture, pc); + return outcome ? *outcome : Pokemon::unsupported_step(step); + } +}; + +struct SegmentRun{ + Pokemon::SegmentReport report; + size_t reloads = 0; + // The program stopped: ProgramFinishedException. + bool finished = false; +}; + +// One checkpoint of `steps`, run by the shared runner with the program's +// retry policy. +SegmentRun run_steps(StorageExecutor& executor, const std::vector& steps){ + const Pokemon::StoryCheckpoint data{.id = "test.01", .title = "capture", .start = std::nullopt, .steps = steps}; + Pokemon::Segment segment; + segment.id = "test"; + segment.checkpoints.push_back(Pokemon::CheckpointSpec{ + .id = data.id, .title = data.title, .saves = false, + .build = [&]{ return Pokemon::build_story_checkpoint(data, executor); }, + }); + SegmentRun run; + Pokemon::SegmentHooks hooks; + hooks.save = [](Pokemon::RunState&){ return Pokemon::outcome_ok(); }; + hooks.reload = [&](Pokemon::RunState&){ + run.reloads++; + return Pokemon::outcome_ok(); + }; + hooks.log = [](const std::string&){}; + Pokemon::RunState state; + try{ + run.report = Pokemon::run_segment(state, segment, {}, Pokemon::SegmentPolicy{}, hooks); + }catch (ProgramFinishedException&){ + run.finished = true; + } + return run; +} + +// A capture that runs out reloads the save and tries again; a shiny stops +// the program where it is, without a reload; a refused deposit fails +// the checkpoint before the PC is touched. +UnitTestResult test_capture_in_segment(){ + { + StorageExecutor executor; + executor.capture.encounters = {{.species = "spearow"}, {.species = "rattata"}}; + const SegmentRun run = run_steps(executor, {capture_step("catch_rattata", "", RATTATA)}); + if (!run.report.completed || run.reloads != 0 || joined(executor.capture.trace) != "spearow, run, rattata, ball, to party"){ + return "Expected the catch in one attempt; got: " + joined(executor.capture.trace); + } + } + { + StorageExecutor executor; + executor.capture.bag_balls = 0; + executor.capture.encounters = {{.species = "rattata"}, {.species = "rattata"}, {.species = "rattata"}}; + const SegmentRun run = run_steps(executor, {capture_step("catch_rattata", "", RATTATA)}); + if (run.report.completed || run.reloads != 2 || run.report.failure.failure != "capture_out_of_balls"){ + return std::format( + "Expected 3 attempts out of balls, reloading between; got {} reloads, failure {}.", + run.reloads, run.report.failure.failure + ); + } + } + { + StorageExecutor executor; + executor.capture.encounters = {{.species = "pidgey", .shiny = true}, {.species = "rattata"}}; + const SegmentRun run = run_steps(executor, {capture_step("catch_rattata", "", RATTATA)}); + const bool told = std::find_if(executor.logger.lines.begin(), executor.logger.lines.end(), [](const std::string& line){ + return line.starts_with("Capture: a shiny Pokemon in encounter 1."); + }) != executor.logger.lines.end(); + if (!run.finished || !told || run.reloads != 0 || joined(executor.capture.trace) != "shiny pidgey"){ + return "Expected the program stopped at the shiny with no reload; got: " + joined(executor.capture.trace) + + std::format(" ({} reloads)", run.reloads); + } + } + { + StorageExecutor executor; + executor.pc.party = {{.species = "squirtle", .fainted = true}, {.species = "magikarp"}}; + const PcTransfer deposit{.action = PcAction::DEPOSIT, .party_slot = 1}; + const SegmentRun run = run_steps(executor, {pc_step("deposit_magikarp", "", deposit)}); + if (run.report.completed || run.report.failure.failure != "pc_refused" || executor.pc.views != 0){ + return "Expected the deposit of the last Pokemon that can fight refused before the PC was read."; + } + } + return true; +} + + + +// A patch where the battle starts on step `battle_step` (from 1): the +// flash shows unless missed; its screen shows from `screen_after` steps +// later on (0: right after the step), or never. +struct FakePatch : public PatchSteps{ + size_t battle_step = 0; + bool flash_missed = false; + std::optional screen_after = 0; + // Whether the overworld shows when no battle does. + bool overworld = true; + + size_t steps = 0; + size_t moved = 0; + std::vector dumps = {}; + + bool battle_started() const{ + return battle_step != 0 && steps >= battle_step; + } + virtual bool step() override{ + if (battle_started()){ + // The D-pad does nothing in a battle. + steps++; + return false; + } + steps++; + moved++; + return steps == battle_step && !flash_missed; + } + virtual bool battle_shows() override{ + return battle_started() && screen_after && steps >= battle_step + *screen_after; + } + virtual bool overworld_shows() override{ + return !battle_started() && overworld; + } + virtual bool message_shows() override{ + return false; + } + virtual std::string fight_trainer() override{ + return ""; + } + virtual void dump_screen(std::string_view label) override{ + dumps.emplace_back(label); + } +}; + +struct PaceCase{ + std::string name; + FakePatch patch; + bool found; + // Steps the player took on the overworld: none in a battle. + size_t moved; + size_t dumps = 0; +}; + +UnitTestResult test_pace_until_battle(){ + const auto patch = [](size_t battle_step, bool flash_missed, std::optional screen_after, bool overworld){ + FakePatch fake; + fake.battle_step = battle_step; + fake.flash_missed = flash_missed; + fake.screen_after = screen_after; + fake.overworld = overworld; + return fake; + }; + std::vector cases{ + {"the flash on step 5", patch(5, false, 0, true), true, 5}, + // Console B, 2026-11-03: "Wild RATTATA appeared!" waited for A + // while 193 more steps paced in vain. + {"the flash missed, the text box right after", patch(5, true, 0, true), true, 5}, + {"the flash missed, the text box a step later", patch(5, true, 1, true), true, 5}, + {"the flash missed, the text box only after the last step", patch(PACE_MAX_STEPS, true, 0, true), true, PACE_MAX_STEPS}, + {"no battle: the overworld, no image", patch(0, false, 0, true), false, PACE_MAX_STEPS}, + {"no battle, not the overworld: an image", patch(0, false, 0, false), false, PACE_MAX_STEPS, 1}, + }; + for (PaceCase& item : cases){ + LineLogger logger; + const bool found = pace_until_battle(logger, item.patch, PACE_MAX_STEPS); + if (found != item.found || item.patch.moved != item.moved || item.patch.dumps.size() != item.dumps){ + return std::format( + "{}: expected found {}, {} steps moved, {} images; got {}, {}, {} ({}).", + item.name, item.found, item.moved, item.dumps, + found, item.patch.moved, item.patch.dumps.size(), joined(logger.lines) + ); + } + } + return true; +} + } // namespace - - + + const AutoStoryCaptureCase AUTO_STORY_CAPTURE_CASES[] = { { .input = {"decisions by encounter", test_capture_decisions}, @@ -633,14 +639,14 @@ const AutoStoryCaptureCase AUTO_STORY_CAPTURE_CASES[] = { }, }; -void add_tests_AutoStoryCapture(UnitTestDatabase& database){ +void add_tests_AutoStoryCapture(UnitTestDatabase& database){ for (const auto& item : AUTO_STORY_CAPTURE_CASES){ database.add(item); } -} - - - +} + + + } // 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 31f017082a..244f8a2c8c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -13,6 +13,7 @@ #include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "Pokemon/Navigation/Pokemon_RouteLog.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldReanchor.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" @@ -20,6 +21,7 @@ #include "PokemonFRLG_AutoStoryBattleData.h" #include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStorySummary.h" #include "PokemonFRLG_AutoStoryTable.h" #include "PokemonFRLG_AutoStoryTeachScreens.h" @@ -90,33 +92,47 @@ WallCheck wall_check(const StoryEnv& env){ return env.checks == StoryChecks::THOROUGH ? WallCheck::TAP : WallCheck::SKIP; } +// Where a ROUTE from `pose` starts: its tile, or nothing for a set of +// tiles (paced_pose()), which the route's first holds bring together. +std::optional route_start(const Pokemon::Pose& pose){ + const std::vector tiles = Pokemon::tiles_of(pose); + if (tiles.size() != 1){ + return std::nullopt; + } + return MapPosition{pose.map, tiles[0]}; +} + Outcome walk_route(StoryEnv& env, RunState& state, const StoryStep& step){ const std::vector tiles = Pokemon::tiles_of(state.pose); - if (tiles.size() != 1 || state.pose.certainty != Pokemon::PoseCertainty::TILE){ + const bool known = state.pose.certainty == Pokemon::PoseCertainty::TILE + || (state.pose.certainty == Pokemon::PoseCertainty::SET && tiles.size() > 1); + if (tiles.empty() || !known){ return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); } - const MapPosition start{state.pose.map, tiles[0]}; + const std::optional start = route_start(state.pose); std::optional facing = facing_of(state.pose); - if (!facing){ - facing = face_a_wall(env, start); + if (!facing && start){ + facing = face_a_wall(env, *start); } - if (!facing){ - return Pokemon::outcome_failed("unknown_facing", to_string(start)); + if (!facing && start){ + return Pokemon::outcome_failed("unknown_facing", to_string(*start)); } const std::optional target = step.target ? story_pose(*step.target) : std::nullopt; if (!target){ 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_overworld_route( - story_maps(), start, goal, default_route_costs(), facing - ); + const std::optional> route = plan_story_route(story_maps(), state.pose, facing, goal); if (!route){ - return Pokemon::outcome_failed("no_route", to_string(start) + " to " + to_string(goal.position)); + return Pokemon::outcome_failed("no_route", Pokemon::to_string(state.pose) + " to " + to_string(goal.position)); + } + if (!start){ + env.console.log("Story: " + step.name + ": on one of " + Pokemon::to_string(state.pose) + "; holds to walls first, to one tile."); } 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. + // 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)}; 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); @@ -342,6 +358,8 @@ std::string save_card_place(const std::string& map){ {"Route3", "route-3"}, {"Route4", "route-4"}, {"CeruleanCity", "cerulean-city"}, + {"Route24", "route-24"}, + {"Route25", "route-25"}, }; for (const auto& [prefix, place] : PLACES){ if (map == prefix || map.rfind(prefix + "_", 0) == 0){ @@ -423,6 +441,43 @@ std::optional> fixed_route( return route; } +Pokemon::Pose paced_pose(const Pokemon::Pose& patch, const StoryStep& step){ + std::optional pace; + if (const std::optional grind = parse_grind_variant(step.variant)){ + pace = grind->pace; + }else if (const std::optional capture = parse_capture_variant(step.variant)){ + pace = capture->pace; + } + const std::vector tiles = Pokemon::tiles_of(patch); + if (!pace || tiles.size() != 1){ + return patch; + } + return Pokemon::tile_pose(patch.map, {tiles[0], tiles[0].next(*pace)}, std::nullopt); +} + +std::optional> plan_story_route( + const std::vector& maps, const Pokemon::Pose& pose, + std::optional facing, const RouteGoal& goal +){ + std::vector tiles; + for (const TilePos& tile : Pokemon::tiles_of(pose)){ + tiles.push_back(MapPosition{pose.map, tile}); + } + const std::optional> holds = holds_to_one_tile(maps, tiles); + if (!holds){ + return std::nullopt; + } + const MapPosition from = holds->empty() ? tiles[0] : holds->back().end; + if (!holds->empty()){ + facing = holds->back().facing_after; + } + std::optional> route = plan_overworld_route(maps, from, goal, default_route_costs(), facing); + if (route){ + route->insert(route->begin(), holds->begin(), holds->end()); + } + return route; +} + std::optional wall_beside(const MapData& map, const TilePos& tile){ for (Direction direction : {Direction::WEST, Direction::EAST, Direction::NORTH, Direction::SOUTH}){ const TilePos next = tile.next(direction); @@ -562,6 +617,25 @@ bool grind_heal_trip(StoryEnv& env, RunState& state, const StoryStep& step, cons return walked(env, state, walk); } +// A trainer saw the player pacing `step` on `patch` (console B, +// 2026-11-03, m63: Camper Shane on Route 24): fights them, then walks back +// onto the patch from either of pacing's tiles (see PaceTrainerBattle). +void pace_trainer_battle(StoryEnv& env, RunState& state, const StoryStep& step, const Pokemon::Pose& patch){ + clear_overworld_message(env.console, env.context, env.supplies, env.checks, env.language); + state.pose = paced_pose(patch, step); + StoryStep walk; + walk.kind = StoryStepKind::ROUTE; + walk.name = step.name + "_back_after_trainer"; + walk.target = patch; + if (!walked(env, state, walk)){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "Pacing: no way back onto the patch after a trainer's battle.", + env.console + ); + } +} + // A MART, TEACH, LEVEL, GRIND or fossil step on the console; nothing for // another variant. std::optional run_abilities(StoryEnv& env, RunState& state, const StoryStep& step){ @@ -573,6 +647,9 @@ std::optional run_abilities(StoryEnv& env, RunState& state, const Story env.console, env.context, env.language, env.supplies, env.checks, [&](const GrindSpot& spot){ return grind_heal_trip(env, state, step, patch, spot); + }, + [&]{ + pace_trainer_battle(env, state, step, patch); } ); ConsoleChoiceActions choice(env.console, env.context); @@ -591,15 +668,21 @@ Outcome StoryExecutor::game(RunState& state, const StoryStep& step){ leave_gift_summary(m_env.console, m_env.context); return Pokemon::outcome_ok(); } - ConsoleCaptureActions capture(m_env.console, m_env.context, m_env.language); + 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); + }); ConsolePcStorageActions pc(m_env.console, m_env.context); - const std::optional stored = run_storage_game_step(m_env.console.logger(), &m_env.console, step, capture, pc); - if (stored){ - return *stored; + std::optional done = run_storage_game_step(m_env.console.logger(), &m_env.console, step, capture, pc); + if (!done){ + done = run_abilities(m_env, state, step); + } + if (done && done->succeeded() && !done->pose){ + // Pacing leaves the player on either of its tiles. + done->pose = paced_pose(state.pose, step); } - const std::optional ability = run_abilities(m_env, state, step); - if (ability){ - return *ability; + if (done){ + return *done; } return Pokemon::unsupported_step(step); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index 728411015a..53a6a3b7b1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -1,318 +1,333 @@ -/* AutoStory Events - * - * From: https://github.com/PokemonAutomation/ - * - * The FRLG story as data for the shared story engine - * (Pokemon/AutoStory/Pokemon_StoryEvents.h, Pokemon_StorySegments.h). - * A checkpoint is a list of the shared StorySteps - * (Pokemon/AutoStory/Pokemon_StorySteps.h), each one kind of event (a - * route, a talk, text read to its end, a YES/NO, a battle, the nurse, the - * Mart); StoryExecutor does each kind on the console, and - * make_story_segment() turns the table into engine checkpoints. Routes - * plan from the pose the run carries and return where they leave the - * player; scenes that move the player say where in the table. - * - * The table itself is in PokemonFRLG_AutoStoryTable.h; it is plain data, - * so its routes are planned in unit tests without a console. - * - */ - -#ifndef PokemonAutomation_PokemonFRLG_AutoStoryEvents_H -#define PokemonAutomation_PokemonFRLG_AutoStoryEvents_H - -#include -#include -#include -#include -#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" -#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" -#include "Pokemon/AutoStory/Pokemon_StoryActions.h" -#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" -#include "Pokemon/AutoStory/Pokemon_StorySegments.h" -#include "Pokemon/AutoStory/Pokemon_StorySteps.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" -#include "PokemonFRLG_AutoStoryActions.h" -#include "PokemonFRLG_AutoStoryBattle.h" -#include "PokemonFRLG_AutoStoryStartFrames.h" -#include "PokemonFRLG_AutoStoryStepInterrupts.h" -#include "PokemonFRLG_AutoStoryStepTable.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - +/* AutoStory Events + * + * From: https://github.com/PokemonAutomation/ + * + * The FRLG story as data for the shared story engine + * (Pokemon/AutoStory/Pokemon_StoryEvents.h, Pokemon_StorySegments.h). + * A checkpoint is a list of the shared StorySteps + * (Pokemon/AutoStory/Pokemon_StorySteps.h), each one kind of event (a + * route, a talk, text read to its end, a YES/NO, a battle, the nurse, the + * Mart); StoryExecutor does each kind on the console, and + * make_story_segment() turns the table into engine checkpoints. Routes + * plan from the pose the run carries and return where they leave the + * player; scenes that move the player say where in the table. + * + * The table itself is in PokemonFRLG_AutoStoryTable.h; it is plain data, + * so its routes are planned in unit tests without a console. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryEvents_H +#define PokemonAutomation_PokemonFRLG_AutoStoryEvents_H + +#include +#include +#include +#include +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/AutoStory/Pokemon_StoryActions.h" +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "Pokemon/AutoStory/Pokemon_StorySegments.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryBattle.h" +#include "PokemonFRLG_AutoStoryStartFrames.h" +#include "PokemonFRLG_AutoStoryStepInterrupts.h" +#include "PokemonFRLG_AutoStoryStepTable.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + // Story leg definition imported from Pokemon AutoStory. -using Pokemon::StoryLeg; +using Pokemon::StoryLeg; // Story step definition imported from Pokemon AutoStory. -using Pokemon::StoryStep; +using Pokemon::StoryStep; // Story step kind classification imported from Pokemon AutoStory. -using Pokemon::StoryStepKind; - -// Where the player stands and faces: the one-tile poses of FRLG's table -// and routes. An unknown facing (after a scene that turns the player) is -// set before the next route by turning to a wall. -struct StoryPose{ - std::string map; - TilePos tile; - std::optional facing; -}; - -// The variant of a ROUTE or LEGS step that runs, with B held: the player -// has the Running Shoes (00.10 on). -inline const std::string RUN_VARIANT = "run"; - -// The variant of a STEP into a trainer's line: a tile at a time, at most -// TRAINER_LINE_TILES, until the trainer sees the player -// (step_until_seen()). The battle after it starts on the trainer's row -// whether the walk before ended on its tile or a tile short. -inline const std::string TRAINER_LINE_VARIANT = "trainer_line"; +using Pokemon::StoryStepKind; + +// Where the player stands and faces: the one-tile poses of FRLG's table +// and routes. An unknown facing (after a scene that turns the player) is +// set before the next route by turning to a wall. +struct StoryPose{ + std::string map; + TilePos tile; + std::optional facing; +}; + +// The variant of a ROUTE or LEGS step that runs, with B held: the player +// has the Running Shoes (00.10 on). +inline const std::string RUN_VARIANT = "run"; + +// The variant of a STEP into a trainer's line: a tile at a time, at most +// TRAINER_LINE_TILES, until the trainer sees the player +// (step_until_seen()). The battle after it starts on the trainer's row +// whether the walk before ended on its tile or a tile short. +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 GAME step that closes the Trainer Card SID Helper leaves open. -inline const std::string CLOSE_TRAINER_CARD = "close_trainer_card"; - -// The GAME step that leaves the gift's summary Gift RNG leaves open for -// the overworld. -inline const std::string LEAVE_GIFT_SUMMARY = "leave_gift_summary"; - -// How `step`, a ROUTE or LEGS, moves the player: RUN_VARIANT runs. -MoveSpeed move_speed(const StoryStep& step); - - -// An upstream RNG program the user may run part-way through the story. -// Each is off unless its option is on. -enum class StoryHandoff{ - NONE, - // The checkpoint's new game stops on the player's keyboard, the name - // typed and OK not pressed, which is where SID Helper starts. - SID_HELPER, - // The checkpoint saves south of the table, facing the starter's ball, - // which is where Starter RNG starts. With the handoff off, it runs on - // into the next checkpoint as one, as the story did before the split. - STARTER_RNG, - // The checkpoint saves beside the Magikarp salesman, facing him, which - // is where Gift RNG starts. - GIFT_RNG, -}; - -// A shared checkpoint with what FRLG adds to it. -struct StoryCheckpoint : public Pokemon::StoryCheckpoint{ - // The healing items at its start, for a run that loads its save. - Supplies supplies; - // The START menu at its end, when it saves. - StartMenu menu = StartMenu::FULL; - // Starts from the game as another program left it, loading no save: - // the way back into the story after an RNG program. A run that starts - // before it passes over it. - bool live_start = false; - // The RNG program the user may run at this checkpoint. - StoryHandoff handoff = StoryHandoff::NONE; -}; - -// The handoffs a run stops for (AutoStory's options). -struct StoryHandoffs{ - bool sid_helper = false; - bool starter_rng = false; - bool gift_rng = false; -}; - -// What a run from table row `first` to `last` does. -struct StoryRunPlan{ - // The checkpoints to run, in order. - std::vector rows; - // NONE: the run ends after `rows`. SID_HELPER: `rows` is empty; the run - // plays the new game to the player's keyboard and stops there. - // STARTER_RNG: the run stops after `rows`, the last of which saves at - // the starter's ball. GIFT_RNG: likewise, beside the Magikarp salesman; - // with `rows` empty, the run started at 00.12b and takes Gift RNG from - // the save beside the salesman, where the row before `first` ends. - StoryHandoff stop = StoryHandoff::NONE; -}; - -// The rows of `checkpoints` from `first` to `last`, both inclusive, as a -// run with `handoffs` takes them: live-start rows only when the run -// starts there (00.12b with Gift RNG's handoff on starts at the handoff -// instead), a run that reaches a handoff that is on stops for it, and -// a STARTER_RNG row whose handoff is off is joined with the row after it, -// under its own id. -StoryRunPlan plan_story_run( - const std::vector& checkpoints, size_t first, size_t last, const StoryHandoffs& handoffs -); - - -// The maps the story plans on: every map to Route 4's Pokemon Center, with -// the objects the game hides after the Hall of Fame removed, as the lab's -// frlg_route does. -const std::vector& story_maps(); - +inline constexpr int TRAINER_LINE_TILES = 3; + +// The GAME step that closes the Trainer Card SID Helper leaves open. +inline const std::string CLOSE_TRAINER_CARD = "close_trainer_card"; + +// The GAME step that leaves the gift's summary Gift RNG leaves open for +// the overworld. +inline const std::string LEAVE_GIFT_SUMMARY = "leave_gift_summary"; + +// How `step`, a ROUTE or LEGS, moves the player: RUN_VARIANT runs. +MoveSpeed move_speed(const StoryStep& step); + + +// An upstream RNG program the user may run part-way through the story. +// Each is off unless its option is on. +enum class StoryHandoff{ + NONE, + // The checkpoint's new game stops on the player's keyboard, the name + // typed and OK not pressed, which is where SID Helper starts. + SID_HELPER, + // The checkpoint saves south of the table, facing the starter's ball, + // which is where Starter RNG starts. With the handoff off, it runs on + // into the next checkpoint as one, as the story did before the split. + STARTER_RNG, + // The checkpoint saves beside the Magikarp salesman, facing him, which + // is where Gift RNG starts. + GIFT_RNG, +}; + +// A shared checkpoint with what FRLG adds to it. +struct StoryCheckpoint : public Pokemon::StoryCheckpoint{ + // The healing items at its start, for a run that loads its save. + Supplies supplies; + // The START menu at its end, when it saves. + StartMenu menu = StartMenu::FULL; + // Starts from the game as another program left it, loading no save: + // the way back into the story after an RNG program. A run that starts + // before it passes over it. + bool live_start = false; + // The RNG program the user may run at this checkpoint. + StoryHandoff handoff = StoryHandoff::NONE; +}; + +// The handoffs a run stops for (AutoStory's options). +struct StoryHandoffs{ + bool sid_helper = false; + bool starter_rng = false; + bool gift_rng = false; +}; + +// What a run from table row `first` to `last` does. +struct StoryRunPlan{ + // The checkpoints to run, in order. + std::vector rows; + // NONE: the run ends after `rows`. SID_HELPER: `rows` is empty; the run + // plays the new game to the player's keyboard and stops there. + // STARTER_RNG: the run stops after `rows`, the last of which saves at + // the starter's ball. GIFT_RNG: likewise, beside the Magikarp salesman; + // with `rows` empty, the run started at 00.12b and takes Gift RNG from + // the save beside the salesman, where the row before `first` ends. + StoryHandoff stop = StoryHandoff::NONE; +}; + +// The rows of `checkpoints` from `first` to `last`, both inclusive, as a +// run with `handoffs` takes them: live-start rows only when the run +// starts there (00.12b with Gift RNG's handoff on starts at the handoff +// instead), a run that reaches a handoff that is on stops for it, and +// a STARTER_RNG row whose handoff is off is joined with the row after it, +// under its own id. +StoryRunPlan plan_story_run( + const std::vector& checkpoints, size_t first, size_t last, const StoryHandoffs& handoffs +); + + +// The maps the story plans on: every map to Route 4's Pokemon Center, with +// the objects the game hides after the Hall of Fame removed, as the lab's +// frlg_route does. +const std::vector& story_maps(); + // Converts a StoryPose to a general navigation Pose. -Pokemon::Pose to_pose(const StoryPose& pose); -// `pose` as a StoryPose: nothing unless it is on one tile. -std::optional story_pose(const Pokemon::Pose& pose); - -// The place the save card names for a save on `map` (see -// read_save_card_location()), or empty for a map whose place isn't known. -std::string save_card_place(const std::string& map); - -// Why a save whose card names `place` can't start a checkpoint that starts -// on `start_map`, or empty if it can: the places match, the card couldn't -// be read (empty `place`), or the map has no known place. -std::string save_place_error(const std::string& start_map, const std::string& place); - -// Why a save standing at `stands` can't start a checkpoint that starts at -// `start`, or empty if it can: the same map and tile, and the same facing -// when both are known. -std::string start_pose_mismatch(const Pokemon::Pose& start, const StoryPose& stands); - -// Where the save just loaded stands, as the game reads it: nothing when it -// can't be read. -using SavePoseReader = std::function()>; -// Where the start check says what it found. -using StoryLog = std::function; - -// Adds to `checkpoint`, built from a row that starts at `start`, the start -// check: the save it starts from, as `read` reads it, must stand there -// (start_pose_mismatch()), or the checkpoint stops before its first event, -// "start_failed". A save that can't be read passes. `log` gets a -// mismatch, or that nothing was read. A row with no start (a new game) -// gets none. (add_start_check() with a reader of the tile.) -void add_start_pose_check( - Pokemon::Checkpoint& checkpoint, const std::optional& start, SavePoseReader read, StoryLog log -); - -// `legs` from `start` as route steps for the walker, each checked against -// the map: nothing if a step is blocked or leaves the map. A leg ends "to -// wall" if the next step its way is blocked. -std::optional> fixed_route( - const std::vector& maps, const MapPosition& start, const std::vector& legs -); - -// A way the player at `tile` can turn to without stepping: a wall there, -// with no one standing in it. West and east first: walking north or south -// into a sign would read it. -std::optional wall_beside(const MapData& map, const TilePos& tile); - -// `items` as orders at the Viridian Mart before the Boulder Badge: the -// rows of POKE BALL, POTION, ANTIDOTE, PARLYZ HEAL. Nothing if it does -// not sell one of them. -std::optional> viridian_mart_orders(const std::vector& items); - - -// What the story's events act on. `supplies` are what the bag holds now, -// `saved_supplies` what the last save holds. -struct StoryEnv{ - ConsoleHandle& console; - ProControllerContext& context; - Supplies supplies; - Supplies saved_supplies; - // Whether the game has a save to reload. - bool has_save = false; - StoryChecks checks = StoryChecks::THOROUGH; - // The game's language, for reading item names. In one the readers have - // no names for, the checks fall back to positions and counts. - Language language = Language::English; - // The name the new game's intro types for the player; empty keeps the - // game's default name. - std::string player_name = {}; - // The map the checkpoint under way starts on, where its save is: a - // reload checks the save card against it. - std::string start_map = {}; - // That checkpoint's START menu: a reload reads the bag again with it. - StartMenu start_menu = StartMenu::BEFORE_POKEMON; - // The once_per_save steps done from the last save that changed - // nothing: a retry after a reload skips them. Emptied by a save. - Pokemon::PreparedSteps prepared = {}; - // The last save's Items pocket was read in full (read_bag_supplies()): - // a reload restores `saved_supplies` without reading it again. - bool bag_read = false; - // The frames of where each checkpoint starts, for the start check: a - // save at a checkpoint's end keeps one for the next checkpoint. - StartFrameStore start_frames = {}; - // The next start check keeps the screen as its checkpoint's start - // frame and passes: the user saved there by hand. - bool keep_start_frame = false; -}; - -// After a save is loaded for a checkpoint that starts on `start_map`: the -// save card's place, read from the START menu, against it, at every checks -// level. Empty if the save is there (or the card can't be read), else why -// not. -std::string check_save_place(StoryEnv& env, const std::string& start_map); - -// After a save is loaded, with START's menu `menu`: the lead's moves as its -// summary shows them, which the battles then start from in place of the -// row's (in env's supplies and the ones a reload restores). The row's are -// kept if no move can be read. -void read_lead_moves(StoryEnv& env, StartMenu menu); - -// After a save is loaded, with START's menu `menu`: the Items pocket's -// counts as read (set_from_loaded_bag()), in place of the row's, which -// are kept if nothing can be read. Only with the full menu: the rows -// before it carry the items they have. Sets `bag_read` when every count -// was read. -void read_bag_supplies(StoryEnv& env, StartMenu menu); - -// Does FRLG's kinds of story step on the console, each with its row of -// story_action_rows() (PokemonFRLG_AutoStoryStepTable.h). A console step -// that fails is a failed Outcome, which the segment answers by reloading. -// Before each step it clears what came up since the step before -// (step_interrupts(), PokemonFRLG_AutoStoryStepInterrupts.h). -class StoryExecutor : public Pokemon::StoryStepExecutor{ -public: - explicit StoryExecutor(StoryEnv& env); +Pokemon::Pose to_pose(const StoryPose& pose); +// `pose` as a StoryPose: nothing unless it is on one tile. +std::optional story_pose(const Pokemon::Pose& pose); + +// The place the save card names for a save on `map` (see +// read_save_card_location()), or empty for a map whose place isn't known. +std::string save_card_place(const std::string& map); + +// Why a save whose card names `place` can't start a checkpoint that starts +// on `start_map`, or empty if it can: the places match, the card couldn't +// be read (empty `place`), or the map has no known place. +std::string save_place_error(const std::string& start_map, const std::string& place); + +// Why a save standing at `stands` can't start a checkpoint that starts at +// `start`, or empty if it can: the same map and tile, and the same facing +// when both are known. +std::string start_pose_mismatch(const Pokemon::Pose& start, const StoryPose& stands); + +// Where the save just loaded stands, as the game reads it: nothing when it +// can't be read. +using SavePoseReader = std::function()>; +// Where the start check says what it found. +using StoryLog = std::function; + +// Adds to `checkpoint`, built from a row that starts at `start`, the start +// check: the save it starts from, as `read` reads it, must stand there +// (start_pose_mismatch()), or the checkpoint stops before its first event, +// "start_failed". A save that can't be read passes. `log` gets a +// mismatch, or that nothing was read. A row with no start (a new game) +// gets none. (add_start_check() with a reader of the tile.) +void add_start_pose_check( + Pokemon::Checkpoint& checkpoint, const std::optional& start, SavePoseReader read, StoryLog log +); + +// `legs` from `start` as route steps for the walker, each checked against +// the map: nothing if a step is blocked or leaves the map. A leg ends "to +// wall" if the next step its way is blocked. +std::optional> fixed_route( + const std::vector& maps, const MapPosition& start, const std::vector& legs +); + +// Where a GRIND or capture `step` that paces (pace = ...) leaves the +// player who started it on `patch`: the patch's tile or the one its pace +// leads to, as the last battle came, facing unknown. `patch` for any +// 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. +// Nothing if either can't be planned. +std::optional> plan_story_route( + const std::vector& maps, const Pokemon::Pose& pose, + std::optional facing, const RouteGoal& goal +); + +// A way the player at `tile` can turn to without stepping: a wall there, +// with no one standing in it. West and east first: walking north or south +// into a sign would read it. +std::optional wall_beside(const MapData& map, const TilePos& tile); + +// `items` as orders at the Viridian Mart before the Boulder Badge: the +// rows of POKE BALL, POTION, ANTIDOTE, PARLYZ HEAL. Nothing if it does +// not sell one of them. +std::optional> viridian_mart_orders(const std::vector& items); + + +// What the story's events act on. `supplies` are what the bag holds now, +// `saved_supplies` what the last save holds. +struct StoryEnv{ + ConsoleHandle& console; + ProControllerContext& context; + Supplies supplies; + Supplies saved_supplies; + // Whether the game has a save to reload. + bool has_save = false; + StoryChecks checks = StoryChecks::THOROUGH; + // The game's language, for reading item names. In one the readers have + // no names for, the checks fall back to positions and counts. + Language language = Language::English; + // The name the new game's intro types for the player; empty keeps the + // game's default name. + std::string player_name = {}; + // The map the checkpoint under way starts on, where its save is: a + // reload checks the save card against it. + std::string start_map = {}; + // That checkpoint's START menu: a reload reads the bag again with it. + StartMenu start_menu = StartMenu::BEFORE_POKEMON; + // The once_per_save steps done from the last save that changed + // nothing: a retry after a reload skips them. Emptied by a save. + Pokemon::PreparedSteps prepared = {}; + // The last save's Items pocket was read in full (read_bag_supplies()): + // a reload restores `saved_supplies` without reading it again. + bool bag_read = false; + // The frames of where each checkpoint starts, for the start check: a + // save at a checkpoint's end keeps one for the next checkpoint. + StartFrameStore start_frames = {}; + // The next start check keeps the screen as its checkpoint's start + // frame and passes: the user saved there by hand. + bool keep_start_frame = false; +}; + +// After a save is loaded for a checkpoint that starts on `start_map`: the +// save card's place, read from the START menu, against it, at every checks +// level. Empty if the save is there (or the card can't be read), else why +// not. +std::string check_save_place(StoryEnv& env, const std::string& start_map); + +// After a save is loaded, with START's menu `menu`: the lead's moves as its +// summary shows them, which the battles then start from in place of the +// row's (in env's supplies and the ones a reload restores). The row's are +// kept if no move can be read. +void read_lead_moves(StoryEnv& env, StartMenu menu); + +// After a save is loaded, with START's menu `menu`: the Items pocket's +// counts as read (set_from_loaded_bag()), in place of the row's, which +// are kept if nothing can be read. Only with the full menu: the rows +// before it carry the items they have. Sets `bag_read` when every count +// was read. +void read_bag_supplies(StoryEnv& env, StartMenu menu); + +// Does FRLG's kinds of story step on the console, each with its row of +// story_action_rows() (PokemonFRLG_AutoStoryStepTable.h). A console step +// that fails is a failed Outcome, which the segment answers by reloading. +// Before each step it clears what came up since the step before +// (step_interrupts(), PokemonFRLG_AutoStoryStepInterrupts.h). +class StoryExecutor : public Pokemon::StoryStepExecutor{ +public: + explicit StoryExecutor(StoryEnv& env); // Its table's actions point at it. StoryExecutor(const StoryExecutor&) = delete; StoryExecutor& operator=(const StoryExecutor&) = delete; - - StoryEnv& env(){ - return m_env; - } - // The START menu SET_OPTIONS leaves: the checkpoint's being built. - void set_menu(StartMenu menu){ - m_menu = menu; - } - - // 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); - - StoryEnv& m_env; - StartMenu m_menu = StartMenu::FULL; - // A failed step fails the step; an interrupt between steps is logged. - InterruptLogGuard m_guard; - Pokemon::StoryActionTable m_table; - Pokemon::TableExecutor m_executor; - // What is cleared between steps, looked for on the screen. - ConsoleScreenLook m_look; - Pokemon::StoryInterrupts m_interrupts; -}; - -// The checkpoint `data` as events, saving at its end. -Pokemon::Checkpoint build_checkpoint(const StoryCheckpoint& data, StoryExecutor& executor); - -// `checkpoints` (kept alive by the caller) as a segment for run_segment(). -Pokemon::Segment make_story_segment(const std::vector& checkpoints, StoryExecutor& executor); - - - + + StoryEnv& env(){ + return m_env; + } + // The START menu SET_OPTIONS leaves: the checkpoint's being built. + void set_menu(StartMenu menu){ + m_menu = menu; + } + + // 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); + + StoryEnv& m_env; + StartMenu m_menu = StartMenu::FULL; + // A failed step fails the step; an interrupt between steps is logged. + InterruptLogGuard m_guard; + Pokemon::StoryActionTable m_table; + Pokemon::TableExecutor m_executor; + // What is cleared between steps, looked for on the screen. + ConsoleScreenLook m_look; + Pokemon::StoryInterrupts m_interrupts; +}; + +// The checkpoint `data` as events, saving at its end. +Pokemon::Checkpoint build_checkpoint(const StoryCheckpoint& data, StoryExecutor& executor); + +// `checkpoints` (kept alive by the caller) as a segment for run_segment(). +Pokemon::Segment make_story_segment(const std::vector& checkpoints, StoryExecutor& executor); + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation -#endif +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp index 177e4b7f3b..9abb8f03a2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp @@ -316,21 +316,22 @@ std::optional healed_win_chance(PartyMember lead, const SimItems& i ConsoleGrindActions::ConsoleGrindActions( ConsoleHandle& console, ProControllerContext& context, Language language, - Supplies& supplies, StoryChecks checks, HealTrip heal_trip + Supplies& supplies, StoryChecks checks, HealTrip heal_trip, PaceTrainerBattle trainer_battle ) : m_console(console) , m_context(context) , m_language(language) , m_supplies(supplies) , m_checks(checks) - , m_encounters(console, context, language) + , m_encounters(console, context, language, trainer_battle) , m_heal_trip(std::move(heal_trip)) + , m_trainer_battle(std::move(trainer_battle)) {} bool ConsoleGrindActions::find_encounter(Direction pace){ // As ConsoleCaptureActions::find_encounter(), keeping track of the // tile: a battle starts on the tile just stepped onto. - ConsolePatchSteps steps(m_console, m_context, pace, m_spot); + ConsolePatchSteps steps(m_console, m_context, pace, m_spot, m_trainer_battle); return pace_until_battle(m_console.logger(), steps, PACE_MAX_STEPS); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h index caa32cc074..09cd66cc08 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h @@ -182,7 +182,7 @@ class ConsoleGrindActions : public GrindActions{ ConsoleGrindActions( ConsoleHandle& console, ProControllerContext& context, Language language, - Supplies& supplies, StoryChecks checks, HealTrip heal_trip + Supplies& supplies, StoryChecks checks, HealTrip heal_trip, PaceTrainerBattle trainer_battle ); virtual bool find_encounter(Pokemon::Direction pace) override; @@ -202,6 +202,7 @@ class ConsoleGrindActions : public GrindActions{ StoryChecks m_checks; ConsoleCaptureActions m_encounters; HealTrip m_heal_trip; + PaceTrainerBattle m_trainer_battle; GrindSpot m_spot; }; 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 332194e9e8..79da386b61 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp @@ -1,41 +1,45 @@ -/* AutoStory Items Tests - * - * From: https://github.com/PokemonAutomation/ - * - * The Mart cursor's moves, the bag's rows as the story counts them, and - * where the story's wild battle seen late places a stopped leg. - * - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include "Common/Cpp/Color.h" -#include "Common/Cpp/Exceptions.h" -#include "Common/Cpp/Logging/AbstractLogger.h" -#include "Common/Cpp/TestRunners/UnitTest.h" -#include "CommonFramework/ImageTypes/ImageRGB32.h" -#include "CommonFramework/Language.h" -#include "Tests/ImageUnitTest.h" -#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" -#include "PokemonFRLG_AutoStoryActions.h" -#include "PokemonFRLG_AutoStoryBattle.h" -#include "PokemonFRLG_AutoStoryBattleDecisions.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -namespace{ - +/* AutoStory Items Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The Mart cursor's moves, the bag's rows as the story counts them, and + * where the story's wild battle seen late places a stopped leg. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Color.h" +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/Language.h" +#include "Tests/ImageUnitTest.h" +#include "Tests/TestUtils.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_SaveCardReader.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryBattle.h" +#include "PokemonFRLG_AutoStoryBattleDecisions.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + struct AutoStoryItemsInput{ std::string name; UnitTestResult (*body)(Logger&); @@ -50,411 +54,437 @@ struct AutoStoryItemsTestCase{ AutoStoryItemsExpectation expectation; }; -class Test_AutoStoryItems : public UnitTest{ -public: +class Test_AutoStoryItems : public UnitTest{ +public: explicit Test_AutoStoryItems(AutoStoryItemsTestCase scenario) : UnitTest("PokemonFRLG::AutoStoryItems - " + scenario.input.name) , m_scenario(std::move(scenario)) - {} - virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ - try{ + {} + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + try{ return m_scenario.input.body(logger); - }catch (Exception& e){ - return std::string(e.name()) + ": " + e.message(); - } - } - -private: + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: AutoStoryItemsTestCase m_scenario; -}; - - -// The orders the story gives the Viridian Mart: rows 1, 2, 3 in turn. The -// cursor stays on the row bought, so each is one row down from the last. -UnitTestResult test_mart_cursor(Logger&){ - if (mart_cursor_moves(0, 1) != 1 || mart_cursor_moves(1, 2) != 1 || mart_cursor_moves(2, 3) != 1){ - return "Expected one press down between the story's orders."; - } - if (mart_cursor_moves(3, 1) != -2 || mart_cursor_moves(2, 2) != 0){ - return "Expected presses up to a row above, none to the same row."; - } - int cursor = 0; - std::vector bought; - for (int row : {1, 2, 3}){ - cursor += mart_cursor_moves(cursor, row); - bought.push_back(cursor); - } - if (bought != std::vector{1, 2, 3}){ - return "The cursor didn't land on rows 1, 2, 3."; - } - return true; -} - -// Rows skip items with none left; the order read from the bag replaces -// the order bought. -UnitTestResult test_pocket_rows(Logger&){ - Supplies supplies; - supplies.potions = 5; - supplies.antidotes = 3; - supplies.parlyz_heals = 1; - if (item_row(supplies, "potion") != 0 || item_row(supplies, "antidote") != 1 || item_row(supplies, "parlyz-heal") != 2){ - return "Wrong rows for the order bought."; - } - supplies.potions = 0; - if (item_row(supplies, "potion") != -1 || item_row(supplies, "antidote") != 0){ - return "An item with none left still takes a row."; - } - set_from_bag(supplies, {{"potion", 4}, {"parlyz-heal", 3}}); - if (supplies.potions != 4 || supplies.antidotes != 0 || supplies.parlyz_heals != 3){ - return "Counts not taken from the bag."; - } - if (item_row(supplies, "parlyz-heal") != 1 || item_row(supplies, "antidote") != -1){ - return "Rows not taken from the bag."; - } - return true; -} - -// #211: a row loaded from a save counted no Potions, so its wild battles -// were all run from. The counts come from the bag as read, both for the -// run and for a reload; with nothing read, the row's are kept. -UnitTestResult test_loaded_bag(Logger&){ - Supplies row; - Supplies saved = row; - const std::vector unread = set_from_loaded_bag(row, saved, {{"potion", 4}, {"antidote", 2}}); - if (row.potions != 4 || saved.potions != 4 || row.antidotes != 2 || saved.antidotes != 2 || !unread.empty()){ - return std::format( - "Expected 4 Potions and 2 Antidotes from the bag, now and for a reload; got {} and {}, {} and {}.", - row.potions, saved.potions, row.antidotes, saved.antidotes - ); - } - if (item_row(saved, "antidote") != 1){ - return "Expected the reload's pocket order from the bag."; - } - - Supplies known; - known.potions = 2; - Supplies known_saved = known; - set_from_loaded_bag(known, known_saved, {}); - if (known.potions != 2 || known_saved.potions != 2){ - return "Expected the row's 2 Potions kept with nothing read."; - } - return true; -} - -// A count the bag reader couldn't read (-1) is never taken for none: the -// row still holds some, so it keeps the count bought, or 1 with none -// known, and the battle's checks (a count above 0, a row) still use it. -UnitTestResult test_unread_counts(Logger&){ - Supplies bought; - bought.potions = 5; - bought.antidotes = 3; - bought.parlyz_heals = 1; - const std::vector rows{{"potion", -1}, {"antidote", -1}, {"parlyz-heal", 1}}; - const std::vector unread = set_from_bag(bought, rows); - if (unread != std::vector{"potion", "antidote"}){ - return "Expected the Potion and Antidote counts reported unread."; - } - if (bought.potions != 5 || bought.antidotes != 3 || bought.parlyz_heals != 1){ - return std::format( - "Expected 5 Potions, 3 Antidotes, 1 Parlyz Heal kept from the order, got {}, {}, {}.", - bought.potions, bought.antidotes, bought.parlyz_heals - ); - } - - Supplies unknown; - unknown.potions = 0; - unknown.antidotes = 0; - unknown.parlyz_heals = 0; - set_from_bag(unknown, rows); - if (item_count(unknown, "potion") <= 0 || item_count(unknown, "antidote") <= 0){ - return std::format( - "Expected unread counts with none known to count as some, got {} Potions, {} Antidotes.", - unknown.potions, unknown.antidotes - ); - } - if (item_row(unknown, "potion") != 0 || item_row(unknown, "antidote") != 1 || item_row(unknown, "parlyz-heal") != 2){ - return std::format( - "Expected rows 0, 1, 2 with unread counts, got {}, {}, {}.", - item_row(unknown, "potion"), item_row(unknown, "antidote"), item_row(unknown, "parlyz-heal") - ); - } - return true; -} - -// Console B, 2026-11-03: a save from Mt. Moon has a MOON STONE above the -// PARLYZ HEAL and 15 POTIONs. An item the story doesn't count still takes -// its row, so the battle knows all 15 Potions and finds them in row 2. -UnitTestResult test_loaded_bag_other_items(Logger&){ - Supplies row; - row.potions = 0; - row.parlyz_heals = 0; - Supplies saved = row; - set_from_loaded_bag(row, saved, {{"", 1}, {"parlyz-heal", 1}, {"potion", 15}}); - if (row.potions != 15 || saved.potions != 15 || row.parlyz_heals != 1){ - return std::format( - "Expected 15 Potions and 1 Parlyz Heal from the bag, now and for a reload; got {}, {} and {}.", - row.potions, saved.potions, row.parlyz_heals - ); - } - if (item_row(row, "parlyz-heal") != 1 || item_row(row, "potion") != 2 || item_row(saved, "potion") != 2){ - return std::format( - "Expected the Parlyz Heal in row 1 and the Potions in row 2 below the Moon Stone, got {} and {}.", - item_row(row, "parlyz-heal"), item_row(row, "potion") - ); - } - return true; -} - -// Awakenings, bought at Cerulean after a MOON STONE took its row: counted -// from the bag and found in their row. -UnitTestResult test_loaded_bag_awakenings(Logger&){ - Supplies row; - Supplies saved = row; - set_from_loaded_bag(row, saved, {{"", 1}, {"parlyz-heal", 1}, {"potion", 15}, {"awakening", 3}}); - if (row.awakenings != 3 || saved.awakenings != 3 || item_count(row, "awakening") != 3 || item_row(row, "awakening") != 3){ - return std::format( - "Expected 3 Awakenings in row 3, now and for a reload; got {} and {}, row {}.", - row.awakenings, saved.awakenings, item_row(row, "awakening") - ); - } - return true; -} - -// The status badge's mean color on console B's captures: none and PSN -// (2026-11-01), and SLP after the Cerulean rival's Sleep Powder -// (2026-11-03, m56 run 2, turns 6 and 7 at 34/65 and 13/65 HP), which -// read as "a status". -UnitTestResult test_status_badge(Logger&){ - const std::vector> cases = { - {combine_rgb(255, 255, 217), StatusCondition::NONE}, - {combine_rgb(228, 168, 212), StatusCondition::POISON}, - {combine_rgb(202, 203, 168), StatusCondition::SLEEP}, - }; - for (const auto& [color, expected] : cases){ - ImageRGB32 badge(12, 4); - badge.fill(color); - const StatusCondition read = read_status_badge(badge); - if (read != expected){ - return std::format("Badge 0x{:08x}: expected {}, got {}.", color, status_name(expected), status_name(read)); - } - } - return true; -} - -// The pocket's rows as read: an unread name keeps its row, so the rows -// below it are still read; the rows end at CANCEL, or at the last name read. -UnitTestResult test_pocket_rows_unread(Logger&){ - const std::vector rows = pocket_rows({{"", 1}, {"parlyz-heal", 1}, {"potion", 15}, {"cancel", -1}, {"", -1}}); - if (rows.size() != 3 || !rows[0].item.empty() || rows[2].item != "potion" || rows[2].count != 15){ - return std::format("Expected 3 rows, the Potions' last with 15, got {}.", rows.size()); - } - if (pocket_rows({{"potion", 3}, {"", -1}, {"", -1}}).size() != 1){ - return "Expected the unread rows after the last name dropped."; - } - if (!pocket_rows({{"", 1}, {"", 2}}).empty()){ - return "Expected nothing with no name read."; - } - return true; -} - -// In a language the reader has no names for, nothing is read, so the -// checks fall back to positions instead of failing. -UnitTestResult test_unread_language(Logger& logger){ - const ImageRGB32 screen(1920, 1080); - if (!read_item_list_name(logger, Language::Japanese, screen, 0).empty()){ - return "Read an item name in Japanese."; - } - if (!read_bag_pocket(logger, Language::Japanese, screen).empty()){ - return "Read bag rows in Japanese."; - } - if (!read_save_card_location(logger, Language::Japanese, screen).empty()){ - return "Read a save card's place in Japanese."; - } - return true; -} - -// The open leg through Route 1's grass on the way from Viridian Mart to -// Oak's Lab ("south 20"), and the tile it starts from. -struct GrassLeg{ - MapPosition start; - RouteStep step; -}; -std::optional route1_grass_leg(const std::vector& maps){ - const MapPosition mart{"ViridianCity_Mart", {4, 3}}; - const RouteGoal lab{MapPosition{"PalletTown_ProfessorOaksLab", {6, 11}}, std::nullopt}; - const std::optional> route = plan_overworld_route(maps, mart, lab, default_route_costs(), Direction::WEST); - if (!route){ - return std::nullopt; - } - MapPosition start = mart; - for (const RouteStep& step : *route){ - if (step.action == RouteAction::WALK && step.wild && !step.to_wall && !step.warps && step.tiles >= 16){ - return GrassLeg{start, step}; - } - start = step.end; - } - return std::nullopt; -} - -// How many tiles of `leg` the player walked to `at`, or -1 if `at` is off it. -int tiles_walked(const std::vector& maps, const GrassLeg& leg, const MapPosition& at){ - const std::vector tiles = leg_tiles(maps, leg.start, leg.step); - for (size_t c = 0; c < tiles.size(); c++){ - if (tiles[c] == at){ - return (int)c; - } - } - return -1; -} - -// r16 and r17 (console B, 2026-11-01), Route 1: a wild battle on the first -// grass step of "south 20" whose flashes were missed shows "Wild -// appeared!" 3.7 s after the step's press. The story's interrupt for it -// must place the stop where the battle stopped the player, as the walker's -// does: with 2 s it put them about 6 tiles further on. -UnitTestResult test_late_battle_placed(Logger&){ - const std::vector maps = overworld_maps(); - const std::optional leg = route1_grass_leg(maps); - if (!leg){ - return "Expected an open leg of 16 tiles or more through Route 1's grass from Viridian Mart to Oak's Lab."; - } - SimScript script; - script.encounters = {{.grass_step = 1, .seen_late = true}}; - OverworldSim sim(maps, leg->start, leg->step.direction, script); - Supplies supplies; - const OverworldInterruptSet story = story_interrupts(supplies, false, StoryChecks::FAST, Language::English); - OverworldSimWalker walker(sim, {}, story); - const StepOutcome outcome = walker.calls().walk(leg->step, leg->step.direction); - const int walked = tiles_walked(maps, *leg, sim.state().position); - if (outcome.end != StepEnd::STOPPED || !outcome.walked || walked < outcome.walked->min || outcome.walked->max < walked){ - return std::format( - "Expected \"{}\" from {} stopped and placed on tile {} ({}), where the battle stopped the player; placed on tiles {} to {}.", - to_string(leg->step), to_string(leg->start), walked, to_string(sim.state().position), - outcome.walked ? outcome.walked->min : -1, outcome.walked ? outcome.walked->max : -1 - ); - } - return true; -} - - -// battle_shows(), the battle loop's look at whether the battle is still on -// screen, on console B's captures. -class Test_BattleShows : public ImageUnitTest{ -public: - Test_BattleShows(const std::string& image, bool expected) - : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) - , m_expected(expected) - {} - - virtual UnitTestResult run_on_images( - Logger& logger, CancellableScope& scope, - const std::vector& images - ) const override{ - return battle_shows(images[0]) == m_expected; - } - -private: - bool m_expected; -}; - -// read_party_status() on console B's captures: the bar's color, and its -// colored share within a pixel of the 48-pixel bar of `hp` / `max_hp`. -// Console B, 2026-11-03, before Miguel: 17/55 HP read as 15% of the bar, -// then 2% with the box a pixel lower. -class Test_PartyHp : public ImageUnitTest{ -public: - Test_PartyHp(const std::string& image, std::string_view color, int hp, int max_hp) - : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) - , m_color(color) - , m_share(double(hp) / max_hp) - {} - - virtual UnitTestResult run_on_images( - Logger& logger, CancellableScope& scope, - const std::vector& images - ) const override{ - const PartyStatus status = read_party_status(images[0]); - const std::string_view color = !status.hp_seen ? "none" : status.hp_red ? "red" : status.hp_yellow ? "yellow" : "green"; - if (color != m_color){ - return std::format("Expected an HP bar read as {}, got {}.", m_color, color); - } - if (std::abs(status.hp_fill - m_share) > 1.0 / 48){ - return std::format("Expected {:.0f}% of the bar, got {:.0f}%.", 100 * m_share, 100 * status.hp_fill); - } - return true; - } - -private: - std::string_view m_color; - double m_share; -}; - - -const AutoStoryItemsTestCase AUTO_STORY_ITEMS_CASES[] = { - { - .input = {.name = "Mart cursor stays on the row bought", .body = test_mart_cursor}, - .expectation = {.description = "verifies mart cursor remains on purchased item index"}, - }, - { - .input = {.name = "Items pocket rows", .body = test_pocket_rows}, - .expectation = {.description = "parses item pocket rows from visual representation"}, - }, - { - .input = {.name = "a loaded save's counts come from the bag", .body = test_loaded_bag}, - .expectation = {.description = "initializes bag counts when loading saved state"}, - }, - { - .input = {.name = "an item the story doesn't count keeps its row", .body = test_loaded_bag_other_items}, - .expectation = {.description = "preserves row indexing for uncounted items"}, - }, - { - .input = {.name = "an unread item name keeps its row", .body = test_pocket_rows_unread}, - .expectation = {.description = "preserves placeholder row when item name is unread"}, - }, - { - .input = {.name = "unread bag counts count as some", .body = test_unread_counts}, - .expectation = {.description = "treats unread quantities as holding at least one"}, - }, - { - .input = {.name = "no item names read in a language without them", .body = test_unread_language}, - .expectation = {.description = "handles unsupported languages gracefully"}, - }, - { - .input = {.name = "a wild battle seen late is placed where it stopped the leg", .body = test_late_battle_placed}, - .expectation = {.description = "places late wild battle at interruption point"}, - }, - { - .input = {.name = "Awakenings counted from the bag", .body = test_loaded_bag_awakenings}, - .expectation = {.description = "verifies awakenings are read and tracked from bag contents"}, - }, - { - .input = {.name = "the status badge: none, PSN, SLP", .body = test_status_badge}, - .expectation = {.description = "detects status conditions accurately from status bar image"}, - }, -}; - -} // namespace - - -void add_tests_AutoStoryItems(UnitTestDatabase& database){ +}; + + +// The orders the story gives the Viridian Mart: rows 1, 2, 3 in turn. The +// cursor stays on the row bought, so each is one row down from the last. +UnitTestResult test_mart_cursor(Logger&){ + if (mart_cursor_moves(0, 1) != 1 || mart_cursor_moves(1, 2) != 1 || mart_cursor_moves(2, 3) != 1){ + return "Expected one press down between the story's orders."; + } + if (mart_cursor_moves(3, 1) != -2 || mart_cursor_moves(2, 2) != 0){ + return "Expected presses up to a row above, none to the same row."; + } + int cursor = 0; + std::vector bought; + for (int row : {1, 2, 3}){ + cursor += mart_cursor_moves(cursor, row); + bought.push_back(cursor); + } + if (bought != std::vector{1, 2, 3}){ + return "The cursor didn't land on rows 1, 2, 3."; + } + return true; +} + +// Rows skip items with none left; the order read from the bag replaces +// the order bought. +UnitTestResult test_pocket_rows(Logger&){ + Supplies supplies; + supplies.potions = 5; + supplies.antidotes = 3; + supplies.parlyz_heals = 1; + if (item_row(supplies, "potion") != 0 || item_row(supplies, "antidote") != 1 || item_row(supplies, "parlyz-heal") != 2){ + return "Wrong rows for the order bought."; + } + supplies.potions = 0; + if (item_row(supplies, "potion") != -1 || item_row(supplies, "antidote") != 0){ + return "An item with none left still takes a row."; + } + set_from_bag(supplies, {{"potion", 4}, {"parlyz-heal", 3}}); + if (supplies.potions != 4 || supplies.antidotes != 0 || supplies.parlyz_heals != 3){ + return "Counts not taken from the bag."; + } + if (item_row(supplies, "parlyz-heal") != 1 || item_row(supplies, "antidote") != -1){ + return "Rows not taken from the bag."; + } + return true; +} + +// #211: a row loaded from a save counted no Potions, so its wild battles +// were all run from. The counts come from the bag as read, both for the +// run and for a reload; with nothing read, the row's are kept. +UnitTestResult test_loaded_bag(Logger&){ + Supplies row; + Supplies saved = row; + const std::vector unread = set_from_loaded_bag(row, saved, {{"potion", 4}, {"antidote", 2}}); + if (row.potions != 4 || saved.potions != 4 || row.antidotes != 2 || saved.antidotes != 2 || !unread.empty()){ + return std::format( + "Expected 4 Potions and 2 Antidotes from the bag, now and for a reload; got {} and {}, {} and {}.", + row.potions, saved.potions, row.antidotes, saved.antidotes + ); + } + if (item_row(saved, "antidote") != 1){ + return "Expected the reload's pocket order from the bag."; + } + + Supplies known; + known.potions = 2; + Supplies known_saved = known; + set_from_loaded_bag(known, known_saved, {}); + if (known.potions != 2 || known_saved.potions != 2){ + return "Expected the row's 2 Potions kept with nothing read."; + } + return true; +} + +// A count the bag reader couldn't read (-1) is never taken for none: the +// row still holds some, so it keeps the count bought, or 1 with none +// known, and the battle's checks (a count above 0, a row) still use it. +UnitTestResult test_unread_counts(Logger&){ + Supplies bought; + bought.potions = 5; + bought.antidotes = 3; + bought.parlyz_heals = 1; + const std::vector rows{{"potion", -1}, {"antidote", -1}, {"parlyz-heal", 1}}; + const std::vector unread = set_from_bag(bought, rows); + if (unread != std::vector{"potion", "antidote"}){ + return "Expected the Potion and Antidote counts reported unread."; + } + if (bought.potions != 5 || bought.antidotes != 3 || bought.parlyz_heals != 1){ + return std::format( + "Expected 5 Potions, 3 Antidotes, 1 Parlyz Heal kept from the order, got {}, {}, {}.", + bought.potions, bought.antidotes, bought.parlyz_heals + ); + } + + Supplies unknown; + unknown.potions = 0; + unknown.antidotes = 0; + unknown.parlyz_heals = 0; + set_from_bag(unknown, rows); + if (item_count(unknown, "potion") <= 0 || item_count(unknown, "antidote") <= 0){ + return std::format( + "Expected unread counts with none known to count as some, got {} Potions, {} Antidotes.", + unknown.potions, unknown.antidotes + ); + } + if (item_row(unknown, "potion") != 0 || item_row(unknown, "antidote") != 1 || item_row(unknown, "parlyz-heal") != 2){ + return std::format( + "Expected rows 0, 1, 2 with unread counts, got {}, {}, {}.", + item_row(unknown, "potion"), item_row(unknown, "antidote"), item_row(unknown, "parlyz-heal") + ); + } + return true; +} + +// Console B, 2026-11-03: a save from Mt. Moon has a MOON STONE above the +// PARLYZ HEAL and 15 POTIONs. An item the story doesn't count still takes +// its row, so the battle knows all 15 Potions and finds them in row 2. +UnitTestResult test_loaded_bag_other_items(Logger&){ + Supplies row; + row.potions = 0; + row.parlyz_heals = 0; + Supplies saved = row; + set_from_loaded_bag(row, saved, {{"", 1}, {"parlyz-heal", 1}, {"potion", 15}}); + if (row.potions != 15 || saved.potions != 15 || row.parlyz_heals != 1){ + return std::format( + "Expected 15 Potions and 1 Parlyz Heal from the bag, now and for a reload; got {}, {} and {}.", + row.potions, saved.potions, row.parlyz_heals + ); + } + if (item_row(row, "parlyz-heal") != 1 || item_row(row, "potion") != 2 || item_row(saved, "potion") != 2){ + return std::format( + "Expected the Parlyz Heal in row 1 and the Potions in row 2 below the Moon Stone, got {} and {}.", + item_row(row, "parlyz-heal"), item_row(row, "potion") + ); + } + return true; +} + +// Awakenings, bought at Cerulean after a MOON STONE took its row: counted +// from the bag and found in their row. +UnitTestResult test_loaded_bag_awakenings(Logger&){ + Supplies row; + Supplies saved = row; + set_from_loaded_bag(row, saved, {{"", 1}, {"parlyz-heal", 1}, {"potion", 15}, {"awakening", 3}}); + if (row.awakenings != 3 || saved.awakenings != 3 || item_count(row, "awakening") != 3 || item_row(row, "awakening") != 3){ + return std::format( + "Expected 3 Awakenings in row 3, now and for a reload; got {} and {}, row {}.", + row.awakenings, saved.awakenings, item_row(row, "awakening") + ); + } + return true; +} + +// The status badge's mean color on console B's captures: none and PSN +// (2026-11-01), and SLP after the Cerulean rival's Sleep Powder +// (2026-11-03, m56 run 2, turns 6 and 7 at 34/65 and 13/65 HP), which +// read as "a status". +UnitTestResult test_status_badge(Logger&){ + const std::vector> cases = { + {combine_rgb(255, 255, 217), StatusCondition::NONE}, + {combine_rgb(228, 168, 212), StatusCondition::POISON}, + {combine_rgb(202, 203, 168), StatusCondition::SLEEP}, + }; + for (const auto& [color, expected] : cases){ + ImageRGB32 badge(12, 4); + badge.fill(color); + const StatusCondition read = read_status_badge(badge); + if (read != expected){ + return std::format("Badge 0x{:08x}: expected {}, got {}.", color, status_name(expected), status_name(read)); + } + } + return true; +} + +// The pocket's rows as read: an unread name keeps its row, so the rows +// below it are still read; the rows end at CANCEL, or at the last name read. +UnitTestResult test_pocket_rows_unread(Logger&){ + const std::vector rows = pocket_rows({{"", 1}, {"parlyz-heal", 1}, {"potion", 15}, {"cancel", -1}, {"", -1}}); + if (rows.size() != 3 || !rows[0].item.empty() || rows[2].item != "potion" || rows[2].count != 15){ + return std::format("Expected 3 rows, the Potions' last with 15, got {}.", rows.size()); + } + if (pocket_rows({{"potion", 3}, {"", -1}, {"", -1}}).size() != 1){ + return "Expected the unread rows after the last name dropped."; + } + if (!pocket_rows({{"", 1}, {"", 2}}).empty()){ + return "Expected nothing with no name read."; + } + return true; +} + +// In a language the reader has no names for, nothing is read, so the +// checks fall back to positions instead of failing. +UnitTestResult test_unread_language(Logger& logger){ + const ImageRGB32 screen(1920, 1080); + if (!read_item_list_name(logger, Language::Japanese, screen, 0).empty()){ + return "Read an item name in Japanese."; + } + if (!read_bag_pocket(logger, Language::Japanese, screen).empty()){ + return "Read bag rows in Japanese."; + } + if (!read_save_card_location(logger, Language::Japanese, screen).empty()){ + return "Read a save card's place in Japanese."; + } + return true; +} + +// The open leg through Route 1's grass on the way from Viridian Mart to +// Oak's Lab ("south 20"), and the tile it starts from. +struct GrassLeg{ + MapPosition start; + RouteStep step; +}; +std::optional route1_grass_leg(const std::vector& maps){ + const MapPosition mart{"ViridianCity_Mart", {4, 3}}; + const RouteGoal lab{MapPosition{"PalletTown_ProfessorOaksLab", {6, 11}}, std::nullopt}; + const std::optional> route = plan_overworld_route(maps, mart, lab, default_route_costs(), Direction::WEST); + if (!route){ + return std::nullopt; + } + MapPosition start = mart; + for (const RouteStep& step : *route){ + if (step.action == RouteAction::WALK && step.wild && !step.to_wall && !step.warps && step.tiles >= 16){ + return GrassLeg{start, step}; + } + start = step.end; + } + return std::nullopt; +} + +// How many tiles of `leg` the player walked to `at`, or -1 if `at` is off it. +int tiles_walked(const std::vector& maps, const GrassLeg& leg, const MapPosition& at){ + const std::vector tiles = leg_tiles(maps, leg.start, leg.step); + for (size_t c = 0; c < tiles.size(); c++){ + if (tiles[c] == at){ + return (int)c; + } + } + return -1; +} + +// r16 and r17 (console B, 2026-11-01), Route 1: a wild battle on the first +// grass step of "south 20" whose flashes were missed shows "Wild +// appeared!" 3.7 s after the step's press. The story's interrupt for it +// must place the stop where the battle stopped the player, as the walker's +// does: with 2 s it put them about 6 tiles further on. +UnitTestResult test_late_battle_placed(Logger&){ + const std::vector maps = overworld_maps(); + const std::optional leg = route1_grass_leg(maps); + if (!leg){ + return "Expected an open leg of 16 tiles or more through Route 1's grass from Viridian Mart to Oak's Lab."; + } + SimScript script; + script.encounters = {{.grass_step = 1, .seen_late = true}}; + OverworldSim sim(maps, leg->start, leg->step.direction, script); + Supplies supplies; + const OverworldInterruptSet story = story_interrupts(supplies, false, StoryChecks::FAST, Language::English); + OverworldSimWalker walker(sim, {}, story); + const StepOutcome outcome = walker.calls().walk(leg->step, leg->step.direction); + const int walked = tiles_walked(maps, *leg, sim.state().position); + if (outcome.end != StepEnd::STOPPED || !outcome.walked || walked < outcome.walked->min || outcome.walked->max < walked){ + return std::format( + "Expected \"{}\" from {} stopped and placed on tile {} ({}), where the battle stopped the player; placed on tiles {} to {}.", + to_string(leg->step), to_string(leg->start), walked, to_string(sim.state().position), + outcome.walked ? outcome.walked->min : -1, outcome.walked ? outcome.walked->max : -1 + ); + } + return true; +} + + +// battle_shows(), the battle loop's look at whether the battle is still on +// screen, on console B's captures named _.png. +// An image whose name doesn't say is registered with its answer. +class Test_BattleShows : public ImageUnitTest{ +public: + Test_BattleShows(const std::string& image, std::optional expected = std::nullopt) + : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) + , m_expected(expected) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + bool expected = false; + if (m_expected){ + expected = *m_expected; + }else{ + 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_shows(images[0]) == expected; + } + +private: + std::optional m_expected; +}; + +// 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. +// Console B, 2026-11-03, before Miguel: 17/55 HP read as 15% of the bar, +// then 2% with the box a pixel lower. +class Test_PartyHp : public ImageUnitTest{ +public: + Test_PartyHp(const std::string& image) + : ImageUnitTest("PokemonFRLG::AutoStoryBattle - " + image, {image}) + {} + + virtual UnitTestResult run_on_images( + Logger& logger, CancellableScope& scope, + const std::vector& images + ) const override{ + std::string expected_color; + int hp = 0; + int max_hp = 0; + for (const std::string& word : parse_words(Filesystem::Path(image_paths()[0]).stem().string())){ + const size_t of = word.find("of"); + if (word.starts_with("Hp") && word.size() > 2){ + expected_color = word.substr(2); + expected_color[0] = char(std::tolower((unsigned char)expected_color[0])); + }else if (of != std::string::npos){ + parse_int(word.substr(0, of), hp); + parse_int(word.substr(of + 2), max_hp); + } + } + if (expected_color.empty() || max_hp <= 0){ + return "Error: filename must be _Hp_of[_]."; + } + const double share = double(hp) / max_hp; + const PartyStatus status = read_party_status(images[0]); + const std::string_view color = !status.hp_seen ? "none" : status.hp_red ? "red" : status.hp_yellow ? "yellow" : "green"; + if (color != expected_color){ + return std::format("Expected an HP bar read as {}, got {}.", expected_color, color); + } + if (std::abs(status.hp_fill - share) > 1.0 / 48){ + return std::format("Expected {:.0f}% of the bar, got {:.0f}%.", 100 * share, 100 * status.hp_fill); + } + return true; + } +}; + + +const AutoStoryItemsTestCase AUTO_STORY_ITEMS_CASES[] = { + { + .input = {.name = "Mart cursor stays on the row bought", .body = test_mart_cursor}, + .expectation = {.description = "verifies mart cursor remains on purchased item index"}, + }, + { + .input = {.name = "Items pocket rows", .body = test_pocket_rows}, + .expectation = {.description = "parses item pocket rows from visual representation"}, + }, + { + .input = {.name = "a loaded save's counts come from the bag", .body = test_loaded_bag}, + .expectation = {.description = "initializes bag counts when loading saved state"}, + }, + { + .input = {.name = "an item the story doesn't count keeps its row", .body = test_loaded_bag_other_items}, + .expectation = {.description = "preserves row indexing for uncounted items"}, + }, + { + .input = {.name = "an unread item name keeps its row", .body = test_pocket_rows_unread}, + .expectation = {.description = "preserves placeholder row when item name is unread"}, + }, + { + .input = {.name = "unread bag counts count as some", .body = test_unread_counts}, + .expectation = {.description = "treats unread quantities as holding at least one"}, + }, + { + .input = {.name = "no item names read in a language without them", .body = test_unread_language}, + .expectation = {.description = "handles unsupported languages gracefully"}, + }, + { + .input = {.name = "a wild battle seen late is placed where it stopped the leg", .body = test_late_battle_placed}, + .expectation = {.description = "places late wild battle at interruption point"}, + }, + { + .input = {.name = "Awakenings counted from the bag", .body = test_loaded_bag_awakenings}, + .expectation = {.description = "verifies awakenings are read and tracked from bag contents"}, + }, + { + .input = {.name = "the status badge: none, PSN, SLP", .body = test_status_badge}, + .expectation = {.description = "detects status conditions accurately from status bar image"}, + }, +}; + +} // namespace + + +void add_tests_AutoStoryItems(UnitTestDatabase& database){ for (const auto& scenario : AUTO_STORY_ITEMS_CASES){ database.add(scenario); } - // "Wild CHANSEY appeared!" with its HP box, and Mt. Moon B2F's corridor - // after "Got away safely!", whose black lower half reads as the battle's - // text box (console B, 2026-11-03: 9 minutes of A and B there). - database.add("PokemonFRLG/WildEncounterReader/eng_chansey.jpg", true); - database.add("PokemonFRLG/AutoStoryBattle/MtMoonB2F_AfterRun_False.png", false); - database.add("PokemonFRLG/AutoStoryBattle/MtMoon1F_HpGreen_Full.png", "green", 45, 45); - database.add("PokemonFRLG/AutoStoryBattle/MtMoon1F_HpGreen_27of45.png", "green", 27, 45); - database.add("PokemonFRLG/AutoStoryBattle/MtMoonB2F_HpYellow_17of55.png", "yellow", 17, 55); - database.add("PokemonFRLG/AutoStoryBattle/MtMoonB2F_HpYellow_17of55_BarLow.png", "yellow", 17, 55); - // The battle's choices, registered here to keep add_tests() within its size. - add_tests_AutoStoryParty(database); -} - - - + // "Wild CHANSEY appeared!" with its HP box, and Mt. Moon B2F's corridor + // after "Got away safely!", whose black lower half reads as the battle's + // text box (console B, 2026-11-03: 9 minutes of A and B there). + database.add("PokemonFRLG/WildEncounterReader/eng_chansey.jpg", true); + database.add("PokemonFRLG/AutoStoryBattle/MtMoonB2F_AfterRun_False.png"); + for (const char* image : { + "MtMoon1F_HpGreen_45of45.png", + "MtMoon1F_HpGreen_27of45.png", + "MtMoonB2F_HpYellow_17of55.png", + "MtMoonB2F_HpYellow_17of55_BarLow.png", + }){ + database.add(std::string("PokemonFRLG/AutoStoryBattle/") + image); + } + // The battle's choices, registered here to keep add_tests() within its size. + add_tests_AutoStoryParty(database); +} + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp new file mode 100644 index 0000000000..19cb975a72 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp @@ -0,0 +1,64 @@ +/* AutoStory Pace Sim + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "PokemonFRLG_AutoStoryPaceSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using Pokemon::Direction; + + +SimPatch::SimPatch(OverworldSim& sim, Direction pace, Direction back, std::function back_to_patch) + : m_sim(sim) + , m_pace(pace) + , m_back(back) + , m_back_to_patch(std::move(back_to_patch)) +{} + +bool SimPatch::step(){ + const Direction way = m_displaced ? m_back : m_pace; + if (m_sim.state().facing != way){ + m_sim.tap(way); + } + m_sim.tap(way); + m_displaced = !m_displaced; + return m_sim.watch({SimWatcher::FLASH}, std::chrono::milliseconds(1000)).has_value(); +} + +bool SimPatch::battle_shows(){ + return m_sim.watch({SimWatcher::BATTLE_SCREEN}, std::chrono::milliseconds(5000)).has_value(); +} + +bool SimPatch::overworld_shows(){ + return m_sim.state().screen == SimScreen::OVERWORLD; +} + +bool SimPatch::message_shows(){ + return m_sim.state().screen == SimScreen::MESSAGE; +} + +std::string SimPatch::fight_trainer(){ + if (!m_sim.close_message() || m_sim.handle_battle(SimBattleChoice::FIGHT) != SimBattleEnd::ENDED){ + return "no trainer's battle"; + } + if (m_back_to_patch){ + m_back_to_patch(); + m_displaced = false; + } + return m_sim.state().trainers_beaten.back(); +} + +void SimPatch::dump_screen(std::string_view){} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h new file mode 100644 index 0000000000..0677bcf2cc --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h @@ -0,0 +1,60 @@ +/* AutoStory Pace Sim + * + * From: https://github.com/PokemonAutomation/ + * + * Pacing in a patch (PatchSteps) on the OverworldSim, as the console + * paces: `pace` and back, a tap to turn first when the player faces + * another way (step_once()). A trainer who sees the player is fought, then + * the test's walk back onto the patch runs, as pace_trainer_battle() does + * on the console. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryPaceSim_H +#define PokemonAutomation_PokemonFRLG_AutoStoryPaceSim_H + +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryCapture.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Simulated grass patch stepping adapter for pacing and capture testing. +class SimPatch : public PatchSteps{ +public: + // `back_to_patch`: after a trainer's battle, the walk back onto the + // patch's tile; none: the player stays where the trainer stopped them. + SimPatch(OverworldSim& sim, Pokemon::Direction pace, Pokemon::Direction back, std::function back_to_patch = {}); + + virtual bool step() override; + virtual bool battle_shows() override; + virtual bool overworld_shows() override; + virtual bool message_shows() override; + virtual std::string fight_trainer() override; + virtual void dump_screen(std::string_view label) override; + +private: + OverworldSim& m_sim; + Pokemon::Direction m_pace; + Pokemon::Direction m_back; + std::function m_back_to_patch; + bool m_displaced = false; +}; + + +// Register AutoStory pace simulation unit tests. +void add_tests_AutoStoryPaceSim(UnitTestDatabase& database); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp new file mode 100644 index 0000000000..6a76612aaa --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp @@ -0,0 +1,171 @@ +/* AutoStory Pace Sim Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Pacing on the OverworldSim (SimPatch) where something other than a wild + * battle stops it. + * + */ + +#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 "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryPaceSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using Pokemon::Direction; + +std::string joined(const std::vector& lines){ + std::string text; + for (const std::string& line : lines){ + text += "\n " + line; + } + return text; +} + +class LinesLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string& line, Color) override{ lines.push_back(line); } + std::vector lines; +}; + +// A capture on the sim: battles found by pacing `patch`, each the target, +// caught with the first ball. +class SimCapture : public CaptureActions{ +public: + SimCapture(Logger& logger, OverworldSim& sim, SimPatch& patch) + : m_logger(logger), m_sim(sim), m_patch(patch) + {} + virtual bool find_encounter(Direction) override{ + return pace_until_battle(m_logger, m_patch, PACE_MAX_STEPS); + } + virtual EncounterSeen read_encounter() override{ return EncounterSeen{.species = "oddish"}; } + virtual bool flee() override{ return m_sim.handle_battle(SimBattleChoice::RUN) == SimBattleEnd::ENDED; } + virtual FoeHp read_foe_hp() override{ return FoeHp::UNKNOWN; } + virtual TurnEnd attack(size_t) override{ return TurnEnd::FAILED; } + virtual ThrowEnd throw_ball() override{ return ThrowEnd::CAUGHT; } + virtual bool finish_catch(CatchDestination) override{ + return m_sim.handle_battle(SimBattleChoice::FIGHT) == SimBattleEnd::ENDED; + } +private: + Logger& m_logger; + OverworldSim& m_sim; + SimPatch& m_patch; +}; + +// Console B, 2026-11-03 (m63, 00.20 run 2): the capture paced on Route +// 24's (5, 19), in Camper Shane's sight; after the first wild battle he +// walked up ("I saw your feat from the grass!"), and pacing ended +// "no-battle-not-overworld" under his message. On the sim, a capture +// pacing east from (4, 19) onto (5, 19) fights him, walks back onto the +// patch from either tile, as pace_trainer_battle() does, and goes on to +// its catch. +UnitTestResult test_capture_paced_in_trainer_sight(){ + const MapPosition patch{"Route24", {4, 19}}; + const CapturePlan plan{.species = "oddish", .max_encounters = 5, .pace = Direction::EAST}; + const StoryStep capture = capture_step("catch_in_sight", "", plan); + SimScript script; + // The first step onto (5, 19) is seen; the battle comes on a later one. + script.encounters.push_back(SimEncounter{.grass_step = 3}); + OverworldSim sim(story_maps(), patch, Direction::SOUTH, script); + std::string walk_failure; + SimPatch pacing(sim, Direction::EAST, Direction::WEST, [&]{ + const Pokemon::Pose paced = paced_pose(Pokemon::tile_pose(patch.map, {patch.point}), capture); + const std::optional> route = plan_story_route( + story_maps(), paced, std::nullopt, RouteGoal{patch, std::nullopt} + ); + const RoutePlanning planning{.maps = &story_maps(), .start = std::nullopt, .label = "back_after_trainer"}; + walk_failure = route ? OverworldSimWalker(sim).run(*route, planning) : "no route back onto the patch."; + }); + LinesLogger logger; + SimCapture actions(logger, sim, pacing); + const CaptureResult result = run_capture(logger, actions, plan, 2); + if (result.end != CaptureEnd::CAUGHT || !walk_failure.empty()){ + return capture_message(plan, result) + " " + walk_failure + joined(logger.lines) + joined(sim.state().log); + } + if (sim.state().trainers_beaten != std::vector{"camper"} || !sim.state().violations.empty()){ + return std::format("Expected Camper Shane beaten and no violations; got {} beaten.", sim.state().trainers_beaten.size()) + + joined(sim.state().violations); + } + const bool logged = std::any_of(logger.lines.begin(), logger.lines.end(), [](const std::string& line){ + return line.starts_with("Pacing: trainer battle during pacing: camper"); + }); + if (!logged || result.encounters != 1){ + return std::format("Expected the trainer's battle logged and 1 wild encounter, got {}.", result.encounters) + joined(logger.lines); + } + return true; +} + + +struct AutoStoryPaceSimInput{ + std::string name; + std::function body; +}; + +struct AutoStoryPaceSimExpectation{ + const char* description; +}; + +struct AutoStoryPaceSimTestCase{ + AutoStoryPaceSimInput input; + AutoStoryPaceSimExpectation expectation; +}; + +class Test_AutoStoryPaceSim : public UnitTest{ +public: + explicit Test_AutoStoryPaceSim(AutoStoryPaceSimTestCase scenario) + : UnitTest("PokemonFRLG::AutoStory - Pace Sim: " + 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: + AutoStoryPaceSimTestCase m_scenario; +}; + +const AutoStoryPaceSimTestCase AUTO_STORY_PACE_SIM_CASES[] = { + { + .input = { + .name = "a capture paced in a trainer's sight fights them and goes on", + .body = test_capture_paced_in_trainer_sight, + }, + .expectation = { + .description = "handles trainer encounter while pacing and resumes capture sequence", + }, + }, +}; + +} // namespace + + +void add_tests_AutoStoryPaceSim(UnitTestDatabase& database){ + for (const auto& scenario : AUTO_STORY_PACE_SIM_CASES){ + database.add(scenario); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp index c167b7678d..ffa92fc042 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp @@ -93,7 +93,9 @@ constexpr Evolution EVOLUTIONS[] = { // it learns over a full move list, and the move whose slot it takes. Any // other move offered over four is declined. Squirtle's is the speedrun's: // Water Gun where Bubble was (so the attack stays bottom left), Bite over -// Tail Whip. TM moves are not level-up prompts and are not here. +// Tail Whip. TM and tutor moves are not level-up prompts and are not +// here; no rule replaces them, so Water Pulse (TM03, 00.14c) and Mega Kick +// (Route 4's tutor, 00.14) are kept over every later level-up move. struct LearnRule{ std::string_view line; std::string_view move; 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 21a2e829cf..5dbe86d9c6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp @@ -166,6 +166,21 @@ UnitTestResult test_learn_prompts(Logger&){ return true; } +// Mega Kick, from Route 4's tutor over Withdraw: no level-up rule takes +// its slot, so Rapid Spin and Protect are declined. +UnitTestResult test_mega_kick_kept(Logger&){ + const std::vector moves{"bite", "water-pulse", "bubble", "mega-kick"}; + for (const auto& [level, offered] : {std::pair{24, "rapid-spin"}, {30, "protect"}}){ + PartyMember member = with_moves("wartortle", moves, level); + const LearnDecision decision = learn_move(member); + if (decision.move != offered || decision.forget_slot || move_names(member) != moves){ + return std::format("Lv{}: expected {} declined, leaving {}; got {}, leaving {}.", + level + 1, offered, joined(moves), decision.move.value_or("nothing"), joined(move_names(member))); + } + } + return true; +} + // NO to "Delete a move?" is followed by YES to "Stop learning?" (B there // asks "Delete a move?" again, console B 2026-11-01); a move the plan // wants is YES with the slot to forget. @@ -824,6 +839,10 @@ const AutoStoryPartyTestCase AUTO_STORY_PARTY_CASES[] = { .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"}, 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 9eb2937429..976aa9d792 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp @@ -1,35 +1,35 @@ -/* AutoStory Run Plan Tests - * - * From: https://github.com/PokemonAutomation/ - * - * Which rows of the story table a run takes (plan_story_run()) with the - * RNG programs' handoffs on and off, the rows the handoffs split or - * join, Misty in Cerulean Gym, and what 00.15 runs before the rival. - * - */ - -#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 "PokemonFRLG_AutoStoryEvents.h" -#include "PokemonFRLG_AutoStoryGrind.h" -#include "PokemonFRLG_AutoStoryMart.h" -#include "PokemonFRLG_AutoStoryTable.h" -#include "PokemonFRLG_AutoStoryTeach.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -namespace{ - +/* AutoStory Run Plan Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Which rows of the story table a run takes (plan_story_run()) with the + * RNG programs' handoffs on and off, the rows the handoffs split or + * join, Misty in Cerulean Gym, and what 00.15 runs before the rival. + * + */ + +#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 "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryMart.h" +#include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTeach.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + struct AutoStoryInput{ std::string name; std::function body; @@ -43,137 +43,137 @@ struct AutoStoryCase{ AutoStoryExpectation expectation; }; -class Test_AutoStory : public UnitTest{ -public: +class Test_AutoStory : public UnitTest{ +public: explicit Test_AutoStory(AutoStoryCase test_case) : UnitTest("PokemonFRLG::AutoStory - " + 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: AutoStoryCase m_case; -}; - - -size_t row_index(std::string_view id){ - const std::vector& checkpoints = story_checkpoints(); - for (size_t c = 0; c < checkpoints.size(); c++){ - if (checkpoints[c].id == id){ - return c; - } - } - throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No checkpoint " + std::string(id)); -} - -std::string joined_ids(const std::vector& rows){ - std::string text; - for (const StoryCheckpoint& row : rows){ - text += (text.empty() ? "" : " ") + row.id; - } - return text; -} - -const char* handoff_name(StoryHandoff handoff){ - switch (handoff){ - case StoryHandoff::NONE: - return "none"; - case StoryHandoff::SID_HELPER: - return "SID Helper"; - case StoryHandoff::STARTER_RNG: - return "Starter RNG"; - case StoryHandoff::GIFT_RNG: - return "Gift RNG"; - } - return "?"; -} - +}; + + +size_t row_index(std::string_view id){ + const std::vector& checkpoints = story_checkpoints(); + for (size_t c = 0; c < checkpoints.size(); c++){ + if (checkpoints[c].id == id){ + return c; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No checkpoint " + std::string(id)); +} + +std::string joined_ids(const std::vector& rows){ + std::string text; + for (const StoryCheckpoint& row : rows){ + text += (text.empty() ? "" : " ") + row.id; + } + return text; +} + +const char* handoff_name(StoryHandoff handoff){ + switch (handoff){ + case StoryHandoff::NONE: + return "none"; + case StoryHandoff::SID_HELPER: + return "SID Helper"; + case StoryHandoff::STARTER_RNG: + return "Starter RNG"; + case StoryHandoff::GIFT_RNG: + return "Gift RNG"; + } + return "?"; +} + struct RunPlanInput{ - std::string name = ""; - std::string first = ""; - // The table's last row if empty. - std::string last = ""; - StoryHandoffs handoffs = {}; + std::string name = ""; + std::string first = ""; + // The table's last row if empty. + std::string last = ""; + StoryHandoffs handoffs = {}; }; struct RunPlanExpectation{ - std::string expected_ids = ""; - StoryHandoff expected_stop = StoryHandoff::NONE; -}; - + std::string expected_ids = ""; + StoryHandoff expected_stop = StoryHandoff::NONE; +}; + struct RunPlanScenario{ RunPlanInput input; RunPlanExpectation expectation; }; -class Test_RunPlan : public UnitTest{ -public: +class Test_RunPlan : public UnitTest{ +public: explicit Test_RunPlan(RunPlanScenario scenario) : UnitTest("PokemonFRLG::AutoStory - run plan: " + scenario.input.name) - , m_scenario(std::move(scenario)) - {} - - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - try{ + , m_scenario(std::move(scenario)) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ const size_t last = m_scenario.input.last.empty() ? story_checkpoints().size() - 1 : row_index(m_scenario.input.last); const StoryRunPlan plan = plan_story_run(story_checkpoints(), row_index(m_scenario.input.first), last, m_scenario.input.handoffs); - const std::string ids = joined_ids(plan.rows); + const std::string ids = joined_ids(plan.rows); if (ids != m_scenario.expectation.expected_ids || plan.stop != m_scenario.expectation.expected_stop){ return "Expected rows \"" + m_scenario.expectation.expected_ids + "\", stop " + handoff_name(m_scenario.expectation.expected_stop) - + "; got \"" + ids + "\", stop " + handoff_name(plan.stop) + "."; - } - return true; - }catch (Exception& e){ - return std::string(e.name()) + ": " + e.message(); - } - } - -private: - const RunPlanScenario m_scenario; -}; - -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"; - -const RunPlanScenario RUN_PLAN_CASES[] = { - { - .input = {.name = "handoffs off: the story as before", .first = "00.01"}, - .expectation = {.expected_ids = TODAY}, - }, - { - .input = {.name = "handoffs off: a range", .first = "00.02", .last = "00.04"}, - .expectation = {.expected_ids = "00.02 00.03 00.04"}, - }, - { - .input = {.name = "handoffs off: Mt. Moon to Nugget Bridge", .first = "00.13", .last = "00.16"}, - .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", - .first = "00.01", - .handoffs = {.sid_helper = true}, + + "; got \"" + ids + "\", stop " + handoff_name(plan.stop) + "."; + } + return true; + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + 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 RunPlanScenario RUN_PLAN_CASES[] = { + { + .input = {.name = "handoffs off: the story as before", .first = "00.01"}, + .expectation = {.expected_ids = TODAY}, + }, + { + .input = {.name = "handoffs off: a range", .first = "00.02", .last = "00.04"}, + .expectation = {.expected_ids = "00.02 00.03 00.04"}, + }, + { + .input = {.name = "handoffs off: Mt. Moon to Nugget Bridge", .first = "00.13", .last = "00.16"}, + .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", + .first = "00.01", + .handoffs = {.sid_helper = true}, }, .expectation = { - .expected_ids = "", - .expected_stop = StoryHandoff::SID_HELPER, - }, + .expected_ids = "", + .expected_stop = StoryHandoff::SID_HELPER, + }, }, { .input = { - .name = "SID Helper: back in at 00.01b", - .first = "00.01b", - .last = "00.03", - .handoffs = {.sid_helper = true}, + .name = "SID Helper: back in at 00.01b", + .first = "00.01b", + .last = "00.03", + .handoffs = {.sid_helper = true}, }, .expectation = { - .expected_ids = "00.01b 00.02 00.03", - }, + .expected_ids = "00.01b 00.02 00.03", + }, }, { .input = {.name = "00.01b runs when started at, option off", .first = "00.01b", .last = "00.02"}, @@ -189,14 +189,14 @@ const RunPlanScenario RUN_PLAN_CASES[] = { }, { .input = { - .name = "Starter RNG: stops after 00.04's save", - .first = "00.02", - .handoffs = {.starter_rng = true}, + .name = "Starter RNG: stops after 00.04's save", + .first = "00.02", + .handoffs = {.starter_rng = true}, }, .expectation = { - .expected_ids = "00.02 00.03 00.04", - .expected_stop = StoryHandoff::STARTER_RNG, - }, + .expected_ids = "00.02 00.03 00.04", + .expected_stop = StoryHandoff::STARTER_RNG, + }, }, { .input = {.name = "00.04b runs from its own save", .first = "00.04b", .last = "00.05"}, @@ -208,374 +208,374 @@ const RunPlanScenario RUN_PLAN_CASES[] = { }, { .input = { - .name = "both handoffs, back in after SID Helper", - .first = "00.01b", - .handoffs = {.sid_helper = true, .starter_rng = true}, + .name = "both handoffs, back in after SID Helper", + .first = "00.01b", + .handoffs = {.sid_helper = true, .starter_rng = true}, }, .expectation = { - .expected_ids = "00.01b 00.02 00.03 00.04", - .expected_stop = StoryHandoff::STARTER_RNG, - }, + .expected_ids = "00.01b 00.02 00.03 00.04", + .expected_stop = StoryHandoff::STARTER_RNG, + }, }, { .input = { - .name = "Gift RNG: stops after 00.12's save", - .first = "00.11", - .handoffs = {.gift_rng = true}, + .name = "Gift RNG: stops after 00.12's save", + .first = "00.11", + .handoffs = {.gift_rng = true}, }, .expectation = { - .expected_ids = "00.11 00.12", - .expected_stop = StoryHandoff::GIFT_RNG, - }, - }, - { - .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"}, - }, - { - .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"}, - }, - { - .input = { - .name = "Gift RNG: started at 00.12b, from the save beside the salesman", - .first = "00.12b", - .handoffs = {.gift_rng = true}, + .expected_ids = "00.11 00.12", + .expected_stop = StoryHandoff::GIFT_RNG, + }, + }, + { + .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"}, + }, + { + .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"}, + }, + { + .input = { + .name = "Gift RNG: started at 00.12b, from the save beside the salesman", + .first = "00.12b", + .handoffs = {.gift_rng = true}, }, .expectation = { - .expected_ids = "", - .expected_stop = StoryHandoff::GIFT_RNG, - }, + .expected_ids = "", + .expected_stop = StoryHandoff::GIFT_RNG, + }, }, { .input = { - .name = "Gift RNG: started at 00.12b with Starter RNG on too", - .first = "00.12b", - .handoffs = {.sid_helper = true, .starter_rng = true, .gift_rng = true}, + .name = "Gift RNG: started at 00.12b with Starter RNG on too", + .first = "00.12b", + .handoffs = {.sid_helper = true, .starter_rng = true, .gift_rng = true}, }, .expectation = { - .expected_ids = "", - .expected_stop = StoryHandoff::GIFT_RNG, - }, - }, - { - .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"}, - }, - { - .input = { - .name = "Starter RNG first, with both on", - .first = "00.03", - .handoffs = {.starter_rng = true, .gift_rng = true}, + .expected_ids = "", + .expected_stop = StoryHandoff::GIFT_RNG, + }, + }, + { + .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"}, + }, + { + .input = { + .name = "Starter RNG first, with both on", + .first = "00.03", + .handoffs = {.starter_rng = true, .gift_rng = true}, }, .expectation = { - .expected_ids = "00.03 00.04", - .expected_stop = StoryHandoff::STARTER_RNG, - }, + .expected_ids = "00.03 00.04", + .expected_stop = StoryHandoff::STARTER_RNG, + }, }, }; void add_tests_RunPlan(UnitTestDatabase& database){ for (const auto& item : RUN_PLAN_CASES){ database.add(item); - } -} - -std::string step_names(const StoryCheckpoint& checkpoint){ - std::string text; - for (const StoryStep& step : checkpoint.steps){ - text += (text.empty() ? "" : " ") + step.name; - } - return text; -} - -// With Starter RNG's handoff off, 00.04 and 00.04b run as the one 00.04 -// the story had before the split: from the lab's table to the rival's -// battle, saving once with the starter's START menu. -UnitTestResult test_starter_rows_joined(){ - const std::vector& checkpoints = story_checkpoints(); - const StoryRunPlan plan = plan_story_run(checkpoints, row_index("00.04"), row_index("00.04b"), StoryHandoffs{}); - const std::string expected_steps = - "below_squirtle squirtle_ball choose_squirtle no_nickname rival_takes_bulbasaur above_rival_trigger rival_trigger rival_battle"; - if (plan.rows.size() != 1){ - return "Expected one checkpoint, got \"" + joined_ids(plan.rows) + "\"."; - } - const StoryCheckpoint& row = plan.rows[0]; - if (row.id != "00.04" || row.title != "Squirtle, then the rival's battle" || step_names(row) != expected_steps){ - return "Expected 00.04 \"Squirtle, then the rival's battle\": " + expected_steps - + "; got " + row.id + " \"" + row.title + "\": " + step_names(row); - } - const std::optional start = story_pose(*row.start); - if (!start || start->map != "PalletTown_ProfessorOaksLab" || !(start->tile == TilePos{6, 4}) || row.menu != StartMenu::BEFORE_POKEDEX){ - return "Expected the joined 00.04 to start at the table (6, 4) and save with the starter's START menu."; - } - // Split, 00.04 saves under the ball facing it, before the starter. - const StoryCheckpoint& to_ball = checkpoints[row_index("00.04")]; - const std::optional ball = story_pose(*checkpoints[row_index("00.04b")].start); - if (to_ball.handoff != StoryHandoff::STARTER_RNG || to_ball.menu != StartMenu::BEFORE_POKEMON - || !ball || !(ball->tile == TilePos{9, 5}) || ball->facing != Direction::NORTH - ){ - return "Expected 00.04 to end under Squirtle's ball (9, 5) facing north, saving before the starter."; - } - return true; -} - -// The ways back in after SID Helper and Gift RNG load no save and start -// where 00.01 and 00.12 leave the player, so the rows after them follow -// unchanged. -UnitTestResult test_after_sid_helper_row(){ - const StoryCheckpoint& sid = story_checkpoints()[row_index("00.01b")]; - if (!sid.live_start || story_checkpoints()[0].handoff != StoryHandoff::SID_HELPER){ - return "Expected 00.01 to be SID Helper's handoff and 00.01b to start from the live game."; - } - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - if (checkpoint.live_start != (checkpoint.id == "00.01b" || checkpoint.id == "00.12b")){ - return checkpoint.id + ": only 00.01b and 00.12b start from the live game."; - } - } - return true; -} - -// The index of the first step of `steps` from `from` that `matches`, or -// steps.size(). -size_t find_step(const std::vector& steps, size_t from, const std::function& matches){ - for (size_t c = from; c < steps.size(); c++){ - if (matches(steps[c])){ - return c; - } - } - return steps.size(); -} - -// Whether a route plans from `from` to `to` (ending facing as `to` does). -bool plans(const Pokemon::Pose& from, const Pokemon::Pose& to){ - const std::optional start = story_pose(from); - const std::optional goal = story_pose(to); - if (!start || !goal){ - return false; - } - return plan_overworld_route( - story_maps(), MapPosition{start->map, start->tile}, RouteGoal{MapPosition{goal->map, goal->tile}, goal->facing}, - default_route_costs(), start->facing.value_or(Direction::NORTH) - ).has_value(); -} - -// 00.15, before the rival, from the Center: first TM03 Water Pulse over -// Tail Whip if the lead doesn't know it, so the sim plays it in the -// GRIND; Potions from Cerulean's Mart (a loaded save's bag may hold too -// few for the wild battles a GRIND fights), and at its money floor 2 or -// more Awakenings for the rival's Sleep Powder; then a GRIND on Route 4's -// grass east of Mt. Moon until the sim beats the rival 00.15b fights 50% -// or more, healing at Cerulean's Center; its trip there plans both ways. -UnitTestResult test_cerulean_mart_and_grind(){ - const StoryCheckpoint& row = story_checkpoints()[row_index("00.15")]; - const std::vector& steps = row.steps; - const std::optional start = row.start ? story_pose(*row.start) : std::nullopt; - if (!start || start->map != "CeruleanCity_PokemonCenter_1F"){ - return "00.15: expected it to start in Cerulean's Center."; - } - const std::optional teach = steps.empty() ? std::nullopt : parse_teach_variant(steps[0].variant); - if (!teach || teach->item != "tm03" || teach->move != "water-pulse" || teach->member != 0 || teach->forget_move != "tail-whip"){ - return "00.15: expected it to start with TM03 Water Pulse over the lead's Tail Whip."; - } - const size_t mart = find_step(steps, 0, [](const StoryStep& step){ - const std::optional plan = parse_mart_variant(step.variant); - return plan && plan->map == "CeruleanCity_Mart" && !plan->lines.empty() && plan->lines[0].item == "potion"; - }); - const std::optional rival = story_trainer("CeruleanCity", "rival_cerulean"); - const size_t grind = find_step(steps, mart, [&](const StoryStep& step){ - const std::optional plan = parse_grind_variant(step.variant); - return plan && plan->member == 0 && rival && plan->trainer == *rival && plan->min_chance == MIN_WIN_CHANCE - && plan->level > 25; - }); - if (mart == steps.size() || grind == steps.size()){ - return "00.15: expected a Cerulean Mart step buying Potions, then a GRIND of the lead until the rival is 50%, capped above Lv25."; - } - const MartPlan plan = *parse_mart_variant(steps[mart].variant); - const std::optional> buys = plan.money_floor - ? plan_mart_buys(mart_stock(plan.map), plan, *plan.money_floor) - : std::nullopt; - const bool awakenings = buys && std::ranges::any_of(*buys, [](const MartBuy& buy){ - return buy.item == "awakening" && buy.quantity >= 2; - }); - if (!awakenings){ - return "00.15: expected the Mart step to buy 2 or more Awakenings at its money floor."; - } - const std::optional clerk = mart > 0 && steps[mart - 1].target ? story_pose(*steps[mart - 1].target) : std::nullopt; - if (!clerk || clerk->map != "CeruleanCity_Mart"){ - return "00.15: expected the Mart step right after a route to the clerk."; - } - if (grind == 0 || !steps[grind - 1].target || !steps[grind].target){ - return "00.15: expected the GRIND right after a route to its patch, with the nurse as its target."; - } - const Pokemon::Pose& patch = *steps[grind - 1].target; - const Pokemon::Pose& nurse = *steps[grind].target; - const std::optional nurse_pose = story_pose(nurse); - if (!nurse_pose || nurse_pose->map != "CeruleanCity_PokemonCenter_1F" || !plans(patch, nurse) || !plans(nurse, patch)){ - return "00.15: expected the GRIND's trips to Cerulean's nurse and back to plan."; - } - return true; -} - -// 00.15b, the rival's battle as its own checkpoint, as every battle lost -// twice or more gets: it starts from a save, 00.15's on (23, 7) below his -// trigger with the nurse just before, so a verify run or a retry reloads -// right before him; and his is its only battle. First Water Pulse over -// Tail Whip if the lead doesn't know it: console B's save (2026-11-03) is -// past the TEACH that failed there, which does nothing once it is known. -UnitTestResult test_cerulean_rival_row(){ - const std::vector& checkpoints = story_checkpoints(); - const size_t index = row_index("00.15b"); - const StoryCheckpoint& rival = checkpoints[index]; - const std::optional start = rival.start ? story_pose(*rival.start) : std::nullopt; - if (!start || rival.live_start || start->map != "CeruleanCity" || !(start->tile == TilePos{23, 7}) - || save_card_place(start->map).empty() - ){ - return "00.15b: expected it to start from a save on (23, 7) in Cerulean City."; - } - std::string battles; - for (const StoryStep& step : rival.steps){ - battles += step.kind == StoryStepKind::BATTLE ? " " + step.name : ""; - } - if (battles != " rival_cerulean" || rival.steps.size() != 3 || rival.steps[1].name != "rival_trigger"){ - return "00.15b: expected a TEACH, the step onto his trigger and his battle; its battles:" + battles; - } - const std::optional teach = parse_teach_variant(rival.steps[0].variant); - if (rival.steps[0].kind != StoryStepKind::GAME || !teach || teach->item != "tm03" || teach->move != "water-pulse" - || teach->member != 0 || teach->forget_move != "tail-whip" - ){ - return "00.15b: expected it to start with TM03 Water Pulse over the lead's Tail Whip."; - } - const StoryCheckpoint& before = checkpoints[index - 1]; - const size_t n = before.steps.size(); - const std::optional end = n > 0 && before.steps[n - 1].target ? story_pose(*before.steps[n - 1].target) : std::nullopt; - if (before.id != "00.15" || n < 2 || before.steps[n - 2].kind != StoryStepKind::HEAL - || !end || end->map != start->map || !(end->tile == start->tile) - ){ - return "00.15: expected it to end with the nurse, then the walk to (23, 7) where 00.15b starts."; - } - for (const StoryStep& step : before.steps){ - if (step.kind == StoryStepKind::BATTLE){ - return "00.15: expected no trainer battle before its save; found " + step.name + "."; - } - } - return true; -} - -// The battles in `row`, each name after a space. -std::string battle_names(const StoryCheckpoint& row){ - std::string battles; - for (const StoryStep& step : row.steps){ - battles += step.kind == StoryStepKind::BATTLE ? " " + step.name : ""; - } - return battles; -} - -// 00.14b, before Misty: Potions at Cerulean's Mart, then into the Gym to -// a save on (9, 7), below her platform; no battle. 00.14c, Misty's battle -// as its own checkpoint: it starts from that save, talks to her from -// beside her, (9, 6), and hers is its only battle; then TM03 Water Pulse -// in place of Tail Whip. -UnitTestResult test_misty_rows(){ - const std::vector& checkpoints = story_checkpoints(); - const StoryCheckpoint& gym = checkpoints[row_index("00.14b")]; - const StoryCheckpoint& misty = checkpoints[row_index("00.14c")]; - if (checkpoints[row_index("00.14b") - 1].id != "00.14" || checkpoints[row_index("00.14c") + 1].id != "00.15"){ - return "Expected 00.14b and 00.14c between 00.14 and 00.15."; - } - const std::vector& steps = gym.steps; - const size_t mart = find_step(steps, 0, [](const StoryStep& step){ - const std::optional plan = parse_mart_variant(step.variant); - return plan && plan->map == "CeruleanCity_Mart" && !plan->lines.empty() && plan->lines[0].item == "potion"; - }); - if (mart == steps.size() || !battle_names(gym).empty()){ - return "00.14b: expected a Cerulean Mart step buying Potions and no battle; its battles:" + battle_names(gym); - } - const std::optional start = misty.start ? story_pose(*misty.start) : std::nullopt; - if (!start || misty.live_start || start->map != "CeruleanCity_Gym" || !(start->tile == TilePos{9, 7}) - || save_card_place(start->map).empty() - ){ - return "00.14c: expected it to start from a save on (9, 7) in Cerulean Gym."; - } - if (battle_names(misty) != " misty" || misty.steps.size() < 3 || misty.steps[1].kind != StoryStepKind::TALK - || misty.steps[2].name != "misty" - ){ - return "00.14c: expected a walk beside Misty, the talk and her battle, the only one; its battles:" + battle_names(misty); - } - const std::optional beside = misty.steps[0].target ? story_pose(*misty.steps[0].target) : std::nullopt; - if (!beside || !(beside->tile == TilePos{9, 6}) || beside->facing != Direction::WEST){ - return "00.14c: expected the talk from (9, 6), facing Misty (8, 6)."; - } - const size_t teach = find_step(misty.steps, 0, [](const StoryStep& step){ - const std::optional plan = parse_teach_variant(step.variant); - return plan && plan->item == "tm03" && plan->move == "water-pulse" && plan->member == 0 && plan->forget_move == "tail-whip"; - }); - if (teach == misty.steps.size()){ - return "00.14c: expected TM03 Water Pulse taught to the lead in place of Tail Whip."; - } - return true; -} - -// 00.14b in order (Misty lost to at Lv20, console B, run m51): the Mart -// first, Potions then Super Potions, the Potions enough for the GRIND's -// wild battles at the table's money floor and at least one Super Potion -// after them; a GRIND of the lead to Lv23 on Route 4, from a route to its -// patch, with Cerulean's nurse as its target; the nurse; then the walk -// into the Gym, ending on (9, 7). -UnitTestResult test_misty_grind_order(){ - const std::vector& steps = story_checkpoints()[row_index("00.14b")].steps; - std::optional plan; - const size_t mart = find_step(steps, 0, [&](const StoryStep& step){ - plan = parse_mart_variant(step.variant); - return plan && plan->map == "CeruleanCity_Mart"; - }); - if (mart == steps.size() || plan->lines.size() < 2 || plan->lines[0].item != "potion" || plan->lines[1].item != "super-potion" - || !plan->money_floor - ){ - return "00.14b: expected a Cerulean Mart step buying Potions, then Super Potions, with a money floor."; - } - const std::optional> buys = plan_mart_buys(mart_stock(plan->map), *plan, *plan->money_floor); - if (!buys || buys->size() < 2 || (*buys)[0].quantity < 2 || (*buys)[1].item != "super-potion"){ - return "00.14b: expected the money floor to buy 2 or more Potions and a Super Potion."; - } - const size_t grind = find_step(steps, mart, [](const StoryStep& step){ - const std::optional grind_plan = parse_grind_variant(step.variant); - return grind_plan && grind_plan->member == 0 && grind_plan->level >= 23; - }); - if (grind == steps.size() || !steps[grind - 1].target || !steps[grind].target){ - return "00.14b: expected a GRIND of the lead to Lv23 after the Mart, right after a route to its patch, the nurse its target."; - } - const std::optional patch = story_pose(*steps[grind - 1].target); - const std::optional nurse = story_pose(*steps[grind].target); - if (!patch || patch->map != "Route4" || !nurse || nurse->map != "CeruleanCity_PokemonCenter_1F" - || !plans(*steps[grind - 1].target, *steps[grind].target) || !plans(*steps[grind].target, *steps[grind - 1].target) - ){ - return "00.14b: expected the GRIND on Route 4, its trips to Cerulean's nurse and back planning."; - } - const size_t heal = find_step(steps, grind + 1, [](const StoryStep& step){ - return step.name == "heal_cerulean"; - }); - const size_t gym = find_step(steps, grind + 1, [](const StoryStep& step){ - const std::optional pose = step.target ? story_pose(*step.target) : std::nullopt; - return pose && pose->map == "CeruleanCity_Gym"; - }); - if (heal == steps.size() || gym == steps.size() || heal > gym){ - return "00.14b: expected the nurse after the GRIND, then the walk into the Gym."; - } - StoryPose end; - for (const StoryStep& step : steps){ - const std::optional pose = step.target ? story_pose(*step.target) : std::nullopt; - end = pose ? *pose : end; - } - if (end.map != "CeruleanCity_Gym"){ - return "00.14b: expected the row to end in Cerulean Gym."; - } - return true; -} - + } +} + +std::string step_names(const StoryCheckpoint& checkpoint){ + std::string text; + for (const StoryStep& step : checkpoint.steps){ + text += (text.empty() ? "" : " ") + step.name; + } + return text; +} + +// With Starter RNG's handoff off, 00.04 and 00.04b run as the one 00.04 +// the story had before the split: from the lab's table to the rival's +// battle, saving once with the starter's START menu. +UnitTestResult test_starter_rows_joined(){ + const std::vector& checkpoints = story_checkpoints(); + const StoryRunPlan plan = plan_story_run(checkpoints, row_index("00.04"), row_index("00.04b"), StoryHandoffs{}); + const std::string expected_steps = + "below_squirtle squirtle_ball choose_squirtle no_nickname rival_takes_bulbasaur above_rival_trigger rival_trigger rival_battle"; + if (plan.rows.size() != 1){ + return "Expected one checkpoint, got \"" + joined_ids(plan.rows) + "\"."; + } + const StoryCheckpoint& row = plan.rows[0]; + if (row.id != "00.04" || row.title != "Squirtle, then the rival's battle" || step_names(row) != expected_steps){ + return "Expected 00.04 \"Squirtle, then the rival's battle\": " + expected_steps + + "; got " + row.id + " \"" + row.title + "\": " + step_names(row); + } + const std::optional start = story_pose(*row.start); + if (!start || start->map != "PalletTown_ProfessorOaksLab" || !(start->tile == TilePos{6, 4}) || row.menu != StartMenu::BEFORE_POKEDEX){ + return "Expected the joined 00.04 to start at the table (6, 4) and save with the starter's START menu."; + } + // Split, 00.04 saves under the ball facing it, before the starter. + const StoryCheckpoint& to_ball = checkpoints[row_index("00.04")]; + const std::optional ball = story_pose(*checkpoints[row_index("00.04b")].start); + if (to_ball.handoff != StoryHandoff::STARTER_RNG || to_ball.menu != StartMenu::BEFORE_POKEMON + || !ball || !(ball->tile == TilePos{9, 5}) || ball->facing != Direction::NORTH + ){ + return "Expected 00.04 to end under Squirtle's ball (9, 5) facing north, saving before the starter."; + } + return true; +} + +// The ways back in after SID Helper and Gift RNG load no save and start +// where 00.01 and 00.12 leave the player, so the rows after them follow +// unchanged. +UnitTestResult test_after_sid_helper_row(){ + const StoryCheckpoint& sid = story_checkpoints()[row_index("00.01b")]; + if (!sid.live_start || story_checkpoints()[0].handoff != StoryHandoff::SID_HELPER){ + return "Expected 00.01 to be SID Helper's handoff and 00.01b to start from the live game."; + } + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.live_start != (checkpoint.id == "00.01b" || checkpoint.id == "00.12b")){ + return checkpoint.id + ": only 00.01b and 00.12b start from the live game."; + } + } + return true; +} + +// The index of the first step of `steps` from `from` that `matches`, or +// steps.size(). +size_t find_step(const std::vector& steps, size_t from, const std::function& matches){ + for (size_t c = from; c < steps.size(); c++){ + if (matches(steps[c])){ + return c; + } + } + return steps.size(); +} + +// Whether a route plans from `from` to `to` (ending facing as `to` does). +bool plans(const Pokemon::Pose& from, const Pokemon::Pose& to){ + const std::optional start = story_pose(from); + const std::optional goal = story_pose(to); + if (!start || !goal){ + return false; + } + return plan_overworld_route( + story_maps(), MapPosition{start->map, start->tile}, RouteGoal{MapPosition{goal->map, goal->tile}, goal->facing}, + default_route_costs(), start->facing.value_or(Direction::NORTH) + ).has_value(); +} + +// 00.15, before the rival, from the Center: first TM03 Water Pulse over +// Tail Whip if the lead doesn't know it, so the sim plays it in the +// GRIND; Potions from Cerulean's Mart (a loaded save's bag may hold too +// few for the wild battles a GRIND fights), and at its money floor 2 or +// more Awakenings for the rival's Sleep Powder; then a GRIND on Route 4's +// grass east of Mt. Moon until the sim beats the rival 00.15b fights 50% +// or more, healing at Cerulean's Center; its trip there plans both ways. +UnitTestResult test_cerulean_mart_and_grind(){ + const StoryCheckpoint& row = story_checkpoints()[row_index("00.15")]; + const std::vector& steps = row.steps; + const std::optional start = row.start ? story_pose(*row.start) : std::nullopt; + if (!start || start->map != "CeruleanCity_PokemonCenter_1F"){ + return "00.15: expected it to start in Cerulean's Center."; + } + const std::optional teach = steps.empty() ? std::nullopt : parse_teach_variant(steps[0].variant); + if (!teach || teach->item != "tm03" || teach->move != "water-pulse" || teach->member != 0 || teach->forget_move != "tail-whip"){ + return "00.15: expected it to start with TM03 Water Pulse over the lead's Tail Whip."; + } + const size_t mart = find_step(steps, 0, [](const StoryStep& step){ + const std::optional plan = parse_mart_variant(step.variant); + return plan && plan->map == "CeruleanCity_Mart" && !plan->lines.empty() && plan->lines[0].item == "potion"; + }); + const std::optional rival = story_trainer("CeruleanCity", "rival_cerulean"); + const size_t grind = find_step(steps, mart, [&](const StoryStep& step){ + const std::optional plan = parse_grind_variant(step.variant); + return plan && plan->member == 0 && rival && plan->trainer == *rival && plan->min_chance == MIN_WIN_CHANCE + && plan->level > 25; + }); + if (mart == steps.size() || grind == steps.size()){ + return "00.15: expected a Cerulean Mart step buying Potions, then a GRIND of the lead until the rival is 50%, capped above Lv25."; + } + const MartPlan plan = *parse_mart_variant(steps[mart].variant); + const std::optional> buys = plan.money_floor + ? plan_mart_buys(mart_stock(plan.map), plan, *plan.money_floor) + : std::nullopt; + const bool awakenings = buys && std::ranges::any_of(*buys, [](const MartBuy& buy){ + return buy.item == "awakening" && buy.quantity >= 2; + }); + if (!awakenings){ + return "00.15: expected the Mart step to buy 2 or more Awakenings at its money floor."; + } + const std::optional clerk = mart > 0 && steps[mart - 1].target ? story_pose(*steps[mart - 1].target) : std::nullopt; + if (!clerk || clerk->map != "CeruleanCity_Mart"){ + return "00.15: expected the Mart step right after a route to the clerk."; + } + if (grind == 0 || !steps[grind - 1].target || !steps[grind].target){ + return "00.15: expected the GRIND right after a route to its patch, with the nurse as its target."; + } + const Pokemon::Pose& patch = *steps[grind - 1].target; + const Pokemon::Pose& nurse = *steps[grind].target; + const std::optional nurse_pose = story_pose(nurse); + if (!nurse_pose || nurse_pose->map != "CeruleanCity_PokemonCenter_1F" || !plans(patch, nurse) || !plans(nurse, patch)){ + return "00.15: expected the GRIND's trips to Cerulean's nurse and back to plan."; + } + return true; +} + +// 00.15b, the rival's battle as its own checkpoint, as every battle lost +// twice or more gets: it starts from a save, 00.15's on (23, 7) below his +// trigger with the nurse just before, so a verify run or a retry reloads +// right before him; and his is its only battle. First Water Pulse over +// Tail Whip if the lead doesn't know it: console B's save (2026-11-03) is +// past the TEACH that failed there, which does nothing once it is known. +UnitTestResult test_cerulean_rival_row(){ + const std::vector& checkpoints = story_checkpoints(); + const size_t index = row_index("00.15b"); + const StoryCheckpoint& rival = checkpoints[index]; + const std::optional start = rival.start ? story_pose(*rival.start) : std::nullopt; + if (!start || rival.live_start || start->map != "CeruleanCity" || !(start->tile == TilePos{23, 7}) + || save_card_place(start->map).empty() + ){ + return "00.15b: expected it to start from a save on (23, 7) in Cerulean City."; + } + std::string battles; + for (const StoryStep& step : rival.steps){ + battles += step.kind == StoryStepKind::BATTLE ? " " + step.name : ""; + } + if (battles != " rival_cerulean" || rival.steps.size() != 3 || rival.steps[1].name != "rival_trigger"){ + return "00.15b: expected a TEACH, the step onto his trigger and his battle; its battles:" + battles; + } + const std::optional teach = parse_teach_variant(rival.steps[0].variant); + if (rival.steps[0].kind != StoryStepKind::GAME || !teach || teach->item != "tm03" || teach->move != "water-pulse" + || teach->member != 0 || teach->forget_move != "tail-whip" + ){ + return "00.15b: expected it to start with TM03 Water Pulse over the lead's Tail Whip."; + } + const StoryCheckpoint& before = checkpoints[index - 1]; + const size_t n = before.steps.size(); + const std::optional end = n > 0 && before.steps[n - 1].target ? story_pose(*before.steps[n - 1].target) : std::nullopt; + if (before.id != "00.15" || n < 2 || before.steps[n - 2].kind != StoryStepKind::HEAL + || !end || end->map != start->map || !(end->tile == start->tile) + ){ + return "00.15: expected it to end with the nurse, then the walk to (23, 7) where 00.15b starts."; + } + for (const StoryStep& step : before.steps){ + if (step.kind == StoryStepKind::BATTLE){ + return "00.15: expected no trainer battle before its save; found " + step.name + "."; + } + } + return true; +} + +// The battles in `row`, each name after a space. +std::string battle_names(const StoryCheckpoint& row){ + std::string battles; + for (const StoryStep& step : row.steps){ + battles += step.kind == StoryStepKind::BATTLE ? " " + step.name : ""; + } + return battles; +} + +// 00.14b, before Misty: Potions at Cerulean's Mart, then into the Gym to +// a save on (9, 7), below her platform; no battle. 00.14c, Misty's battle +// as its own checkpoint: it starts from that save, talks to her from +// beside her, (9, 6), and hers is its only battle; then TM03 Water Pulse +// in place of Tail Whip. +UnitTestResult test_misty_rows(){ + const std::vector& checkpoints = story_checkpoints(); + const StoryCheckpoint& gym = checkpoints[row_index("00.14b")]; + const StoryCheckpoint& misty = checkpoints[row_index("00.14c")]; + if (checkpoints[row_index("00.14b") - 1].id != "00.14" || checkpoints[row_index("00.14c") + 1].id != "00.15"){ + return "Expected 00.14b and 00.14c between 00.14 and 00.15."; + } + const std::vector& steps = gym.steps; + const size_t mart = find_step(steps, 0, [](const StoryStep& step){ + const std::optional plan = parse_mart_variant(step.variant); + return plan && plan->map == "CeruleanCity_Mart" && !plan->lines.empty() && plan->lines[0].item == "potion"; + }); + if (mart == steps.size() || !battle_names(gym).empty()){ + return "00.14b: expected a Cerulean Mart step buying Potions and no battle; its battles:" + battle_names(gym); + } + const std::optional start = misty.start ? story_pose(*misty.start) : std::nullopt; + if (!start || misty.live_start || start->map != "CeruleanCity_Gym" || !(start->tile == TilePos{9, 7}) + || save_card_place(start->map).empty() + ){ + return "00.14c: expected it to start from a save on (9, 7) in Cerulean Gym."; + } + if (battle_names(misty) != " misty" || misty.steps.size() < 3 || misty.steps[1].kind != StoryStepKind::TALK + || misty.steps[2].name != "misty" + ){ + return "00.14c: expected a walk beside Misty, the talk and her battle, the only one; its battles:" + battle_names(misty); + } + const std::optional beside = misty.steps[0].target ? story_pose(*misty.steps[0].target) : std::nullopt; + if (!beside || !(beside->tile == TilePos{9, 6}) || beside->facing != Direction::WEST){ + return "00.14c: expected the talk from (9, 6), facing Misty (8, 6)."; + } + const size_t teach = find_step(misty.steps, 0, [](const StoryStep& step){ + const std::optional plan = parse_teach_variant(step.variant); + return plan && plan->item == "tm03" && plan->move == "water-pulse" && plan->member == 0 && plan->forget_move == "tail-whip"; + }); + if (teach == misty.steps.size()){ + return "00.14c: expected TM03 Water Pulse taught to the lead in place of Tail Whip."; + } + return true; +} + +// 00.14b in order (Misty lost to at Lv20, console B, run m51): the Mart +// first, Potions then Super Potions, the Potions enough for the GRIND's +// wild battles at the table's money floor and at least one Super Potion +// after them; a GRIND of the lead to Lv23 on Route 4, from a route to its +// patch, with Cerulean's nurse as its target; the nurse; then the walk +// into the Gym, ending on (9, 7). +UnitTestResult test_misty_grind_order(){ + const std::vector& steps = story_checkpoints()[row_index("00.14b")].steps; + std::optional plan; + const size_t mart = find_step(steps, 0, [&](const StoryStep& step){ + plan = parse_mart_variant(step.variant); + return plan && plan->map == "CeruleanCity_Mart"; + }); + if (mart == steps.size() || plan->lines.size() < 2 || plan->lines[0].item != "potion" || plan->lines[1].item != "super-potion" + || !plan->money_floor + ){ + return "00.14b: expected a Cerulean Mart step buying Potions, then Super Potions, with a money floor."; + } + const std::optional> buys = plan_mart_buys(mart_stock(plan->map), *plan, *plan->money_floor); + if (!buys || buys->size() < 2 || (*buys)[0].quantity < 2 || (*buys)[1].item != "super-potion"){ + return "00.14b: expected the money floor to buy 2 or more Potions and a Super Potion."; + } + const size_t grind = find_step(steps, mart, [](const StoryStep& step){ + const std::optional grind_plan = parse_grind_variant(step.variant); + return grind_plan && grind_plan->member == 0 && grind_plan->level >= 23; + }); + if (grind == steps.size() || !steps[grind - 1].target || !steps[grind].target){ + return "00.14b: expected a GRIND of the lead to Lv23 after the Mart, right after a route to its patch, the nurse its target."; + } + const std::optional patch = story_pose(*steps[grind - 1].target); + const std::optional nurse = story_pose(*steps[grind].target); + if (!patch || patch->map != "Route4" || !nurse || nurse->map != "CeruleanCity_PokemonCenter_1F" + || !plans(*steps[grind - 1].target, *steps[grind].target) || !plans(*steps[grind].target, *steps[grind - 1].target) + ){ + return "00.14b: expected the GRIND on Route 4, its trips to Cerulean's nurse and back planning."; + } + const size_t heal = find_step(steps, grind + 1, [](const StoryStep& step){ + return step.name == "heal_cerulean"; + }); + const size_t gym = find_step(steps, grind + 1, [](const StoryStep& step){ + const std::optional pose = step.target ? story_pose(*step.target) : std::nullopt; + return pose && pose->map == "CeruleanCity_Gym"; + }); + if (heal == steps.size() || gym == steps.size() || heal > gym){ + return "00.14b: expected the nurse after the GRIND, then the walk into the Gym."; + } + StoryPose end; + for (const StoryStep& step : steps){ + const std::optional pose = step.target ? story_pose(*step.target) : std::nullopt; + end = pose ? *pose : end; + } + if (end.map != "CeruleanCity_Gym"){ + return "00.14b: expected the row to end in Cerulean Gym."; + } + return true; +} + } // namespace - - + + const AutoStoryCase AUTO_STORY_CASES[] = { { .input = {"00.01b and 00.12b: back in after SID Helper and Gift RNG", test_after_sid_helper_row}, @@ -603,13 +603,13 @@ const AutoStoryCase AUTO_STORY_CASES[] = { }, }; -void add_tests_AutoStoryRunPlan(UnitTestDatabase& database){ - add_tests_RunPlan(database); +void add_tests_AutoStoryRunPlan(UnitTestDatabase& database){ + add_tests_RunPlan(database); for (const auto& item : AUTO_STORY_CASES){ database.add(item); } -} - +} + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation 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 6c108a5b76..5d2f8b41bb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -1,315 +1,333 @@ -/* AutoStory Simulation Tests - * - * From: https://github.com/PokemonAutomation/ - * - * 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. - * - */ - -#include -#include -#include -#include -#include -#include -#include -#include "Common/Cpp/Exceptions.h" -#include "Common/Cpp/TestRunners/UnitTest.h" -#include "Pokemon/AutoStory/Pokemon_StorySegments.h" -#include "Pokemon/AutoStory/Pokemon_StorySteps.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" -#include "PokemonFRLG_AutoStoryActions.h" -#include "PokemonFRLG_AutoStoryEvents.h" +/* AutoStory Simulation Tests + * + * From: https://github.com/PokemonAutomation/ + * + * 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. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "Pokemon/AutoStory/Pokemon_StorySegments.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "Pokemon/AutoStory/Pokemon_TilePose.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryCapture.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryPaceSim.h" #include "PokemonFRLG_AutoStoryStepTable.h" -#include "PokemonFRLG_AutoStoryTable.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -namespace{ - -using Pokemon::Outcome; -using Pokemon::RunState; - -std::string joined(const std::vector& lines){ - std::string text; - for (const std::string& line : lines){ - text += "\n " + line; - } - 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{ -public: - struct Failure{ - std::string failure; - size_t times = 0; - }; - - std::vector trace; - 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{ - m_row = data.id; - trace.push_back("begin " + data.id); - } - virtual Outcome run(RunState& state, const StoryStep& step) override{ - const std::string key = m_row + "/" + step.name; - trace.push_back(key); - auto failure = failures.find(key); - if (failure != failures.end() && failure->second.times > 0){ - 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(); } - virtual Outcome game(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } - // 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(); - } - -private: - Outcome walk_to(const Pokemon::Pose& pose, const StoryStep& step){ - 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; -}; - - -// One simulated run, segment built as the program builds it: the rows a -// run takes with every handoff off, each saving at its end (hooks.save -// stands for save_game()). -struct Simulation{ - std::vector rows = plan_story_run(story_checkpoints(), 0, story_checkpoints().size() - 1, StoryHandoffs{}).rows; - FakeGame game; - std::vector lines; - // Where each save stood: the end of its checkpoint. - std::vector> saves; - Pokemon::SegmentPolicy policy; - - // A language no reader supports, so a step that needed text would show. - Pokemon::SegmentReport run(const Pokemon::SegmentRange& range){ - game.set_language(Language::Japanese); - Pokemon::Segment segment; - segment.id = "00"; - for (const StoryCheckpoint& data : rows){ - segment.checkpoints.push_back(Pokemon::CheckpointSpec{ - .id = data.id, .title = data.title, .saves = true, - .build = [this, &data]{ - Pokemon::Checkpoint checkpoint = Pokemon::build_story_checkpoint(data, game); - add_start_pose_check(checkpoint, data.start, [this]{ return game.save_stands; }, [this](const std::string& line){ lines.push_back(line); }); - return checkpoint; - }, - }); - } - Pokemon::SegmentHooks hooks; - hooks.save = [this](RunState& state){ - game.trace.push_back("save"); - saves.emplace_back(last_begun(), state.pose); - return Pokemon::outcome_ok(); - }; - hooks.reload = [this](RunState&){ - game.trace.push_back("reload"); - return Pokemon::outcome_ok(); - }; - hooks.log = [this](const std::string& line){ lines.push_back(line); }; - RunState state; - return Pokemon::run_segment(state, segment, range, policy, hooks); - } - std::string last_begun() const{ - for (auto it = game.trace.rbegin(); it != game.trace.rend(); ++it){ - if (it->starts_with("begin ")){ - return it->substr(6); - } - } - return ""; - } - std::vector begun() const{ - std::vector ids; - for (const std::string& item : game.trace){ - if (item.starts_with("begin ")){ - ids.push_back(item.substr(6)); - } - } - return ids; - } - size_t count(const std::string& item) const{ - return (size_t)std::count(game.trace.begin(), game.trace.end(), item); - } -}; - - +#include "PokemonFRLG_AutoStoryTable.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +using Pokemon::Outcome; +using Pokemon::RunState; + +std::string joined(const std::vector& lines){ + std::string text; + for (const std::string& line : lines){ + text += "\n " + line; + } + 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{ +public: + struct Failure{ + std::string failure; + size_t times = 0; + }; + + std::vector trace; + 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{ + m_row = data.id; + trace.push_back("begin " + data.id); + } + virtual Outcome run(RunState& state, const StoryStep& step) override{ + const std::string key = m_row + "/" + step.name; + trace.push_back(key); + auto failure = failures.find(key); + if (failure != failures.end() && failure->second.times > 0){ + 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(); + } + +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; +}; + + +// One simulated run, segment built as the program builds it: the rows a +// run takes with every handoff off, each saving at its end (hooks.save +// stands for save_game()). +struct Simulation{ + std::vector rows = plan_story_run(story_checkpoints(), 0, story_checkpoints().size() - 1, StoryHandoffs{}).rows; + FakeGame game; + std::vector lines; + // Where each save stood: the end of its checkpoint. + std::vector> saves; + Pokemon::SegmentPolicy policy; + + // A language no reader supports, so a step that needed text would show. + Pokemon::SegmentReport run(const Pokemon::SegmentRange& range){ + game.set_language(Language::Japanese); + Pokemon::Segment segment; + segment.id = "00"; + for (const StoryCheckpoint& data : rows){ + segment.checkpoints.push_back(Pokemon::CheckpointSpec{ + .id = data.id, .title = data.title, .saves = true, + .build = [this, &data]{ + Pokemon::Checkpoint checkpoint = Pokemon::build_story_checkpoint(data, game); + add_start_pose_check(checkpoint, data.start, [this]{ return game.save_stands; }, [this](const std::string& line){ lines.push_back(line); }); + return checkpoint; + }, + }); + } + Pokemon::SegmentHooks hooks; + hooks.save = [this](RunState& state){ + game.trace.push_back("save"); + saves.emplace_back(last_begun(), state.pose); + return Pokemon::outcome_ok(); + }; + hooks.reload = [this](RunState&){ + game.trace.push_back("reload"); + return Pokemon::outcome_ok(); + }; + hooks.log = [this](const std::string& line){ lines.push_back(line); }; + RunState state; + return Pokemon::run_segment(state, segment, range, policy, hooks); + } + std::string last_begun() const{ + for (auto it = game.trace.rbegin(); it != game.trace.rend(); ++it){ + if (it->starts_with("begin ")){ + return it->substr(6); + } + } + return ""; + } + std::vector begun() const{ + std::vector ids; + for (const std::string& item : game.trace){ + if (item.starts_with("begin ")){ + ids.push_back(item.substr(6)); + } + } + return ids; + } + size_t count(const std::string& item) const{ + return (size_t)std::count(game.trace.begin(), game.trace.end(), item); + } +}; + + struct AutoStorySimulationInput{ std::string name; UnitTestResult (*body)(); @@ -324,317 +342,658 @@ struct AutoStorySimulationTestCase{ AutoStorySimulationExpectation expectation; }; -class Test_AutoStorySimulation : public UnitTest{ -public: +class Test_AutoStorySimulation : public UnitTest{ +public: explicit Test_AutoStorySimulation(AutoStorySimulationTestCase scenario) : UnitTest("PokemonFRLG::AutoStory - Simulation: " + 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: AutoStorySimulationTestCase m_scenario; -}; - - -UnitTestResult test_whole_story(){ - Simulation sim; - const Pokemon::SegmentReport report = sim.run({}); - if (!report.completed){ - return "Expected the whole story through:" + joined(sim.lines); - } - std::vector ids; - for (const StoryCheckpoint& data : sim.rows){ - ids.push_back(data.id); - } - if (sim.begun() != ids || sim.saves.size() != ids.size()){ - return "Expected each checkpoint once, in order, each saving at its end:" + joined(sim.game.trace); - } - // Each checkpoint starts where the save before it stands. - for (size_t c = 1; c < sim.rows.size(); c++){ - const StoryCheckpoint& data = sim.rows[c]; - const std::optional start = story_pose(*data.start); - const std::optional saved = story_pose(sim.saves[c - 1].second); - const bool same = start && saved && start->map == saved->map && start->tile == saved->tile - && (!start->facing || start->facing == saved->facing); - if (!same){ - return std::format("{} starts at {}, but {} saved at {}.", data.id, - Pokemon::to_string(*data.start), sim.saves[c - 1].first, Pokemon::to_string(sim.saves[c - 1].second)); - } - } - if (report.one_shot_passes() != report.checkpoints_run){ - return "Expected every checkpoint one-shot:" + joined(sim.lines); - } - // 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){ - std::string text; - 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 + "."; - } - return true; -} - -UnitTestResult test_start_and_end(){ - Simulation sim; - const Pokemon::SegmentReport report = sim.run({.from = "00.05", .to = "00.07"}); - if (!report.completed || sim.begun() != std::vector{"00.05", "00.06", "00.07"}){ - return "Expected 00.05 to 00.07 only:" + joined(sim.begun()); - } - return true; -} - -// A walk that fails once (a trainer the walker could not recover from): -// the checkpoint reloads its last save and runs again from its start. -UnitTestResult test_retry_from_save(){ - Simulation sim; - sim.game.failures["00.08/forest_north"] = {.failure = "operation_failed", .times = 1}; - const Pokemon::SegmentReport report = sim.run({.from = "00.08", .to = "00.09"}); - if (!report.completed || report.reloads != 1 || sim.begun() != std::vector{"00.08", "00.08", "00.09"} - || sim.count("00.08/forest_east") != 2 - ){ - return "Expected 00.08 reloaded once and run again from its start:" + joined(sim.game.trace); - } - if (std::find(sim.lines.begin(), sim.lines.end(), "[Checkpoint] 00.08: passed on attempt 2 (1 reset)") == sim.lines.end()){ - return "Expected the retried pass logged:" + joined(sim.lines); - } - return true; -} - -// console B, FRLG 00.27, 2026-11-09: the save stood below Cerulean Center's -// door, and the run walked as if from the counter where 00.27 starts. 00.09 -// from below Pewter Center's door (17, 25) stops before any step or save. -UnitTestResult test_save_off_start_pose(){ - Simulation sim; - sim.game.save_stands = StoryPose{"PewterCity", TilePos{17, 26}, Direction::SOUTH}; - const Pokemon::SegmentReport report = sim.run({.from = "00.09", .to = "00.09"}); - const auto stepped = [](const std::string& item){ return item.starts_with("00.09/"); }; - const auto told = [](const std::string& line){ return line.starts_with("start pose mismatch: "); }; - if (report.completed || report.failure.failure != "start_failed" || !sim.saves.empty() - || std::ranges::any_of(sim.game.trace, stepped) || std::ranges::none_of(sim.lines, told) - ){ - return "Expected 00.09 stopped at its start check, the mismatch logged:" + joined(sim.game.trace) + joined(sim.lines); - } - return true; -} - -// Brock's battle lost every time: three attempts, then the stop. -UnitTestResult test_gives_up_after_attempts(){ - Simulation sim; - sim.game.failures["00.09/brock"] = {.failure = "blackout", .times = 10}; - const Pokemon::SegmentReport report = sim.run({.from = "00.09", .to = std::nullopt}); - if (report.completed || report.reloads != 2 || report.failed_checkpoint != "00.09" || report.failed_event != "brock" - || report.failure.failure != "blackout" || sim.count("00.09/brock") != 3 || !sim.saves.empty() - ){ - return "Expected 00.09 tried 3 times, then a stop on Brock:" + joined(sim.game.trace) + joined(sim.lines); - } - 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. -std::string walk_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step){ - const std::string where = "00.14/" + step.name + ": "; - // A walk that ends on an open leg may leave the player short of it. - bool open_end = false; - if (step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS){ - const std::optional> route = walked_route(at, step); - if (!route || route->empty()){ - return where + "no route from " + to_string(MapPosition{at.map, at.tile}) + "."; - } - const RoutePlanning planning{.maps = &story_maps(), .start = MapPosition{at.map, at.tile}, .label = step.name}; - const std::string failure = OverworldSimWalker(sim).run(*route, planning); - if (!failure.empty()){ - return where + failure + " On " + to_string(sim.state().position) + "."; - } - const RouteStep& last = route->back(); - open_end = last.action == RouteAction::WALK && !last.to_wall && !last.warps; - at = StoryPose{last.end.map, last.end.point, last.facing_after}; - }else if (step.kind == StoryStepKind::STEP){ - if (sim.state().facing != step.direction){ - sim.tap(step.direction); - } - sim.tap(step.direction); - sim.watch({SimWatcher::MESSAGE}, std::chrono::milliseconds(2000)); - at = *story_pose(*step.pose_after); - } - if (!open_end && !(sim.state().position == MapPosition{at.map, at.tile})){ - return where + "expected " + to_string(MapPosition{at.map, at.tile}) + ", but on " + to_string(sim.state().position) + "."; - } - return ""; -} - -// Console B, 2026-11-03 (row 00.14): the open leg east from Route 4 -// into Cerulean City ended a tile short, on (6, 20), with nothing on -// screen. No hold to a wall finds the player there, and the walk went on -// a column off, the hold south stopped by the fence on (6, 23), to a -// wall below a row of houses, not the Pokemon Center's door. From the -// foot of Route 4's ledge, the row's walks on the sim reach the nurse -// with that leg whole or a tile short. -UnitTestResult test_cerulean_entry_short(){ - const std::vector& rows = story_checkpoints(); - const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.14"; }); - if (row == rows.end()){ - return "No checkpoint 00.14."; - } - const auto hop = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "ledge_hop"; }); - if (hop == row->steps.end() || !hop->pose_after){ - return "00.14: no ledge_hop."; - } - for (int short_by : {0, 1}){ - StoryPose at = *story_pose(*hop->pose_after); - SimScript script; - script.short_holds[1] = short_by; - OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing, script); - for (auto step = hop + 1; step != row->steps.end() && step->kind != StoryStepKind::HEAL; ++step){ - const std::string failure = walk_on_sim(sim, at, *step); - if (!failure.empty()){ - return std::format("Entry {} tile(s) short: ", short_by) + failure; - } - } - } - return true; -} - -// Runs `step` of 00.16 on `sim` as the program does: legs by the sim's -// walker from `at`, where the table says the player stands; 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 bridge_step_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step){ - const std::string where = "00.16/" + step.name + ": "; - const auto on = [&sim]{ return " On " + to_string(sim.state().position) + "."; }; - if (step.kind == StoryStepKind::LEGS){ - 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 -// of his sight: "bug_catcher_cale: failed: no_battle". From the walk's -// end, whole or a tile short, the five trainers are met and beaten on -// the sim, each battle on the trainer's row. -UnitTestResult test_nugget_bridge_short(){ - const std::vector& rows = story_checkpoints(); - const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.16"; }); - if (row == rows.end()){ - return "No checkpoint 00.16."; - } - const auto walk = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "below_cale_line"; }); - if (walk == row->steps.end() || !walk->target){ - return "No step 00.16/below_cale_line."; - } - for (int short_by : {0, 1}){ - StoryPose at = *story_pose(*walk->target); - OverworldSim sim(story_maps(), MapPosition{at.map, {at.tile.x, at.tile.y + short_by}}, Direction::NORTH); - for (auto step = walk + 1; step != row->steps.end() && step->kind != StoryStepKind::READY; ++step){ - const std::string failure = bridge_step_on_sim(sim, at, *step); - if (!failure.empty()){ - return std::format("Walk {} tile(s) short: ", short_by) + failure; - } - } - if (sim.state().trainers_beaten.size() != 5){ - return std::format("Walk {} tile(s) short: expected 5 trainers beaten, got {}.", short_by, sim.state().trainers_beaten.size()); - } - } - 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 = "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"}, - }, - { - .input = {.name = "00.14 into Cerulean, the entry leg a tile short", .body = test_cerulean_entry_short}, - .expectation = {.description = "recovers when entering Cerulean City if movement ends one tile short"}, - }, - { - .input = {.name = "00.16 Nugget Bridge, the walk onto it a tile short", .body = test_nugget_bridge_short}, - .expectation = {.description = "completes Nugget Bridge battles even if initial approach stops short"}, - }, - { - .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"}, - }, -}; - -} // namespace - - -void add_tests_AutoStorySimulation(UnitTestDatabase& database){ +}; + + +UnitTestResult test_whole_story(){ + Simulation sim; + const Pokemon::SegmentReport report = sim.run({}); + if (!report.completed){ + return "Expected the whole story through:" + joined(sim.lines); + } + std::vector ids; + for (const StoryCheckpoint& data : sim.rows){ + ids.push_back(data.id); + } + if (sim.begun() != ids || sim.saves.size() != ids.size()){ + return "Expected each checkpoint once, in order, each saving at its end:" + joined(sim.game.trace); + } + // Each checkpoint starts where the save before it stands. + for (size_t c = 1; c < sim.rows.size(); c++){ + const StoryCheckpoint& data = sim.rows[c]; + const std::optional start = story_pose(*data.start); + const std::optional saved = story_pose(sim.saves[c - 1].second); + const bool same = start && saved && start->map == saved->map && start->tile == saved->tile + && (!start->facing || start->facing == saved->facing); + if (!same){ + return std::format("{} starts at {}, but {} saved at {}.", data.id, + Pokemon::to_string(*data.start), sim.saves[c - 1].first, Pokemon::to_string(sim.saves[c - 1].second)); + } + } + if (report.one_shot_passes() != report.checkpoints_run){ + return "Expected every checkpoint one-shot:" + joined(sim.lines); + } + // 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){ + std::string text; + 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 + "."; + } + return true; +} + +UnitTestResult test_start_and_end(){ + Simulation sim; + const Pokemon::SegmentReport report = sim.run({.from = "00.05", .to = "00.07"}); + if (!report.completed || sim.begun() != std::vector{"00.05", "00.06", "00.07"}){ + return "Expected 00.05 to 00.07 only:" + joined(sim.begun()); + } + return true; +} + +// A walk that fails once (a trainer the walker could not recover from): +// the checkpoint reloads its last save and runs again from its start. +UnitTestResult test_retry_from_save(){ + Simulation sim; + sim.game.failures["00.08/forest_north"] = {.failure = "operation_failed", .times = 1}; + const Pokemon::SegmentReport report = sim.run({.from = "00.08", .to = "00.09"}); + if (!report.completed || report.reloads != 1 || sim.begun() != std::vector{"00.08", "00.08", "00.09"} + || sim.count("00.08/forest_east") != 2 + ){ + return "Expected 00.08 reloaded once and run again from its start:" + joined(sim.game.trace); + } + if (std::find(sim.lines.begin(), sim.lines.end(), "[Checkpoint] 00.08: passed on attempt 2 (1 reset)") == sim.lines.end()){ + return "Expected the retried pass logged:" + joined(sim.lines); + } + return true; +} + +// console B, FRLG 00.27, 2026-11-09: the save stood below Cerulean Center's +// door, and the run walked as if from the counter where 00.27 starts. 00.09 +// from below Pewter Center's door (17, 25) stops before any step or save. +UnitTestResult test_save_off_start_pose(){ + Simulation sim; + sim.game.save_stands = StoryPose{"PewterCity", TilePos{17, 26}, Direction::SOUTH}; + const Pokemon::SegmentReport report = sim.run({.from = "00.09", .to = "00.09"}); + const auto stepped = [](const std::string& item){ return item.starts_with("00.09/"); }; + const auto told = [](const std::string& line){ return line.starts_with("start pose mismatch: "); }; + if (report.completed || report.failure.failure != "start_failed" || !sim.saves.empty() + || std::ranges::any_of(sim.game.trace, stepped) || std::ranges::none_of(sim.lines, told) + ){ + return "Expected 00.09 stopped at its start check, the mismatch logged:" + joined(sim.game.trace) + joined(sim.lines); + } + return true; +} + +// Brock's battle lost every time: three attempts, then the stop. +UnitTestResult test_gives_up_after_attempts(){ + Simulation sim; + sim.game.failures["00.09/brock"] = {.failure = "blackout", .times = 10}; + const Pokemon::SegmentReport report = sim.run({.from = "00.09", .to = std::nullopt}); + if (report.completed || report.reloads != 2 || report.failed_checkpoint != "00.09" || report.failed_event != "brock" + || report.failure.failure != "blackout" || sim.count("00.09/brock") != 3 || !sim.saves.empty() + ){ + return "Expected 00.09 tried 3 times, then a stop on Brock:" + joined(sim.game.trace) + joined(sim.lines); + } + 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. +std::string walk_on_sim(OverworldSim& sim, StoryPose& at, const StoryStep& step){ + const std::string where = "00.14/" + step.name + ": "; + // A walk that ends on an open leg may leave the player short of it. + bool open_end = false; + if (step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS){ + const std::optional> route = walked_route(at, step); + if (!route || route->empty()){ + return where + "no route from " + to_string(MapPosition{at.map, at.tile}) + "."; + } + const RoutePlanning planning{.maps = &story_maps(), .start = MapPosition{at.map, at.tile}, .label = step.name}; + const std::string failure = OverworldSimWalker(sim).run(*route, planning); + if (!failure.empty()){ + return where + failure + " On " + to_string(sim.state().position) + "."; + } + const RouteStep& last = route->back(); + open_end = last.action == RouteAction::WALK && !last.to_wall && !last.warps; + at = StoryPose{last.end.map, last.end.point, last.facing_after}; + }else if (step.kind == StoryStepKind::STEP){ + if (sim.state().facing != step.direction){ + sim.tap(step.direction); + } + sim.tap(step.direction); + sim.watch({SimWatcher::MESSAGE}, std::chrono::milliseconds(2000)); + at = *story_pose(*step.pose_after); + } + if (!open_end && !(sim.state().position == MapPosition{at.map, at.tile})){ + return where + "expected " + to_string(MapPosition{at.map, at.tile}) + ", but on " + to_string(sim.state().position) + "."; + } + return ""; +} + +// Console B, 2026-11-03 (row 00.14): the open leg east from Route 4 +// into Cerulean City ended a tile short, on (6, 20), with nothing on +// screen. No hold to a wall finds the player there, and the walk went on +// a column off, the hold south stopped by the fence on (6, 23), to a +// wall below a row of houses, not the Pokemon Center's door. From the +// foot of Route 4's ledge, the row's walks on the sim reach the nurse +// with that leg whole or a tile short. +UnitTestResult test_cerulean_entry_short(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.14"; }); + if (row == rows.end()){ + return "No checkpoint 00.14."; + } + const auto hop = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "ledge_hop"; }); + if (hop == row->steps.end() || !hop->pose_after){ + return "00.14: no ledge_hop."; + } + for (int short_by : {0, 1}){ + StoryPose at = *story_pose(*hop->pose_after); + SimScript script; + script.short_holds[1] = short_by; + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing, script); + for (auto step = hop + 1; step != row->steps.end() && step->kind != StoryStepKind::HEAL; ++step){ + const std::string failure = walk_on_sim(sim, at, *step); + if (!failure.empty()){ + return std::format("Entry {} tile(s) short: ", short_by) + failure; + } + } + } + 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 +// of his sight: "bug_catcher_cale: failed: no_battle". From the walk's +// end, whole or a tile short, the five trainers are met and beaten on +// the sim, each battle on the trainer's row. +UnitTestResult test_nugget_bridge_short(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.16"; }); + if (row == rows.end()){ + return "No checkpoint 00.16."; + } + const auto walk = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "below_cale_line"; }); + if (walk == row->steps.end() || !walk->target){ + return "No step 00.16/below_cale_line."; + } + for (int short_by : {0, 1}){ + StoryPose at = *story_pose(*walk->target); + OverworldSim sim(story_maps(), MapPosition{at.map, {at.tile.x, at.tile.y + short_by}}, Direction::NORTH); + for (auto step = walk + 1; step != row->steps.end() && step->kind != StoryStepKind::READY; ++step){ + const std::string failure = row_step_on_sim(sim, at, *step, "00.16"); + if (!failure.empty()){ + return std::format("Walk {} tile(s) short: ", short_by) + failure; + } + } + if (sim.state().trainers_beaten.size() != 5){ + return std::format("Walk {} tile(s) short: expected 5 trainers beaten, got {}.", short_by, sim.state().trainers_beaten.size()); + } + } + return true; +} + +// 00.17 on the sim, from its start on Route 24 to the Sea Cottage: +// Franklin, Kelsey, Nob and Haley are met on the steps into their lines +// and beaten there. The sim's trainers who look around see every way +// they may look, so Youngster Dan sees the player on (20, 4), walks up +// to (21, 4) and is fought there, off the way on. +UnitTestResult test_route25_trainers(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.17"; }); + if (row == rows.end()){ + return "No checkpoint 00.17."; + } + StoryPose at = *story_pose(*row->start); + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + for (const StoryStep& step : row->steps){ + const std::string failure = row_step_on_sim(sim, at, step, "00.17"); + if (!failure.empty()){ + return failure; + } + } + const std::vector expected{"hiker", "youngster", "picnicker", "hiker", "lass"}; + if (sim.state().trainers_beaten != expected){ + std::string beaten; + for (const std::string& name : sim.state().trainers_beaten){ + beaten += " " + name; + } + return "00.17: expected Franklin, Dan, Kelsey, Nob and Haley beaten; beat" + beaten + "."; + } + if (!(sim.state().position == MapPosition{"Route25_SeaCottage", {7, 8}})){ + return "00.17: expected to end in the Sea Cottage on (7, 8), not " + to_string(sim.state().position) + "."; + } + return true; +} + +// 00.20 on the sim, its walks from the Center: over Nugget Bridge, whose +// trainers 00.16 beat (they stand there, but don't battle), onto Route +// 24's grass at (4, 19) out of Camper Shane's sight, and back to the +// nurse. No trainer battles on the way. The open leg north from Cerulean +// City onto the bridge crosses onto Route 24, where the game holds the +// player up a tile: held for its tiles alone it ended on (11, 16), a tile +// short, and the leg west bumped the bridge's posts (console B, +// 2026-11-03, row 00.20, twice). +// The story maps after 00.16: Nugget Bridge's trainers beaten, standing +// on columns 10 and 12 without battling. +std::vector maps_bridge_beaten(){ + std::vector maps = story_maps(); + for (MapData& map : maps){ + if (map.name != "Route24"){ + continue; + } + for (MapObject& object : map.objects){ + if (object.trainer && object.x >= 10 && object.x <= 12){ + object.trainer.reset(); + } + } + } + return maps; +} + +UnitTestResult test_oddish_walks(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); + if (row == rows.end()){ + return "No checkpoint 00.20."; + } + StoryPose at = *story_pose(*row->start); + OverworldSim sim(maps_bridge_beaten(), MapPosition{at.map, at.tile}, *at.facing); + const std::map ends{ + {"mart_clerk", {"CeruleanCity_Mart", {4, 3}}}, + {"oddish_patch", {"Route24", {3, 19}}}, + {"oddish_patch_cliff", {"Route24", {3, 19}}}, + {"cerulean_nurse", {"CeruleanCity_PokemonCenter_1F", {7, 4}}}, + }; + for (const StoryStep& step : row->steps){ + if (step.kind != StoryStepKind::ROUTE && step.kind != StoryStepKind::LEGS){ + continue; + } + const std::string failure = row_step_on_sim(sim, at, step, "00.20"); + if (!failure.empty()){ + return failure; + } + const auto end = ends.find(step.name); + if (end == ends.end() || !(sim.state().position == end->second)){ + return "00.20/" + step.name + ": ended on " + to_string(sim.state().position) + "."; + } + } + if (!sim.state().trainers_beaten.empty() || !sim.state().violations.empty()){ + return "00.20: a trainer's battle or a violation on the way."; + } + return true; +} + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +// Walks `route`, planned from the pacing step's tiles by +// plan_story_route(), on `sim`, as walk_route() does: no start tile, the +// facing read. Returns the failure, or empty. +std::string walk_paced_route(OverworldSim& sim, const std::vector& route, const std::string& label){ + const RoutePlanning planning{.maps = &story_maps(), .start = std::nullopt, .label = label}; + const std::string failure = OverworldSimWalker(sim).run(route, planning); + return failure.empty() ? "" : label + ": " + failure + " On " + to_string(sim.state().position) + "."; +} + +// Console B, 2026-11-03 (row 00.20, run m61): the Oddish's battle came +// on the pace's far step, so the capture left the player on (3, 19), not +// the table's (4, 19). The walk back planned from (4, 19) went a column +// west of its plan, down the bridge's column 10, not 11, and stopped +// against the beaten Lass on (10, 22); every later leg bumped, and the +// warp to the Center never came. On the sim, the battle on either step +// of the pace, the row's later walks, from the capture's tiles, reach the +// nurse. +// Console B, 2026-11-03 (m63, 00.20 verify run 2): the open leg west +// from Nugget Bridge's head ended a tile short, so the walk down onto the +// grass ended on (5, 19), in Camper Shane's sight; after the first wild +// battle he walked up ("I saw your feat from the grass!") and the capture +// stopped. From the Mart, the walk to the patch on the sim keeps out of +// his sight with the leg west whole or a tile short. +UnitTestResult test_oddish_patch_west_short(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); + if (row == rows.end()){ + return "No checkpoint 00.20."; + } + const auto mart = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "mart_clerk"; }); + const auto patch = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "oddish_patch"; }); + if (mart == row->steps.end() || patch == row->steps.end() || !mart->target || !patch->target){ + return "00.20: no mart_clerk or oddish_patch."; + } + for (int short_by : {0, 1}){ + StoryPose at = *story_pose(*mart->target); + SimScript script; + // Holds from the Mart: south, west, north, east, north, east, north + // over the bridge, then the 8th, west from the bridge's head. + script.short_holds[8] = short_by; + OverworldSim sim(maps_bridge_beaten(), MapPosition{at.map, at.tile}, *at.facing, script); + const std::string failure = row_step_on_sim(sim, at, *patch, "00.20"); + const std::string label = std::format("West leg {} tile(s) short: ", short_by); + if (!failure.empty()){ + return label + failure; + } + if (!sim.state().trainers_beaten.empty()){ + return label + "a trainer saw the player, on " + to_string(sim.state().position) + "."; + } + const StoryPose end = *story_pose(*patch->target); + if (!(sim.state().position == MapPosition{end.map, end.tile})){ + return label + "ended on " + to_string(sim.state().position) + ", not " + to_string(MapPosition{end.map, end.tile}) + "."; + } + } + return true; +} + +UnitTestResult test_oddish_battle_on_far_step(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.20"; }); + if (row == rows.end()){ + return "No checkpoint 00.20."; + } + const auto capture = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "catch_oddish"; }); + if (capture == row->steps.end() || capture + 1 == row->steps.end() || !(capture + 1)->target){ + return "00.20: no catch_oddish with a route after it."; + } + const auto patch = std::find_if(row->steps.begin(), capture, [](const StoryStep& step){ return step.name == "oddish_patch"; }); + if (patch == capture || !patch->target){ + return "00.20: no oddish_patch before catch_oddish."; + } + // The sim starts on the patch (3, 19): grass step 1 is the pace's far + // step onto (4, 19), step 2 the one back. + for (int grass_step : {1, 2}){ + SimScript script; + script.encounters.push_back(SimEncounter{.grass_step = grass_step}); + const StoryPose start = *story_pose(*patch->target); + OverworldSim sim(maps_bridge_beaten(), MapPosition{start.map, start.tile}, Direction::SOUTH, script); + const std::string label = std::format("Battle on grass step {}: ", grass_step); + QuietLogger logger; + SimPatch pacing(sim, Direction::EAST, Direction::WEST); + if (!pace_until_battle(logger, pacing, PACE_MAX_STEPS) || sim.handle_battle(SimBattleChoice::RUN) != SimBattleEnd::ENDED){ + return label + "no wild battle on the pace."; + } + const StoryStep& back = *(capture + 1); + StoryPose at = *story_pose(*back.target); + const std::optional> route = plan_story_route( + story_maps(), paced_pose(*patch->target, *capture), std::nullopt, RouteGoal{{at.map, at.tile}, at.facing} + ); + if (!route){ + return label + "no route from the capture's tiles."; + } + if (const std::string failure = walk_paced_route(sim, *route, "00.20/" + back.name); !failure.empty()){ + return label + failure; + } + for (auto step = capture + 2; step != row->steps.end(); ++step){ + const std::string failure = row_step_on_sim(sim, at, *step, "00.20"); + if (!failure.empty()){ + return label + failure; + } + } + if (!(sim.state().position == MapPosition{"CeruleanCity_PokemonCenter_1F", {7, 4}})){ + return label + "00.20 ended on " + to_string(sim.state().position) + ", not the nurse."; + } + } + return true; +} + +// Whether `row`'s step `c` is a GRIND or capture that paces, with a ROUTE +// after it: nothing if not; else the route, planned from the pace's two +// tiles (paced_pose()), walked on the sim from each: the failure, or empty. +std::optional route_after_pacing(const StoryCheckpoint& row, size_t c){ + if (c == 0 || c + 1 >= row.steps.size() || row.steps[c + 1].kind != StoryStepKind::ROUTE || !row.steps[c - 1].target){ + return std::nullopt; + } + const StoryStep& next = row.steps[c + 1]; + const Pokemon::Pose paced = paced_pose(*row.steps[c - 1].target, row.steps[c]); + const std::vector tiles = Pokemon::tiles_of(paced); + if (tiles.size() < 2){ + return std::nullopt; + } + const StoryPose end = *story_pose(*next.target); + const std::optional> route = plan_story_route( + story_maps(), paced, std::nullopt, RouteGoal{{end.map, end.tile}, end.facing} + ); + const std::string where = row.id + "/" + next.name; + if (!route){ + return where + ": no route from " + Pokemon::to_string(paced) + "."; + } + for (const TilePos& tile : tiles){ + const std::string from = "From " + to_string(MapPosition{paced.map, tile}) + ": "; + OverworldSim sim(maps_bridge_beaten(), MapPosition{paced.map, tile}, Direction::SOUTH); + if (const std::string failure = walk_paced_route(sim, *route, where); !failure.empty()){ + return from + failure; + } + if (!(sim.state().position == MapPosition{end.map, end.tile})){ + return from + where + " ended on " + to_string(sim.state().position) + "."; + } + } + return std::string(); +} + +// Every GRIND or capture that paces, and a ROUTE after it: the route +// planned from the pace's two tiles walks on the sim from either to its +// end, its first holds bringing them to one tile. +UnitTestResult test_routes_after_pacing(){ + size_t checked = 0; + for (const StoryCheckpoint& row : story_checkpoints()){ + for (size_t c = 0; c < row.steps.size(); c++){ + const std::optional failure = route_after_pacing(row, c); + if (failure && !failure->empty()){ + return *failure; + } + checked += failure ? 1 : 0; + } + } + if (checked < 3){ + return std::format("Expected the routes after 00.14b's and 00.15's GRINDs and 00.20's capture; checked {}.", checked); + } + return true; +} + +// 00.14 on the sim, from Mt. Moon's exit (32, 6): east and up through +// (48, 5) into the tutors' pen on Route 4 to (50, 4), facing the Mega +// Kick tutor (50, 3), then on to above the ledge (93, 8). Each walk ends +// where the table says, and no trainer sees the player on the way. +UnitTestResult test_mega_kick_tutor_walks(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.14"; }); + if (row == rows.end()){ + return "No checkpoint 00.14."; + } + const auto ledge = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "above_ledge"; }); + if (ledge == row->steps.end()){ + return "00.14: no above_ledge."; + } + StoryPose at = *story_pose(*row->start); + OverworldSim sim(story_maps(), MapPosition{at.map, at.tile}, *at.facing); + bool at_tutor = false; + for (auto step = row->steps.begin(); step != ledge + 1; ++step){ + if (step->kind != StoryStepKind::ROUTE && step->kind != StoryStepKind::LEGS){ + continue; + } + const std::string failure = row_step_on_sim(sim, at, *step, "00.14"); + if (!failure.empty()){ + return failure; + } + if (!(sim.state().position == MapPosition{at.map, at.tile})){ + return "00.14/" + step->name + ": expected " + to_string(MapPosition{at.map, at.tile}) + ", but on " + to_string(sim.state().position) + "."; + } + if (sim.state().position == MapPosition{"Route4", {50, 4}} && sim.state().facing == Direction::NORTH){ + at_tutor = true; + } + } + if (!at_tutor){ + return "00.14: no walk ended on Route 4's (50, 4) facing the Mega Kick tutor (50, 3)."; + } + if (!sim.state().trainers_beaten.empty() || !sim.state().violations.empty()){ + return "00.14: a trainer's battle or a violation on the way."; + } + return true; +} + +// Console B, 2026-11-03 (row 00.18): after his scene Bill stands on +// (7, 6), east of the player on (6, 6), not on the maps' (7, 5). The +// walk out planned east onto (7, 6) and south to the mat (7, 9); the +// east tap bumped Bill, and the hold south took the player down column 6 +// to (6, 9), beside the mat, with no warp. On a copy of the maps with +// Bill on (7, 6) and only the mat (7, 9) as the way out (the console +// didn't warp from (6, 9)), the row's walks after the ticket end each +// where the table says and leave on Route 25's (51, 5). +UnitTestResult test_out_of_bill_cottage(){ + const std::vector& rows = story_checkpoints(); + const auto row = std::find_if(rows.begin(), rows.end(), [](const StoryCheckpoint& data){ return data.id == "00.18"; }); + if (row == rows.end()){ + return "No checkpoint 00.18."; + } + const auto ticket = std::find_if(row->steps.begin(), row->steps.end(), [](const StoryStep& step){ return step.name == "ss_ticket"; }); + if (ticket == row->steps.end() || !ticket->pose_after){ + return "00.18: no ss_ticket."; + } + std::vector maps = story_maps(); + for (MapData& map : maps){ + if (map.name != "Route25_SeaCottage"){ + continue; + } + for (MapObject& object : map.objects){ + if (object.name == "bill"){ + object.x = 7; + object.y = 6; + } + } + std::erase_if(map.warps, [](const MapWarp& warp){ return warp.x != 7; }); + } + StoryPose at = *story_pose(*ticket->pose_after); + OverworldSim sim(maps, MapPosition{at.map, at.tile}, *at.facing); + for (auto step = ticket + 1; step != row->steps.end(); ++step){ + if (step->kind != StoryStepKind::ROUTE && step->kind != StoryStepKind::LEGS){ + continue; + } + const std::string failure = row_step_on_sim(sim, at, *step, "00.18"); + if (!failure.empty()){ + return failure; + } + if (!(sim.state().position == MapPosition{at.map, at.tile})){ + return "00.18/" + step->name + ": expected " + to_string(MapPosition{at.map, at.tile}) + ", but on " + to_string(sim.state().position) + "."; + } + } + if (!(sim.state().position == MapPosition{"Route25", {51, 5}})){ + return "00.18: expected to leave onto Route 25's (51, 5), not " + to_string(sim.state().position) + "."; + } + if (!sim.state().violations.empty()){ + return "00.18: violations:" + joined(sim.state().violations); + } + 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 = "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"}}, + {.input = {.name = "00.14 into Cerulean, the entry leg a tile short", .body = test_cerulean_entry_short}, .expectation = {.description = "recovers when entering Cerulean City if movement ends one tile short"}}, + {.input = {.name = "00.14 from Mt. Moon to the Mega Kick tutor, then above the ledge", .body = test_mega_kick_tutor_walks}, .expectation = {.description = "verifies route navigation from Mt. Moon to tutor and past ledge"}}, + {.input = {.name = "00.16 Nugget Bridge, the walk onto it a tile short", .body = test_nugget_bridge_short}, .expectation = {.description = "completes Nugget Bridge battles even if initial approach stops short"}}, + {.input = {.name = "00.17 Route 25, its four trainers and Dan", .body = test_route25_trainers}, .expectation = {.description = "simulates Route 25 trainer battles and Dan detour"}}, + {.input = {.name = "00.18 out of Bill's Sea Cottage, Bill on (7, 6)", .body = test_out_of_bill_cottage}, .expectation = {.description = "simulates exiting Bill sea cottage with Bill relocated"}}, + {.input = {.name = "00.20 to Route 24's grass and back, past Shane", .body = test_oddish_walks}, .expectation = {.description = "navigates to Route 24 grass avoiding trainer line of sight"}}, + {.input = {.name = "00.20 back to the Center, the Oddish's battle on the pace's far step", .body = test_oddish_battle_on_far_step}, .expectation = {.description = "handles Oddish capture encounter occurring on far pace step"}}, + {.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"}}, +}; + +} // namespace + + +void add_tests_AutoStorySimulation(UnitTestDatabase& database){ add_tests_AutoStoryStepTable(database); - add_tests_AutoStoryStartFrames(database); - for (const auto& scenario : AUTO_STORY_SIMULATION_CASES){ - database.add(scenario); - } - -} - - - + add_tests_AutoStoryStartFrames(database); + for (const auto& scenario : AUTO_STORY_SIMULATION_CASES){ + database.add(scenario); + } +} + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 3e5958a67b..68fa3d2b41 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -1,729 +1,895 @@ -/* AutoStory Table - * - * From: https://github.com/PokemonAutomation/ - * - */ - -#include "PokemonFRLG_AutoStoryCapture.h" -#include "PokemonFRLG_AutoStoryChoice.h" -#include "PokemonFRLG_AutoStoryGrind.h" -#include "PokemonFRLG_AutoStoryMart.h" -#include "PokemonFRLG_AutoStoryPcStorage.h" -#include "PokemonFRLG_AutoStoryTable.h" -#include "PokemonFRLG_AutoStoryTeach.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -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"; - -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"); -} - - -StoryCheckpoint new_game(){ - StoryCheckpoint checkpoint = row("00.01", "new game to the bedroom", std::nullopt, {}, StartMenu::BEFORE_POKEMON, { - // new_game.c: the bedroom at (6, 6), facing the TV. - new_game_intro(at(BEDROOM, 6, 6, NORTH), "Oak's speech, BOY, the player's name (default if none is set), rival GREEN"), - options_step(), - }); - checkpoint.handoff = StoryHandoff::SID_HELPER; - return checkpoint; -} - -// SID Helper confirms the name, finishes the intro and reads the Trainer -// Card from the START menu, without moving the player or saving. -StoryCheckpoint after_sid_helper(){ - StoryCheckpoint checkpoint = row("00.01b", "after SID Helper: close the Trainer Card, save in the bedroom", at(BEDROOM, 6, 6, NORTH), {}, StartMenu::BEFORE_POKEMON, { - game_step(CLOSE_TRAINER_CARD, at(BEDROOM, 6, 6, NORTH), "the Trainer Card SID Helper left open, or HOME"), - options_step(), - }); - checkpoint.live_start = true; - return checkpoint; -} - -StoryCheckpoint leave_house(){ - return row("00.02", "bedroom to Pallet Town", at(BEDROOM, 6, 6, NORTH), {}, StartMenu::BEFORE_POKEMON, { - route("front_door", at(PALLET, 6, 8), "down the stairs and out of the house"), - }); -} - -StoryCheckpoint oaks_trigger(){ - return row("00.03", "Oak stops the player and walks them to the lab", at(PALLET, 6, 8, SOUTH), {}, StartMenu::BEFORE_POKEMON, { - route("below_oaks_trigger", at(PALLET, 12, 2, NORTH), "below the north exit, short of Oak's trigger at (12, 1)"), - step_into("oaks_trigger", NORTH, "the trigger: \"Don't go out!\""), - // PlayerEnter walks the player up 8 tiles from the lab's door. - scene("to_the_lab", at(LAB, 6, 4, NORTH), "Oak walks the player into the lab; the scene ends at the table"), - }); -} - -// Starter RNG starts from a save south of the table, facing the ball. -StoryCheckpoint to_starter_ball(){ - StoryCheckpoint checkpoint = row("00.04", "to Squirtle's ball, south of the table", at(LAB, 6, 4, NORTH), {}, StartMenu::BEFORE_POKEMON, { - route("below_squirtle", at(LAB, 9, 5, NORTH), "under Squirtle's ball (9, 4)"), - }); - checkpoint.handoff = StoryHandoff::STARTER_RNG; - return checkpoint; -} - -StoryCheckpoint starter(){ - return row("00.04b", "Squirtle, then the rival's battle", at(LAB, 9, 5, NORTH), {}, StartMenu::BEFORE_POKEDEX, { - talk("squirtle_ball", "\"So, you want the WATER POKEMON SQUIRTLE?\""), - answer("choose_squirtle", true, "take Squirtle"), - answer("no_nickname", false, "\"give a nickname?\" NO"), - scene("rival_takes_bulbasaur", at(LAB, 9, 5, NORTH), "the rival picks his ball; the player still faces the table (console B, 2026-11-01)"), - route("above_rival_trigger", at(LAB, 7, 7, SOUTH), "short of the rival's trigger row 8"), - step_into("rival_trigger", SOUTH, "\"Wait, let's check out our POKEMON!\""), - // The rival leaves; PlayerWatchRivalExitAfterBattleRight ends facing down. - battle("rival_battle", at(LAB, 7, 8, SOUTH), true, "the tutorial battle; losing goes on too"), - }); -} - -StoryCheckpoint parcel(){ - return row("00.05", "Route 1 to Viridian Mart: Oak's Parcel", at(LAB, 7, 8, SOUTH), {}, StartMenu::BEFORE_POKEDEX, { - route("below_mart_door", at(VIRIDIAN, 36, 20, NORTH), "Route 1, running from wild battles"), - step_into("mart_door", NORTH, "the clerk's scene starts on entering"), - // ApproachCounter: up 4 from the mat, then faces the clerk. - scene("parcel_from_clerk", at(VIRIDIAN_MART, 4, 3, WEST), "the clerk gives OAK'S PARCEL"), - }); -} - -StoryCheckpoint pokedex(){ - return row("00.06", "Oak's Parcel to the Pokedex", at(VIRIDIAN_MART, 4, 3, WEST), {}, StartMenu::FULL, { - route("below_oak", at(LAB, 6, 4, NORTH), "back down Route 1 to Oak"), - talk("give_parcel", "Oak takes the parcel"), - scene("pokedex_scene", at(LAB, 6, 4, SOUTH), "the rival comes in, Pokedexes from the desk; the player ends facing south (console B, 2026-11-01)"), - }); -} - -StoryCheckpoint shopping(){ - return row("00.07", "Viridian shopping, then into Viridian Forest", at(LAB, 6, 4, SOUTH), {}, StartMenu::FULL, { - route("mart_clerk", at(VIRIDIAN_MART, 4, 3, WEST), "Route 1 again"), - // 2000 of the 3000 a new game starts with, plus the rival's prize. - shop("potions_and_antidotes", {{"potion", 5}, {"antidote", 3}, {"parlyz_heal", 1}}, "5 Potions, 3 Antidotes, 1 Parlyz Heal"), - route("viridian_nurse", at(VIRIDIAN_CENTER, 7, 4, NORTH), "heal before the forest"), - heal("heal_viridian"), - route("below_old_man_trigger", at(VIRIDIAN, 20, 9, NORTH), "short of the tutorial trigger (20, 8)"), - step_into("old_man_trigger", NORTH, "the old man's catching tutorial can't be walked around"), - // TutorialTriggerLeft turns the player right, to the old man. - scene("old_man_tutorial", at(VIRIDIAN, 20, 8, EAST), "about 60 lines and his demo battle; TEACHY TV"), - // After his demo the old man stands east of the player, beside (20, 8), where - // the map doesn't have him: "east 2" from there didn't move, and the walk north - // ended under Route 2's ledge in column 8 (console B, 2026-11-01). Go north first. - route("past_old_man", at(VIRIDIAN, 20, 5, NORTH), "north along column 20, clear of the old man"), - // The gate's north warp lands on the forest's exit mat (29, 62), facing - // north. The save there keeps a forest failure from going back to Pallet. - route("forest_entrance", at(FOREST, 29, 62, NORTH), "Route 2 and the south gate, running from wild battles"), - }); -} - -StoryCheckpoint forest(){ - // The supplies bought in 00.07. - return row("00.08", "Viridian Forest to Pewter Center", at(FOREST, 29, 62, NORTH), {5, 3, 1}, StartMenu::FULL, { - // Wild battles are fought for EXP. Straight up the east side from the - // entrance, each leg ending on a wall (the Boy at (45, 58) stops the - // east one). Rick (47, 45) sees (42..46, 45), so the last leg meets - // his battle and is held on to its wall after it. The by-hand run's - // detour west to (13, 46) and back is dropped. - legs("forest_east", {{EAST, 1}, {NORTH, 4}, {EAST, 14}, {NORTH, 15}}, "up the east side to (44, 43)", WildBattles::FIGHT), - route("forest_north", at(FOREST, 39, 25), "north part", WildBattles::FIGHT), - // To the gate by legs that end on walls, which the walker holds again - // after a battle instead of estimating where it stopped: planned - // routes with open legs lost the player in the north of the forest - // (console B, 2026-11-01). The two 1-tile legs are single steps. - legs("forest_exit", { - {NORTH, 10}, {WEST, 12}, {SOUTH, 10}, {WEST, 6}, {NORTH, 20}, - {SOUTH, 1}, {WEST, 10}, {SOUTH, 27}, {WEST, 8}, {NORTH, 20}, {EAST, 1}, - }, "walls all the way to the gate's column", WildBattles::FIGHT), - route("forest_gate", at(FOREST_GATE, 7, 10, NORTH), "north onto the exit, arriving on the gate's mat", WildBattles::FIGHT), - // Out of the forest, wild battles are run from; trainers are still fought. - route("pewter_nurse", at(PEWTER_CENTER, 7, 4, NORTH), "Route 2 north to Pewter's Center"), - heal("heal_pewter"), - }); -} - -StoryCheckpoint brock(){ - // The by-hand save: 2 Potions and 1 Antidote left after the forest. - return row("00.09", "Pewter Gym: Camper Liam, then Brock", at(PEWTER_CENTER, 7, 4, NORTH), {2, 1}, StartMenu::FULL, { - // Camper Liam (3, 8) always faces east (MOVEMENT_TYPE_FACE_RIGHT, sight - // 4) and sees (4..7, 8): walking north up column 6 from the door, - // the step onto (6, 8) starts his battle. He walks up to the player, - // who stays put and keeps facing north (FRLG doesn't turn the player - // to a trainer). That step is taken on its own, so no leg is cut. - route("below_liam_line", at(GYM, 6, 9, NORTH), "in by the door, up column 6 to below Liam's row"), - step_into("liam_line", NORTH, "onto (6, 8), in Liam's line"), - battle("camper_liam", at(GYM, 6, 8, NORTH), false, "Geodude and Sandshrew; EXP before Brock"), - // On to Brock unless red HP or a status is left that no item cures. - ready("ready_for_brock", at(PEWTER_CENTER, 7, 4, NORTH), "items for a status or low HP; the nurse only if they can't"), - route("before_brock", at(GYM, 6, 6, NORTH), "below Brock (6, 5); Liam, beaten, stands at (5, 8)"), - talk("challenge_brock", "Brock's speech"), - battle("brock", at(GYM, 6, 6, NORTH), false, "Geodude and Onix, with Bubble; Boulder Badge and TM39"), - }); -} - -StoryCheckpoint running_shoes(){ - return row("00.10", "Pewter: the Running Shoes from Oak's aide", at(GYM, 6, 6, NORTH), after_brock(), StartMenu::FULL, { - // Brock's win sets VAR_MAP_SCENE_PEWTER_CITY to 1 and shows the aide at - // (46, 20); his triggers are (46, 21..23) (PewterCity map.json). The - // Gym guide's triggers at (42..43, 21..23) need the scene at 0. - route("before_aide_trigger", at(PEWTER, 45, 21, EAST), "out of the Gym, east along row 21, short of the top trigger"), - step_into("aide_trigger", EAST, "onto (46, 21), below the aide"), - // RunningShoesAideTriggerTop: the aide talks from (46, 20), no YES/NO; - // the player only turns up. He walks off and is removed; FLAG_SYS_B_DASH - // is set (console B, 2026-11-02: about 20 B presses to the overworld). - scene("shoes_from_aide", at(PEWTER, 46, 21, NORTH), "\"Oh, PLAYER!\" ... the Running Shoes; ends facing north"), - // Where the by-hand walk stopped to start 00.11 (console B, 2026-11-02). - route("below_pewter_center", at(PEWTER, 17, 26, SOUTH), "west along row 21 and down to below the Pokemon Center's door (17, 25)"), - }); -} - -// Route 3's trainers (Route3 map.json): the way east meets three of them. -// Bug Catcher Colton (12, 6) faces east, sight 3: the only way north past -// the row 7 ledges is the gap at x 14..15, below his line. Youngster Ben -// (17, 4) faces south, sight 2, so the way east keeps to row 3 past him. -// Bug Catcher Greg (25, 4) faces south, sight 2, over the way east along -// row 5. Bug Catcher James (32, 6) faces east, sight 4, over the way down -// at x 35..36. The others are kept out of: Lass Janice (19, 9) sees west -// along row 9 to x 14, Lass Sally (30, 3) west along row 3 to x 25, and -// Youngster Calvin (29, 10) west along row 10. Each line is stepped into on -// its own, so no leg is cut; the trainer walks up to the player, who stays -// put facing the way they stepped. -StoryCheckpoint route3(){ - return row("00.11", "Pewter Center, then Route 3's trainers", at(PEWTER, 17, 26, SOUTH), after_brock(), StartMenu::FULL, { - running(route("pewter_nurse", at(PEWTER_CENTER, 7, 4, NORTH), "in by the door below the player")), - heal("heal_pewter"), - // Up from row 9 west of Janice's line, which ends at x 14. - running(route("west_of_janice_line", at(ROUTE3, 13, 8), "out of Pewter by row 21, east into Route 3, short of Janice's line")), - running(legs("below_colton_line", {{EAST, 1}, {NORTH, 1}}, "to (14, 7), in the ledges' gap")), - step_into("colton_line", NORTH, "onto (14, 6), in Colton's line"), - battle("bug_catcher_colton", at(ROUTE3, 14, 6, NORTH), false, "his Bug Pokemon; Colton stops at (13, 6)"), - running(legs("past_ben", {{NORTH, 3}, {EAST, 4}, {SOUTH, 2}, {EAST, 6}}, "up to row 3 above Ben, down at x 18 east of his line, to (24, 5)")), - step_into("greg_line", EAST, "onto (25, 5), below Greg"), - battle("bug_catcher_greg", at(ROUTE3, 25, 5, EAST), false, "his Bug Pokemon; Greg is already next to the player"), - running(legs("above_james_line", {{EAST, 11}}, "along row 5 to the wall at (36, 5)")), - step_into("james_line", SOUTH, "onto (36, 6), at the end of James's line"), - battle("bug_catcher_james", at(ROUTE3, 36, 6, SOUTH), false, "his Bug Pokemon; James stops at (35, 6)"), - // Saved here, the trainers behind, so a failure on the way to Route 4 - // doesn't fight them again. - running(legs("save_spot", {{SOUTH, 2}}, "down to (36, 8), beside the wall east of it")), - }); -} - -// Route 3's north exit (x 68..75) joins Route 4 (offset 60) at x 8..15, up -// which column 12 leads to the Pokemon Center's door (12, 5). Lass Robin -// (40, 11) wanders up and down at x 40, looking north and south, sight 3. -// Rock above (y <= 8) and grass below (y >= 12) leave every way east -// across her column at y 9..11, so whether she sees the player depends on -// her facing. The walk watches for her after the leg along row 9, which -// crosses her sight on (40, 9) only; if she saw the player, her message -// goes to story_interrupts(), which fight her, and the route goes on from -// (40, 9) (run_route(); console B, 2026-11-02, she stopped the player -// there and spoke after the leg's short watch). -StoryCheckpoint magikarp_salesman(){ - StoryCheckpoint checkpoint = row("00.12", "Route 3 and 4 to the Magikarp salesman", at(ROUTE3, 36, 8, SOUTH), after_brock(), StartMenu::FULL, { - running(route("route4_nurse", at(ROUTE4_CENTER, 7, 4, NORTH), "east over Route 3, off the grass, north into Route 4 and its Pokemon Center")), - heal("heal_route4"), - // Gift RNG starts from a save facing the salesman (1, 3). - running(route("before_salesman", at(ROUTE4_CENTER, 2, 3, WEST), "beside the Magikarp salesman, facing him")), - }); - checkpoint.handoff = StoryHandoff::GIFT_RNG; - return checkpoint; -} - -// Gift RNG buys the Magikarp (500 of the money 00.12 brings) without -// moving the player and leaves its summary open, or HOME. This saves the -// shiny where 00.12 ended. A run started here with Gift RNG's handoff on -// loads the save beside the salesman and runs Gift RNG first. -StoryCheckpoint after_gift_rng(){ - StoryCheckpoint checkpoint = row("00.12b", "after Gift RNG: save with the Magikarp", at(ROUTE4_CENTER, 2, 3, WEST), after_brock(), StartMenu::FULL, { - game_step(LEAVE_GIFT_SUMMARY, at(ROUTE4_CENTER, 2, 3, WEST), "the Magikarp's summary Gift RNG left open, or HOME"), - }); - checkpoint.live_start = true; - return checkpoint; -} - -// The Magikarp goes to BOX 1 at the Center's PC (11, 1), so Mt. Moon is -// fought with the starter alone (console B, 2026-11-02: done by hand and -// saved at (11, 2), facing the PC, where this row ends). The wandering -// boy (4..6, 3..5) is walked around. The Magikarp comes from Gift RNG: -// with Gift RNG off the party is the starter alone, and the deposit is -// skipped. -StoryCheckpoint magikarp_to_box(){ - return row("00.12c", "Route 4 Center: the Magikarp into BOX 1", at(ROUTE4_CENTER, 2, 3, WEST), after_brock(), StartMenu::FULL, { - running(route("pc_front", at(ROUTE4_CENTER, 11, 2, NORTH), "below the PC (11, 1), facing it")), - pc_step("magikarp_to_box", "DEPOSIT the party's second slot into BOX 1, if it has one", PcTransfer{ - .action = PcAction::DEPOSIT, .party_slot = 1, .box = 0, .if_missing = PcMissing::SKIP, - }), - }); -} - -// Mt. Moon (pokefirered data/maps/MtMoon_1F, _B1F, _B2F). Every floor tile -// of the cave has wild encounters, which are run from, so its legs end on -// walls, ladders or someone standing still, which the walker holds again -// after a battle; the few open ones are short. The way through: up 1F's -// middle and west side, its north-west ladder, B1F's west ladder down to -// B2F's south-east, round B2F's south edge to its west, north past -// Miguel and the fossils, and the ladder up to B1F's east end, which -// leads out to Route 4 east of the mountain. -// - 1F's trainers are walked around: Youngster Josh (13, 17) sees east -// along row 17 to x 17, so the way west into the west room is down -// column 18 and up column 11; Lass Miriam walks round (32..33, 4..5), -// sight 2, so the way east keeps to row 9 and columns 38 and 40; Hiker -// Marcos wanders (5..9, 8..12) and sees one tile, so the way north is -// column 11 and row 3. -// - The Moon Stone ball (3, 2) on 1F (FLAG_HIDE_MT_MOON_1F_MOON_STONE) is -// a few tiles off the way to the ladder (5, 6). -// - The Grunt at (37, 21) on B2F looks around (sight 3) over the only way -// east round B2F. His room is crossed on legs to walls, at one tile of -// his sight each, (37, 22) and (40, 21); if he looks the player's way, -// he walks up beside the leg, story_interrupts() fights him, and the -// leg is held on to its wall. -// - The Grunt at (12, 20) faces south (sight 2) over (12, 21..22), across -// rows 21..22, the only way north to Miguel: stepped into from the -// west, he is next to the player and doesn't walk. -// - Super Nerd Miguel (13, 11) battles on the step onto (14, 11) -// (MtMoon_B2F_EventScript_MiguelTrigger, VAR_MAP_SCENE_MT_MOON_B2F 0), -// then "We'll each take one!" -// - The fossils: DOME (13, 7), HELIX (14, 7). After the HELIX, Miguel -// walks up to (13, 8) and takes the DOME, which opens the gap north. -// The save is on Route 4: the save card's places don't include Mt. Moon. -StoryCheckpoint mt_moon(){ - return row("00.13", "Mt. Moon: a Moon Stone, a Rocket, Miguel and the Helix Fossil", at(ROUTE4_CENTER, 11, 2, NORTH), after_brock(), StartMenu::FULL, { - running(route("route4_nurse", at(ROUTE4_CENTER, 7, 4, NORTH), "the nurse, below the counter")), - heal("heal_route4"), - running(route("mt_moon_entrance", at(MT_MOON_1F, 18, 37, NORTH), "out of the Center, west into Mt. Moon")), - running(legs("mt_moon_1f", { - {NORTH, 12}, {EAST, 8}, {NORTH, 12}, {EAST, 3}, {SOUTH, 1}, {EAST, 9}, - {NORTH, 1}, {EAST, 2}, {NORTH, 4}, {WEST, 24}, {EAST, 2}, {SOUTH, 12}, - {WEST, 7}, {NORTH, 18}, {WEST, 9}, {EAST, 1}, - }, "up the middle, east of Miriam, west along row 9, down column 18 past Josh, up column 11 past Marcos, to (3, 3)")), - running(route("below_moon_stone", at(MT_MOON_1F, 3, 3, NORTH), "facing the Moon Stone (3, 2)")), - talk("moon_stone_ball", "A on the item ball"), - // No reader for the text yet: the scene's end is the overworld. - scene("found_moon_stone", at(MT_MOON_1F, 3, 3, NORTH), "\" found a MOON STONE!\", then the pocket line; the ball is gone"), - running(route("b2f_ladder_foot", at(MT_MOON_B2F, 26, 21, EAST), "1F's ladder (5, 6), down B1F's west side to its ladder (22, 18), onto B2F")), - running(legs("b2f_south", { - {NORTH, 4}, {EAST, 8}, {WEST, 2}, {SOUTH, 5}, {EAST, 8}, {NORTH, 3}, {WEST, 1}, {NORTH, 5}, - {EAST, 7}, {SOUTH, 14}, {WEST, 9}, {SOUTH, 10}, {WEST, 29}, {EAST, 3}, {NORTH, 17}, - }, "through the Grunt's room at (37, 21), round the south edge, up column 11 to (11, 21)")), - step_into("grunt_line", EAST, "onto (12, 21), below the Grunt at (12, 20)"), - battle("rocket_grunt", at(MT_MOON_B2F, 12, 21, EAST), false, "Rattata and Zubat, Lv13"), - ready("ready_for_miguel", at(ROUTE4_CENTER, 7, 4, NORTH), "items for a status or low HP; the nurse, back through the cave, only if they can't"), - // Up column 13 to Miguel, who stops the leg on (13, 12), then east - // to the wall: the tile below his trigger. - running(legs("below_miguel_trigger", {{EAST, 1}, {NORTH, 9}, {EAST, 1}}, "to (14, 12), below Miguel's trigger (14, 11)")), - step_into("miguel_trigger", NORTH, "onto (14, 11), beside Miguel"), - battle("super_nerd_miguel", at(MT_MOON_B2F, 14, 11, NORTH), false, "Grimer, Voltorb and Koffing, Lv12; \"We'll each take one!\""), - running(legs("below_helix_fossil", {{NORTH, 3}}, "up column 14 to the HELIX FOSSIL (14, 7)")), - fossil_step("helix_fossil", "the HELIX FOSSIL; Miguel takes the DOME FOSSIL", Fossil::HELIX), - // The maps keep the fossils, which the game removes after the - // choice: the step onto the HELIX's tile is taken on its own. - step_into("helix_gap", NORTH, "onto (14, 7), where the HELIX FOSSIL lay"), - running(route("route4_east", at(ROUTE4, 32, 6, SOUTH), "west to B2F's ladder (5, 10), up to B1F, east to its exit onto Route 4")), - }); -} - -// Route 4 east of Mt. Moon (Route4 map.json) joins Cerulean City at its -// east end (x 107; Route 4's row 3 is the city's row 13). Its upper road -// (rows 2..8) reaches the city only by water: the way on by land is down -// the ledge row 9 at x 90..96 to the lower road (rows 10..12), which -// joins the city's rows 20..22. Lass Crissy (75, 3) faces east, sight 4, -// over row 3 from x 76 to 79; the way east keeps out of her line and off -// the grass south of the road (75..87, 11..17). -// -// The open leg east across the city's edge ended a tile short on the -// console (2026-11-03: on (6, 20), not (7, 20)), and no hold to a wall -// in the city's west end finds the player from there: the next hold -// south met the fence on (6, 23) and the walk went on a column off. So -// the entry ends on (9, 20), and only legs to walls follow: north up -// column 9 (or 8, a tile short) to row 12, west to (8, 12), east to -// (30, 12) beside the policeman (31, 12), south to (30, 17), west to -// (25, 17), and down the ledge row 18 to (25, 19), a few tiles from the -// door (22, 19). The way keeps out of the boy's (16, 18..24) and the -// balding man's (8..10, 22..24) wander boxes. -StoryCheckpoint route4_to_cerulean(){ - return row("00.14", "Route 4 east to Cerulean's Pokemon Center", at(ROUTE4, 32, 6, SOUTH), after_brock(), StartMenu::FULL, { - running(route("above_ledge", at(ROUTE4, 93, 8, SOUTH), "east along Route 4's upper road to above the ledge row 9")), - hop("ledge_hop", SOUTH, at(ROUTE4, 93, 10, SOUTH), "down the ledge (93, 9) to the lower road"), - running(route("cerulean_entry", at(CERULEAN, 9, 20, EAST), "east into Cerulean City, to (9, 20) below column 9")), - running(legs("above_city_ledge", {{NORTH, 8}, {WEST, 1}, {EAST, 22}, {SOUTH, 5}, {WEST, 5}}, "to walls: (9, 12), (8, 12), (30, 12), (30, 17), (25, 17)")), - hop("city_ledge_hop", SOUTH, at(CERULEAN, 25, 19, SOUTH), "down the ledge (25, 18)"), - running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "west along row 20 to the Pokemon Center's door (22, 19)")), - heal("heal_cerulean"), - }); -} - -// Cerulean Gym (pokefirered CeruleanCity_Gym map.json and scripts.inc): -// a pool with walkways. Misty (8, 6) stands on a platform (7..9, 6) above -// row 7. Picnicker Diana (4, 7) faces east, sight 4, over (5..8, 7), so -// misty_front (8, 7) is in her line; Swimmer Luis (10, 12) in the water -// faces west, sight 1, over (9, 12), on column 9 north of the door. No -// gym trainer has to be fought for Misty, so neither is: from the door up -// column 9 to row 13 (a route there, so the planner's walk to a wall -// doesn't run on past it through (9, 12)), west to (4, 13), and to walls -// round the pool's west and north sides, (4, 10), (12, 10), (12, 7), then -// west to (9, 7), the tile below the platform's east end, out of Diana's -// line. 00.14c steps up onto (9, 6) and talks to Misty from beside her. -// -// Misty beat a Lv20 Wartortle (console B, run m51), so first a GRIND to -// Lv23 on 00.15's patch. Gen 3 numbers (pokefirered: her mons have IV 0; -// Wartortle taken at IV 15, no EVs): Starmie Lv21 has 56 HP, Atk 36, SpA -// 47, SpD 40. Bite is Dark, special in Gen 3, no STAB, x2 on Psychic: at -// Lv23 (SpA 39) it does 23..28, three hits (two on top rolls); at Lv20, -// 20..24. Her Swift (physical, 60) does 9..11 to a Lv23 Wartortle (about -// 67 HP, Def 45) and Water Pulse (STAB, resisted) 8..10: 6 or more hits, -// though m51 saw about 20 a turn. Her Recover (28) and Super Potion undo -// a Bite, so the margin is in Bite's damage. -// -// The Mart comes before the GRIND: its wild battles are fought only with -// 2 or more Potions, and 00.14's save has none (m51 met Misty with only -// the 5 this row bought). 5 Potions (row 3, 300 each), then up to 3 Super -// Potions (row 2, 700), last so they take the Items pocket's last row; -// the battle doesn't use them yet. The money box can't be read, so the -// floor stands for it, and a floor above the money fails the step (the -// quantity box wraps). Prizes (pokefirered: 4 x last level x the class's -// factor) from a new game's 3000, less Viridian's 2000 and the Magikarp's -// 500: Liam 220, Brock 1400, Route 3's bug catchers 360, the Mt. Moon -// grunt 416 and Miguel 288 (the first rival's 80 only if won), at least -// 3184 here. Floor and budget 3000: 5 Potions and 2 Super Potions, 2900. -// Misty's 2100 (4 x 21 x 25) then leaves 00.15 its floor of 1500. Then the -// nurse, and the save before Misty, so her battle is its own checkpoint, -// 00.14c, verified and retried from it. 00.15's GRIND then fights -// until the sim beats the Cerulean rival often enough. -StoryCheckpoint cerulean_gym(){ - const MartPlan supplies{ - .map = CERULEAN_MART, - .lines = {{.item = "potion", .quantity = 5}, {.item = "super-potion", .quantity = 3}}, - .budget = 3000, - .money_floor = 3000, - }; - const GrindPlan to_lv23{.member = 0, .level = 23, .max_encounters = 100, .pace = WEST, .heal_every = 0}; - return row("00.14b", "Cerulean: Potions, a GRIND to Lv23, then into the Gym, saved below Misty's platform", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { - running(route("mart_clerk", at(CERULEAN_MART, 4, 3, WEST), "out of the Center to the Mart's clerk")), - mart_step("buy_potions", "5 Potions for the GRIND's wild battles, then Super Potions as the money allows", supplies), - running(route("grind_patch", at(ROUTE4, 86, 16, WEST), "west out of the city along Route 4's lower road onto its grass's east edge")), - grind_step("grind_to_lv23", "wild battles until the lead is Lv23, where Bite takes Starmie in three", to_lv23, at(CERULEAN_CENTER, 7, 4, NORTH)), - running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back to the Center: Misty's battle starts at full HP and PP")), - heal("heal_cerulean"), - running(route("gym_below_luis", at(CERULEAN_GYM, 9, 13), "into the Gym, up column 9 to row 13, short of Luis's (9, 12)")), - running(legs("gym_west_walkway", {{WEST, 5}}, "west to the wall (4, 13)")), - running(legs("below_misty_platform", {{NORTH, 3}, {EAST, 8}, {NORTH, 3}, {WEST, 3}}, "walls (4, 10), (12, 10), (12, 7), then to (9, 7), out of Diana's line")), - }); -} - -// Misty (TRAINER_LEADER_MISTY: Staryu Lv18, Starmie Lv21), talked to from -// (9, 6), beside her on the platform. Her win text runs on to the CASCADE -// BADGE, TM03 (Water Pulse) and its explanation, all read with the -// battle's. Bite is chosen against her party (Starmie is Water/Psychic). -// Back to the nurse round the pool's west side, out of Luis's line; then -// Water Pulse in place of Tail Whip, last, at the Center, from the bag's -// TM CASE. Ends where 00.15 starts. -StoryCheckpoint misty(){ - const TeachPlan water_pulse{.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; - return row("00.14c", "Cerulean Gym: Misty, then Water Pulse", at(CERULEAN_GYM, 9, 7, WEST), after_brock(), StartMenu::FULL, { - running(route("beside_misty", at(CERULEAN_GYM, 9, 6, WEST), "up onto (9, 6), facing Misty (8, 6)")), - talk("challenge_misty", "Misty's speech"), - battle("misty", at(CERULEAN_GYM, 9, 6, WEST), false, "Staryu and Starmie, with Bite; CASCADE BADGE and TM03"), - running(legs("gym_way_out", {{SOUTH, 1}, {EAST, 3}, {SOUTH, 3}, {WEST, 8}, {SOUTH, 3}, {EAST, 5}}, "walls (12, 7), (12, 10), (4, 10), (4, 13), (9, 13), round Luis's (9, 12)")), - running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "down column 9 out of the Gym, to the Center")), - heal("heal_cerulean"), - teach_step("teach_water_pulse", "TM03 Water Pulse in place of Tail Whip", water_pulse), - }); -} - -// Before the rival north of Cerulean (cerulean_rival()), from the -// Center: first TM03 Water Pulse over Tail Whip, if the lead doesn't know -// it yet, so the GRIND's sim plays it (a run without TM03 from Misty -// skips the step). Then 3 Potions at the Mart -// (CeruleanCity_Mart_Items: POTION is row 3, 300 each; Mt. Moon's -// trainers leave well over the floor) and 2 Awakenings (row 6, 250 each; -// pokefirered items.json) for the rival's Bulbasaur's Sleep Powder -// (console B, m56: asleep at 34/65 HP, it fainted two turns later with -// 15 Potions), 1400 of the floor's 1500. Then a GRIND on Route 4's grass -// east of Mt. Moon (75..86, 11..17; Spearow, Rattata, Ekans and Mankey -// around Lv10, about 80 EXP each), healing at the Center, until the -// battle sim beats the rival 00.15b fights 50% or more: the win chance -// his battle refuses below. Console B's Wartortle (Lv22, 65 HP, Water -// Pulse) is about 42% there and 62% at Lv23; Lv28 (about 88%, some 120 -// battles from Lv22) is the cap. Then the nurse, and the save on (23, 7), -// the tile below his trigger: his battle is its own checkpoint, 00.15b, -// verified and retried from this save. -StoryCheckpoint cerulean_grind(){ - const TeachPlan water_pulse{.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; - const MartPlan potions{ - .map = CERULEAN_MART, - .lines = {{.item = "potion", .quantity = 3}, {.item = "awakening", .quantity = 2}}, - .budget = 1500, - .money_floor = 1500, - }; - const GrindPlan for_rival{ - .member = 0, .level = 28, .max_encounters = 150, .pace = WEST, .heal_every = 0, - .trainer = std::string(story_trainer(CERULEAN, "rival_cerulean").value_or("")), .min_chance = MIN_WIN_CHANCE, - }; - return row("00.15", "Cerulean: Water Pulse, Potions, a GRIND until the rival is winnable, then saved below his trigger", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { - teach_step("teach_water_pulse", "TM03 Water Pulse in place of Tail Whip, unless the lead knows it or there is no TM03", water_pulse), - running(route("mart_clerk", at(CERULEAN_MART, 4, 3, WEST), "out of the Center to the Mart's clerk")), - mart_step("buy_potions", "3 Potions: the GRIND fights wild battles only with 2 or more; 2 Awakenings for the rival's Sleep Powder", potions), - running(route("grind_patch", at(ROUTE4, 86, 16, WEST), "west out of the city along Route 4's lower road onto its grass's east edge")), - grind_step("grind_for_rival", "wild battles until the sim beats the rival 50% or more, Lv28 at most; the nurse when hurt or low on PP", for_rival, at(CERULEAN_CENTER, 7, 4, NORTH)), - running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back to the Center: the rival's battle starts at full HP and PP")), - heal("heal_cerulean"), - running(route("below_rival_trigger", at(CERULEAN, 23, 7, NORTH), "out of the Center, north up to (23, 7), short of the trigger row 6")), - }); -} - -// The rival north of Cerulean (CeruleanCity scripts.inc): the only way -// north to Nugget Bridge is columns 22..24, and a step onto row 6 there -// (CeruleanCity_EventScript_RivalTrigger*, VAR_MAP_SCENE_CERULEAN_CITY_RIVAL -// 0) brings him down to the player. From the middle, (23, 6), he stops -// at (23, 5); after the battle he walks round the player and off south, -// and the player, turning to watch him, ends facing south. His FAME -// CHECKER's text is read to the overworld with the battle's. For a player -// who took Squirtle he has Pidgeotto Lv17, Abra Lv16, Rattata Lv15 and -// Bulbasaur Lv18 (sParty_RivalCeruleanBulbasaur), whose Vine Whip and -// Sleep Powder are the danger. The row starts from 00.15's save below the -// trigger, and saves after him. First TM03 Water Pulse over Tail Whip (from -// the bag, where the player stands), if the lead doesn't know it yet: -// console B's save (2026-11-03) is past Misty's TEACH, which failed there. -// Where she taught it, the step reads the moves and does nothing. -StoryCheckpoint cerulean_rival(){ - const TeachPlan water_pulse{.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; - return row("00.15b", "Cerulean: Water Pulse if not known, then the rival north of the city", at(CERULEAN, 23, 7, NORTH), after_brock(), StartMenu::FULL, { - teach_step("teach_water_pulse", "TM03 Water Pulse in place of Tail Whip, unless the lead knows it", water_pulse), - step_into("rival_trigger", NORTH, "onto (23, 6), the middle trigger: \"Yo! !\""), - battle("rival_cerulean", at(CERULEAN, 23, 6, SOUTH), false, "Pidgeotto, Abra, Rattata and Bulbasaur; then the FAME CHECKER"), - }); -} - -// Nugget Bridge (Route24 map.json) is x 10..12 from row 32 north to row -// 16, the city's columns 22..24 (offset 12). Its five trainers stand on -// it facing across, sight 2, so each line crosses the middle column 11 -// on the trainer's row: Bug Catcher Cale (12, 31), Lass Ali (10, 28), -// Youngster Timmy (12, 25), Lass Reli (10, 22) and Camper Ethan (12, 19). -// Each is fought on the step onto their row, beside them: they don't -// walk, and the player keeps facing north. Two tiles separate the rows, -// walked as one short leg; the step onto a row goes on a tile at a time -// until the trainer sees the player (into_line()), as the open leg onto -// the bridge ended a tile short twice on console B (2026-11-03), on -// (11, 33), and one step left the player out of Cale's sight. Then the -// Rocket (12, 15): the step onto (11, 15) -// (Route24_EventScript_RocketTriggerRight, VAR_MAP_SCENE_ROUTE24 0) gives -// the NUGGET, "join Team Rocket", and his battle; he only turns, and the -// player turns east to him. Camper Shane (5, 21) looks north from the -// west bank, off the way. -StoryCheckpoint nugget_bridge(){ - return row("00.16", "Nugget Bridge: the nurse, five trainers and the Rocket", at(CERULEAN, 23, 6, SOUTH), after_brock(), StartMenu::FULL, { - running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back to the Center after the rival, before Nugget Bridge")), - heal("heal_cerulean"), - running(route("below_cale_line", at(ROUTE24, 11, 32, NORTH), "out of the Center, north past the rival's row onto the bridge, below Cale's row")), - into_line("cale_line", NORTH, "onto (11, 31), beside Cale"), - battle("bug_catcher_cale", at(ROUTE24, 11, 31, NORTH), false, "his Bug Pokemon"), - running(legs("below_ali_line", {{NORTH, 2}}, "up to (11, 29)")), - into_line("ali_line", NORTH, "onto (11, 28), beside Ali"), - battle("lass_ali", at(ROUTE24, 11, 28, NORTH), false, "her Pokemon"), - running(legs("below_timmy_line", {{NORTH, 2}}, "up to (11, 26)")), - into_line("timmy_line", NORTH, "onto (11, 25), beside Timmy"), - battle("youngster_timmy", at(ROUTE24, 11, 25, NORTH), false, "his Pokemon"), - running(legs("below_reli_line", {{NORTH, 2}}, "up to (11, 23)")), - into_line("reli_line", NORTH, "onto (11, 22), beside Reli"), - battle("lass_reli", at(ROUTE24, 11, 22, NORTH), false, "her Pokemon"), - running(legs("below_ethan_line", {{NORTH, 2}}, "up to (11, 20)")), - into_line("ethan_line", NORTH, "onto (11, 19), beside Ethan"), - battle("camper_ethan", at(ROUTE24, 11, 19, NORTH), false, "his Pokemon"), - // A route, not legs: the nurse may have been needed. - ready("ready_for_rocket", at(CERULEAN_CENTER, 7, 4, NORTH), "items for a status or low HP; the nurse only if they can't"), - running(route("below_rocket_trigger", at(ROUTE24, 11, 16, NORTH), "up the bridge to below the Rocket's trigger (11, 15)")), - step_into("rocket_trigger", NORTH, "onto (11, 15), beside the Rocket"), - battle("rocket_grunt", at(ROUTE24, 11, 15, EAST), false, "the NUGGET, then Ekans and Zubat, Lv15"), - }); -} - -} // 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(), - }; - return checkpoints; -} - - - +/* AutoStory Table + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonFRLG_AutoStoryCapture.h" +#include "PokemonFRLG_AutoStoryChoice.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryMart.h" +#include "PokemonFRLG_AutoStoryPcStorage.h" +#include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_AutoStoryTeach.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +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"); +} + + +StoryCheckpoint new_game(){ + StoryCheckpoint checkpoint = row("00.01", "new game to the bedroom", std::nullopt, {}, StartMenu::BEFORE_POKEMON, { + // new_game.c: the bedroom at (6, 6), facing the TV. + new_game_intro(at(BEDROOM, 6, 6, NORTH), "Oak's speech, BOY, the player's name (default if none is set), rival GREEN"), + options_step(), + }); + checkpoint.handoff = StoryHandoff::SID_HELPER; + return checkpoint; +} + +// SID Helper confirms the name, finishes the intro and reads the Trainer +// Card from the START menu, without moving the player or saving. +StoryCheckpoint after_sid_helper(){ + StoryCheckpoint checkpoint = row("00.01b", "after SID Helper: close the Trainer Card, save in the bedroom", at(BEDROOM, 6, 6, NORTH), {}, StartMenu::BEFORE_POKEMON, { + game_step(CLOSE_TRAINER_CARD, at(BEDROOM, 6, 6, NORTH), "the Trainer Card SID Helper left open, or HOME"), + options_step(), + }); + checkpoint.live_start = true; + return checkpoint; +} + +StoryCheckpoint leave_house(){ + return row("00.02", "bedroom to Pallet Town", at(BEDROOM, 6, 6, NORTH), {}, StartMenu::BEFORE_POKEMON, { + route("front_door", at(PALLET, 6, 8), "down the stairs and out of the house"), + }); +} + +StoryCheckpoint oaks_trigger(){ + return row("00.03", "Oak stops the player and walks them to the lab", at(PALLET, 6, 8, SOUTH), {}, StartMenu::BEFORE_POKEMON, { + route("below_oaks_trigger", at(PALLET, 12, 2, NORTH), "below the north exit, short of Oak's trigger at (12, 1)"), + step_into("oaks_trigger", NORTH, "the trigger: \"Don't go out!\""), + // PlayerEnter walks the player up 8 tiles from the lab's door. + scene("to_the_lab", at(LAB, 6, 4, NORTH), "Oak walks the player into the lab; the scene ends at the table"), + }); +} + +// Starter RNG starts from a save south of the table, facing the ball. +StoryCheckpoint to_starter_ball(){ + StoryCheckpoint checkpoint = row("00.04", "to Squirtle's ball, south of the table", at(LAB, 6, 4, NORTH), {}, StartMenu::BEFORE_POKEMON, { + route("below_squirtle", at(LAB, 9, 5, NORTH), "under Squirtle's ball (9, 4)"), + }); + checkpoint.handoff = StoryHandoff::STARTER_RNG; + return checkpoint; +} + +StoryCheckpoint starter(){ + return row("00.04b", "Squirtle, then the rival's battle", at(LAB, 9, 5, NORTH), {}, StartMenu::BEFORE_POKEDEX, { + talk("squirtle_ball", "\"So, you want the WATER POKEMON SQUIRTLE?\""), + answer("choose_squirtle", true, "take Squirtle"), + answer("no_nickname", false, "\"give a nickname?\" NO"), + scene("rival_takes_bulbasaur", at(LAB, 9, 5, NORTH), "the rival picks his ball; the player still faces the table (console B, 2026-11-01)"), + route("above_rival_trigger", at(LAB, 7, 7, SOUTH), "short of the rival's trigger row 8"), + step_into("rival_trigger", SOUTH, "\"Wait, let's check out our POKEMON!\""), + // The rival leaves; PlayerWatchRivalExitAfterBattleRight ends facing down. + battle("rival_battle", at(LAB, 7, 8, SOUTH), true, "the tutorial battle; losing goes on too"), + }); +} + +StoryCheckpoint parcel(){ + return row("00.05", "Route 1 to Viridian Mart: Oak's Parcel", at(LAB, 7, 8, SOUTH), {}, StartMenu::BEFORE_POKEDEX, { + route("below_mart_door", at(VIRIDIAN, 36, 20, NORTH), "Route 1, running from wild battles"), + step_into("mart_door", NORTH, "the clerk's scene starts on entering"), + // ApproachCounter: up 4 from the mat, then faces the clerk. + scene("parcel_from_clerk", at(VIRIDIAN_MART, 4, 3, WEST), "the clerk gives OAK'S PARCEL"), + }); +} + +StoryCheckpoint pokedex(){ + return row("00.06", "Oak's Parcel to the Pokedex", at(VIRIDIAN_MART, 4, 3, WEST), {}, StartMenu::FULL, { + route("below_oak", at(LAB, 6, 4, NORTH), "back down Route 1 to Oak"), + talk("give_parcel", "Oak takes the parcel"), + scene("pokedex_scene", at(LAB, 6, 4, SOUTH), "the rival comes in, Pokedexes from the desk; the player ends facing south (console B, 2026-11-01)"), + }); +} + +StoryCheckpoint shopping(){ + return row("00.07", "Viridian shopping, then into Viridian Forest", at(LAB, 6, 4, SOUTH), {}, StartMenu::FULL, { + route("mart_clerk", at(VIRIDIAN_MART, 4, 3, WEST), "Route 1 again"), + // 2000 of the 3000 a new game starts with, plus the rival's prize. + shop("potions_and_antidotes", {{"potion", 5}, {"antidote", 3}, {"parlyz_heal", 1}}, "5 Potions, 3 Antidotes, 1 Parlyz Heal"), + route("viridian_nurse", at(VIRIDIAN_CENTER, 7, 4, NORTH), "heal before the forest"), + heal("heal_viridian"), + route("below_old_man_trigger", at(VIRIDIAN, 20, 9, NORTH), "short of the tutorial trigger (20, 8)"), + step_into("old_man_trigger", NORTH, "the old man's catching tutorial can't be walked around"), + // TutorialTriggerLeft turns the player right, to the old man. + scene("old_man_tutorial", at(VIRIDIAN, 20, 8, EAST), "about 60 lines and his demo battle; TEACHY TV"), + // After his demo the old man stands east of the player, beside (20, 8), where + // the map doesn't have him: "east 2" from there didn't move, and the walk north + // ended under Route 2's ledge in column 8 (console B, 2026-11-01). Go north first. + route("past_old_man", at(VIRIDIAN, 20, 5, NORTH), "north along column 20, clear of the old man"), + // The gate's north warp lands on the forest's exit mat (29, 62), facing + // north. The save there keeps a forest failure from going back to Pallet. + route("forest_entrance", at(FOREST, 29, 62, NORTH), "Route 2 and the south gate, running from wild battles"), + }); +} + +StoryCheckpoint forest(){ + // The supplies bought in 00.07. + return row("00.08", "Viridian Forest to Pewter Center", at(FOREST, 29, 62, NORTH), {5, 3, 1}, StartMenu::FULL, { + // Wild battles are fought for EXP. Straight up the east side from the + // entrance, each leg ending on a wall (the Boy at (45, 58) stops the + // east one). Rick (47, 45) sees (42..46, 45), so the last leg meets + // his battle and is held on to its wall after it. The by-hand run's + // detour west to (13, 46) and back is dropped. + legs("forest_east", {{EAST, 1}, {NORTH, 4}, {EAST, 14}, {NORTH, 15}}, "up the east side to (44, 43)", WildBattles::FIGHT), + route("forest_north", at(FOREST, 39, 25), "north part", WildBattles::FIGHT), + // To the gate by legs that end on walls, which the walker holds again + // after a battle instead of estimating where it stopped: planned + // routes with open legs lost the player in the north of the forest + // (console B, 2026-11-01). The two 1-tile legs are single steps. + legs("forest_exit", { + {NORTH, 10}, {WEST, 12}, {SOUTH, 10}, {WEST, 6}, {NORTH, 20}, + {SOUTH, 1}, {WEST, 10}, {SOUTH, 27}, {WEST, 8}, {NORTH, 20}, {EAST, 1}, + }, "walls all the way to the gate's column", WildBattles::FIGHT), + route("forest_gate", at(FOREST_GATE, 7, 10, NORTH), "north onto the exit, arriving on the gate's mat", WildBattles::FIGHT), + // Out of the forest, wild battles are run from; trainers are still fought. + route("pewter_nurse", at(PEWTER_CENTER, 7, 4, NORTH), "Route 2 north to Pewter's Center"), + heal("heal_pewter"), + }); +} + +StoryCheckpoint brock(){ + // The by-hand save: 2 Potions and 1 Antidote left after the forest. + return row("00.09", "Pewter Gym: Camper Liam, then Brock", at(PEWTER_CENTER, 7, 4, NORTH), {2, 1}, StartMenu::FULL, { + // Camper Liam (3, 8) always faces east (MOVEMENT_TYPE_FACE_RIGHT, sight + // 4) and sees (4..7, 8): walking north up column 6 from the door, + // the step onto (6, 8) starts his battle. He walks up to the player, + // who stays put and keeps facing north (FRLG doesn't turn the player + // to a trainer). That step is taken on its own, so no leg is cut. + route("below_liam_line", at(GYM, 6, 9, NORTH), "in by the door, up column 6 to below Liam's row"), + step_into("liam_line", NORTH, "onto (6, 8), in Liam's line"), + battle("camper_liam", at(GYM, 6, 8, NORTH), false, "Geodude and Sandshrew; EXP before Brock"), + // On to Brock unless red HP or a status is left that no item cures. + ready("ready_for_brock", at(PEWTER_CENTER, 7, 4, NORTH), "items for a status or low HP; the nurse only if they can't"), + route("before_brock", at(GYM, 6, 6, NORTH), "below Brock (6, 5); Liam, beaten, stands at (5, 8)"), + talk("challenge_brock", "Brock's speech"), + battle("brock", at(GYM, 6, 6, NORTH), false, "Geodude and Onix, with Bubble; Boulder Badge and TM39"), + }); +} + +StoryCheckpoint running_shoes(){ + return row("00.10", "Pewter: the Running Shoes from Oak's aide", at(GYM, 6, 6, NORTH), after_brock(), StartMenu::FULL, { + // Brock's win sets VAR_MAP_SCENE_PEWTER_CITY to 1 and shows the aide at + // (46, 20); his triggers are (46, 21..23) (PewterCity map.json). The + // Gym guide's triggers at (42..43, 21..23) need the scene at 0. + route("before_aide_trigger", at(PEWTER, 45, 21, EAST), "out of the Gym, east along row 21, short of the top trigger"), + step_into("aide_trigger", EAST, "onto (46, 21), below the aide"), + // RunningShoesAideTriggerTop: the aide talks from (46, 20), no YES/NO; + // the player only turns up. He walks off and is removed; FLAG_SYS_B_DASH + // is set (console B, 2026-11-02: about 20 B presses to the overworld). + scene("shoes_from_aide", at(PEWTER, 46, 21, NORTH), "\"Oh, PLAYER!\" ... the Running Shoes; ends facing north"), + // Where the by-hand walk stopped to start 00.11 (console B, 2026-11-02). + route("below_pewter_center", at(PEWTER, 17, 26, SOUTH), "west along row 21 and down to below the Pokemon Center's door (17, 25)"), + }); +} + +// Route 3's trainers (Route3 map.json): the way east meets three of them. +// Bug Catcher Colton (12, 6) faces east, sight 3: the only way north past +// the row 7 ledges is the gap at x 14..15, below his line. Youngster Ben +// (17, 4) faces south, sight 2, so the way east keeps to row 3 past him. +// Bug Catcher Greg (25, 4) faces south, sight 2, over the way east along +// row 5. Bug Catcher James (32, 6) faces east, sight 4, over the way down +// at x 35..36. The others are kept out of: Lass Janice (19, 9) sees west +// along row 9 to x 14, Lass Sally (30, 3) west along row 3 to x 25, and +// Youngster Calvin (29, 10) west along row 10. Each line is stepped into on +// its own, so no leg is cut; the trainer walks up to the player, who stays +// put facing the way they stepped. +StoryCheckpoint route3(){ + return row("00.11", "Pewter Center, then Route 3's trainers", at(PEWTER, 17, 26, SOUTH), after_brock(), StartMenu::FULL, { + running(route("pewter_nurse", at(PEWTER_CENTER, 7, 4, NORTH), "in by the door below the player")), + heal("heal_pewter"), + // Up from row 9 west of Janice's line, which ends at x 14. + running(route("west_of_janice_line", at(ROUTE3, 13, 8), "out of Pewter by row 21, east into Route 3, short of Janice's line")), + running(legs("below_colton_line", {{EAST, 1}, {NORTH, 1}}, "to (14, 7), in the ledges' gap")), + step_into("colton_line", NORTH, "onto (14, 6), in Colton's line"), + battle("bug_catcher_colton", at(ROUTE3, 14, 6, NORTH), false, "his Bug Pokemon; Colton stops at (13, 6)"), + running(legs("past_ben", {{NORTH, 3}, {EAST, 4}, {SOUTH, 2}, {EAST, 6}}, "up to row 3 above Ben, down at x 18 east of his line, to (24, 5)")), + step_into("greg_line", EAST, "onto (25, 5), below Greg"), + battle("bug_catcher_greg", at(ROUTE3, 25, 5, EAST), false, "his Bug Pokemon; Greg is already next to the player"), + running(legs("above_james_line", {{EAST, 11}}, "along row 5 to the wall at (36, 5)")), + step_into("james_line", SOUTH, "onto (36, 6), at the end of James's line"), + battle("bug_catcher_james", at(ROUTE3, 36, 6, SOUTH), false, "his Bug Pokemon; James stops at (35, 6)"), + // Saved here, the trainers behind, so a failure on the way to Route 4 + // doesn't fight them again. + running(legs("save_spot", {{SOUTH, 2}}, "down to (36, 8), beside the wall east of it")), + }); +} + +// Route 3's north exit (x 68..75) joins Route 4 (offset 60) at x 8..15, up +// which column 12 leads to the Pokemon Center's door (12, 5). Lass Robin +// (40, 11) wanders up and down at x 40, looking north and south, sight 3. +// Rock above (y <= 8) and grass below (y >= 12) leave every way east +// across her column at y 9..11, so whether she sees the player depends on +// her facing. The walk watches for her after the leg along row 9, which +// crosses her sight on (40, 9) only; if she saw the player, her message +// goes to story_interrupts(), which fight her, and the route goes on from +// (40, 9) (run_route(); console B, 2026-11-02, she stopped the player +// there and spoke after the leg's short watch). +StoryCheckpoint magikarp_salesman(){ + StoryCheckpoint checkpoint = row("00.12", "Route 3 and 4 to the Magikarp salesman", at(ROUTE3, 36, 8, SOUTH), after_brock(), StartMenu::FULL, { + running(route("route4_nurse", at(ROUTE4_CENTER, 7, 4, NORTH), "east over Route 3, off the grass, north into Route 4 and its Pokemon Center")), + heal("heal_route4"), + // Gift RNG starts from a save facing the salesman (1, 3). + running(route("before_salesman", at(ROUTE4_CENTER, 2, 3, WEST), "beside the Magikarp salesman, facing him")), + }); + checkpoint.handoff = StoryHandoff::GIFT_RNG; + return checkpoint; +} + +// Gift RNG buys the Magikarp (500 of the money 00.12 brings) without +// moving the player and leaves its summary open, or HOME. This saves the +// shiny where 00.12 ended. A run started here with Gift RNG's handoff on +// loads the save beside the salesman and runs Gift RNG first. +StoryCheckpoint after_gift_rng(){ + StoryCheckpoint checkpoint = row("00.12b", "after Gift RNG: save with the Magikarp", at(ROUTE4_CENTER, 2, 3, WEST), after_brock(), StartMenu::FULL, { + game_step(LEAVE_GIFT_SUMMARY, at(ROUTE4_CENTER, 2, 3, WEST), "the Magikarp's summary Gift RNG left open, or HOME"), + }); + checkpoint.live_start = true; + return checkpoint; +} + +// The Magikarp goes to BOX 1 at the Center's PC (11, 1), so Mt. Moon is +// fought with the starter alone (console B, 2026-11-02: done by hand and +// saved at (11, 2), facing the PC, where this row ends). The wandering +// boy (4..6, 3..5) is walked around. The Magikarp comes from Gift RNG: +// with Gift RNG off the party is the starter alone, and the deposit is +// skipped. +StoryCheckpoint magikarp_to_box(){ + return row("00.12c", "Route 4 Center: the Magikarp into BOX 1", at(ROUTE4_CENTER, 2, 3, WEST), after_brock(), StartMenu::FULL, { + running(route("pc_front", at(ROUTE4_CENTER, 11, 2, NORTH), "below the PC (11, 1), facing it")), + pc_step("magikarp_to_box", "DEPOSIT the party's second slot into BOX 1, if it has one", PcTransfer{ + .action = PcAction::DEPOSIT, .party_slot = 1, .box = 0, .if_missing = PcMissing::SKIP, + }), + }); +} + +// Mt. Moon (pokefirered data/maps/MtMoon_1F, _B1F, _B2F). Every floor tile +// of the cave has wild encounters, which are run from, so its legs end on +// walls, ladders or someone standing still, which the walker holds again +// after a battle; the few open ones are short. The way through: up 1F's +// middle and west side, its north-west ladder, B1F's west ladder down to +// B2F's south-east, round B2F's south edge to its west, north past +// Miguel and the fossils, and the ladder up to B1F's east end, which +// leads out to Route 4 east of the mountain. +// - 1F's trainers are walked around: Youngster Josh (13, 17) sees east +// along row 17 to x 17, so the way west into the west room is down +// column 18 and up column 11; Lass Miriam walks round (32..33, 4..5), +// sight 2, so the way east keeps to row 9 and columns 38 and 40; Hiker +// Marcos wanders (5..9, 8..12) and sees one tile, so the way north is +// column 11 and row 3. +// - The Moon Stone ball (3, 2) on 1F (FLAG_HIDE_MT_MOON_1F_MOON_STONE) is +// a few tiles off the way to the ladder (5, 6). +// - The Grunt at (37, 21) on B2F looks around (sight 3) over the only way +// east round B2F. His room is crossed on legs to walls, at one tile of +// his sight each, (37, 22) and (40, 21); if he looks the player's way, +// he walks up beside the leg, story_interrupts() fights him, and the +// leg is held on to its wall. +// - The Grunt at (12, 20) faces south (sight 2) over (12, 21..22), across +// rows 21..22, the only way north to Miguel: stepped into from the +// west, he is next to the player and doesn't walk. +// - Super Nerd Miguel (13, 11) battles on the step onto (14, 11) +// (MtMoon_B2F_EventScript_MiguelTrigger, VAR_MAP_SCENE_MT_MOON_B2F 0), +// then "We'll each take one!" +// - The fossils: DOME (13, 7), HELIX (14, 7). After the HELIX, Miguel +// walks up to (13, 8) and takes the DOME, which opens the gap north. +// The save is on Route 4: the save card's places don't include Mt. Moon. +StoryCheckpoint mt_moon(){ + return row("00.13", "Mt. Moon: a Moon Stone, a Rocket, Miguel and the Helix Fossil", at(ROUTE4_CENTER, 11, 2, NORTH), after_brock(), StartMenu::FULL, { + running(route("route4_nurse", at(ROUTE4_CENTER, 7, 4, NORTH), "the nurse, below the counter")), + heal("heal_route4"), + running(route("mt_moon_entrance", at(MT_MOON_1F, 18, 37, NORTH), "out of the Center, west into Mt. Moon")), + running(legs("mt_moon_1f", { + {NORTH, 12}, {EAST, 8}, {NORTH, 12}, {EAST, 3}, {SOUTH, 1}, {EAST, 9}, + {NORTH, 1}, {EAST, 2}, {NORTH, 4}, {WEST, 24}, {EAST, 2}, {SOUTH, 12}, + {WEST, 7}, {NORTH, 18}, {WEST, 9}, {EAST, 1}, + }, "up the middle, east of Miriam, west along row 9, down column 18 past Josh, up column 11 past Marcos, to (3, 3)")), + running(route("below_moon_stone", at(MT_MOON_1F, 3, 3, NORTH), "facing the Moon Stone (3, 2)")), + talk("moon_stone_ball", "A on the item ball"), + // No reader for the text yet: the scene's end is the overworld. + scene("found_moon_stone", at(MT_MOON_1F, 3, 3, NORTH), "\" found a MOON STONE!\", then the pocket line; the ball is gone"), + running(route("b2f_ladder_foot", at(MT_MOON_B2F, 26, 21, EAST), "1F's ladder (5, 6), down B1F's west side to its ladder (22, 18), onto B2F")), + running(legs("b2f_south", { + {NORTH, 4}, {EAST, 8}, {WEST, 2}, {SOUTH, 5}, {EAST, 8}, {NORTH, 3}, {WEST, 1}, {NORTH, 5}, + {EAST, 7}, {SOUTH, 14}, {WEST, 9}, {SOUTH, 10}, {WEST, 29}, {EAST, 3}, {NORTH, 17}, + }, "through the Grunt's room at (37, 21), round the south edge, up column 11 to (11, 21)")), + step_into("grunt_line", EAST, "onto (12, 21), below the Grunt at (12, 20)"), + battle("rocket_grunt", at(MT_MOON_B2F, 12, 21, EAST), false, "Rattata and Zubat, Lv13"), + ready("ready_for_miguel", at(ROUTE4_CENTER, 7, 4, NORTH), "items for a status or low HP; the nurse, back through the cave, only if they can't"), + // Up column 13 to Miguel, who stops the leg on (13, 12), then east + // to the wall: the tile below his trigger. + running(legs("below_miguel_trigger", {{EAST, 1}, {NORTH, 9}, {EAST, 1}}, "to (14, 12), below Miguel's trigger (14, 11)")), + step_into("miguel_trigger", NORTH, "onto (14, 11), beside Miguel"), + battle("super_nerd_miguel", at(MT_MOON_B2F, 14, 11, NORTH), false, "Grimer, Voltorb and Koffing, Lv12; \"We'll each take one!\""), + running(legs("below_helix_fossil", {{NORTH, 3}}, "up column 14 to the HELIX FOSSIL (14, 7)")), + fossil_step("helix_fossil", "the HELIX FOSSIL; Miguel takes the DOME FOSSIL", Fossil::HELIX), + // The maps keep the fossils, which the game removes after the + // choice: the step onto the HELIX's tile is taken on its own. + step_into("helix_gap", NORTH, "onto (14, 7), where the HELIX FOSSIL lay"), + running(route("route4_east", at(ROUTE4, 32, 6, SOUTH), "west to B2F's ladder (5, 10), up to B1F, east to its exit onto Route 4")), + }); +} + +// Route 4 east of Mt. Moon (Route4 map.json) joins Cerulean City at its +// east end (x 107; Route 4's row 3 is the city's row 13). Its upper road +// (rows 2..8) reaches the city only by water: the way on by land is down +// the ledge row 9 at x 90..96 to the lower road (rows 10..12), which +// joins the city's rows 20..22. Lass Crissy (75, 3) faces east, sight 4, +// over row 3 from x 76 to 79; the way east keeps out of her line and off +// the grass south of the road (75..87, 11..17). +// +// On the way, the Mega Kick tutor (Black Belt (50, 3), facing west, +// Route4_EventScript_MegaKickTutor) stands in a pen (46..51, 2..4) north +// of the road, beside the Mega Punch tutor (47, 3); the pen opens south +// only on (48, 5) and (49, 5), the rest of row 5 ledges out of it. From +// Mt. Moon's exit the walk goes east and up into the pen to (50, 4), +// below the tutor, and Mega Kick takes Withdraw's slot. Cerulean can't +// be walked back to Route 4's upper road without Surf, so it is now or +// much later. +// +// The open leg east across the city's edge ended a tile short on the +// console (2026-11-03: on (6, 20), not (7, 20)), and no hold to a wall +// in the city's west end finds the player from there: the next hold +// south met the fence on (6, 23) and the walk went on a column off. So +// the entry ends on (9, 20), and only legs to walls follow: north up +// column 9 (or 8, a tile short) to row 12, west to (8, 12), east to +// (30, 12) beside the policeman (31, 12), south to (30, 17), west to +// (25, 17), and down the ledge row 18 to (25, 19), a few tiles from the +// door (22, 19). The way keeps out of the boy's (16, 18..24) and the +// balding man's (8..10, 22..24) wander boxes. +StoryCheckpoint route4_to_cerulean(){ + const TeachPlan mega_kick{.item = std::string(TEACH_TUTOR), .move = "mega-kick", .member = 0, .forget_move = "withdraw"}; + return row("00.14", "Route 4: Mega Kick from its tutor, then east to Cerulean's Pokemon Center", at(ROUTE4, 32, 6, SOUTH), after_brock(), StartMenu::FULL, { + running(route("mega_kick_tutor", at(ROUTE4, 50, 4, NORTH), "east and up through (48, 5) into the tutors' pen, below the Mega Kick tutor (50, 3)")), + teach_step("teach_mega_kick", "the tutor's Mega Kick in place of Withdraw, unless the lead knows it", mega_kick), + running(route("above_ledge", at(ROUTE4, 93, 8, SOUTH), "east along Route 4's upper road to above the ledge row 9")), + hop("ledge_hop", SOUTH, at(ROUTE4, 93, 10, SOUTH), "down the ledge (93, 9) to the lower road"), + running(route("cerulean_entry", at(CERULEAN, 9, 20, EAST), "east into Cerulean City, to (9, 20) below column 9")), + running(legs("above_city_ledge", {{NORTH, 8}, {WEST, 1}, {EAST, 22}, {SOUTH, 5}, {WEST, 5}}, "to walls: (9, 12), (8, 12), (30, 12), (30, 17), (25, 17)")), + hop("city_ledge_hop", SOUTH, at(CERULEAN, 25, 19, SOUTH), "down the ledge (25, 18)"), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "west along row 20 to the Pokemon Center's door (22, 19)")), + heal("heal_cerulean"), + }); +} + +// Cerulean Gym (pokefirered CeruleanCity_Gym map.json and scripts.inc): +// a pool with walkways. Misty (8, 6) stands on a platform (7..9, 6) above +// row 7. Picnicker Diana (4, 7) faces east, sight 4, over (5..8, 7), so +// misty_front (8, 7) is in her line; Swimmer Luis (10, 12) in the water +// faces west, sight 1, over (9, 12), on column 9 north of the door. No +// gym trainer has to be fought for Misty, so neither is: from the door up +// column 9 to row 13 (a route there, so the planner's walk to a wall +// doesn't run on past it through (9, 12)), west to (4, 13), and to walls +// round the pool's west and north sides, (4, 10), (12, 10), (12, 7), then +// west to (9, 7), the tile below the platform's east end, out of Diana's +// line. 00.14c steps up onto (9, 6) and talks to Misty from beside her. +// +// Misty beat a Lv20 Wartortle (console B, run m51), so first a GRIND to +// Lv23 on 00.15's patch. Gen 3 numbers (pokefirered: her mons have IV 0; +// Wartortle taken at IV 15, no EVs): Starmie Lv21 has 56 HP, Atk 36, SpA +// 47, SpD 40. Bite is Dark, special in Gen 3, no STAB, x2 on Psychic: at +// Lv23 (SpA 39) it does 23..28, three hits (two on top rolls); at Lv20, +// 20..24. Her Swift (physical, 60) does 9..11 to a Lv23 Wartortle (about +// 67 HP, Def 45) and Water Pulse (STAB, resisted) 8..10: 6 or more hits, +// though m51 saw about 20 a turn. Her Recover (28) and Super Potion undo +// a Bite, so the margin is in Bite's damage. +// +// The Mart comes before the GRIND: its wild battles are fought only with +// 2 or more Potions, and 00.14's save has none (m51 met Misty with only +// the 5 this row bought). 5 Potions (row 3, 300 each), then up to 3 Super +// Potions (row 2, 700), last so they take the Items pocket's last row; +// the battle doesn't use them yet. The money box can't be read, so the +// floor stands for it, and a floor above the money fails the step (the +// quantity box wraps). Prizes (pokefirered: 4 x last level x the class's +// factor) from a new game's 3000, less Viridian's 2000 and the Magikarp's +// 500: Liam 220, Brock 1400, Route 3's bug catchers 360, the Mt. Moon +// grunt 416 and Miguel 288 (the first rival's 80 only if won), at least +// 3184 here. Floor and budget 3000: 5 Potions and 2 Super Potions, 2900. +// Misty's 2100 (4 x 21 x 25) then leaves 00.15 its floor of 1500. Then the +// nurse, and the save before Misty, so her battle is its own checkpoint, +// 00.14c, verified and retried from it. 00.15's GRIND then fights +// until the sim beats the Cerulean rival often enough. +StoryCheckpoint cerulean_gym(){ + const MartPlan supplies{ + .map = CERULEAN_MART, + .lines = {{.item = "potion", .quantity = 5}, {.item = "super-potion", .quantity = 3}}, + .budget = 3000, + .money_floor = 3000, + }; + const GrindPlan to_lv23{.member = 0, .level = 23, .max_encounters = 100, .pace = WEST, .heal_every = 0}; + return row("00.14b", "Cerulean: Potions, a GRIND to Lv23, then into the Gym, saved below Misty's platform", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("mart_clerk", at(CERULEAN_MART, 4, 3, WEST), "out of the Center to the Mart's clerk")), + mart_step("buy_potions", "5 Potions for the GRIND's wild battles, then Super Potions as the money allows", supplies), + running(route("grind_patch", at(ROUTE4, 86, 16, WEST), "west out of the city along Route 4's lower road onto its grass's east edge")), + grind_step("grind_to_lv23", "wild battles until the lead is Lv23, where Bite takes Starmie in three", to_lv23, at(CERULEAN_CENTER, 7, 4, NORTH)), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back to the Center: Misty's battle starts at full HP and PP")), + heal("heal_cerulean"), + running(route("gym_below_luis", at(CERULEAN_GYM, 9, 13), "into the Gym, up column 9 to row 13, short of Luis's (9, 12)")), + running(legs("gym_west_walkway", {{WEST, 5}}, "west to the wall (4, 13)")), + running(legs("below_misty_platform", {{NORTH, 3}, {EAST, 8}, {NORTH, 3}, {WEST, 3}}, "walls (4, 10), (12, 10), (12, 7), then to (9, 7), out of Diana's line")), + }); +} + +// Misty (TRAINER_LEADER_MISTY: Staryu Lv18, Starmie Lv21), talked to from +// (9, 6), beside her on the platform. Her win text runs on to the CASCADE +// BADGE, TM03 (Water Pulse) and its explanation, all read with the +// battle's. Bite is chosen against her party (Starmie is Water/Psychic). +// Back to the nurse round the pool's west side, out of Luis's line; then +// Water Pulse in place of Tail Whip, last, at the Center, from the bag's +// TM CASE. Ends where 00.15 starts. +StoryCheckpoint misty(){ + const TeachPlan water_pulse{.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; + return row("00.14c", "Cerulean Gym: Misty, then Water Pulse", at(CERULEAN_GYM, 9, 7, WEST), after_brock(), StartMenu::FULL, { + running(route("beside_misty", at(CERULEAN_GYM, 9, 6, WEST), "up onto (9, 6), facing Misty (8, 6)")), + talk("challenge_misty", "Misty's speech"), + battle("misty", at(CERULEAN_GYM, 9, 6, WEST), false, "Staryu and Starmie, with Bite; CASCADE BADGE and TM03"), + running(legs("gym_way_out", {{SOUTH, 1}, {EAST, 3}, {SOUTH, 3}, {WEST, 8}, {SOUTH, 3}, {EAST, 5}}, "walls (12, 7), (12, 10), (4, 10), (4, 13), (9, 13), round Luis's (9, 12)")), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "down column 9 out of the Gym, to the Center")), + heal("heal_cerulean"), + teach_step("teach_water_pulse", "TM03 Water Pulse in place of Tail Whip", water_pulse), + }); +} + +// Before the rival north of Cerulean (cerulean_rival()), from the +// Center: first TM03 Water Pulse over Tail Whip, if the lead doesn't know +// it yet, so the GRIND's sim plays it (a run without TM03 from Misty +// skips the step). Then 3 Potions at the Mart +// (CeruleanCity_Mart_Items: POTION is row 3, 300 each; Mt. Moon's +// trainers leave well over the floor) and 2 Awakenings (row 6, 250 each; +// pokefirered items.json) for the rival's Bulbasaur's Sleep Powder +// (console B, m56: asleep at 34/65 HP, it fainted two turns later with +// 15 Potions), 1400 of the floor's 1500. Then a GRIND on Route 4's grass +// east of Mt. Moon (75..86, 11..17; Spearow, Rattata, Ekans and Mankey +// around Lv10, about 80 EXP each), healing at the Center, until the +// battle sim beats the rival 00.15b fights 50% or more: the win chance +// his battle refuses below. Console B's Wartortle (Lv22, 65 HP, Water +// Pulse) is about 42% there and 62% at Lv23; Lv28 (about 88%, some 120 +// battles from Lv22) is the cap. Then the nurse, and the save on (23, 7), +// the tile below his trigger: his battle is its own checkpoint, 00.15b, +// verified and retried from this save. +StoryCheckpoint cerulean_grind(){ + const TeachPlan water_pulse{.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; + const MartPlan potions{ + .map = CERULEAN_MART, + .lines = {{.item = "potion", .quantity = 3}, {.item = "awakening", .quantity = 2}}, + .budget = 1500, + .money_floor = 1500, + }; + const GrindPlan for_rival{ + .member = 0, .level = 28, .max_encounters = 150, .pace = WEST, .heal_every = 0, + .trainer = std::string(story_trainer(CERULEAN, "rival_cerulean").value_or("")), .min_chance = MIN_WIN_CHANCE, + }; + return row("00.15", "Cerulean: Water Pulse, Potions, a GRIND until the rival is winnable, then saved below his trigger", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + teach_step("teach_water_pulse", "TM03 Water Pulse in place of Tail Whip, unless the lead knows it or there is no TM03", water_pulse), + running(route("mart_clerk", at(CERULEAN_MART, 4, 3, WEST), "out of the Center to the Mart's clerk")), + mart_step("buy_potions", "3 Potions: the GRIND fights wild battles only with 2 or more; 2 Awakenings for the rival's Sleep Powder", potions), + running(route("grind_patch", at(ROUTE4, 86, 16, WEST), "west out of the city along Route 4's lower road onto its grass's east edge")), + grind_step("grind_for_rival", "wild battles until the sim beats the rival 50% or more, Lv28 at most; the nurse when hurt or low on PP", for_rival, at(CERULEAN_CENTER, 7, 4, NORTH)), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back to the Center: the rival's battle starts at full HP and PP")), + heal("heal_cerulean"), + running(route("below_rival_trigger", at(CERULEAN, 23, 7, NORTH), "out of the Center, north up to (23, 7), short of the trigger row 6")), + }); +} + +// The rival north of Cerulean (CeruleanCity scripts.inc): the only way +// north to Nugget Bridge is columns 22..24, and a step onto row 6 there +// (CeruleanCity_EventScript_RivalTrigger*, VAR_MAP_SCENE_CERULEAN_CITY_RIVAL +// 0) brings him down to the player. From the middle, (23, 6), he stops +// at (23, 5); after the battle he walks round the player and off south, +// and the player, turning to watch him, ends facing south. His FAME +// CHECKER's text is read to the overworld with the battle's. For a player +// who took Squirtle he has Pidgeotto Lv17, Abra Lv16, Rattata Lv15 and +// Bulbasaur Lv18 (sParty_RivalCeruleanBulbasaur), whose Vine Whip and +// Sleep Powder are the danger. The row starts from 00.15's save below the +// trigger, and saves after him. First TM03 Water Pulse over Tail Whip (from +// the bag, where the player stands), if the lead doesn't know it yet: +// console B's save (2026-11-03) is past Misty's TEACH, which failed there. +// Where she taught it, the step reads the moves and does nothing. +StoryCheckpoint cerulean_rival(){ + const TeachPlan water_pulse{.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; + return row("00.15b", "Cerulean: Water Pulse if not known, then the rival north of the city", at(CERULEAN, 23, 7, NORTH), after_brock(), StartMenu::FULL, { + teach_step("teach_water_pulse", "TM03 Water Pulse in place of Tail Whip, unless the lead knows it", water_pulse), + step_into("rival_trigger", NORTH, "onto (23, 6), the middle trigger: \"Yo! !\""), + battle("rival_cerulean", at(CERULEAN, 23, 6, SOUTH), false, "Pidgeotto, Abra, Rattata and Bulbasaur; then the FAME CHECKER"), + }); +} + +// Nugget Bridge (Route24 map.json) is x 10..12 from row 32 north to row +// 16, the city's columns 22..24 (offset 12). Its five trainers stand on +// it facing across, sight 2, so each line crosses the middle column 11 +// on the trainer's row: Bug Catcher Cale (12, 31), Lass Ali (10, 28), +// Youngster Timmy (12, 25), Lass Reli (10, 22) and Camper Ethan (12, 19). +// Each is fought on the step onto their row, beside them: they don't +// walk, and the player keeps facing north. Two tiles separate the rows, +// walked as one short leg; the step onto a row goes on a tile at a time +// until the trainer sees the player (into_line()), as the open leg onto +// the bridge ended a tile short twice on console B (2026-11-03), on +// (11, 33), and one step left the player out of Cale's sight. Then the +// Rocket (12, 15): the step onto (11, 15) +// (Route24_EventScript_RocketTriggerRight, VAR_MAP_SCENE_ROUTE24 0) gives +// the NUGGET, "join Team Rocket", and his battle; he only turns, and the +// player turns east to him. Camper Shane (5, 21) looks north from the +// west bank, off the way. +StoryCheckpoint nugget_bridge(){ + return row("00.16", "Nugget Bridge: the nurse, five trainers and the Rocket", at(CERULEAN, 23, 6, SOUTH), after_brock(), StartMenu::FULL, { + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back to the Center after the rival, before Nugget Bridge")), + heal("heal_cerulean"), + running(route("below_cale_line", at(ROUTE24, 11, 32, NORTH), "out of the Center, north past the rival's row onto the bridge, below Cale's row")), + into_line("cale_line", NORTH, "onto (11, 31), beside Cale"), + battle("bug_catcher_cale", at(ROUTE24, 11, 31, NORTH), false, "his Bug Pokemon"), + running(legs("below_ali_line", {{NORTH, 2}}, "up to (11, 29)")), + into_line("ali_line", NORTH, "onto (11, 28), beside Ali"), + battle("lass_ali", at(ROUTE24, 11, 28, NORTH), false, "her Pokemon"), + running(legs("below_timmy_line", {{NORTH, 2}}, "up to (11, 26)")), + into_line("timmy_line", NORTH, "onto (11, 25), beside Timmy"), + battle("youngster_timmy", at(ROUTE24, 11, 25, NORTH), false, "his Pokemon"), + running(legs("below_reli_line", {{NORTH, 2}}, "up to (11, 23)")), + into_line("reli_line", NORTH, "onto (11, 22), beside Reli"), + battle("lass_reli", at(ROUTE24, 11, 22, NORTH), false, "her Pokemon"), + running(legs("below_ethan_line", {{NORTH, 2}}, "up to (11, 20)")), + into_line("ethan_line", NORTH, "onto (11, 19), beside Ethan"), + battle("camper_ethan", at(ROUTE24, 11, 19, NORTH), false, "his Pokemon"), + // A route, not legs: the nurse may have been needed. + ready("ready_for_rocket", at(CERULEAN_CENTER, 7, 4, NORTH), "items for a status or low HP; the nurse only if they can't"), + running(route("below_rocket_trigger", at(ROUTE24, 11, 16, NORTH), "up the bridge to below the Rocket's trigger (11, 15)")), + step_into("rocket_trigger", NORTH, "onto (11, 15), beside the Rocket"), + battle("rocket_grunt", at(ROUTE24, 11, 15, EAST), false, "the NUGGET, then Ekans and Zubat, Lv15"), + }); +} + +// Route 24 north of Nugget Bridge (Route24 map.json) joins Route 25 by +// its rows 7..9; the ledges on Route 25's row 6 (x 0..9) leave the way +// north at x 12..13. Route 25 (Route25 map.json, scripts.inc) to Bill's +// Sea Cottage door (51, 4): of its nine trainers, four can't be walked +// around. +// - Hiker Franklin (11, 4) faces east, sight 4, over (12..15, 4), and +// Hiker Wayne (17, 7) east over (18..19, 7): the only ways east past +// x 15. Franklin is fought, from (15, 5) on the step onto (15, 4), the +// end of his line: he walks up to (14, 4). +// - Youngster Joey (18, 2) faces south over (18, 3..4): the way keeps +// off column 18, south round Wayne to (19, 6) and up to (19, 4). +// - Youngster Dan (22, 4) looks around, sight 2, over the only way east, +// (20..21, 4). On (20, 4), a one-tile leg, he may see the player and +// walk up to (21, 4); so the way goes north from there to row 2, east, +// and down column 23 past him to (23, 5), off every tile he may walk +// up to. Where his sight is crossed, the leg ends on a wall or is one +// tile; if he looks the player's way, story_interrupts() fight him. +// - Picnicker Kelsey (22, 8) faces east over (23..24, 8), across the +// only way on: fought on the step onto (24, 8); she walks up to (23, 8). +// - Hiker Nob (27, 9) faces north over (27, 6..8), Camper Flint (28, 4) +// south over (28, 5..7): every way east crosses one. Nob is fought on +// the step onto (27, 8), beside him; the way on keeps off Flint's +// column, by one-tile legs and legs to walls. +// - Youngster Chad (36, 4) walks up and down column 36 (rows 2..7), so +// he may see any tile of it down to row 9. The way crosses his column +// on row 9, a leg to the water at (42, 9), where he can only walk up +// beside it. +// - Lass Haley (42, 5) faces south over (42, 6..8), the only way east of +// x 42: fought on the step onto (42, 8); she walks up to (42, 7). +// The row saves inside the Sea Cottage, which the save card names +// ROUTE 25. +StoryCheckpoint route25(){ + return row("00.17", "Route 24 and 25 to Bill's Sea Cottage: Franklin, Kelsey, Nob and Haley", at(ROUTE24, 11, 15, EAST), after_brock(), StartMenu::FULL, { + running(route("below_franklin_line", at(ROUTE25, 15, 5, NORTH), "north off Nugget Bridge, east into Route 25, round its ledges to (15, 5), below the end of Franklin's line")), + into_line("franklin_line", NORTH, "onto (15, 4), at the end of Franklin's line"), + battle("hiker_franklin", at(ROUTE25, 15, 4, NORTH), false, "Machop and Geodude, Lv15; Franklin stops at (14, 4)"), + running(legs("west_of_dan", {{EAST, 2}, {SOUTH, 2}, {EAST, 2}, {NORTH, 2}}, "to (17, 4), short of Joey's (18, 4); round Wayne (17, 7) to (19, 6); up to (19, 4)")), + running(legs("round_dan", { + {EAST, 1}, {NORTH, 2}, {EAST, 4}, {WEST, 1}, {SOUTH, 3}, {EAST, 1}, {SOUTH, 2}, + }, "onto (20, 4); up to (20, 2), east to (24, 2), down column 23 past Dan to (23, 5), to (24, 7) above Kelsey's line")), + into_line("kelsey_line", SOUTH, "onto (24, 8), at the end of Kelsey's line"), + battle("picnicker_kelsey", at(ROUTE25, 24, 8, SOUTH), false, "Nidoran M and Nidoran F, Lv15; Kelsey stops at (23, 8)"), + running(legs("west_of_nob_line", {{EAST, 2}}, "along row 8 to (26, 8)")), + into_line("nob_line", EAST, "onto (27, 8), above Nob"), + battle("hiker_nob", at(ROUTE25, 27, 8, EAST), false, "three Geodude and a Machop, Lv13"), + running(legs("below_haley_line", { + {EAST, 1}, {SOUTH, 1}, {EAST, 1}, {NORTH, 3}, {EAST, 5}, {SOUTH, 3}, {EAST, 8}, + }, "below Flint's column to (29, 9), up to (29, 6), east to (34, 6), down to (34, 9), east along row 9 past Chad's column to the water, (42, 9)")), + into_line("haley_line", NORTH, "onto (42, 8), at the end of Haley's line"), + battle("lass_haley", at(ROUTE25, 42, 8, NORTH), false, "Oddish, Pidgey and Oddish, Lv13; Haley stops at (42, 7)"), + running(route("sea_cottage", at(SEA_COTTAGE, 7, 8, NORTH), "east along row 8, up column 51 into the Sea Cottage (51, 4), a step in from its mat")), + }); +} + +// Bill's Sea Cottage (Route25_SeaCottage scripts.inc). Bill is the +// Clefairy at (10, 6) (FLAG_HELPED_BILL_IN_SEA_COTTAGE unset): talked to +// from (10, 7), below him, he asks "Help me out!" (YES/NO; NO goes on the +// same), says to run the Cell Separation on the PC, and walks up into +// the right teleporter (10, 3); with the player facing north he walks +// straight up. The PC (4, 5) answers only from the south, (4, 6). Its +// scene: the teleporters run, Bill comes out of the left one (3, 3) and +// walks to (7, 6) (BillWalkToMiddleOfRoom); the player isn't moved. +// Talked to from (6, 6), Bill gives the S.S. TICKET, no YES/NO, and goes +// on about the S.S. ANNE (FLAG_GOT_SS_TICKET, which also hides Nugget +// Bridge's Rocket). Out by the mat (7, 9); the door puts the player on +// (51, 5), facing south. The maps keep Bill at (7, 5), but after his +// scene he stands on (7, 6), in the way from (6, 6) to the mat (console +// B, 2026-11-03), so the walk out goes around him to (7, 8) first. +StoryCheckpoint bill(){ + return row("00.18", "Bill's Sea Cottage: the Cell Separator and the S.S. Ticket", at(SEA_COTTAGE, 7, 8, NORTH), after_brock(), StartMenu::FULL, { + running(route("below_bill_clefairy", at(SEA_COTTAGE, 10, 7, NORTH), "below the Clefairy (10, 6), facing it")), + talk("bill_clefairy", "\"Hiya! I'm a POKEMON... ...No I'm not!\" ... \"Help me out!\""), + answer("help_bill", true, "YES: \"run the Cell Separation on the PC\""), + scene("bill_into_teleporter", at(SEA_COTTAGE, 10, 7, NORTH), "the Clefairy walks up into the right teleporter"), + running(route("pc_front", at(SEA_COTTAGE, 4, 6, NORTH), "below the PC (4, 5), facing it")), + talk("cell_separator", "the PC: \" initiated the TELEPORTER's CELL SEPARATOR!\""), + scene("bill_comes_out", at(SEA_COTTAGE, 4, 6, NORTH), "the teleporters run; Bill comes out of the left one and walks to (7, 6)"), + running(legs("beside_bill", {{EAST, 2}}, "to (6, 6), west of Bill, facing him")), + talk("thank_bill", "\"Thanks, bud! ... take this\""), + scene("ss_ticket", at(SEA_COTTAGE, 6, 6, EAST), "the S.S. TICKET; \"... the S.S. ANNE ... go instead\""), + running(legs("around_bill", {{SOUTH, 2}, {EAST, 1}}, "to (7, 8), above the mat; Bill stands on (7, 6) after his scene")), + running(route("out_of_cottage", at(ROUTE25, 51, 5, SOUTH), "down to the mat and out; the door puts the player below it")), + }); +} + +// Back to Cerulean by Route 25's south path (Route25 map.json): from +// below the cottage down column 51 to row 12, west along row 12 and row +// 11 into Route 24 (x 14..23, row 11), and down its column 14..16 east +// of Nugget Bridge to Cerulean's north edge (x 26..28, offset 12). No +// trainer stands on it. Cerulean's row 1 there is a ledge (26..28, 1), +// hopped south from (27, 0); then on into the city to the Pokemon Center. +StoryCheckpoint back_to_cerulean(){ + return row("00.19", "Route 25's south path back to Cerulean's Pokemon Center", at(ROUTE25, 51, 5, SOUTH), after_brock(), StartMenu::FULL, { + running(route("above_cerulean_ledge", at(CERULEAN, 27, 0, SOUTH), "Route 25's south path west, Route 24 east of the bridge south, to above Cerulean's ledge (27, 1)")), + hop("cerulean_ledge_hop", SOUTH, at(CERULEAN, 27, 2, SOUTH), "down the ledge (27, 1)"), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "into the city to the Pokemon Center")), + heal("heal_cerulean"), + }); +} + +// Stopgap for console B's save, which caught nothing on Route 1. A new +// playthrough should instead catch a Rattata on Route 1 or 22 early, as +// its Cut user and spare member, and drop this row (and its walk over +// Nugget Bridge and back). +// +// An Oddish for HM01 Cut, and a second member behind the lead +// (docs/design/AutoStory-FRLG-FutureEvents.md, "Console B run choices"). +// Route 24's grass (Route24 map.json: x 3..5, rows 19..31, west of Nugget +// Bridge) is the nearest to the Center with Oddish: FireRed's +// sRoute24_FireRed (pokefirered wild_encounters.json) has it in 25% of +// battles, Lv12..14, as Route 25's grass; Routes 5 and 6 aren't on the +// story's maps yet. Up the bridge, whose trainers 00.16 beat, west along +// rows 10..15 to the cliff (3, 15) and down column 3 to the grass's first +// row, (3, 19); the step paces east to (4, 19) and back. Camper Shane +// (5, 21) faces north, sight 5, over (5, 16..20): column 5 is kept off. +// The leg west ends on the cliff, not open: on console B (m63) an open +// leg west to (4, 15) ended a tile short, and the walk down column 5 +// came into Shane's sight. +// +// First 7 Poke Balls at the Mart (row 1, 200 each; the bag isn't read, so +// the step buys them whatever the bag holds). The floor, from 00.15's +// 1500 less its 1400: the rival's 288 (4 x 18 x 4), the Rocket's 480 (4 +// x 15 x 8), Franklin's 540 and Nob's 468 (hikers, 9) alone make 1876 +// (pokefirered gTrainerMoneyTable). An Oddish at full HP (catch rate 255) +// stays in a Poke Ball about 1 time in 3 (Gen 3: a = 85, four shakes of +// 0.76), so 7 balls catch it about 94% of the time; the step throws 7 at +// most, in 30 battles (none an Oddish: 0.02%). Nothing weakens it: the HP +// bar isn't read on the console yet. +// +// The Oddish joins the party second. Battles keep the lead in +// (PokemonFRLG_AutoStoryBattleDecisions.h): the Oddish is sent only when +// the lead faints in a trainer's battle, which counts as lost. Back to +// the nurse, and the save in the Center. +StoryCheckpoint oddish(){ + const MartPlan balls{ + .map = CERULEAN_MART, + .lines = {{.item = "poke-ball", .quantity = 7}}, + .budget = 1400, + .money_floor = 1500, + }; + const CapturePlan catch_oddish{.species = "oddish", .catches = 1, .max_encounters = 30, .max_balls = 7, .pace = EAST}; + return row("00.20", "Route 24's grass: an Oddish for Cut, then the Center", at(CERULEAN_CENTER, 7, 4, NORTH), after_brock(), StartMenu::FULL, { + running(route("mart_clerk", at(CERULEAN_MART, 4, 3, WEST), "out of the Center to the Mart's clerk")), + mart_step("buy_poke_balls", "7 Poke Balls for the Oddish", balls), + running(route("oddish_patch", at(ROUTE24, 3, 19), "north over Nugget Bridge, west to the cliff and down column 3 onto the grass's first row, out of Shane's column 5")), + capture_step("catch_oddish", "wild battles until an Oddish, then Poke Balls; any other species is run from", catch_oddish), + running(route("cerulean_nurse", at(CERULEAN_CENTER, 7, 4, NORTH), "back over the bridge to the Center")), + heal("heal_cerulean"), + }); +} + +} // 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(), + }; + return checkpoints; +} + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp index 90e100d5d7..fb487e8f09 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.cpp @@ -82,7 +82,7 @@ std::optional parse_teach_variant(std::string_view variant){ return std::nullopt; } const std::vector words = variant_words(variant.substr(TEACH_VARIANT_PREFIX.size())); - if (words.size() < 3 || !is_tm_or_hm(words[0]) || variant_pair(words[1])){ + if (words.size() < 3 || !(is_tm_or_hm(words[0]) || words[0] == TEACH_TUTOR) || variant_pair(words[1])){ return std::nullopt; } TeachPlan plan{.item = std::string(words[0]), .move = std::string(words[1])}; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h index 648bd53d35..53a2b1f37d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach.h @@ -1,188 +1,201 @@ -/* AutoStory Teach - * - * From: https://github.com/PokemonAutomation/ - * - * A story step that teaches a TM or HM from the bag to a party member, - * forgetting a move the table names, as the game runs it (console B, - * 2026-11-03; the screens are in PokemonFRLG_AutoStoryTeachScreens.h): - * - * - BAG, KEY ITEMS, TM CASE, OPEN, the TM or HM, USE: the party menu, - * "Teach which POKEMON?", each member ABLE or NOT ABLE. A on a member - * that knows the move says so; one NOT ABLE: " and are not - * compatible.". - * - " wants to learn the move ."; with fewer than four moves - * it learns. - * - With four: "However, already knows four moves.", "Should a - * move be deleted and replaced with ?" YES/NO. YES opens the - * moves page; A on a move forgets it and it learns. An HM move can't be - * forgotten (pokefirered: "HM moves can't be forgotten now.") and the - * moves page shows again. B there, then "Stop trying to teach ?" - * YES, leaves it unlearned. - * - A TM is used up; an HM is not. - * - * A TM or HM the TM CASE hasn't got skips the step, with a log line: the - * table can teach a TM a run may not have won yet. - * - * The step reads the member's moves first (MemberReader) and decides - * everything before it opens the bag: the move to forget, by name or - * slot; a refusal if the member already knows the move, has four moves - * and no move to forget is named, or the move named is an HM move. After - * it, the moves are read again: the step succeeds only if the new move - * shows in the forgotten move's place. - * - * The decisions take their game steps from a TeachActions, so they run in - * tests against a fake party and TM Case; on the console they are - * ScreenTeachActions (PokemonFRLG_AutoStoryTeachScreens.h). - * - */ - -#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTeach_H -#define PokemonAutomation_PokemonFRLG_AutoStoryTeach_H - -#include -#include -#include -#include -#include -#include "Common/Cpp/Logging/AbstractLogger.h" -#include "Common/Cpp/TestRunners/UnitTestDatabase.h" -#include "Pokemon/AutoStory/Pokemon_StorySteps.h" -#include "PokemonFRLG_AutoStoryMember.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -// What a TEACH step teaches. -struct TeachPlan{ - // The TM or HM's slug, e.g. "hm03", "tm28". - std::string item = {}; - // The move it holds, e.g. "surf". - std::string move = {}; - // The party slot (0-5). - size_t member = 0; - // The move to forget when it knows four: by name, or else by its - // slot (0-3). Neither: it must have a free slot. - std::string forget_move = {}; - std::optional forget_slot = std::nullopt; -}; - -// Whether `move` is an HM's, which the game won't let a Pokemon forget. -bool is_hm_move(std::string_view move); - -// The prefix of a GAME step's variant that names a TEACH step. -inline constexpr std::string_view TEACH_VARIANT_PREFIX = "teach "; - -// `plan` as a variant: "teach hm03 surf slot=1 forget=tail-whip" or -// "... forget_slot=2", slots counted from 1, no forget when it has room. -std::string teach_variant(const TeachPlan& plan); +/* AutoStory Teach + * + * From: https://github.com/PokemonAutomation/ + * + * A story step that teaches a TM or HM from the bag to a party member, + * forgetting a move the table names, as the game runs it (console B, + * 2026-11-03; the screens are in PokemonFRLG_AutoStoryTeachScreens.h): + * + * - BAG, KEY ITEMS, TM CASE, OPEN, the TM or HM, USE: the party menu, + * "Teach which POKEMON?", each member ABLE or NOT ABLE. A on a member + * that knows the move says so; one NOT ABLE: " and are not + * compatible.". + * - " wants to learn the move ."; with fewer than four moves + * it learns. + * - With four: "However, already knows four moves.", "Should a + * move be deleted and replaced with ?" YES/NO. YES opens the + * moves page; A on a move forgets it and it learns. An HM move can't be + * forgotten (pokefirered: "HM moves can't be forgotten now.") and the + * moves page shows again. B there, then "Stop trying to teach ?" + * YES, leaves it unlearned. + * - A TM is used up; an HM is not. + * + * A TM or HM the TM CASE hasn't got skips the step, with a log line: the + * table can teach a TM a run may not have won yet. + * + * A move tutor teaches the same way from the overworld, the player facing + * the tutor (pokefirered data/scripts/move_tutors.inc, e.g. + * Route4_EventScript_MegaKickTutor): its offer, YES/NO; "This move can be + * learned only once. Is that okay?", YES/NO; "Which POKEMON wants to + * learn it?", then the party menu (ChooseMonForMoveTutor) with the TM's + * prompts. After it, the party menu closes onto the tutor's last line, + * not the TM CASE. + * + * The step reads the member's moves first (MemberReader) and decides + * everything before it opens the bag: the move to forget, by name or + * slot; a refusal if the member already knows the move, has four moves + * and no move to forget is named, or the move named is an HM move. After + * it, the moves are read again: the step succeeds only if the new move + * shows in the forgotten move's place. + * + * The decisions take their game steps from a TeachActions, so they run in + * tests against a fake party and TM Case; on the console they are + * ScreenTeachActions (PokemonFRLG_AutoStoryTeachScreens.h). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTeach_H +#define PokemonAutomation_PokemonFRLG_AutoStoryTeach_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "PokemonFRLG_AutoStoryMember.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// What a TEACH step teaches. +struct TeachPlan{ + // The TM or HM's slug, e.g. "hm03", "tm28"; or TEACH_TUTOR, the move + // tutor the player faces. + std::string item = {}; + // The move it holds, e.g. "surf". + std::string move = {}; + // The party slot (0-5). + size_t member = 0; + // The move to forget when it knows four: by name, or else by its + // slot (0-3). Neither: it must have a free slot. + std::string forget_move = {}; + std::optional forget_slot = std::nullopt; +}; + +// TeachPlan::item for a move tutor. +inline constexpr std::string_view TEACH_TUTOR = "tutor"; + +// Whether `move` is an HM's, which the game won't let a Pokemon forget. +bool is_hm_move(std::string_view move); + +// The prefix of a GAME step's variant that names a TEACH step. +inline constexpr std::string_view TEACH_VARIANT_PREFIX = "teach "; + +// `plan` as a variant: "teach hm03 surf slot=1 forget=tail-whip" or +// "... forget_slot=2", slots counted from 1, no forget when it has room. +std::string teach_variant(const TeachPlan& plan); // Parses a move teaching plan from a variant string. -std::optional parse_teach_variant(std::string_view variant); - -// The table's TEACH step, once per save (StoryStep::once_per_save): a -// retry from the same save skips it when the lead knew the move or there -// was no TM. -Pokemon::StoryStep teach_step(std::string name, std::string why, const TeachPlan& plan); - - -// The move slot `plan` forgets for a member that knows `moves`: nothing -// when it has room. Empty `error` if it can be taught, else why not. -struct TeachDecision{ - std::optional forget = std::nullopt; - std::string error = {}; - // It knows the move already: nothing to do. - bool already_known = false; -}; +std::optional parse_teach_variant(std::string_view variant); + +// The table's TEACH step, once per save (StoryStep::once_per_save): a +// retry from the same save skips it when the lead knew the move or there +// was no TM. +Pokemon::StoryStep teach_step(std::string name, std::string why, const TeachPlan& plan); + + +// The move slot `plan` forgets for a member that knows `moves`: nothing +// when it has room. Empty `error` if it can be taught, else why not. +struct TeachDecision{ + std::optional forget = std::nullopt; + std::string error = {}; + // It knows the move already: nothing to do. + bool already_known = false; +}; // Determines which move slot to overwrite given current moves. -TeachDecision decide_teach(const TeachPlan& plan, const std::vector& moves); - - -// What A on the member showed. -enum class TeachPrompt{ - LEARNED, - // "Delete an older move to make room?" - DELETE_PROMPT, - NOT_COMPATIBLE, - ALREADY_KNOWS, - FAILED, -}; - +TeachDecision decide_teach(const TeachPlan& plan, const std::vector& moves); + + +// What A on the member showed. +enum class TeachPrompt{ + LEARNED, + // "Delete an older move to make room?" + DELETE_PROMPT, + NOT_COMPATIBLE, + ALREADY_KNOWS, + FAILED, +}; + // The outcome of attempting to forget a move. -enum class ForgetEnd{ - LEARNED, - // "HM moves can't be forgotten now.": back on the moves page. - HM_REFUSED, - FAILED, -}; - -// What opening the TM or HM from the bag came to. -enum class ItemOpen{ - // On "Teach which POKEMON?". - OPENED, - // The TM CASE's list was read and hasn't got it. - NOT_IN_CASE, - FAILED, -}; - -// The game steps a TEACH step takes, injected so its decisions run in -// tests. -class TeachActions{ -public: - virtual ~TeachActions() = default; - - // From the overworld: the bag, the TM Case, `item` (which holds - // `move`), USE, to the party menu. - virtual ItemOpen open_item(std::string_view item, std::string_view move) = 0; - // On "Teach which POKEMON?": A on `slot`. - virtual TeachPrompt choose_member(size_t slot) = 0; - // On "Delete an older move?": YES, then A on move `slot` (0-3). - virtual ForgetEnd forget(size_t slot) = 0; - // From any of its prompts: give up teaching (NO, "Stop trying?" YES). - virtual void stop_teaching() = 0; - // Back to the overworld. False if it didn't come back. - virtual bool leave() = 0; - // Saves the screen as a debug image, named `label`: the step failed. - virtual void dump_screen(std::string_view label) = 0; -}; - +enum class ForgetEnd{ + LEARNED, + // "HM moves can't be forgotten now.": back on the moves page. + HM_REFUSED, + FAILED, +}; + +// What opening the TM or HM from the bag came to. +enum class ItemOpen{ + // On "Teach which POKEMON?". + OPENED, + // The TM CASE's list was read and hasn't got it. + NOT_IN_CASE, + FAILED, +}; + +// The game steps a TEACH step takes, injected so its decisions run in +// tests. +class TeachActions{ +public: + virtual ~TeachActions() = default; + + // From the overworld: the bag, the TM Case, `item` (which holds + // `move`), USE, to the party menu. For TEACH_TUTOR: talk to the + // tutor the player faces, YES to its questions, to the party menu. + virtual ItemOpen open_item(std::string_view item, std::string_view move) = 0; + // On "Teach which POKEMON?": A on `slot`. + virtual TeachPrompt choose_member(size_t slot) = 0; + // On "Delete an older move?": YES, then A on move `slot` (0-3). + virtual ForgetEnd forget(size_t slot) = 0; + // From any of its prompts: give up teaching (NO, "Stop trying?" YES). + virtual void stop_teaching() = 0; + // Back to the overworld. False if it didn't come back. + virtual bool leave() = 0; + // Saves the screen as a debug image, named `label`: the step failed. + virtual void dump_screen(std::string_view label) = 0; +}; + // How a move teaching sequence concluded. -enum class TeachEnd{ - LEARNED, - ALREADY_KNOWN, - // decide_teach() or the game refused it. - REFUSED, - // The member's moves couldn't be read: nothing was pressed. - UNREAD, - // The TM or HM isn't in the TM CASE: skipped, nothing taught. - NO_ITEM, - FAILED, -}; +enum class TeachEnd{ + LEARNED, + ALREADY_KNOWN, + // decide_teach() or the game refused it. + REFUSED, + // The member's moves couldn't be read: nothing was pressed. + UNREAD, + // The TM or HM isn't in the TM CASE: skipped, nothing taught. + NO_ITEM, + FAILED, +}; // The human-readable name of a teach outcome. -const char* teach_end_name(TeachEnd end); - +const char* teach_end_name(TeachEnd end); + // The summary result of a move teaching operation. -struct TeachResult{ - TeachEnd end = TeachEnd::FAILED; - // The move forgotten, if any. - std::string forgot = {}; - std::string detail = {}; -}; - -// From the overworld: reads the member, teaches as `plan` says, back to -// the overworld, and reads the member again to confirm. -TeachResult run_teach(Logger& logger, TeachActions& actions, const MemberReader& reader, const TeachPlan& plan); - +struct TeachResult{ + TeachEnd end = TeachEnd::FAILED; + // The move forgotten, if any. + std::string forgot = {}; + std::string detail = {}; +}; + +// From the overworld: reads the member, teaches as `plan` says, back to +// the overworld, and reads the member again to confirm. +TeachResult run_teach(Logger& logger, TeachActions& actions, const MemberReader& reader, const TeachPlan& plan); + // Formats a log message summarizing the move teaching result. -std::string teach_message(const TeachPlan& plan, const TeachResult& result); - - +std::string teach_message(const TeachPlan& plan, const TeachResult& result); + + // Register auto-story move teaching unit tests. -void add_tests_AutoStoryTeach(UnitTestDatabase& database); - - - +void add_tests_AutoStoryTeach(UnitTestDatabase& database); + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation -#endif +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp index 4740398e92..41bb533259 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.cpp @@ -10,6 +10,7 @@ #include "CommonFramework/Tools/DebugDumper.h" #include "CommonFramework/VideoPipeline/VideoFeed.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryTeachScreens.h" @@ -131,6 +132,14 @@ ItemOpen ScreenTeachActions::open_row(TeachScreen screen, std::string_view name) } ItemOpen ScreenTeachActions::open_item(std::string_view item, std::string_view move){ + m_tutor = item == TEACH_TUTOR; + if (m_tutor){ + if (!m_screens.talk_to_tutor()){ + m_logger.log(std::format("Teach: the tutor's questions for {} didn't lead to the party menu.", move), COLOR_RED); + return ItemOpen::FAILED; + } + return wait_for(TeachScreen::PARTY_PROMPT).has_value() ? ItemOpen::OPENED : ItemOpen::FAILED; + } if (!m_screens.open_bag()){ m_logger.log("Teach: the bag didn't open.", COLOR_RED); return ItemOpen::FAILED; @@ -160,6 +169,7 @@ TeachPrompt ScreenTeachActions::choose_member(size_t slot){ TeachPrompt ScreenTeachActions::member_answer(){ bool incompatible = false; + bool texts = false; size_t prompts = 0; for (size_t c = 0; c < MAX_LOOKS; c++){ const TeachView view = m_screens.look(); @@ -169,8 +179,15 @@ TeachPrompt ScreenTeachActions::member_answer(){ return TeachPrompt::DELETE_PROMPT; } incompatible |= view.text == TeachText::NOT_COMPATIBLE; + texts = true; press(TeachPress::A); break; + case TeachScreen::OTHER: + // A tutor's party menu closes onto the overworld once learned. + if (m_tutor && texts){ + return TeachPrompt::LEARNED; + } + break; case TeachScreen::PARTY_YES_NO: return view.text == TeachText::STOP_QUESTION ? TeachPrompt::FAILED : TeachPrompt::DELETE_PROMPT; case TeachScreen::KNOWN_MOVES: @@ -233,6 +250,10 @@ ForgetEnd ScreenTeachActions::forget(size_t slot){ case TeachScreen::ITEM_MENU: return picked ? ForgetEnd::LEARNED : ForgetEnd::FAILED; default: + // A tutor's: leave() pages through them; the moves read decide. + 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){ @@ -318,6 +339,31 @@ bool ConsoleTeachScreens::open_bag(){ } } +bool ConsoleTeachScreens::talk_to_tutor(){ + try{ + // A talks, pages through its lines and says YES, the top row. + answer_yes_no(m_console, m_context, true, BUTTON_A, 30000ms); + answer_yes_no(m_console, m_context, true, BUTTON_A, 15000ms); + }catch (OperationFailedException& e){ + m_console.log("Teach: " + e.message(), COLOR_RED); + return false; + } + // "Which POKEMON wants to learn it?": A only on its advance arrow, as + // A on the party menu would choose the lead. + for (size_t c = 0; c < 50; c++){ + m_context.wait_for_all_requests(); + VideoSnapshot snapshot = m_console.video().snapshot(); + if (snapshot && AdvanceWhiteDialogDetector(COLOR_RED).detect(snapshot)){ + pbf_press_button(m_context, BUTTON_A, 160ms, 240ms); + }else if (snapshot && read_teach_screen(snapshot) == TeachScreen::PARTY_PROMPT){ + return true; + }else{ + pbf_wait(m_context, 300ms); + } + } + return false; +} + TeachView ConsoleTeachScreens::look(){ // Two looks 150 ms apart that agree: the screen has stopped changing. std::optional>> last; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h index 9f665101aa..d90618b306 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens.h @@ -1,164 +1,177 @@ -/* AutoStory Teach Screens - * - * From: https://github.com/PokemonAutomation/ - * - * The TEACH step's game steps on the console, screen by screen as console - * B showed them (2026-11-03, FireRed, TM03 to a Wartortle with four - * moves): - * - * - START, BAG: the bag opens on the pocket it was left on (ITEMS); - * RIGHT goes to KEY ITEMS, LEFT back (POKE BALLS is right of KEY ITEMS). - * - KEY ITEMS: TEACHY TV, TM CASE, HELIX FOSSIL, CANCEL. A on TM CASE: - * OPEN/REGISTER/CANCEL, OPEN on top; A: the TM CASE's list ("No03 - * WATER PULSE x1", "No39 ROCK TOMB x1", CLOSE). - * - A on a TM: USE/GIVE/EXIT, USE on top; A: the party menu, "Teach which - * POKEMON?", each member ABLE or NOT ABLE, the cursor on the lead. - * - A on a member: " wants to learn the move ."; with room it - * learns. With four: "However, already knows four moves.", - * "Should a move be deleted and replaced with ?" YES/NO (YES on - * top), YES: "Which move should be forgotten?", then KNOWN MOVES with - * the four moves and the new one fifth, the red cursor on the first. A - * on a move forgets it: "1, 2 and... ... Poof!", " forgot .", - * "And... learned !", which the reader doesn't place (read - * "other"; B pages through them), then the TM CASE's list. B instead: "Stop trying to teach ?" - * YES/NO, YES: " did not learn the move ." and the TM CASE's - * list again, the TM kept. - * - From the TM CASE's list, B closes it to the bag, and B the bag. - * - * Each step looks at the screen before it presses, and stops at a screen - * it doesn't expect instead of pressing on. The screens come from a - * TeachScreens, so the steps run in tests against a fake bag, TM CASE and - * party menu that answer as the console did. - * - */ - -#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTeachScreens_H -#define PokemonAutomation_PokemonFRLG_AutoStoryTeachScreens_H - -#include -#include -#include -#include -#include "Common/Cpp/Logging/AbstractLogger.h" -#include "Common/Cpp/TestRunners/UnitTestDatabase.h" -#include "CommonFramework/Language.h" -#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" -#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" -#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h" -#include "PokemonFRLG_AutoStoryTeach.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -// A screen on the way, read. -struct TeachView{ - TeachScreen screen = TeachScreen::OTHER; - // The cursor's row, from the top. - std::optional cursor = std::nullopt; - // In the bag: the pocket's name ("items", "key-items", "poke-balls"). - std::string pocket = {}; - // In KEY ITEMS, its items ("tm-case", ...); in the TM CASE, its TMs' - // moves ("water-pulse", ...): from the top to the first not read. - std::vector rows = {}; - // Over the party menu: its message. - TeachText text = TeachText::UNKNOWN; -}; - +/* AutoStory Teach Screens + * + * From: https://github.com/PokemonAutomation/ + * + * The TEACH step's game steps on the console, screen by screen as console + * B showed them (2026-11-03, FireRed, TM03 to a Wartortle with four + * moves): + * + * - START, BAG: the bag opens on the pocket it was left on (ITEMS); + * RIGHT goes to KEY ITEMS, LEFT back (POKE BALLS is right of KEY ITEMS). + * - KEY ITEMS: TEACHY TV, TM CASE, HELIX FOSSIL, CANCEL. A on TM CASE: + * OPEN/REGISTER/CANCEL, OPEN on top; A: the TM CASE's list ("No03 + * WATER PULSE x1", "No39 ROCK TOMB x1", CLOSE). + * - A on a TM: USE/GIVE/EXIT, USE on top; A: the party menu, "Teach which + * POKEMON?", each member ABLE or NOT ABLE, the cursor on the lead. + * - A on a member: " wants to learn the move ."; with room it + * learns. With four: "However, already knows four moves.", + * "Should a move be deleted and replaced with ?" YES/NO (YES on + * top), YES: "Which move should be forgotten?", then KNOWN MOVES with + * the four moves and the new one fifth, the red cursor on the first. A + * on a move forgets it: "1, 2 and... ... Poof!", " forgot .", + * "And... learned !", which the reader doesn't place (read + * "other"; B pages through them), then the TM CASE's list. B instead: "Stop trying to teach ?" + * YES/NO, YES: " did not learn the move ." and the TM CASE's + * list again, the TM kept. + * - From the TM CASE's list, B closes it to the bag, and B the bag. + * - A move tutor (not seen on console B yet; pokefirered + * move_tutors.inc): its lines and two YES/NOs in the overworld's + * message box, then the same party menu and prompts. After the learn, + * or "did not learn", the party menu closes onto the tutor's last line + * in the overworld, which the reader doesn't place. + * + * Each step looks at the screen before it presses, and stops at a screen + * it doesn't expect instead of pressing on. The screens come from a + * TeachScreens, so the steps run in tests against a fake bag, TM CASE and + * party menu that answer as the console did. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryTeachScreens_H +#define PokemonAutomation_PokemonFRLG_AutoStoryTeachScreens_H + +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Language.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TeachScreenReader.h" +#include "PokemonFRLG_AutoStoryTeach.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// A screen on the way, read. +struct TeachView{ + TeachScreen screen = TeachScreen::OTHER; + // The cursor's row, from the top. + std::optional cursor = std::nullopt; + // In the bag: the pocket's name ("items", "key-items", "poke-balls"). + std::string pocket = {}; + // In KEY ITEMS, its items ("tm-case", ...); in the TM CASE, its TMs' + // moves ("water-pulse", ...): from the top to the first not read. + std::vector rows = {}; + // Over the party menu: its message. + TeachText text = TeachText::UNKNOWN; +}; + // A controller action taken while navigating teaching menus. -enum class TeachPress{ - A, - B, - UP, - DOWN, - LEFT, - RIGHT, -}; +enum class TeachPress{ + A, + B, + UP, + DOWN, + LEFT, + RIGHT, +}; // The human-readable name of a teaching navigation action. -const char* teach_press_name(TeachPress press); - -// The console's screens and buttons, as the TEACH step uses them. -class TeachScreens{ -public: - virtual ~TeachScreens() = default; - - // From the overworld: START, BAG. False if the bag didn't open. - virtual bool open_bag() = 0; - // The screen, once it has stopped changing. - virtual TeachView look() = 0; - virtual void press(TeachPress press) = 0; - // B until the overworld is back. False if it didn't come back. - virtual bool to_overworld() = 0; - // Saves the screen as a debug image, named `label`. - virtual void dump_screen(std::string_view label) = 0; -}; - - -// The TEACH step's game steps through `screens`. -class ScreenTeachActions : public TeachActions{ -public: - ScreenTeachActions(Logger& logger, TeachScreens& screens); - - virtual ItemOpen open_item(std::string_view item, std::string_view move) override; - virtual TeachPrompt choose_member(size_t slot) override; - virtual ForgetEnd forget(size_t slot) override; - virtual void stop_teaching() override; - virtual bool leave() override; - virtual void dump_screen(std::string_view label) override; - -private: - // Looks until `screen` shows, `looks` times at most. - std::optional wait_for(TeachScreen screen, size_t looks = 12); - // On `screen`: the cursor to `row`. False if it didn't get there. - bool move_cursor_to(TeachScreen screen, size_t row); - // In the bag: to KEY ITEMS. - bool to_key_items(); - // On the list `screen`: A on the row `name`, then A on the item menu's - // top row (OPEN or USE). NOT_IN_CASE: the rows read haven't got it. - ItemOpen open_row(TeachScreen screen, std::string_view name); - // After A on a member: what it showed. - TeachPrompt member_answer(); - // On a YES/NO while forgetting: the answer that goes on to the moves. - void answer_to_forget(const TeachView& view); - void press(TeachPress press); - - Logger& m_logger; - TeachScreens& m_screens; -}; - - -// The console's screens: read with PokemonFRLG_TeachScreenReader.h in a -// game in `language`. -class ConsoleTeachScreens : public TeachScreens{ -public: - ConsoleTeachScreens(ConsoleHandle& console, ProControllerContext& context, Language language); - - virtual bool open_bag() override; - virtual TeachView look() override; - virtual void press(TeachPress press) override; - virtual bool to_overworld() override; - virtual void dump_screen(std::string_view label) override; - -private: - // The screen in `image`, read. - TeachView read_view(const ImageViewRGB32& image, TeachScreen screen, std::optional cursor); - void log_view(const TeachView& view); - - ConsoleHandle& m_console; - ProControllerContext& m_context; - Language m_language; - // The last view logged. - std::string m_logged; -}; - - +const char* teach_press_name(TeachPress press); + +// The console's screens and buttons, as the TEACH step uses them. +class TeachScreens{ +public: + virtual ~TeachScreens() = default; + + // From the overworld: START, BAG. False if the bag didn't open. + virtual bool open_bag() = 0; + // From the overworld, facing a move tutor: talks, YES to its offer and + // to "This move can be learned only once", its messages to the party + // menu. False if a YES/NO or the party menu didn't show. + virtual bool talk_to_tutor() = 0; + // The screen, once it has stopped changing. + virtual TeachView look() = 0; + virtual void press(TeachPress press) = 0; + // B until the overworld is back. False if it didn't come back. + virtual bool to_overworld() = 0; + // Saves the screen as a debug image, named `label`. + virtual void dump_screen(std::string_view label) = 0; +}; + + +// The TEACH step's game steps through `screens`. +class ScreenTeachActions : public TeachActions{ +public: + ScreenTeachActions(Logger& logger, TeachScreens& screens); + + virtual ItemOpen open_item(std::string_view item, std::string_view move) override; + virtual TeachPrompt choose_member(size_t slot) override; + virtual ForgetEnd forget(size_t slot) override; + virtual void stop_teaching() override; + virtual bool leave() override; + virtual void dump_screen(std::string_view label) override; + +private: + // Looks until `screen` shows, `looks` times at most. + std::optional wait_for(TeachScreen screen, size_t looks = 12); + // On `screen`: the cursor to `row`. False if it didn't get there. + bool move_cursor_to(TeachScreen screen, size_t row); + // In the bag: to KEY ITEMS. + bool to_key_items(); + // On the list `screen`: A on the row `name`, then A on the item menu's + // top row (OPEN or USE). NOT_IN_CASE: the rows read haven't got it. + ItemOpen open_row(TeachScreen screen, std::string_view name); + // After A on a member: what it showed. + TeachPrompt member_answer(); + // On a YES/NO while forgetting: the answer that goes on to the moves. + void answer_to_forget(const TeachView& view); + void press(TeachPress press); + + Logger& m_logger; + TeachScreens& m_screens; + // Taught by a tutor: after the party menu comes the overworld, not + // the TM CASE. + bool m_tutor = false; +}; + + +// The console's screens: read with PokemonFRLG_TeachScreenReader.h in a +// game in `language`. +class ConsoleTeachScreens : public TeachScreens{ +public: + ConsoleTeachScreens(ConsoleHandle& console, ProControllerContext& context, Language language); + + virtual bool open_bag() override; + virtual bool talk_to_tutor() override; + virtual TeachView look() override; + virtual void press(TeachPress press) override; + virtual bool to_overworld() override; + virtual void dump_screen(std::string_view label) override; + +private: + // The screen in `image`, read. + TeachView read_view(const ImageViewRGB32& image, TeachScreen screen, std::optional cursor); + void log_view(const TeachView& view); + + ConsoleHandle& m_console; + ProControllerContext& m_context; + Language m_language; + // The last view logged. + std::string m_logged; +}; + + // Register teach screen navigation unit tests. -void add_tests_AutoStoryTeachScreens(UnitTestDatabase& database); - - - +void add_tests_AutoStoryTeachScreens(UnitTestDatabase& database); + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation -#endif +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp index f9c8c15cfb..ce0ec98291 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeachScreens_Tests.cpp @@ -1,458 +1,549 @@ -/* AutoStory Teach Screens Tests - * - * The console's TEACH steps against a fake bag, TM CASE and party menu - * that answer as console B did on 2026-11-03 (FireRed): the bag left on - * ITEMS; KEY ITEMS with TEACHY TV, TM CASE, HELIX FOSSIL; TM03 WATER - * PULSE and TM39 ROCK TOMB in the TM CASE; a Wartortle Lv22 with BITE, - * TAIL WHIP, BUBBLE, WITHDRAW. That run's step failed with "couldn't open - * tm03 from the TM Case": the console's steps had no readers and stopped - * before the bag. - * - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include "Common/Cpp/Exceptions.h" -#include "Common/Cpp/TestRunners/UnitTest.h" -#include "PokemonFRLG_AutoStoryTeachScreens.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -namespace{ - -class QuietLogger : public Logger{ -public: - using Logger::log; - virtual void log(const std::string&, Color) override{} -}; - -struct FakeTm{ - std::string move; - size_t count = 1; -}; - -// The game's state behind the screens. -enum class FakeScreen{ - OVERWORLD, - START_MENU, - BAG, - BAG_MENU, - TM_CASE, - TM_MENU, - PROMPT, - TEXT, - // Messages the reader doesn't place: "other". - UNREAD_TEXT, - DELETE_YES_NO, - STOP_YES_NO, - KNOWN_MOVES, -}; - -// The bag, TM CASE and party menu as console B showed them. -struct FakeTeachGame{ - std::vector pockets{"items", "key-items", "poke-balls"}; - size_t pocket = 0; - std::vector key_items{"teachy-tv", "tm-case", "helix-fossil", "cancel"}; - size_t bag_cursor = 0; - std::vector tms{{"water-pulse", 1}, {"rock-tomb", 1}}; - std::vector moves{"bite", "tail-whip", "bubble", "withdraw"}; - std::set compatible{"water-pulse", "rock-tomb"}; - - FakeScreen screen = FakeScreen::OVERWORLD; - size_t cursor = 0; - // The messages left to page through, and the screen after them. - std::deque texts = {}; - FakeScreen after_texts = FakeScreen::OVERWORLD; - // The messages after a move is forgotten ("1, 2 and... Poof!", "... - // forgot TAIL WHIP.", "And... learned WATER PULSE!") read as "other", - // as on console B (2026-11-03). - bool forget_texts_unread = false; - // The TM in use, by row. - size_t tm = 0; - std::vector presses = {}; - std::vector dumps = {}; - std::string error = {}; -}; - -std::vector tm_rows(const FakeTeachGame& game){ - std::vector rows; - for (const FakeTm& tm : game.tms){ - if (tm.count > 0){ - rows.push_back(tm.move); - } - } - return rows; -} - -void show_texts(FakeTeachGame& game, std::deque texts, FakeScreen after){ - game.texts = std::move(texts); - game.after_texts = after; - game.screen = FakeScreen::TEXT; -} - -void move_cursor(FakeTeachGame& game, TeachPress press, size_t rows){ - if (press == TeachPress::UP && game.cursor > 0){ - game.cursor--; - }else if (press == TeachPress::DOWN && game.cursor + 1 < rows){ - game.cursor++; - } -} - -std::string tm_move(const FakeTeachGame& game){ - const std::vector rows = tm_rows(game); - return game.tm < rows.size() ? rows[game.tm] : ""; -} - -void use_up(FakeTeachGame& game){ - const std::string move = tm_move(game); - for (FakeTm& tm : game.tms){ - if (tm.move == move && tm.count > 0){ - tm.count--; - return; - } - } -} - -void choose_member(FakeTeachGame& game){ - const std::string move = tm_move(game); - if (!game.compatible.contains(move)){ - show_texts(game, {TeachText::NOT_COMPATIBLE}, FakeScreen::PROMPT); - return; - } - if (game.moves.size() < MOVE_SLOTS){ - game.moves.push_back(move); - use_up(game); - game.cursor = 0; - show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::UNKNOWN}, FakeScreen::TM_CASE); - return; - } - show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::KNOWS_FOUR}, FakeScreen::DELETE_YES_NO); - game.cursor = 0; -} - -void press_bag(FakeTeachGame& game, TeachPress press){ - const bool key_items = game.pockets[game.pocket] == "key-items"; - switch (press){ - case TeachPress::LEFT: - case TeachPress::RIGHT: - if (press == TeachPress::LEFT && game.pocket > 0){ - game.pocket--; - game.cursor = 0; - }else if (press == TeachPress::RIGHT && game.pocket + 1 < game.pockets.size()){ - game.pocket++; - game.cursor = 0; - } - return; - case TeachPress::A: - if (key_items && game.key_items[game.cursor] == "tm-case"){ - game.screen = FakeScreen::BAG_MENU; - game.bag_cursor = game.cursor; - game.cursor = 0; - } - return; - case TeachPress::B: - game.screen = FakeScreen::START_MENU; - return; - default: - move_cursor(game, press, key_items ? game.key_items.size() : 4); - } -} - -void press_bag_menu(FakeTeachGame& game, TeachPress press){ - if (press == TeachPress::A && game.cursor == 0){ - game.screen = FakeScreen::TM_CASE; - game.cursor = 0; - }else if (press == TeachPress::A || press == TeachPress::B){ - game.screen = FakeScreen::BAG; - game.cursor = game.bag_cursor; - }else{ - move_cursor(game, press, 3); - } -} - -void press_tm_case(FakeTeachGame& game, TeachPress press){ - const size_t rows = tm_rows(game).size(); - if (press == TeachPress::B || (press == TeachPress::A && game.cursor == rows)){ - game.screen = FakeScreen::BAG; - game.cursor = game.bag_cursor; - }else if (press == TeachPress::A){ - game.tm = game.cursor; - game.screen = FakeScreen::TM_MENU; - game.cursor = 0; - }else{ - move_cursor(game, press, rows + 1); - } -} - -void press_tm_menu(FakeTeachGame& game, TeachPress press){ - if (press == TeachPress::A && game.cursor == 0){ - game.screen = FakeScreen::PROMPT; - }else if (press == TeachPress::A || press == TeachPress::B){ - game.screen = FakeScreen::TM_CASE; - game.cursor = game.tm; - }else{ - move_cursor(game, press, 3); - } -} - -void press_yes_no(FakeTeachGame& game, TeachPress press){ - const bool yes = press == TeachPress::A && game.cursor == 0; - const bool no = press == TeachPress::B || (press == TeachPress::A && game.cursor == 1); - if (game.screen == FakeScreen::DELETE_YES_NO && yes){ - show_texts(game, {TeachText::WHICH_MOVE}, FakeScreen::KNOWN_MOVES); - game.cursor = 0; - }else if (game.screen == FakeScreen::STOP_YES_NO && yes){ - show_texts(game, {TeachText::DID_NOT_LEARN}, FakeScreen::TM_CASE); - game.cursor = game.tm; - }else if (no){ - game.screen = game.screen == FakeScreen::DELETE_YES_NO ? FakeScreen::STOP_YES_NO : FakeScreen::DELETE_YES_NO; - game.cursor = 0; - }else{ - move_cursor(game, press, 2); - } -} - -void press_known_moves(FakeTeachGame& game, TeachPress press){ - if (press == TeachPress::B || (press == TeachPress::A && game.cursor == MOVE_SLOTS)){ - game.screen = FakeScreen::STOP_YES_NO; - game.cursor = 0; - }else if (press == TeachPress::A){ - if (is_hm_move(game.moves[game.cursor])){ - return; - } - game.moves[game.cursor] = tm_move(game); - use_up(game); - show_texts(game, {TeachText::UNKNOWN, TeachText::UNKNOWN, TeachText::UNKNOWN}, FakeScreen::TM_CASE); - if (game.forget_texts_unread){ - game.screen = FakeScreen::UNREAD_TEXT; - } - game.cursor = 0; - }else{ - move_cursor(game, press, MOVE_SLOTS + 1); - } -} - -void press_game(FakeTeachGame& game, TeachPress press){ - switch (game.screen){ - case FakeScreen::OVERWORLD: - case FakeScreen::START_MENU: - game.error = std::format("pressed {} off the menus", teach_press_name(press)); - return; - case FakeScreen::BAG: press_bag(game, press); return; - case FakeScreen::BAG_MENU: press_bag_menu(game, press); return; - case FakeScreen::TM_CASE: press_tm_case(game, press); return; - case FakeScreen::TM_MENU: press_tm_menu(game, press); return; - case FakeScreen::DELETE_YES_NO: - case FakeScreen::STOP_YES_NO: press_yes_no(game, press); return; - case FakeScreen::KNOWN_MOVES: press_known_moves(game, press); return; - case FakeScreen::PROMPT: - if (press == TeachPress::A){ - choose_member(game); - }else if (press == TeachPress::B){ - game.screen = FakeScreen::TM_CASE; - game.cursor = game.tm; - } - return; - case FakeScreen::TEXT: - case FakeScreen::UNREAD_TEXT: - game.texts.pop_front(); - if (game.texts.empty()){ - game.screen = game.after_texts; - } - return; - } -} - -TeachView view_of(const FakeTeachGame& game){ - switch (game.screen){ - case FakeScreen::OVERWORLD: - case FakeScreen::START_MENU: - case FakeScreen::UNREAD_TEXT: - return {}; - case FakeScreen::BAG:{ - const bool key_items = game.pockets[game.pocket] == "key-items"; - return {.screen = TeachScreen::BAG, .cursor = game.cursor, .pocket = game.pockets[game.pocket], - .rows = key_items ? game.key_items : std::vector{}}; - } - case FakeScreen::BAG_MENU: - case FakeScreen::TM_MENU: - return {.screen = TeachScreen::ITEM_MENU, .cursor = game.cursor}; - case FakeScreen::TM_CASE: - return {.screen = TeachScreen::TM_CASE, .cursor = game.cursor, .rows = tm_rows(game)}; - case FakeScreen::PROMPT: - return {.screen = TeachScreen::PARTY_PROMPT, .text = TeachText::TEACH_WHICH}; - case FakeScreen::TEXT: - return {.screen = TeachScreen::PARTY_TEXT, .text = game.texts.front()}; - case FakeScreen::DELETE_YES_NO: - return {.screen = TeachScreen::PARTY_YES_NO, .cursor = game.cursor, .text = TeachText::DELETE_QUESTION}; - case FakeScreen::STOP_YES_NO: - return {.screen = TeachScreen::PARTY_YES_NO, .cursor = game.cursor, .text = TeachText::STOP_QUESTION}; - case FakeScreen::KNOWN_MOVES: - return {.screen = TeachScreen::KNOWN_MOVES, .cursor = game.cursor}; - } - return {}; -} - -class FakeTeachScreens : public TeachScreens{ -public: - explicit FakeTeachScreens(FakeTeachGame& game) - : m_game(game) - {} - - virtual bool open_bag() override{ - if (m_game.screen != FakeScreen::OVERWORLD){ - m_game.error = "opened the bag off the overworld"; - return false; - } - m_game.screen = FakeScreen::BAG; - m_game.cursor = 0; - return true; - } - virtual TeachView look() override{ - return view_of(m_game); - } - virtual void press(TeachPress press) override{ - m_game.presses.emplace_back(teach_press_name(press)); - if (m_game.presses.size() > 200){ - throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "more than 200 presses"); - } - press_game(m_game, press); - } - virtual bool to_overworld() override{ - if (m_game.screen == FakeScreen::START_MENU || m_game.screen == FakeScreen::OVERWORLD){ - m_game.screen = FakeScreen::OVERWORLD; - return true; - } - return false; - } - virtual void dump_screen(std::string_view label) override{ - m_game.dumps.emplace_back(label); - } - -private: - FakeTeachGame& m_game; -}; - - -struct ScreensCase{ - std::string name = {}; - std::function setup = {}; - TeachPlan plan = {.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; - TeachEnd end = TeachEnd::LEARNED; - std::vector moves = {"bite", "water-pulse", "bubble", "withdraw"}; - // TM03's count after. - size_t tm03 = 0; -}; - -void left_on_poke_balls(FakeTeachGame& game){ - game.pocket = 2; -} -void three_moves(FakeTeachGame& game){ - game.moves = {"bite", "bubble", "withdraw"}; -} -void no_tm03(FakeTeachGame& game){ - game.tms = {{"rock-tomb", 1}}; -} -void forget_texts_unread(FakeTeachGame& game){ - game.forget_texts_unread = true; -} -void nothing_compatible(FakeTeachGame& game){ - game.compatible.clear(); -} - -std::vector screens_cases(){ - return { - {.name = "console B: TM03 over TAIL WHIP"}, - { - // Console B, 2026-11-03: known_moves, then "other" until the - // step gave up; the move was learned. - .name = "the texts after the forget read as other", - .setup = forget_texts_unread, - }, - { - .name = "the bag left on POKE BALLS", - .setup = left_on_poke_balls, - }, - { - .name = "a free slot: no move forgotten", - .setup = three_moves, - .plan = {.item = "tm03", .move = "water-pulse", .member = 0}, - .moves = {"bite", "bubble", "withdraw", "water-pulse"}, - }, - { - .name = "no TM03 in the TM CASE: skipped, back to the overworld", - .setup = no_tm03, - .end = TeachEnd::NO_ITEM, - .moves = {"bite", "tail-whip", "bubble", "withdraw"}, - }, - { - .name = "not compatible: given up, back to the overworld", - .setup = nothing_compatible, - .end = TeachEnd::REFUSED, - .moves = {"bite", "tail-whip", "bubble", "withdraw"}, - .tm03 = 1, - }, - }; -} - -UnitTestResult check_screens_case(const ScreensCase& item){ - QuietLogger logger; - FakeTeachGame game; - if (item.setup){ - item.setup(game); - } - FakeTeachScreens screens(game); - ScreenTeachActions actions(logger, screens); - const MemberReader reader = [&game](size_t) -> std::optional{ - if (game.screen != FakeScreen::OVERWORLD){ - game.error = "read the summary off the overworld"; - } - return MemberInfo{.species = "wartortle", .level = 22, .moves = game.moves}; - }; - const TeachResult result = run_teach(logger, actions, reader, item.plan); - const size_t tm03 = game.tms.empty() || game.tms[0].move != "water-pulse" ? 0 : game.tms[0].count; - std::string presses; - for (const std::string& press : game.presses){ - presses += press + " "; - } - if (!game.error.empty()){ - return std::format("{}: {} (presses: {}).", item.name, game.error, presses); - } - if (result.end != item.end || game.moves != item.moves || tm03 != item.tm03){ - return std::format("{}: ended {} ({}), TM03 x{}; presses: {}.", - item.name, teach_end_name(result.end), result.detail, tm03, presses); - } - if (game.screen != FakeScreen::OVERWORLD){ - return std::format("{}: didn't end on the overworld; presses: {}.", item.name, presses); - } - // A debug image for each failure, none otherwise. - const bool failed = item.end == TeachEnd::FAILED || item.end == TeachEnd::REFUSED; - if (failed == game.dumps.empty()){ - return std::format("{}: {} debug images.", item.name, game.dumps.size()); - } - return true; -} - -UnitTestResult test_screens(){ - for (const ScreensCase& item : screens_cases()){ - const UnitTestResult result = check_screens_case(item); - if (result.result != UnitTestResult::PASSED){ - return result; - } - } - return true; -} - - +/* AutoStory Teach Screens Tests + * + * The console's TEACH steps against a fake bag, TM CASE and party menu + * that answer as console B did on 2026-11-03 (FireRed): the bag left on + * ITEMS; KEY ITEMS with TEACHY TV, TM CASE, HELIX FOSSIL; TM03 WATER + * PULSE and TM39 ROCK TOMB in the TM CASE; a Wartortle Lv22 with BITE, + * TAIL WHIP, BUBBLE, WITHDRAW. That run's step failed with "couldn't open + * tm03 from the TM Case": the console's steps had no readers and stopped + * before the bag. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryTeachScreens.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +struct FakeTm{ + std::string move; + size_t count = 1; +}; + +// The game's state behind the screens. +enum class FakeScreen{ + OVERWORLD, + START_MENU, + BAG, + BAG_MENU, + TM_CASE, + TM_MENU, + PROMPT, + TEXT, + // Messages the reader doesn't place: "other". + UNREAD_TEXT, + DELETE_YES_NO, + STOP_YES_NO, + KNOWN_MOVES, + // A tutor's last line in the overworld, after its party menu. + TUTOR_TEXT, +}; + +// The bag, TM CASE and party menu as console B showed them. +struct FakeTeachGame{ + std::vector pockets{"items", "key-items", "poke-balls"}; + size_t pocket = 0; + std::vector key_items{"teachy-tv", "tm-case", "helix-fossil", "cancel"}; + size_t bag_cursor = 0; + std::vector tms{{"water-pulse", 1}, {"rock-tomb", 1}}; + std::vector moves{"bite", "tail-whip", "bubble", "withdraw"}; + std::set compatible{"water-pulse", "rock-tomb", "mega-kick"}; + // The move of the tutor the player faces; empty: none. + std::string tutor_move = {}; + // The party menu is the tutor's. + bool tutoring = false; + + FakeScreen screen = FakeScreen::OVERWORLD; + size_t cursor = 0; + // The messages left to page through, and the screen after them. + std::deque texts = {}; + FakeScreen after_texts = FakeScreen::OVERWORLD; + // The messages after a move is forgotten ("1, 2 and... Poof!", "... + // forgot TAIL WHIP.", "And... learned WATER PULSE!") read as "other", + // as on console B (2026-11-03). + bool forget_texts_unread = false; + // The TM in use, by row. + size_t tm = 0; + std::vector presses = {}; + std::vector dumps = {}; + std::string error = {}; +}; + +std::vector tm_rows(const FakeTeachGame& game){ + std::vector rows; + for (const FakeTm& tm : game.tms){ + if (tm.count > 0){ + rows.push_back(tm.move); + } + } + return rows; +} + +void show_texts(FakeTeachGame& game, std::deque texts, FakeScreen after){ + game.texts = std::move(texts); + game.after_texts = after; + game.screen = FakeScreen::TEXT; +} + +void move_cursor(FakeTeachGame& game, TeachPress press, size_t rows){ + if (press == TeachPress::UP && game.cursor > 0){ + game.cursor--; + }else if (press == TeachPress::DOWN && game.cursor + 1 < rows){ + game.cursor++; + } +} + +// Where the party menu goes once done: the TM CASE, or a tutor's last line. +FakeScreen after_party(const FakeTeachGame& game){ + return game.tutoring ? FakeScreen::TUTOR_TEXT : FakeScreen::TM_CASE; +} + +std::string tm_move(const FakeTeachGame& game){ + if (game.tutoring){ + return game.tutor_move; + } + const std::vector rows = tm_rows(game); + return game.tm < rows.size() ? rows[game.tm] : ""; +} + +void use_up(FakeTeachGame& game){ + if (game.tutoring){ + return; + } + const std::string move = tm_move(game); + for (FakeTm& tm : game.tms){ + if (tm.move == move && tm.count > 0){ + tm.count--; + return; + } + } +} + +void choose_member(FakeTeachGame& game){ + const std::string move = tm_move(game); + if (!game.compatible.contains(move)){ + show_texts(game, {TeachText::NOT_COMPATIBLE}, FakeScreen::PROMPT); + return; + } + if (game.moves.size() < MOVE_SLOTS){ + game.moves.push_back(move); + use_up(game); + game.cursor = 0; + show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::UNKNOWN}, after_party(game)); + return; + } + show_texts(game, {TeachText::WANTS_TO_LEARN, TeachText::KNOWS_FOUR}, FakeScreen::DELETE_YES_NO); + game.cursor = 0; +} + +void press_bag(FakeTeachGame& game, TeachPress press){ + const bool key_items = game.pockets[game.pocket] == "key-items"; + switch (press){ + case TeachPress::LEFT: + case TeachPress::RIGHT: + if (press == TeachPress::LEFT && game.pocket > 0){ + game.pocket--; + game.cursor = 0; + }else if (press == TeachPress::RIGHT && game.pocket + 1 < game.pockets.size()){ + game.pocket++; + game.cursor = 0; + } + return; + case TeachPress::A: + if (key_items && game.key_items[game.cursor] == "tm-case"){ + game.screen = FakeScreen::BAG_MENU; + game.bag_cursor = game.cursor; + game.cursor = 0; + } + return; + case TeachPress::B: + game.screen = FakeScreen::START_MENU; + return; + default: + move_cursor(game, press, key_items ? game.key_items.size() : 4); + } +} + +void press_bag_menu(FakeTeachGame& game, TeachPress press){ + if (press == TeachPress::A && game.cursor == 0){ + game.screen = FakeScreen::TM_CASE; + game.cursor = 0; + }else if (press == TeachPress::A || press == TeachPress::B){ + game.screen = FakeScreen::BAG; + game.cursor = game.bag_cursor; + }else{ + move_cursor(game, press, 3); + } +} + +void press_tm_case(FakeTeachGame& game, TeachPress press){ + const size_t rows = tm_rows(game).size(); + if (press == TeachPress::B || (press == TeachPress::A && game.cursor == rows)){ + game.screen = FakeScreen::BAG; + game.cursor = game.bag_cursor; + }else if (press == TeachPress::A){ + game.tm = game.cursor; + game.screen = FakeScreen::TM_MENU; + game.cursor = 0; + }else{ + move_cursor(game, press, rows + 1); + } +} + +void press_tm_menu(FakeTeachGame& game, TeachPress press){ + if (press == TeachPress::A && game.cursor == 0){ + game.screen = FakeScreen::PROMPT; + }else if (press == TeachPress::A || press == TeachPress::B){ + game.screen = FakeScreen::TM_CASE; + game.cursor = game.tm; + }else{ + move_cursor(game, press, 3); + } +} + +void press_yes_no(FakeTeachGame& game, TeachPress press){ + const bool yes = press == TeachPress::A && game.cursor == 0; + const bool no = press == TeachPress::B || (press == TeachPress::A && game.cursor == 1); + if (game.screen == FakeScreen::DELETE_YES_NO && yes){ + show_texts(game, {TeachText::WHICH_MOVE}, FakeScreen::KNOWN_MOVES); + game.cursor = 0; + }else if (game.screen == FakeScreen::STOP_YES_NO && yes){ + show_texts(game, {TeachText::DID_NOT_LEARN}, after_party(game)); + game.cursor = game.tm; + }else if (no){ + game.screen = game.screen == FakeScreen::DELETE_YES_NO ? FakeScreen::STOP_YES_NO : FakeScreen::DELETE_YES_NO; + game.cursor = 0; + }else{ + move_cursor(game, press, 2); + } +} + +void press_known_moves(FakeTeachGame& game, TeachPress press){ + if (press == TeachPress::B || (press == TeachPress::A && game.cursor == MOVE_SLOTS)){ + game.screen = FakeScreen::STOP_YES_NO; + game.cursor = 0; + }else if (press == TeachPress::A){ + if (is_hm_move(game.moves[game.cursor])){ + return; + } + game.moves[game.cursor] = tm_move(game); + use_up(game); + show_texts(game, {TeachText::UNKNOWN, TeachText::UNKNOWN, TeachText::UNKNOWN}, after_party(game)); + if (game.forget_texts_unread){ + game.screen = FakeScreen::UNREAD_TEXT; + } + game.cursor = 0; + }else{ + move_cursor(game, press, MOVE_SLOTS + 1); + } +} + +void press_game(FakeTeachGame& game, TeachPress press){ + switch (game.screen){ + case FakeScreen::OVERWORLD: + case FakeScreen::START_MENU: + game.error = std::format("pressed {} off the menus", teach_press_name(press)); + return; + case FakeScreen::BAG: press_bag(game, press); return; + case FakeScreen::BAG_MENU: press_bag_menu(game, press); return; + case FakeScreen::TM_CASE: press_tm_case(game, press); return; + case FakeScreen::TM_MENU: press_tm_menu(game, press); return; + case FakeScreen::DELETE_YES_NO: + case FakeScreen::STOP_YES_NO: press_yes_no(game, press); return; + case FakeScreen::KNOWN_MOVES: press_known_moves(game, press); return; + case FakeScreen::PROMPT: + if (press == TeachPress::A){ + choose_member(game); + }else if (press == TeachPress::B){ + game.screen = after_party(game); + game.cursor = game.tm; + } + return; + case FakeScreen::TUTOR_TEXT: + game.screen = FakeScreen::OVERWORLD; + return; + case FakeScreen::TEXT: + case FakeScreen::UNREAD_TEXT: + game.texts.pop_front(); + if (game.texts.empty()){ + game.screen = game.after_texts; + } + return; + } +} + +TeachView view_of(const FakeTeachGame& game){ + switch (game.screen){ + case FakeScreen::OVERWORLD: + case FakeScreen::START_MENU: + case FakeScreen::UNREAD_TEXT: + case FakeScreen::TUTOR_TEXT: + return {}; + case FakeScreen::BAG:{ + const bool key_items = game.pockets[game.pocket] == "key-items"; + return {.screen = TeachScreen::BAG, .cursor = game.cursor, .pocket = game.pockets[game.pocket], + .rows = key_items ? game.key_items : std::vector{}}; + } + case FakeScreen::BAG_MENU: + case FakeScreen::TM_MENU: + return {.screen = TeachScreen::ITEM_MENU, .cursor = game.cursor}; + case FakeScreen::TM_CASE: + return {.screen = TeachScreen::TM_CASE, .cursor = game.cursor, .rows = tm_rows(game)}; + case FakeScreen::PROMPT: + return {.screen = TeachScreen::PARTY_PROMPT, .text = TeachText::TEACH_WHICH}; + case FakeScreen::TEXT: + return {.screen = TeachScreen::PARTY_TEXT, .text = game.texts.front()}; + case FakeScreen::DELETE_YES_NO: + return {.screen = TeachScreen::PARTY_YES_NO, .cursor = game.cursor, .text = TeachText::DELETE_QUESTION}; + case FakeScreen::STOP_YES_NO: + return {.screen = TeachScreen::PARTY_YES_NO, .cursor = game.cursor, .text = TeachText::STOP_QUESTION}; + case FakeScreen::KNOWN_MOVES: + return {.screen = TeachScreen::KNOWN_MOVES, .cursor = game.cursor}; + } + return {}; +} + +class FakeTeachScreens : public TeachScreens{ +public: + explicit FakeTeachScreens(FakeTeachGame& game) + : m_game(game) + {} + + virtual bool open_bag() override{ + if (m_game.screen != FakeScreen::OVERWORLD){ + m_game.error = "opened the bag off the overworld"; + return false; + } + m_game.screen = FakeScreen::BAG; + m_game.cursor = 0; + return true; + } + virtual bool talk_to_tutor() override{ + if (m_game.screen != FakeScreen::OVERWORLD || m_game.tutor_move.empty()){ + m_game.error = "talked to no tutor"; + return false; + } + m_game.tutoring = true; + m_game.screen = FakeScreen::PROMPT; + m_game.cursor = 0; + return true; + } + virtual TeachView look() override{ + return view_of(m_game); + } + virtual void press(TeachPress press) override{ + m_game.presses.emplace_back(teach_press_name(press)); + if (m_game.presses.size() > 200){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "more than 200 presses"); + } + press_game(m_game, press); + } + virtual bool to_overworld() override{ + // B pages through a tutor's lines too. + const bool tutor_lines = m_game.screen == FakeScreen::TUTOR_TEXT + || (m_game.tutoring && m_game.after_texts == FakeScreen::TUTOR_TEXT + && (m_game.screen == FakeScreen::TEXT || m_game.screen == FakeScreen::UNREAD_TEXT)); + if (tutor_lines || m_game.screen == FakeScreen::START_MENU || m_game.screen == FakeScreen::OVERWORLD){ + m_game.screen = FakeScreen::OVERWORLD; + return true; + } + return false; + } + virtual void dump_screen(std::string_view label) override{ + m_game.dumps.emplace_back(label); + } + +private: + FakeTeachGame& m_game; +}; + + +const TeachPlan MEGA_KICK{.item = std::string(TEACH_TUTOR), .move = "mega-kick", .member = 0, .forget_move = "withdraw"}; + +struct ScreensCase{ + std::string name = {}; + std::function setup = {}; + TeachPlan plan = {.item = "tm03", .move = "water-pulse", .member = 0, .forget_move = "tail-whip"}; + TeachEnd end = TeachEnd::LEARNED; + std::vector moves = {"bite", "water-pulse", "bubble", "withdraw"}; + // TM03's count after. + size_t tm03 = 0; +}; + +void left_on_poke_balls(FakeTeachGame& game){ + game.pocket = 2; +} +void three_moves(FakeTeachGame& game){ + game.moves = {"bite", "bubble", "withdraw"}; +} +void no_tm03(FakeTeachGame& game){ + game.tms = {{"rock-tomb", 1}}; +} +void forget_texts_unread(FakeTeachGame& game){ + game.forget_texts_unread = true; +} +void nothing_compatible(FakeTeachGame& game){ + game.compatible.clear(); +} +// Route 4's Mega Kick tutor, the party as console B's (2026-11-03). +void mega_kick_tutor(FakeTeachGame& game){ + game.tutor_move = "mega-kick"; + game.moves = {"bite", "water-pulse", "bubble", "withdraw"}; +} +void mega_kick_tutor_unread(FakeTeachGame& game){ + mega_kick_tutor(game); + game.forget_texts_unread = true; +} +void mega_kick_tutor_room(FakeTeachGame& game){ + mega_kick_tutor(game); + game.moves = {"bite", "water-pulse", "bubble"}; +} +void mega_kick_tutor_incompatible(FakeTeachGame& game){ + mega_kick_tutor(game); + game.compatible.clear(); +} + +std::vector screens_cases(){ + return { + {.name = "console B: TM03 over TAIL WHIP"}, + { + // Console B, 2026-11-03: known_moves, then "other" until the + // step gave up; the move was learned. + .name = "the texts after the forget read as other", + .setup = forget_texts_unread, + }, + { + .name = "the bag left on POKE BALLS", + .setup = left_on_poke_balls, + }, + { + .name = "a free slot: no move forgotten", + .setup = three_moves, + .plan = {.item = "tm03", .move = "water-pulse", .member = 0}, + .moves = {"bite", "bubble", "withdraw", "water-pulse"}, + }, + { + .name = "no TM03 in the TM CASE: skipped, back to the overworld", + .setup = no_tm03, + .end = TeachEnd::NO_ITEM, + .moves = {"bite", "tail-whip", "bubble", "withdraw"}, + }, + { + .name = "not compatible: given up, back to the overworld", + .setup = nothing_compatible, + .end = TeachEnd::REFUSED, + .moves = {"bite", "tail-whip", "bubble", "withdraw"}, + .tm03 = 1, + }, + }; +} + +// Route 4's Mega Kick tutor: the TM03 still in the TM CASE after each. +std::vector tutor_screens_cases(){ + return { + { + .name = "the Mega Kick tutor: over WITHDRAW, back to the overworld past his line", + .setup = mega_kick_tutor, + .plan = MEGA_KICK, + .moves = {"bite", "water-pulse", "bubble", "mega-kick"}, + .tm03 = 1, + }, + { + .name = "the Mega Kick tutor, the texts after the forget read as other", + .setup = mega_kick_tutor_unread, + .plan = MEGA_KICK, + .moves = {"bite", "water-pulse", "bubble", "mega-kick"}, + .tm03 = 1, + }, + { + .name = "the Mega Kick tutor, a free slot", + .setup = mega_kick_tutor_room, + .plan = MEGA_KICK, + .moves = {"bite", "water-pulse", "bubble", "mega-kick"}, + .tm03 = 1, + }, + { + .name = "the Mega Kick tutor, not compatible: given up past his line", + .setup = mega_kick_tutor_incompatible, + .plan = MEGA_KICK, + .end = TeachEnd::REFUSED, + .moves = {"bite", "water-pulse", "bubble", "withdraw"}, + .tm03 = 1, + }, + }; +} + +UnitTestResult check_screens_case(const ScreensCase& item){ + QuietLogger logger; + FakeTeachGame game; + if (item.setup){ + item.setup(game); + } + FakeTeachScreens screens(game); + ScreenTeachActions actions(logger, screens); + const MemberReader reader = [&game](size_t) -> std::optional{ + if (game.screen != FakeScreen::OVERWORLD){ + game.error = "read the summary off the overworld"; + } + return MemberInfo{.species = "wartortle", .level = 22, .moves = game.moves}; + }; + const TeachResult result = run_teach(logger, actions, reader, item.plan); + const size_t tm03 = game.tms.empty() || game.tms[0].move != "water-pulse" ? 0 : game.tms[0].count; + std::string presses; + for (const std::string& press : game.presses){ + presses += press + " "; + } + if (!game.error.empty()){ + return std::format("{}: {} (presses: {}).", item.name, game.error, presses); + } + if (result.end != item.end || game.moves != item.moves || tm03 != item.tm03){ + return std::format("{}: ended {} ({}), TM03 x{}; presses: {}.", + item.name, teach_end_name(result.end), result.detail, tm03, presses); + } + if (game.screen != FakeScreen::OVERWORLD){ + return std::format("{}: didn't end on the overworld; presses: {}.", item.name, presses); + } + // A debug image for each failure, none otherwise. + const bool failed = item.end == TeachEnd::FAILED || item.end == TeachEnd::REFUSED; + if (failed == game.dumps.empty()){ + return std::format("{}: {} debug images.", item.name, game.dumps.size()); + } + return true; +} + +UnitTestResult test_screens(){ + for (const std::vector& cases : {screens_cases(), tutor_screens_cases()}){ + for (const ScreensCase& item : cases){ + const UnitTestResult result = check_screens_case(item); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + } + return true; +} + + struct AutoStoryTeachScreensInput{ std::string name; std::function body; @@ -466,27 +557,27 @@ struct AutoStoryTeachScreensCase{ AutoStoryTeachScreensExpectation expectation; }; -class Test_AutoStoryTeachScreens : public UnitTest{ -public: +class Test_AutoStoryTeachScreens : public UnitTest{ +public: explicit Test_AutoStoryTeachScreens(AutoStoryTeachScreensCase test_case) : UnitTest("PokemonFRLG::AutoStoryTeachScreens - " + 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: AutoStoryTeachScreensCase m_case; -}; - +}; + } // namespace - - + + const AutoStoryTeachScreensCase AUTO_STORY_TEACH_SCREENS_CASES[] = { { .input = {"TM03 through the console's screens", test_screens}, @@ -494,14 +585,14 @@ const AutoStoryTeachScreensCase AUTO_STORY_TEACH_SCREENS_CASES[] = { }, }; -void add_tests_AutoStoryTeachScreens(UnitTestDatabase& database){ +void add_tests_AutoStoryTeachScreens(UnitTestDatabase& database){ for (const auto& item : AUTO_STORY_TEACH_SCREENS_CASES){ database.add(item); } -} - - - +} + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp index def0427bf3..202e979a0b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTeach_Tests.cpp @@ -1,171 +1,171 @@ -/* AutoStory Teach Tests - * - * From: https://github.com/PokemonAutomation/ - * - * The TEACH step against a fake party and TM Case that answer as - * pokefirered's tm_case.c and party_menu.c do (see - * PokemonFRLG_AutoStoryTeach.h): a member with room learns, one with four - * moves asks to forget one, an HM move can't be forgotten, a TM is used up - * and an HM is not. The party is console B's (2026-11-02): Squirtle Lv16 - * with WATER GUN, TAIL WHIP, BUBBLE, WITHDRAW. - * - */ - -#include -#include -#include -#include -#include -#include -#include -#include "Common/Cpp/Exceptions.h" -#include "Common/Cpp/TestRunners/UnitTest.h" -#include "PokemonFRLG_AutoStoryTeach.h" -#include "PokemonFRLG_AutoStoryTeachScreens.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - - -namespace{ - -class QuietLogger : public Logger{ -public: - using Logger::log; - virtual void log(const std::string&, Color) override{} -}; - -struct FakeMon{ - std::string species; - std::vector moves; - std::set compatible; -}; - -struct FakeDisc{ - std::string move; - size_t count = 1; -}; - -class FakeTeachGame : public TeachActions{ -public: - std::vector party; - std::map tm_case = {}; - bool moves_readable = true; - // What the reader says before teaching, if not the party's moves. - std::optional> stale_moves = std::nullopt; - // The moves page forgets nothing: a bug the confirmation must catch. - bool forget_ignored = false; - // The bag or the TM Case didn't open. - bool open_fails = false; - size_t items_opened = 0; - size_t stopped = 0; - std::string error = {}; - - std::optional read_member(size_t slot){ - if (m_screen != Screen::OVERWORLD){ - error = "read the summary off the overworld"; - } - if (slot >= party.size() || !moves_readable){ - return std::nullopt; - } - MemberInfo info{.species = party[slot].species, .level = 16, .moves = party[slot].moves}; - if (stale_moves && items_opened == 0){ - info.moves = *stale_moves; - } - return info; - } - virtual ItemOpen open_item(std::string_view item, std::string_view) override{ - items_opened++; - if (open_fails){ - m_screen = Screen::BAG; - return ItemOpen::FAILED; - } - const auto found = tm_case.find(std::string(item)); - if (found == tm_case.end() || found->second.count == 0){ - m_screen = Screen::BAG; - return ItemOpen::NOT_IN_CASE; - } - m_item = found->first; - m_screen = Screen::CHOOSE_MEMBER; - return ItemOpen::OPENED; - } - virtual TeachPrompt choose_member(size_t slot) override{ - if (m_screen != Screen::CHOOSE_MEMBER || slot >= party.size()){ - error = "chose a member off the party menu"; - return TeachPrompt::FAILED; - } - m_slot = slot; - FakeMon& mon = party[slot]; - const std::string& move = tm_case[m_item].move; - if (std::find(mon.moves.begin(), mon.moves.end(), move) != mon.moves.end()){ - return TeachPrompt::ALREADY_KNOWS; - } - if (!mon.compatible.contains(move)){ - return TeachPrompt::NOT_COMPATIBLE; - } - if (mon.moves.size() < MOVE_SLOTS){ - mon.moves.push_back(move); - used_up(); - return TeachPrompt::LEARNED; - } - m_screen = Screen::DELETE_PROMPT; - return TeachPrompt::DELETE_PROMPT; - } - virtual ForgetEnd forget(size_t slot) override{ - if (m_screen != Screen::DELETE_PROMPT){ - error = "forgot a move off the prompt"; - return ForgetEnd::FAILED; - } - std::vector& moves = party[m_slot].moves; - if (is_hm_move(moves[slot])){ - return ForgetEnd::HM_REFUSED; - } - if (!forget_ignored){ - moves[slot] = tm_case[m_item].move; - } - used_up(); - m_screen = Screen::CHOOSE_MEMBER; - return ForgetEnd::LEARNED; - } - virtual void stop_teaching() override{ - stopped++; - m_screen = Screen::BAG; - } - virtual bool leave() override{ - m_screen = Screen::OVERWORLD; - return true; - } - virtual void dump_screen(std::string_view) override{} - -private: - enum class Screen{ - OVERWORLD, - BAG, - CHOOSE_MEMBER, - DELETE_PROMPT, - }; - - // A TM is used up; an HM is not. - void used_up(){ - if (m_item.starts_with("tm")){ - tm_case[m_item].count--; - } - } - - Screen m_screen = Screen::OVERWORLD; - std::string m_item; - size_t m_slot = 0; -}; - - -MemberReader reader_of(FakeTeachGame& game){ - return [&game](size_t slot){ - return game.read_member(slot); - }; -} - - +/* AutoStory Teach Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The TEACH step against a fake party and TM Case that answer as + * pokefirered's tm_case.c and party_menu.c do (see + * PokemonFRLG_AutoStoryTeach.h): a member with room learns, one with four + * moves asks to forget one, an HM move can't be forgotten, a TM is used up + * and an HM is not. The party is console B's (2026-11-02): Squirtle Lv16 + * with WATER GUN, TAIL WHIP, BUBBLE, WITHDRAW. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_AutoStoryTeach.h" +#include "PokemonFRLG_AutoStoryTeachScreens.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class QuietLogger : public Logger{ +public: + using Logger::log; + virtual void log(const std::string&, Color) override{} +}; + +struct FakeMon{ + std::string species; + std::vector moves; + std::set compatible; +}; + +struct FakeDisc{ + std::string move; + size_t count = 1; +}; + +class FakeTeachGame : public TeachActions{ +public: + std::vector party; + std::map tm_case = {}; + bool moves_readable = true; + // What the reader says before teaching, if not the party's moves. + std::optional> stale_moves = std::nullopt; + // The moves page forgets nothing: a bug the confirmation must catch. + bool forget_ignored = false; + // The bag or the TM Case didn't open. + bool open_fails = false; + size_t items_opened = 0; + size_t stopped = 0; + std::string error = {}; + + std::optional read_member(size_t slot){ + if (m_screen != Screen::OVERWORLD){ + error = "read the summary off the overworld"; + } + if (slot >= party.size() || !moves_readable){ + return std::nullopt; + } + MemberInfo info{.species = party[slot].species, .level = 16, .moves = party[slot].moves}; + if (stale_moves && items_opened == 0){ + info.moves = *stale_moves; + } + return info; + } + virtual ItemOpen open_item(std::string_view item, std::string_view) override{ + items_opened++; + if (open_fails){ + m_screen = Screen::BAG; + return ItemOpen::FAILED; + } + const auto found = tm_case.find(std::string(item)); + if (found == tm_case.end() || found->second.count == 0){ + m_screen = Screen::BAG; + return ItemOpen::NOT_IN_CASE; + } + m_item = found->first; + m_screen = Screen::CHOOSE_MEMBER; + return ItemOpen::OPENED; + } + virtual TeachPrompt choose_member(size_t slot) override{ + if (m_screen != Screen::CHOOSE_MEMBER || slot >= party.size()){ + error = "chose a member off the party menu"; + return TeachPrompt::FAILED; + } + m_slot = slot; + FakeMon& mon = party[slot]; + const std::string& move = tm_case[m_item].move; + if (std::find(mon.moves.begin(), mon.moves.end(), move) != mon.moves.end()){ + return TeachPrompt::ALREADY_KNOWS; + } + if (!mon.compatible.contains(move)){ + return TeachPrompt::NOT_COMPATIBLE; + } + if (mon.moves.size() < MOVE_SLOTS){ + mon.moves.push_back(move); + used_up(); + return TeachPrompt::LEARNED; + } + m_screen = Screen::DELETE_PROMPT; + return TeachPrompt::DELETE_PROMPT; + } + virtual ForgetEnd forget(size_t slot) override{ + if (m_screen != Screen::DELETE_PROMPT){ + error = "forgot a move off the prompt"; + return ForgetEnd::FAILED; + } + std::vector& moves = party[m_slot].moves; + if (is_hm_move(moves[slot])){ + return ForgetEnd::HM_REFUSED; + } + if (!forget_ignored){ + moves[slot] = tm_case[m_item].move; + } + used_up(); + m_screen = Screen::CHOOSE_MEMBER; + return ForgetEnd::LEARNED; + } + virtual void stop_teaching() override{ + stopped++; + m_screen = Screen::BAG; + } + virtual bool leave() override{ + m_screen = Screen::OVERWORLD; + return true; + } + virtual void dump_screen(std::string_view) override{} + +private: + enum class Screen{ + OVERWORLD, + BAG, + CHOOSE_MEMBER, + DELETE_PROMPT, + }; + + // A TM is used up; an HM is not. + void used_up(){ + if (m_item.starts_with("tm")){ + tm_case[m_item].count--; + } + } + + Screen m_screen = Screen::OVERWORLD; + std::string m_item; + size_t m_slot = 0; +}; + + +MemberReader reader_of(FakeTeachGame& game){ + return [&game](size_t slot){ + return game.read_member(slot); + }; +} + + struct AutoStoryTeachInput{ std::string name; std::function body; @@ -179,230 +179,234 @@ struct AutoStoryTeachCase{ AutoStoryTeachExpectation expectation; }; -class Test_AutoStoryTeach : public UnitTest{ -public: +class Test_AutoStoryTeach : public UnitTest{ +public: explicit Test_AutoStoryTeach(AutoStoryTeachCase test_case) : UnitTest("PokemonFRLG::AutoStoryTeach - " + 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: AutoStoryTeachCase m_case; -}; - - -FakeTeachGame console_b_party(){ - FakeTeachGame game; - game.party = { - {"squirtle", {"water-gun", "tail-whip", "bubble", "withdraw"}, {"surf", "strength", "dig", "rock-smash"}}, - {"rattata", {"tackle", "tail-whip", "quick-attack"}, {"dig", "cut"}}, - {"oddish", {"absorb", "cut", "poison-powder", "acid"}, {"cut", "flash"}}, - }; - game.tm_case = {{"hm03", {"surf", 1}}, {"tm28", {"dig", 1}}, {"hm01", {"cut", 1}}}; - return game; -} - -struct TeachCase{ - std::string name = {}; - TeachPlan plan = {}; - std::function setup = {}; - TeachEnd end = TeachEnd::LEARNED; - // The member's moves after the step. - std::vector moves = {}; - // Whether the bag was opened at all. - bool opened = true; -}; - -std::vector teach_cases_1(){ - return { - { - .name = "Surf over TAIL WHIP by name", - .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, - .moves = {"water-gun", "surf", "bubble", "withdraw"}, - }, - { - .name = "Strength over the fourth slot by number", - .plan = {.item = "hm04", .move = "strength", .member = 0, .forget_slot = 3}, - .setup = [](FakeTeachGame& game){ game.tm_case["hm04"] = {"strength", 1}; }, - .moves = {"water-gun", "tail-whip", "bubble", "strength"}, - }, - { - .name = "a TM into a free slot", - .plan = {.item = "tm28", .move = "dig", .member = 1}, - .moves = {"tackle", "tail-whip", "quick-attack", "dig"}, - }, - { - .name = "a move it knows already: nothing pressed", - .plan = {.item = "hm01", .move = "cut", .member = 2}, - .end = TeachEnd::ALREADY_KNOWN, - .moves = {"absorb", "cut", "poison-powder", "acid"}, - .opened = false, - }, - }; -} - -std::vector teach_cases_2(){ - return { - { - .name = "four moves and none named to forget", - .plan = {.item = "hm03", .move = "surf", .member = 0}, - .end = TeachEnd::REFUSED, - .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, - .opened = false, - }, - { - .name = "a move to forget it doesn't know (the console Squirtle has no Tackle)", - .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tackle"}, - .end = TeachEnd::REFUSED, - .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, - .opened = false, - }, - { - .name = "an HM move named to forget", - .plan = {.item = "tm28", .move = "dig", .member = 2, .forget_move = "cut"}, - .end = TeachEnd::REFUSED, - .moves = {"absorb", "cut", "poison-powder", "acid"}, - .opened = false, - }, - { - .name = "not compatible: given up, back on the overworld", - .plan = {.item = "hm01", .move = "cut", .member = 0, .forget_move = "withdraw"}, - .end = TeachEnd::REFUSED, - .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, - }, - }; -} - -std::vector teach_cases_3(){ - return { - { - .name = "moves read with room, but the game asks to forget one", - .plan = {.item = "hm03", .move = "surf", .member = 0}, - .setup = [](FakeTeachGame& game){ game.stale_moves = std::vector{"water-gun", "bubble"}; }, - .end = TeachEnd::FAILED, - .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, - }, - { - .name = "moves that can't be read: nothing pressed", - .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, - .setup = [](FakeTeachGame& game){ game.moves_readable = false; }, - .end = TeachEnd::UNREAD, - .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, - .opened = false, - }, - { - .name = "an item the TM Case hasn't got: skipped", - .plan = {.item = "hm05", .move = "flash", .member = 2, .forget_move = "acid"}, - .end = TeachEnd::NO_ITEM, - .moves = {"absorb", "cut", "poison-powder", "acid"}, - }, - { - .name = "the TM Case didn't open", - .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, - .setup = [](FakeTeachGame& game){ game.open_fails = true; }, - .end = TeachEnd::FAILED, - .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, - }, - { - .name = "the move didn't land: the read after catches it", - .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, - .setup = [](FakeTeachGame& game){ game.forget_ignored = true; }, - .end = TeachEnd::FAILED, - .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, - }, - }; -} - -std::vector teach_cases(){ - std::vector cases; - for (std::vector part : {teach_cases_1(), teach_cases_2(), teach_cases_3()}){ - cases.insert(cases.end(), part.begin(), part.end()); - } - return cases; -} - -UnitTestResult check_teach_case(const TeachCase& item){ - QuietLogger logger; - FakeTeachGame game = console_b_party(); - if (item.setup){ - item.setup(game); - } - const TeachResult result = run_teach(logger, game, reader_of(game), item.plan); - if (!game.error.empty()){ - return item.name + ": " + game.error; - } - if (result.end != item.end){ - return std::format("{}: ended {}, expected {} ({}).", item.name, - teach_end_name(result.end), teach_end_name(item.end), result.detail); - } - if (game.party[item.plan.member].moves != item.moves){ - return item.name + ": the moves after aren't the ones expected."; - } - if ((game.items_opened > 0) != item.opened){ - return item.name + (item.opened ? ": the bag wasn't opened." : ": the bag was opened."); - } - return true; -} - -UnitTestResult test_teach(){ - for (const TeachCase& item : teach_cases()){ - const UnitTestResult result = check_teach_case(item); - if (result.result != UnitTestResult::PASSED){ - return result; - } - } - return true; -} - - -// A TM is used up, an HM is not. -UnitTestResult test_tm_used_up(){ - QuietLogger logger; - FakeTeachGame game = console_b_party(); - run_teach(logger, game, reader_of(game), {.item = "tm28", .move = "dig", .member = 1}); - run_teach(logger, game, reader_of(game), {.item = "hm03", .move = "surf", .member = 0, .forget_move = "withdraw"}); - if (game.tm_case["tm28"].count != 0 || game.tm_case["hm03"].count != 1){ - return "Expected TM28 used up and HM03 kept."; - } - return true; -} - - -UnitTestResult test_teach_variant(){ - const TeachPlan plan{.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}; - const std::string variant = teach_variant(plan); - if (variant != "teach hm03 surf slot=1 forget=tail-whip"){ - return "Unexpected variant \"" + variant + "\"."; - } - const std::optional back = parse_teach_variant(variant); - if (!back || back->item != "hm03" || back->move != "surf" || back->member != 0 || back->forget_move != "tail-whip"){ - return "\"" + variant + "\" didn't read back as its plan."; - } - const std::optional by_slot = parse_teach_variant("teach tm28 dig slot=2 forget_slot=4"); - if (!by_slot || by_slot->member != 1 || by_slot->forget_slot != 3u){ - return "\"teach tm28 dig slot=2 forget_slot=4\" didn't read back."; - } - for (std::string_view bad : { - "teach", "teach hm03 surf", "teach potion surf slot=1", "teach hm03 surf slot=7", - "teach hm03 surf slot=1 forget_slot=5", "teach hm03 slot=1", "mart PewterCity_Mart potion=1", - }){ - if (parse_teach_variant(bad)){ - return "Expected \"" + std::string(bad) + "\" refused."; - } - } - return true; -} - +}; + + +FakeTeachGame console_b_party(){ + FakeTeachGame game; + game.party = { + {"squirtle", {"water-gun", "tail-whip", "bubble", "withdraw"}, {"surf", "strength", "dig", "rock-smash"}}, + {"rattata", {"tackle", "tail-whip", "quick-attack"}, {"dig", "cut"}}, + {"oddish", {"absorb", "cut", "poison-powder", "acid"}, {"cut", "flash"}}, + }; + game.tm_case = {{"hm03", {"surf", 1}}, {"tm28", {"dig", 1}}, {"hm01", {"cut", 1}}}; + return game; +} + +struct TeachCase{ + std::string name = {}; + TeachPlan plan = {}; + std::function setup = {}; + TeachEnd end = TeachEnd::LEARNED; + // The member's moves after the step. + std::vector moves = {}; + // Whether the bag was opened at all. + bool opened = true; +}; + +std::vector teach_cases_1(){ + return { + { + .name = "Surf over TAIL WHIP by name", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, + .moves = {"water-gun", "surf", "bubble", "withdraw"}, + }, + { + .name = "Strength over the fourth slot by number", + .plan = {.item = "hm04", .move = "strength", .member = 0, .forget_slot = 3}, + .setup = [](FakeTeachGame& game){ game.tm_case["hm04"] = {"strength", 1}; }, + .moves = {"water-gun", "tail-whip", "bubble", "strength"}, + }, + { + .name = "a TM into a free slot", + .plan = {.item = "tm28", .move = "dig", .member = 1}, + .moves = {"tackle", "tail-whip", "quick-attack", "dig"}, + }, + { + .name = "a move it knows already: nothing pressed", + .plan = {.item = "hm01", .move = "cut", .member = 2}, + .end = TeachEnd::ALREADY_KNOWN, + .moves = {"absorb", "cut", "poison-powder", "acid"}, + .opened = false, + }, + }; +} + +std::vector teach_cases_2(){ + return { + { + .name = "four moves and none named to forget", + .plan = {.item = "hm03", .move = "surf", .member = 0}, + .end = TeachEnd::REFUSED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + .opened = false, + }, + { + .name = "a move to forget it doesn't know (the console Squirtle has no Tackle)", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tackle"}, + .end = TeachEnd::REFUSED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + .opened = false, + }, + { + .name = "an HM move named to forget", + .plan = {.item = "tm28", .move = "dig", .member = 2, .forget_move = "cut"}, + .end = TeachEnd::REFUSED, + .moves = {"absorb", "cut", "poison-powder", "acid"}, + .opened = false, + }, + { + .name = "not compatible: given up, back on the overworld", + .plan = {.item = "hm01", .move = "cut", .member = 0, .forget_move = "withdraw"}, + .end = TeachEnd::REFUSED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + }, + }; +} + +std::vector teach_cases_3(){ + return { + { + .name = "moves read with room, but the game asks to forget one", + .plan = {.item = "hm03", .move = "surf", .member = 0}, + .setup = [](FakeTeachGame& game){ game.stale_moves = std::vector{"water-gun", "bubble"}; }, + .end = TeachEnd::FAILED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + }, + { + .name = "moves that can't be read: nothing pressed", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, + .setup = [](FakeTeachGame& game){ game.moves_readable = false; }, + .end = TeachEnd::UNREAD, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + .opened = false, + }, + { + .name = "an item the TM Case hasn't got: skipped", + .plan = {.item = "hm05", .move = "flash", .member = 2, .forget_move = "acid"}, + .end = TeachEnd::NO_ITEM, + .moves = {"absorb", "cut", "poison-powder", "acid"}, + }, + { + .name = "the TM Case didn't open", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, + .setup = [](FakeTeachGame& game){ game.open_fails = true; }, + .end = TeachEnd::FAILED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + }, + { + .name = "the move didn't land: the read after catches it", + .plan = {.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}, + .setup = [](FakeTeachGame& game){ game.forget_ignored = true; }, + .end = TeachEnd::FAILED, + .moves = {"water-gun", "tail-whip", "bubble", "withdraw"}, + }, + }; +} + +std::vector teach_cases(){ + std::vector cases; + for (std::vector part : {teach_cases_1(), teach_cases_2(), teach_cases_3()}){ + cases.insert(cases.end(), part.begin(), part.end()); + } + return cases; +} + +UnitTestResult check_teach_case(const TeachCase& item){ + QuietLogger logger; + FakeTeachGame game = console_b_party(); + if (item.setup){ + item.setup(game); + } + const TeachResult result = run_teach(logger, game, reader_of(game), item.plan); + if (!game.error.empty()){ + return item.name + ": " + game.error; + } + if (result.end != item.end){ + return std::format("{}: ended {}, expected {} ({}).", item.name, + teach_end_name(result.end), teach_end_name(item.end), result.detail); + } + if (game.party[item.plan.member].moves != item.moves){ + return item.name + ": the moves after aren't the ones expected."; + } + if ((game.items_opened > 0) != item.opened){ + return item.name + (item.opened ? ": the bag wasn't opened." : ": the bag was opened."); + } + return true; +} + +UnitTestResult test_teach(){ + for (const TeachCase& item : teach_cases()){ + const UnitTestResult result = check_teach_case(item); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + return true; +} + + +// A TM is used up, an HM is not. +UnitTestResult test_tm_used_up(){ + QuietLogger logger; + FakeTeachGame game = console_b_party(); + run_teach(logger, game, reader_of(game), {.item = "tm28", .move = "dig", .member = 1}); + run_teach(logger, game, reader_of(game), {.item = "hm03", .move = "surf", .member = 0, .forget_move = "withdraw"}); + if (game.tm_case["tm28"].count != 0 || game.tm_case["hm03"].count != 1){ + return "Expected TM28 used up and HM03 kept."; + } + return true; +} + + +UnitTestResult test_teach_variant(){ + const TeachPlan plan{.item = "hm03", .move = "surf", .member = 0, .forget_move = "tail-whip"}; + const std::string variant = teach_variant(plan); + if (variant != "teach hm03 surf slot=1 forget=tail-whip"){ + return "Unexpected variant \"" + variant + "\"."; + } + const std::optional back = parse_teach_variant(variant); + if (!back || back->item != "hm03" || back->move != "surf" || back->member != 0 || back->forget_move != "tail-whip"){ + return "\"" + variant + "\" didn't read back as its plan."; + } + const std::optional by_slot = parse_teach_variant("teach tm28 dig slot=2 forget_slot=4"); + if (!by_slot || by_slot->member != 1 || by_slot->forget_slot != 3u){ + return "\"teach tm28 dig slot=2 forget_slot=4\" didn't read back."; + } + const std::optional tutor = parse_teach_variant("teach tutor mega-kick slot=1 forget=withdraw"); + if (!tutor || tutor->item != TEACH_TUTOR || tutor->move != "mega-kick" || tutor->forget_move != "withdraw"){ + return "\"teach tutor mega-kick slot=1 forget=withdraw\" didn't read back."; + } + for (std::string_view bad : { + "teach", "teach hm03 surf", "teach potion surf slot=1", "teach hm03 surf slot=7", "teach tutors mega-kick slot=1", + "teach hm03 surf slot=1 forget_slot=5", "teach hm03 slot=1", "mart PewterCity_Mart potion=1", + }){ + if (parse_teach_variant(bad)){ + return "Expected \"" + std::string(bad) + "\" refused."; + } + } + return true; +} + } // namespace - - + + const AutoStoryTeachCase AUTO_STORY_TEACH_CASES[] = { { .input = {"TMs and HMs on a fake party", test_teach}, @@ -418,15 +422,15 @@ const AutoStoryTeachCase AUTO_STORY_TEACH_CASES[] = { }, }; -void add_tests_AutoStoryTeach(UnitTestDatabase& database){ +void add_tests_AutoStoryTeach(UnitTestDatabase& database){ for (const auto& item : AUTO_STORY_TEACH_CASES){ database.add(item); } - add_tests_AutoStoryTeachScreens(database); -} - - - + add_tests_AutoStoryTeachScreens(database); +} + + + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation 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 53d90c17ae..f423c1e54d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -1,1000 +1,989 @@ -/* AutoStory Tests - * - * From: https://github.com/PokemonAutomation/ - * - * Plans every route of the story table on the map files, from where the - * table says the player stands, and checks that each checkpoint starts - * where the one before it ends. - * - */ - -#include -#include -#include -#include -#include -#include -#include -#include "Common/Cpp/Exceptions.h" -#include "Common/Cpp/TestRunners/UnitTest.h" -#include "CommonFramework/ImageTypes/ImageRGB32.h" -#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_OptionMenuReader.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h" -#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" -#include "PokemonFRLG_AutoStoryActions.h" -#include "PokemonFRLG_AutoStoryGrind.h" -#include "PokemonFRLG_AutoStoryPcStorage.h" -#include "PokemonFRLG_AutoStoryTable.h" - -namespace PokemonAutomation{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ - -namespace{ - -struct AutoStoryInput{ - std::string name; - UnitTestResult (*body)(); -}; - -struct AutoStoryExpectation{ - const char* description; -}; - -struct AutoStoryTestCase{ - AutoStoryInput input; - AutoStoryExpectation expectation; -}; - -class Test_AutoStory : public UnitTest{ -public: - explicit Test_AutoStory(AutoStoryTestCase scenario) - : UnitTest("PokemonFRLG::AutoStory - " + 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: - AutoStoryTestCase m_scenario; -}; - -const MapData* find_map_named(const std::string& name){ - for (const MapData& map : story_maps()){ - if (map.name == name){ - return ↦ - } - } - return nullptr; -} - -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 StoryPose& pose){ - return to_string(MapPosition{pose.map, pose.tile}) - + (pose.facing ? std::string(" facing ") + direction_name(*pose.facing) : ""); -} - -// A table pose as a StoryPose; FRLG's table has one-tile poses only. -StoryPose one_tile(const Pokemon::Pose& pose){ - const std::optional ret = story_pose(pose); - if (!ret){ - throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Not a one-tile pose: " + Pokemon::to_string(pose)); - } - return *ret; -} - -Direction reverse(Direction direction){ - switch (direction){ - case Direction::NORTH: - return Direction::SOUTH; - case Direction::EAST: - return Direction::WEST; - case Direction::SOUTH: - return Direction::NORTH; - case Direction::WEST: - return Direction::EAST; - } - return direction; -} - -// The tiles a route's walking legs pass over on `map`. -std::vector tiles_walked_on(const std::vector& route, const std::string& map){ - std::vector tiles; - for (const RouteStep& step : route){ - if (step.action != RouteAction::WALK || step.end.map != map){ - continue; - } - TilePos tile = step.end.point; - for (int c = 0; c < step.tiles; c++){ - tiles.push_back(tile); - tile = tile.next(reverse(step.direction)); - } - } - return tiles; -} - -// Steps that must reach Viridian Forest's gate by legs the walker can hold -// again after a battle: each ends on a wall or a warp, or is one step. -bool held_legs_only(const std::string& name){ - return name == "forest_east" || name == "forest_exit" || name == "forest_gate"; -} -UnitTestResult check_held_legs(const std::string& name, const std::vector& route){ - for (const RouteStep& step : route){ - if (step.action == RouteAction::WALK && !step.to_wall && !step.warps && step.tiles != 1){ - return name + ": an open leg, " + to_string(step) + ":" + describe(route); - } - } - return true; -} - -// Walks `step`'s fixed legs from `pose` and moves `pose` to their end. -UnitTestResult check_legs(StoryPose& pose, const StoryStep& step){ - const std::optional> route = fixed_route(story_maps(), MapPosition{pose.map, pose.tile}, step.legs); - if (!route || route->empty()){ - return step.name + ": the legs can't be walked from " + describe(pose) + "."; - } - if (held_legs_only(step.name)){ - UnitTestResult result = check_held_legs(step.name, *route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - } - pose = StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after}; - return true; -} - -// Plans `step` from `pose` and moves `pose` to its end. -UnitTestResult check_route(StoryPose& pose, const StoryStep& step){ - const MapPosition start{pose.map, pose.tile}; - std::optional facing = pose.facing; - if (!facing){ - const MapData* map = find_map_named(pose.map); - facing = map ? wall_beside(*map, pose.tile) : std::nullopt; - if (!facing){ - return step.name + ": the facing is unknown at " + describe(pose) + " and no wall is beside it."; - } - } - if (!step.target){ - return step.name + ": no target."; - } - const StoryPose target = one_tile(*step.target); - const RouteGoal goal{{target.map, target.tile}, target.facing}; - const std::optional> route = plan_overworld_route(story_maps(), start, goal, default_route_costs(), facing); - if (!route){ - return step.name + ": no route from " + to_string(start) + " to " + to_string(goal.position) + "."; - } - const MapPosition end = follow_overworld_route(story_maps(), start, *route); - if (!(end == goal.position)){ - return step.name + ": the route ends at " + to_string(end) + ":" + describe(*route); - } - if (goal.facing && !route->empty() && route->back().facing_after != *goal.facing){ - return step.name + ": the route doesn't end facing " + direction_name(*goal.facing) + ":" + describe(*route); - } - if (held_legs_only(step.name)){ - UnitTestResult result = check_held_legs(step.name, *route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - } - if (step.name == "below_old_man_trigger"){ - for (const TilePos& tile : tiles_walked_on(*route, "ViridianCity")){ - if (tile == TilePos{20, 8} || tile == TilePos{22, 8}){ - return step.name + ": walks over the old man's trigger " + to_string(tile) + ":" + describe(*route); - } - } - } - pose = StoryPose{target.map, target.tile, route->empty() ? facing : route->back().facing_after}; - return true; -} - -// Follows `checkpoint` from its start; `pose` ends where it leaves the player. -UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& pose){ - for (const StoryStep& step : checkpoint.steps){ - if (step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS){ - UnitTestResult result = step.kind == StoryStepKind::ROUTE ? check_route(pose, step) : check_legs(pose, step); - if (result.result != UnitTestResult::PASSED){ - return checkpoint.id + " " + result.message; - } - }else if (step.kind == StoryStepKind::READY){ - // The nurse may be needed: that way must plan too, but the pose - // goes on from where the player stands. - StoryPose nurse_pose = pose; - StoryStep to_nurse = step; - UnitTestResult result = check_route(nurse_pose, to_nurse); - if (result.result != UnitTestResult::PASSED){ - return checkpoint.id + " " + result.message; - } - }else if (step.kind == StoryStepKind::STEP){ - pose = StoryPose{pose.map, pose.tile.next(step.direction), step.direction}; - } - if (step.pose_after){ - pose = one_tile(*step.pose_after); - } - } - return true; -} - -UnitTestResult test_routes_and_chain(){ - const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 23 || checkpoints[0].start){ - return "Expected 23 checkpoints, the first from a new game."; - } - StoryPose pose; - for (size_t c = 0; c < checkpoints.size(); c++){ - const StoryCheckpoint& checkpoint = checkpoints[c]; - if (c > 0){ - const StoryPose start = one_tile(*checkpoint.start); - const bool same_facing = !start.facing || start.facing == pose.facing; - if (start.map != pose.map || !(start.tile == pose.tile) || !same_facing){ - return checkpoint.id + " starts at " + describe(start) + ", but " + checkpoints[c - 1].id + " ends at " + describe(pose) + "."; - } - pose = start; - } - UnitTestResult result = check_checkpoint(checkpoint, pose); - if (result.result != UnitTestResult::PASSED){ - return result; - } - } - return true; -} - -struct ExpectedLeg{ - Direction direction; - int tiles; - TilePos end; -}; - -// Plans `start` to `goal` on the story maps and checks the walking legs -// on `map`, except the step that warps onto it, against `expected`. -UnitTestResult check_legs_on( - const MapPosition& start, Direction start_facing, const RouteGoal& goal, - const std::string& map, const std::vector& expected -){ - const std::optional> route = plan_overworld_route( - story_maps(), start, goal, default_route_costs(), start_facing - ); - if (!route){ - return "No route from " + to_string(start) + " to " + to_string(goal.position) + "."; - } - std::vector legs; - for (const RouteStep& step : *route){ - if (step.action == RouteAction::WALK && !step.warps && step.end.map == map){ - legs.push_back(step); - } - } - bool same = legs.size() == expected.size(); - for (size_t c = 0; same && c < legs.size(); c++){ - same = legs[c].direction == expected[c].direction && legs[c].tiles == expected[c].tiles && legs[c].end.point == expected[c].end; - } - if (!same){ - return "Unexpected legs on " + map + " from " + to_string(start) + " to " + to_string(goal.position) + ":" + describe(*route); - } - return true; -} - -// 00.09, each walk from where the one before leaves the player. -UnitTestResult test_pewter_gym_walks(){ - const MapPosition nurse{"PewterCity_PokemonCenter_1F", {7, 4}}; - // In by the door's mat (6, 14), straight up column 6 to below Liam's row. - UnitTestResult result = check_legs_on( - nurse, Direction::NORTH, RouteGoal{{"PewterCity_Gym", {6, 9}}, Direction::NORTH}, - "PewterCity_Gym", {{Direction::NORTH, 5, {6, 9}}} - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - // The step onto (6, 8), into Liam's line. - const MapData& gym = *find_map_named("PewterCity_Gym"); - if (classify_step(gym, PlayerState{{6, 9}, elevation_at(gym, {6, 9}), false}, Direction::NORTH) != StepKind::WALK){ - return "Can't step north from (6, 9) onto (6, 8)."; - } - // After Liam, on to Brock: two tiles north. - result = check_legs_on( - MapPosition{"PewterCity_Gym", {6, 8}}, Direction::NORTH, RouteGoal{{"PewterCity_Gym", {6, 6}}, Direction::NORTH}, - "PewterCity_Gym", {{Direction::NORTH, 2, {6, 6}}} - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - // The nurse's way: from her counter back in and up column 6 to Brock. - return check_legs_on( - nurse, Direction::NORTH, RouteGoal{{"PewterCity_Gym", {6, 6}}, Direction::NORTH}, - "PewterCity_Gym", {{Direction::NORTH, 8, {6, 6}}} - ); -} - -// 00.10: out of the Gym to the aide's top trigger. The route keeps off his -// three trigger tiles (46, 21..23), which would start the scene a step -// early, and the step east from its end lands on (46, 21). -UnitTestResult test_pewter_aide_walk(){ - const MapPosition gym{"PewterCity_Gym", {6, 6}}; - const MapPosition goal{"PewterCity", {45, 21}}; - const std::optional> route = plan_overworld_route( - story_maps(), gym, RouteGoal{goal, Direction::EAST}, default_route_costs(), Direction::NORTH - ); - if (!route || route->empty()){ - return "No route from " + to_string(gym) + " to " + to_string(goal) + "."; - } - MapPosition from = gym; - for (const RouteStep& step : *route){ - if (step.action != RouteAction::WALK){ - continue; - } - for (const MapPosition& tile : leg_tiles(story_maps(), from, step)){ - if (tile.map == "PewterCity" && tile.point.x == 46 && tile.point.y >= 21 && tile.point.y <= 23){ - return "The route crosses the aide's trigger " + to_string(tile) + ":" + describe(*route); - } - } - from = step.end; - } - if (route->back().end != goal){ - return "Expected the route to end at " + to_string(goal) + ", got " + to_string(route->back().end) + "."; - } - const MapData& pewter = *find_map_named("PewterCity"); - if (classify_step(pewter, PlayerState{{45, 21}, elevation_at(pewter, {45, 21}), false}, Direction::EAST) != StepKind::WALK){ - return "Can't step east from (45, 21) onto the trigger (46, 21)."; - } - return true; -} - -// Every ROUTE and LEGS after the Running Shoes (00.10) runs; none before. -UnitTestResult test_runs_after_shoes(){ - bool has_shoes = false; - size_t runs = 0; - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - for (const StoryStep& step : checkpoint.steps){ - if (step.kind != StoryStepKind::ROUTE && step.kind != StoryStepKind::LEGS){ - continue; - } - const MoveSpeed expected = has_shoes ? MoveSpeed::RUN : MoveSpeed::WALK; - if (move_speed(step) != expected){ - return checkpoint.id + "/" + step.name + ": expected to " + move_speed_name(expected) + ", it would " + move_speed_name(move_speed(step)) + "."; - } - runs += has_shoes ? 1 : 0; - } - has_shoes = has_shoes || checkpoint.id == "00.10"; - } - if (!has_shoes || runs == 0){ - return "No checkpoint 00.10, where the player gets the Running Shoes, or no route after it."; - } - return true; -} - -// A trainer who stands still but turns to look more than one way (Mt. -// Moon B2F's Grunt at (37, 21) looks around): whether they see the player -// depends on where they look as the player passes. -bool looks_around(const MapObject& object){ - return object.trainer && !object.wander && object.trainer->looks.size() > 1; -} - -// The trainer on `map` who would see the player on `tile`, other than the -// `beaten`. Wanderers and those who look around are left out: no route -// can plan around one (see wanderer_stops() and held_past_lookers()). -const MapObject* trainer_seeing(const MapData& map, const TilePos& tile, const std::set& beaten){ - for (const MapObject& object : map.objects){ - if (object.trainer && !object.wander && !looks_around(object) && !beaten.contains(&object) && trainer_may_see(map, object, tile)){ - return &object; - } - } - return nullptr; -} - -// The tile a STEP from `pose` ends on: the next one; for a step into a -// trainer's line, the first within TRAINER_LINE_TILES that a trainer -// other than the `beaten` sees, where step_until_seen() stops, else the -// last. -TilePos step_end(const StoryPose& pose, const StoryStep& step, const std::set& beaten){ - const MapData* map = find_map_named(pose.map); - if (step.variant != TRAINER_LINE_VARIANT || map == nullptr){ - return pose.tile.next(step.direction); - } - TilePos tile = pose.tile; - step_until_seen( - TRAINER_LINE_TILES, - [&]{ tile = tile.next(step.direction); }, - [&]{ return trainer_seeing(*map, tile, beaten) != nullptr; } - ); - return tile; -} - -// Whether every floor tile of `map` has wild encounters, as in a cave: -// every open tile is tall grass, but for warps. No route keeps out of -// its grass. -bool wild_everywhere(const MapData& map){ - for (int y = 0; y < map.tiles.height; y++){ - for (int x = 0; x < map.tiles.width; x++){ - const TilePos tile{x, y}; - if (map.tiles.at(tile) == TileKind::OPEN && warp_at(map, tile) == nullptr){ - return false; - } - } - } - return true; -} - -// The legs a ROUTE or LEGS `step` from `pose` walks; none if it plans none. -std::vector legs_walked(const StoryPose& pose, const StoryStep& step){ - const MapPosition start{pose.map, pose.tile}; - std::optional> route; - if (step.kind == StoryStepKind::LEGS){ - route = fixed_route(story_maps(), start, step.legs); - }else if (step.target){ - const MapData* map = find_map_named(pose.map); - const std::optional facing = pose.facing ? pose.facing : map ? wall_beside(*map, pose.tile) : std::nullopt; - const StoryPose target = one_tile(*step.target); - route = plan_overworld_route(story_maps(), start, RouteGoal{{target.map, target.tile}, target.facing}, default_route_costs(), facing); - } - return route ? *route : std::vector{}; -} - -// Whether a wandering trainer may see `tile`. -bool wanderer_sees(const MapPosition& tile){ - const MapData* map = find_map_named(tile.map); - if (map == nullptr){ - return false; - } - for (const MapObject& object : map->objects){ - if (object.wander && trainer_may_see(*map, object, tile.point)){ - return true; - } - } - return false; -} - -// Whether they look the player's way can't be planned for, so the walk -// watches for a wanderer after each leg that crosses their sight, fights -// them, and goes on from where they stopped the player, if it can tell -// (run_route()'s trainer_stop(): the leg has one tile in any trainer's -// sight and is off the grass). For each leg of `step` from `pose` that -// crosses a wanderer's sight, that tile, or nothing if it can't tell. -std::vector> wanderer_stops(const StoryPose& pose, const StoryStep& step){ - std::vector> stops; - MapPosition from{pose.map, pose.tile}; - for (const RouteStep& leg : legs_walked(pose, step)){ - const std::optional c = trainer_crossing(story_maps(), from, leg); - if (c && std::any_of(c->seen.begin(), c->seen.end(), wanderer_sees)){ - stops.push_back(c->seen.size() == 1 && !leg.wild && !leg.warps ? std::optional(c->seen[0]) : std::nullopt); - } - from = leg.end; - } - return stops; -} - -// Where a leg of a ROUTE or LEGS `step` from `pose` crosses the sight of a -// trainer who looks around, the leg ends on a wall: if the trainer looks -// the player's way, they walk up beside the leg, and the walker holds it -// on to its wall after the battle. Empty if so, else the leg that doesn't. -std::string held_past_lookers(const StoryPose& pose, const StoryStep& step){ - MapPosition from{pose.map, pose.tile}; - for (const RouteStep& leg : legs_walked(pose, step)){ - const MapData* map = find_map_named(from.map); - bool crosses = false; - for (const MapPosition& tile : leg.action == RouteAction::WALK ? leg_tiles(story_maps(), from, leg) : std::vector{}){ - for (const MapObject& object : map ? map->objects : std::vector{}){ - crosses |= looks_around(object) && trainer_may_see(*map, object, tile.point); - } - } - if (crosses && !leg.to_wall){ - return "crosses a trainer's sight on " + to_string(leg) + ", not a leg to a wall."; - } - from = leg.end; - } - return ""; -} - -// The tiles a ROUTE or LEGS `step` from `pose` walks onto, in order. -std::vector tiles_walked(const StoryPose& pose, const StoryStep& step){ - std::vector tiles; - MapPosition from{pose.map, pose.tile}; - for (const RouteStep& leg : legs_walked(pose, step)){ - if (leg.action == RouteAction::WALK){ - const std::vector passed = leg_tiles(story_maps(), from, leg); - tiles.insert(tiles.end(), passed.begin() + 1, passed.end()); - } - from = leg.end; - tiles.push_back(from); - } - return tiles; -} - -// Why the player shouldn't walk onto `tile`, or empty: tall grass, a warp -// to a map not loaded (Route 4's ways into Mt. Moon), a trainer's line. -std::string tile_problem(const MapPosition& tile, const std::set& beaten){ - const MapData* map = find_map_named(tile.map); - if (map == nullptr){ - return "off the story's maps at " + to_string(tile); - } - if (is_tall_grass(*map, tile.point) && !wild_everywhere(*map)){ - return "in the grass at " + to_string(tile); - } - const MapWarp* warp = warp_at(*map, tile.point); - if (warp != nullptr && !warp->to){ - return "onto a warp to a map not loaded at " + to_string(tile); - } - const MapObject* trainer = trainer_seeing(*map, tile.point, beaten); - if (trainer != nullptr){ - return "in the line of the trainer at " + to_string(TilePos{trainer->x, trainer->y}) + " at " + to_string(tile); - } - return ""; -} - -// Moves `pose` past `step`, as check_checkpoint() does. -UnitTestResult advance(StoryPose& pose, const StoryStep& step){ - if (step.kind == StoryStepKind::ROUTE){ - return check_route(pose, step); - } - if (step.kind == StoryStepKind::LEGS){ - return check_legs(pose, step); - } - if (step.kind == StoryStepKind::STEP){ - pose = StoryPose{pose.map, step_end(pose, step, {}), step.direction}; - } - if (step.pose_after){ - pose = one_tile(*step.pose_after); - } - return true; -} - -// `checkpoint`'s routes and legs keep to tiles with no tile_problem(); a -// step into a trainer's line is followed by the battle, which beats them. -UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set& beaten){ - StoryPose pose = one_tile(*checkpoint.start); - for (size_t c = 0; c < checkpoint.steps.size(); c++){ - const StoryStep& step = checkpoint.steps[c]; - const std::string where = checkpoint.id + "/" + step.name + ": "; - const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS; - // A GRIND's patch is grass: the route there may end on it. - const bool to_patch = step.target && c + 1 < checkpoint.steps.size() && parse_grind_variant(checkpoint.steps[c + 1].variant); - for (const MapPosition& tile : walks ? tiles_walked(pose, step) : std::vector{}){ - 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"))){ - return where + problem; - } - } - const std::vector> stops = walks ? wanderer_stops(pose, step) : std::vector>{}; - if (std::count(stops.begin(), stops.end(), std::nullopt) > 0){ - return where + "crosses a wandering trainer's sight where the walk can't tell where they'd stop the player."; - } - if (const std::string problem = walks ? held_past_lookers(pose, step) : ""; !problem.empty()){ - return where + problem; - } - const MapData* map = find_map_named(pose.map); - const MapObject* trainer = step.kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, step, beaten), beaten) : nullptr; - if (trainer != nullptr && (c + 1 == checkpoint.steps.size() || checkpoint.steps[c + 1].kind != StoryStepKind::BATTLE)){ - return where + "steps into a trainer's line with no battle after it."; - } - if (trainer != nullptr){ - beaten.insert(trainer); - } - if (UnitTestResult result = advance(pose, step); result.result != UnitTestResult::PASSED){ - return checkpoint.id + " " + result.message; - } - } - return true; -} - -// From the Running Shoes on, routes and legs keep out of the grass (but -// in caves, which are all grass, and onto a GRIND's patch), off warps to maps not loaded and out of -// trainers' lines, but for the three Route 3 trainers, Mt. Moon's Grunt -// at (12, 20) and Nugget Bridge's five the table steps in front of to -// fight; where they cross a wandering trainer's sight, the walk can place -// the player if the trainer stops them; and where they cross the sight of -// one who looks around, on a leg to a wall. -UnitTestResult test_trainer_lines(){ - std::set beaten; - bool has_shoes = false; - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - if (has_shoes){ - if (UnitTestResult result = check_lines(checkpoint, beaten); result.result != UnitTestResult::PASSED){ - return result; - } - } - has_shoes = has_shoes || checkpoint.id == "00.10"; - } - if (beaten.size() != 9){ - return "Expected to fight Colton, Greg and James on Route 3, the Grunt at (12, 20) in Mt. Moon and Nugget Bridge's five; the table fights " - + std::to_string(beaten.size()) + " trainers."; - } - return true; -} - -// 00.12's walk east over Route 3 crosses Lass Robin's sight (she wanders -// x 40, y 10..12, looking north and south, sight 3) on (40, 9) only: if -// she sees the player, the walk goes on from there (console B, -// 2026-11-02, she stopped "east 11 (to wall)" there). -UnitTestResult test_robin_crossing(){ - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - if (checkpoint.id != "00.12" || checkpoint.steps.empty() || checkpoint.steps[0].name != "route4_nurse"){ - continue; - } - const std::vector> expected{MapPosition{"Route3", {40, 9}}}; - if (wanderer_stops(one_tile(*checkpoint.start), checkpoint.steps[0]) != expected){ - return "Expected 00.12/route4_nurse to cross Lass Robin's sight once, the walk placing her stop on Route3 (40, 9)."; - } - return true; - } - return "No step 00.12/route4_nurse."; -} - -// A scene the table starts on a step onto its trigger, and the walks of -// its checkpoint before that step, which keep off the trigger's tiles: -// the maps have no triggers, so a route over one would start the scene -// part-way. -struct TriggerCase{ - std::string checkpoint; - std::string step; - std::string map; - // The trigger's tiles: row `y` from `x0` to `x1`. - int y = 0; - int x0 = 0; - int x1 = 0; - // The tile the step lands on. - int onto_x = 0; -}; - -const StoryCheckpoint* find_checkpoint(const std::string& id){ - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - if (checkpoint.id == id){ - return &checkpoint; - } - } - return nullptr; -} - -UnitTestResult check_trigger(const TriggerCase& c){ - const StoryCheckpoint* checkpoint = find_checkpoint(c.checkpoint); - if (checkpoint == nullptr){ - return "No checkpoint " + c.checkpoint + "."; - } - const MapPosition onto{c.map, {c.onto_x, c.y}}; - StoryPose pose = one_tile(*checkpoint->start); - for (const StoryStep& step : checkpoint->steps){ - const std::string where = c.checkpoint + "/" + step.name + ": "; - if (step.name == c.step && step.kind == StoryStepKind::STEP && onto == MapPosition{pose.map, pose.tile.next(step.direction)}){ - return true; - } - if (step.name == c.step){ - return where + "expected a step onto " + to_string(onto) + ", from " + describe(pose) + "."; - } - const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS; - for (const MapPosition& tile : walks ? tiles_walked(pose, step) : std::vector{}){ - if (tile.map == c.map && tile.point.y == c.y && tile.point.x >= c.x0 && tile.point.x <= c.x1){ - return where + "walks over the trigger at " + to_string(tile) + "."; - } - } - if (UnitTestResult result = advance(pose, step); result.result != UnitTestResult::PASSED){ - return c.checkpoint + " " + result.message; - } - } - return "No step " + c.checkpoint + "/" + c.step + "."; -} - -// 00.15b meets the rival on the middle of his three trigger tiles, row 6 -// at x 22..24 north of Cerulean (VAR_MAP_SCENE_CERULEAN_CITY_RIVAL 0), -// and 00.16 the Rocket on the east one of his two, (10..11, 15) on Nugget -// Bridge (VAR_MAP_SCENE_ROUTE24 0), each from the tile below. -UnitTestResult test_cerulean_triggers(){ - const TriggerCase CASES[] = { - {.checkpoint = "00.15b", .step = "rival_trigger", .map = "CeruleanCity", .y = 6, .x0 = 22, .x1 = 24, .onto_x = 23}, - {.checkpoint = "00.16", .step = "rocket_trigger", .map = "Route24", .y = 15, .x0 = 10, .x1 = 11, .onto_x = 11}, - }; - for (const TriggerCase& c : CASES){ - if (UnitTestResult result = check_trigger(c); result.result != UnitTestResult::PASSED){ - return result; - } - } - return true; -} - -// 00.16 steps beside Nugget Bridge's five trainers from the south, one -// step at a time, each battle right after its step: Cale, Ali, Timmy, -// Reli and Ethan, by where they stand. -UnitTestResult test_nugget_bridge_order(){ - const StoryCheckpoint* checkpoint = find_checkpoint("00.16"); - if (checkpoint == nullptr){ - return "No checkpoint 00.16."; - } - const std::vector& steps = checkpoint->steps; - std::string met; - StoryPose pose = one_tile(*checkpoint->start); - 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; - 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."; - } - met += trainer ? " " + to_string(TilePos{trainer->x, trainer->y}) : ""; - if (UnitTestResult result = advance(pose, steps[c]); result.result != UnitTestResult::PASSED){ - return "00.16 " + result.message; - } - } - const std::string expected = " (12, 31) (10, 28) (12, 25) (10, 22) (12, 19)"; - if (met != expected){ - return "00.16: expected the five bridge trainers south to north," + expected + "; met" + met + "."; - } - return true; -} - -// Every checkpoint that starts from a save has a place the save card -// names, so a save left elsewhere is caught before it runs; and the -// places of the starts that matter differ. -UnitTestResult test_save_places(){ - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - if (checkpoint.start && save_card_place(checkpoint.start->map).empty()){ - return checkpoint.id + " starts on " + checkpoint.start->map + ", which has no save card place."; - } - } - if (save_card_place("ViridianForest") != "viridian-forest" || save_card_place("ViridianCity_Mart") != "viridian-city" - || save_card_place("PewterCity_PokemonCenter_1F") != "pewter-city" || save_card_place("PalletTown_ProfessorOaksLab") != "pallet-town" - ){ - return "Wrong save card places."; - } - if (save_card_place("Route3") != "route-3" || save_card_place("Route4_PokemonCenter_1F") != "route-4"){ - return "Wrong save card places on Route 3 and 4."; - } - if (save_card_place("CeruleanCity_PokemonCenter_1F") != "cerulean-city"){ - return "Wrong save card place in Cerulean City."; - } - // Not Route 2's or Route 4's: a map name is a place's or a building's in it. - if (!save_card_place("Route22").empty() || !save_card_place("Route24").empty() || !save_card_place("Route2").empty()){ - return "A place for a map the table doesn't use."; - } - return true; -} - -// A reload's save checked against the checkpoint's start: a save left in -// Pewter City (a new game whose save didn't happen) can't start 00.02 in -// the bedroom. The check takes no checks level: Fast runs it too. -UnitTestResult test_save_place_error(){ - struct Case{ - std::string start_map; - std::string place; - bool error; - }; - const Case CASES[] = { - {.start_map = "PalletTown_PlayersHouse_2F", .place = "pewter-city", .error = true}, - {.start_map = "PalletTown_PlayersHouse_2F", .place = "pallet-town", .error = false}, - // A card that can't be read is let pass. - {.start_map = "PalletTown_PlayersHouse_2F", .place = "", .error = false}, - {.start_map = "ViridianForest", .place = "viridian-city", .error = true}, - // A map with no known place isn't checked. - {.start_map = "Route22", .place = "pewter-city", .error = false}, - }; - for (const Case& c : CASES){ - const std::string error = save_place_error(c.start_map, c.place); - if (error.empty() == c.error){ - return "A save in \"" + c.place + "\" for a start on " + c.start_map + ": expected " + - (c.error ? "an error" : "no error") + ", got \"" + error + "\"."; - } - } - return true; -} - -const char* save_action_name(SaveAction action){ - switch (action){ - case SaveAction::WAIT: - return "WAIT"; - case SaveAction::ANSWER_YES: - return "ANSWER_YES"; - case SaveAction::NEXT_PAGE: - return "NEXT_PAGE"; - case SaveAction::CHECK_OVERWORLD: - return "CHECK_OVERWORLD"; - case SaveAction::FAIL_CANCELLED: - return "FAIL_CANCELLED"; - case SaveAction::FAIL_ERROR: - return "FAIL_ERROR"; - } - return "?"; -} - -// save_game()'s answers to the save dialogs of pokefirered's start_menu.c, -// screen by screen from the first YES/NO. -UnitTestResult test_save_dialog(){ - using S = SaveScreen; - using A = SaveAction; - struct Case{ - std::string name; - std::vector> screens; - }; - const std::vector CASES = { - { - .name = "first save", - .screens = {{S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::TEXT, A::WAIT}, {S::NOTHING, A::WAIT}, {S::NOTHING, A::CHECK_OVERWORLD}}, - }, - { - .name = "over this game's save", - .screens = {{S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::NOTHING, A::WAIT}, {S::NOTHING, A::CHECK_OVERWORLD}}, - }, - { - // WARNING!, four pages, then the YES/NO that starts on NO. - .name = "a new game over another game's save", - .screens = { - {S::YES_NO, A::ANSWER_YES}, - {S::PAGED_TEXT, A::NEXT_PAGE}, {S::NOTHING, A::WAIT}, {S::PAGED_TEXT, A::NEXT_PAGE}, {S::PAGED_TEXT, A::NEXT_PAGE}, - {S::TEXT, A::WAIT}, {S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::NOTHING, A::WAIT}, {S::NOTHING, A::CHECK_OVERWORLD}, - }, - }, - { - .name = "cancelled: the START menu is back", - .screens = {{S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::START_MENU, A::FAIL_CANCELLED}}, - }, - { - .name = "Save error.", - .screens = {{S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::PAGED_TEXT, A::NEXT_PAGE}, {S::NOTHING, A::WAIT}, {S::NOTHING, A::FAIL_ERROR}}, - }, - }; - for (const Case& c : CASES){ - SaveDialog dialog; - for (size_t i = 0; i < c.screens.size(); i++){ - const SaveAction action = dialog.next(c.screens[i].first); - if (action != c.screens[i].second){ - return c.name + ", screen " + std::to_string(i + 1) + ": expected " + save_action_name(c.screens[i].second) + - ", got " + save_action_name(action) + "."; - } - } - } - return true; -} - -UnitTestResult test_lint(){ - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - const std::vector problems = Pokemon::lint_story_checkpoint(checkpoint); - if (!problems.empty()){ - return problems[0]; - } - } - return true; -} - -UnitTestResult test_unique_names(){ - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - for (size_t i = 0; i < checkpoint.steps.size(); i++){ - for (size_t j = i + 1; j < checkpoint.steps.size(); j++){ - if (checkpoint.steps[i].name == checkpoint.steps[j].name){ - return checkpoint.id + " has two steps named " + checkpoint.steps[i].name + "."; - } - } - } - } - return true; -} -// The intro's names: the player's keyboard first, then the rival's list, -// where a preset is picked; a keyboard after the player's means the list -// was missed. -UnitTestResult test_intro_names(){ - struct Case{ - const char* what; - IntroNameScreen screen; - size_t keyboards_seen; - size_t presets_picked; - IntroNameStep expected; - }; - const std::vector cases{ - {.what = "the first keyboard", .screen = IntroNameScreen::KEYBOARD, .keyboards_seen = 0, .presets_picked = 0, .expected = IntroNameStep::PLAYER_KEYBOARD}, - {.what = "the list after the player's keyboard", .screen = IntroNameScreen::RIVAL_LIST, .keyboards_seen = 1, .presets_picked = 0, .expected = IntroNameStep::RIVAL_PRESET}, - {.what = "a list read before the player's keyboard", .screen = IntroNameScreen::RIVAL_LIST, .keyboards_seen = 0, .presets_picked = 0, .expected = IntroNameStep::NONE}, - {.what = "the second keyboard", .screen = IntroNameScreen::KEYBOARD, .keyboards_seen = 1, .presets_picked = 0, .expected = IntroNameStep::RIVAL_KEYBOARD}, - {.what = "a keyboard after a preset was picked", .screen = IntroNameScreen::KEYBOARD, .keyboards_seen = 1, .presets_picked = 1, .expected = IntroNameStep::RIVAL_KEYBOARD}, - {.what = "the list again after a NO", .screen = IntroNameScreen::RIVAL_LIST, .keyboards_seen = 1, .presets_picked = 1, .expected = IntroNameStep::RIVAL_PRESET}, - {.what = "the list after every try", .screen = IntroNameScreen::RIVAL_LIST, .keyboards_seen = 1, .presets_picked = RIVAL_PRESET_TRIES, .expected = IntroNameStep::NONE}, - {.what = "a message box", .screen = IntroNameScreen::OTHER, .keyboards_seen = 1, .presets_picked = 0, .expected = IntroNameStep::NONE}, - }; - for (const Case& c : cases){ - const IntroNameStep step = intro_name_step(c.screen, c.keyboards_seen, c.presets_picked); - if (step != c.expected){ - return std::string(c.what) + ": expected step " + std::to_string(static_cast(c.expected)) - + ", got " + std::to_string(static_cast(step)) + "."; - } - } - // A black screen is no list. - ImageRGB32 black(1920, 1080); - black.fill(0xff000000); - if (rival_name_list_cursor(black).has_value()){ - return "Read the rival's name list on a black screen."; - } - return true; -} - -// 00.12c stores the Magikarp Gift RNG bought; with Gift RNG off there is -// none, and the deposit is skipped instead of refused. -UnitTestResult test_magikarp_deposit_skippable(){ - for (const StoryCheckpoint& checkpoint : story_checkpoints()){ - if (checkpoint.id != "00.12c"){ - continue; - } - for (const StoryStep& step : checkpoint.steps){ - const std::optional transfer = parse_pc_variant(step.variant); - if (transfer && transfer->party_slot == 1 && transfer->if_missing == PcMissing::SKIP){ - return true; - } - } - return "00.12c: expected a deposit of the second slot, skipped if the party hasn't got it."; - } - return "No checkpoint 00.12c."; -} - -// set_options()'s RIGHT presses from each value an old save can leave: -// on console B a save with FAST and SET became SLOW and SHIFT when the -// presses assumed MID and SHIFT. -UnitTestResult test_option_presses(){ - const std::vector& speed = option_menu_values(OptionMenuItem::TEXT_SPEED); - const std::vector& scene = option_menu_values(OptionMenuItem::BATTLE_SCENE); - const std::vector& style = option_menu_values(OptionMenuItem::BATTLE_STYLE); - struct Case{ - const std::vector& values; - std::string current; - std::string target; - std::optional expected; - }; - const std::vector cases{ - {.values = speed, .current = "slow", .target = "fast", .expected = 2}, - {.values = speed, .current = "mid", .target = "fast", .expected = 1}, - {.values = speed, .current = "fast", .target = "fast", .expected = 0}, - {.values = scene, .current = "on", .target = "off", .expected = 1}, - {.values = scene, .current = "off", .target = "off", .expected = 0}, - {.values = style, .current = "shift", .target = "set", .expected = 1}, - {.values = style, .current = "set", .target = "set", .expected = 0}, - // RIGHT wraps from the last value to the first. - {.values = speed, .current = "fast", .target = "slow", .expected = 1}, - // Unread, or another row's value: no guess. - {.values = speed, .current = "", .target = "fast", .expected = std::nullopt}, - {.values = style, .current = "fast", .target = "set", .expected = std::nullopt}, - {.values = scene, .current = "set", .target = "off", .expected = std::nullopt}, - }; - for (const Case& c : cases){ - const std::optional presses = option_right_presses(c.values, c.current, c.target); - if (presses != c.expected){ - auto text = [](const std::optional& n){ - return n ? std::to_string(*n) + " RIGHT" : std::string("no presses"); - }; - return "\"" + c.current + "\" to " + c.target + ": expected " + text(c.expected) + ", got " + text(presses) + "."; - } - } - return true; -} - -const AutoStoryTestCase AUTO_STORY_CASES[] = { - {.input = {.name = "every route plans, and checkpoints chain", .body = test_routes_and_chain}, .expectation = {.description = "verifies route planning validity across all chained checkpoints"},}, - {.input = {.name = "step names are unique per checkpoint", .body = test_unique_names}, .expectation = {.description = "ensures step identifier uniqueness within checkpoints"},}, - {.input = {.name = "every save has a place on the save card", .body = test_save_places}, .expectation = {.description = "validates save location identifiers on save card"},}, - {.input = {.name = "a reload's save is checked against the start", .body = test_save_place_error}, .expectation = {.description = "detects mismatch between checkpoint initial state and save point"},}, - {.input = {.name = "save dialogs: every YES/NO is YES, a cancel fails", .body = test_save_dialog}, .expectation = {.description = "verifies save confirmation prompt interactions"},}, - {.input = {.name = "every checkpoint lints clean", .body = test_lint}, .expectation = {.description = "validates semantic lint rules across story step sequence"},}, - {.input = {.name = "Pewter Gym walks", .body = test_pewter_gym_walks}, .expectation = {.description = "verifies Pewter Gym route planning and obstacle avoidance"},}, - {.input = {.name = "Pewter: to the aide's trigger", .body = test_pewter_aide_walk}, .expectation = {.description = "plans path to Running Shoes aide trigger tile"},}, - {.input = {.name = "routes run after the Running Shoes", .body = test_runs_after_shoes}, .expectation = {.description = "switches movement speed to running once shoes acquired"},}, - {.input = {.name = "intro: names, the rival's from the list", .body = test_intro_names}, .expectation = {.description = "verifies character and rival naming inputs"},}, - {.input = {.name = "options: RIGHT presses from the shown value", .body = test_option_presses}, .expectation = {.description = "calculates Dpad RIGHT inputs for setting game options"},}, - {.input = {.name = "Route 3: only the planned trainers' lines", .body = test_trainer_lines}, .expectation = {.description = "verifies route 3 trainer interaction sightlines"},}, - {.input = {.name = "Route 3: Lass Robin stops the walk on (40, 9)", .body = test_robin_crossing}, .expectation = {.description = "verifies trainer interruption on route 3"},}, - {.input = {.name = "00.12c: the Magikarp's deposit is skipped without Gift RNG", .body = test_magikarp_deposit_skippable}, .expectation = {.description = "skips magikarp deposit when gift rng is disabled"},}, - {.input = {"Cerulean: the rival's and the Rocket's triggers", test_cerulean_triggers}, .expectation = {},}, - {.input = {"Nugget Bridge: its five trainers, south to north", test_nugget_bridge_order}, .expectation = {},},}; - -} // namespace - -void add_tests_AutoStory(UnitTestDatabase& database){ - add_tests_AutoStoryRunPlan(database); - for (const auto& scenario : AUTO_STORY_CASES){ - database.add(scenario); - } -} - -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation +/* AutoStory Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Plans every route of the story table on the map files, from where the + * table says the player stands, and checks that each checkpoint starts + * where the one before it ends. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_OptionMenuReader.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryCapture.h" +#include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryPcStorage.h" +#include "PokemonFRLG_AutoStoryTable.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +namespace{ + +struct AutoStoryInput{ + std::string name; + UnitTestResult (*body)(); +}; + +struct AutoStoryExpectation{ + const char* description; +}; + +struct AutoStoryTestCase{ + AutoStoryInput input; + AutoStoryExpectation expectation; +}; + +class Test_AutoStory : public UnitTest{ +public: + explicit Test_AutoStory(AutoStoryTestCase scenario) + : UnitTest("PokemonFRLG::AutoStory - " + 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: + AutoStoryTestCase m_scenario; +}; + +const MapData* find_map_named(const std::string& name){ + for (const MapData& map : story_maps()){ + if (map.name == name){ + return ↦ + } + } + return nullptr; +} + +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 StoryPose& pose){ + return to_string(MapPosition{pose.map, pose.tile}) + + (pose.facing ? std::string(" facing ") + direction_name(*pose.facing) : ""); +} + +// A table pose as a StoryPose; FRLG's table has one-tile poses only. +StoryPose one_tile(const Pokemon::Pose& pose){ + const std::optional ret = story_pose(pose); + if (!ret){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Not a one-tile pose: " + Pokemon::to_string(pose)); + } + return *ret; +} + +Direction reverse(Direction direction){ + switch (direction){ + case Direction::NORTH: + return Direction::SOUTH; + case Direction::EAST: + return Direction::WEST; + case Direction::SOUTH: + return Direction::NORTH; + case Direction::WEST: + return Direction::EAST; + } + return direction; +} + +// The tiles a route's walking legs pass over on `map`. +std::vector tiles_walked_on(const std::vector& route, const std::string& map){ + std::vector tiles; + for (const RouteStep& step : route){ + if (step.action != RouteAction::WALK || step.end.map != map){ + continue; + } + TilePos tile = step.end.point; + for (int c = 0; c < step.tiles; c++){ + tiles.push_back(tile); + tile = tile.next(reverse(step.direction)); + } + } + return tiles; +} + +// Steps that must reach Viridian Forest's gate by legs the walker can hold +// again after a battle: each ends on a wall or a warp, or is one step. +bool held_legs_only(const std::string& name){ + return name == "forest_east" || name == "forest_exit" || name == "forest_gate"; +} +UnitTestResult check_held_legs(const std::string& name, const std::vector& route){ + for (const RouteStep& step : route){ + if (step.action == RouteAction::WALK && !step.to_wall && !step.warps && step.tiles != 1){ + return name + ": an open leg, " + to_string(step) + ":" + describe(route); + } + } + return true; +} + +// Walks `step`'s fixed legs from `pose` and moves `pose` to their end. +UnitTestResult check_legs(StoryPose& pose, const StoryStep& step){ + const std::optional> route = fixed_route(story_maps(), MapPosition{pose.map, pose.tile}, step.legs); + if (!route || route->empty()){ + return step.name + ": the legs can't be walked from " + describe(pose) + "."; + } + if (held_legs_only(step.name)){ + UnitTestResult result = check_held_legs(step.name, *route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + pose = StoryPose{route->back().end.map, route->back().end.point, route->back().facing_after}; + return true; +} + +// Plans `step` from `pose` and moves `pose` to its end. +UnitTestResult check_route(StoryPose& pose, const StoryStep& step){ + const MapPosition start{pose.map, pose.tile}; + std::optional facing = pose.facing; + if (!facing){ + const MapData* map = find_map_named(pose.map); + facing = map ? wall_beside(*map, pose.tile) : std::nullopt; + if (!facing){ + return step.name + ": the facing is unknown at " + describe(pose) + " and no wall is beside it."; + } + } + if (!step.target){ + return step.name + ": no target."; + } + const StoryPose target = one_tile(*step.target); + const RouteGoal goal{{target.map, target.tile}, target.facing}; + const std::optional> route = plan_overworld_route(story_maps(), start, goal, default_route_costs(), facing); + if (!route){ + return step.name + ": no route from " + to_string(start) + " to " + to_string(goal.position) + "."; + } + const MapPosition end = follow_overworld_route(story_maps(), start, *route); + if (!(end == goal.position)){ + return step.name + ": the route ends at " + to_string(end) + ":" + describe(*route); + } + if (goal.facing && !route->empty() && route->back().facing_after != *goal.facing){ + return step.name + ": the route doesn't end facing " + direction_name(*goal.facing) + ":" + describe(*route); + } + if (held_legs_only(step.name)){ + UnitTestResult result = check_held_legs(step.name, *route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + if (step.name == "below_old_man_trigger"){ + for (const TilePos& tile : tiles_walked_on(*route, "ViridianCity")){ + if (tile == TilePos{20, 8} || tile == TilePos{22, 8}){ + return step.name + ": walks over the old man's trigger " + to_string(tile) + ":" + describe(*route); + } + } + } + pose = StoryPose{target.map, target.tile, route->empty() ? facing : route->back().facing_after}; + return true; +} + +// Follows `checkpoint` from its start; `pose` ends where it leaves the player. +UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& pose){ + for (const StoryStep& step : checkpoint.steps){ + if (step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS){ + UnitTestResult result = step.kind == StoryStepKind::ROUTE ? check_route(pose, step) : check_legs(pose, step); + if (result.result != UnitTestResult::PASSED){ + return checkpoint.id + " " + result.message; + } + }else if (step.kind == StoryStepKind::READY){ + // The nurse may be needed: that way must plan too, but the pose + // goes on from where the player stands. + StoryPose nurse_pose = pose; + StoryStep to_nurse = step; + UnitTestResult result = check_route(nurse_pose, to_nurse); + if (result.result != UnitTestResult::PASSED){ + return checkpoint.id + " " + result.message; + } + }else if (step.kind == StoryStepKind::STEP){ + pose = StoryPose{pose.map, pose.tile.next(step.direction), step.direction}; + } + if (step.pose_after){ + pose = one_tile(*step.pose_after); + } + } + return true; +} + +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."; + } + StoryPose pose; + for (size_t c = 0; c < checkpoints.size(); c++){ + const StoryCheckpoint& checkpoint = checkpoints[c]; + if (c > 0){ + const StoryPose start = one_tile(*checkpoint.start); + const bool same_facing = !start.facing || start.facing == pose.facing; + if (start.map != pose.map || !(start.tile == pose.tile) || !same_facing){ + return checkpoint.id + " starts at " + describe(start) + ", but " + checkpoints[c - 1].id + " ends at " + describe(pose) + "."; + } + pose = start; + } + UnitTestResult result = check_checkpoint(checkpoint, pose); + if (result.result != UnitTestResult::PASSED){ + return result; + } + } + return true; +} + +struct ExpectedLeg{ + Direction direction; + int tiles; + TilePos end; +}; + +// Plans `start` to `goal` on the story maps and checks the walking legs +// on `map`, except the step that warps onto it, against `expected`. +UnitTestResult check_legs_on( + const MapPosition& start, Direction start_facing, const RouteGoal& goal, + const std::string& map, const std::vector& expected +){ + const std::optional> route = plan_overworld_route( + story_maps(), start, goal, default_route_costs(), start_facing + ); + if (!route){ + return "No route from " + to_string(start) + " to " + to_string(goal.position) + "."; + } + std::vector legs; + for (const RouteStep& step : *route){ + if (step.action == RouteAction::WALK && !step.warps && step.end.map == map){ + legs.push_back(step); + } + } + bool same = legs.size() == expected.size(); + for (size_t c = 0; same && c < legs.size(); c++){ + same = legs[c].direction == expected[c].direction && legs[c].tiles == expected[c].tiles && legs[c].end.point == expected[c].end; + } + if (!same){ + return "Unexpected legs on " + map + " from " + to_string(start) + " to " + to_string(goal.position) + ":" + describe(*route); + } + return true; +} + +// 00.09, each walk from where the one before leaves the player. +UnitTestResult test_pewter_gym_walks(){ + const MapPosition nurse{"PewterCity_PokemonCenter_1F", {7, 4}}; + // In by the door's mat (6, 14), straight up column 6 to below Liam's row. + UnitTestResult result = check_legs_on( + nurse, Direction::NORTH, RouteGoal{{"PewterCity_Gym", {6, 9}}, Direction::NORTH}, + "PewterCity_Gym", {{Direction::NORTH, 5, {6, 9}}} + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + // The step onto (6, 8), into Liam's line. + const MapData& gym = *find_map_named("PewterCity_Gym"); + if (classify_step(gym, PlayerState{{6, 9}, elevation_at(gym, {6, 9}), false}, Direction::NORTH) != StepKind::WALK){ + return "Can't step north from (6, 9) onto (6, 8)."; + } + // After Liam, on to Brock: two tiles north. + result = check_legs_on( + MapPosition{"PewterCity_Gym", {6, 8}}, Direction::NORTH, RouteGoal{{"PewterCity_Gym", {6, 6}}, Direction::NORTH}, + "PewterCity_Gym", {{Direction::NORTH, 2, {6, 6}}} + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + // The nurse's way: from her counter back in and up column 6 to Brock. + return check_legs_on( + nurse, Direction::NORTH, RouteGoal{{"PewterCity_Gym", {6, 6}}, Direction::NORTH}, + "PewterCity_Gym", {{Direction::NORTH, 8, {6, 6}}} + ); +} + +// 00.10: out of the Gym to the aide's top trigger. The route keeps off his +// three trigger tiles (46, 21..23), which would start the scene a step +// early, and the step east from its end lands on (46, 21). +UnitTestResult test_pewter_aide_walk(){ + const MapPosition gym{"PewterCity_Gym", {6, 6}}; + const MapPosition goal{"PewterCity", {45, 21}}; + const std::optional> route = plan_overworld_route( + story_maps(), gym, RouteGoal{goal, Direction::EAST}, default_route_costs(), Direction::NORTH + ); + if (!route || route->empty()){ + return "No route from " + to_string(gym) + " to " + to_string(goal) + "."; + } + MapPosition from = gym; + for (const RouteStep& step : *route){ + if (step.action != RouteAction::WALK){ + continue; + } + for (const MapPosition& tile : leg_tiles(story_maps(), from, step)){ + if (tile.map == "PewterCity" && tile.point.x == 46 && tile.point.y >= 21 && tile.point.y <= 23){ + return "The route crosses the aide's trigger " + to_string(tile) + ":" + describe(*route); + } + } + from = step.end; + } + if (route->back().end != goal){ + return "Expected the route to end at " + to_string(goal) + ", got " + to_string(route->back().end) + "."; + } + const MapData& pewter = *find_map_named("PewterCity"); + if (classify_step(pewter, PlayerState{{45, 21}, elevation_at(pewter, {45, 21}), false}, Direction::EAST) != StepKind::WALK){ + return "Can't step east from (45, 21) onto the trigger (46, 21)."; + } + return true; +} + +// Every ROUTE and LEGS after the Running Shoes (00.10) runs; none before. +UnitTestResult test_runs_after_shoes(){ + bool has_shoes = false; + size_t runs = 0; + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + for (const StoryStep& step : checkpoint.steps){ + if (step.kind != StoryStepKind::ROUTE && step.kind != StoryStepKind::LEGS){ + continue; + } + const MoveSpeed expected = has_shoes ? MoveSpeed::RUN : MoveSpeed::WALK; + if (move_speed(step) != expected){ + return checkpoint.id + "/" + step.name + ": expected to " + move_speed_name(expected) + ", it would " + move_speed_name(move_speed(step)) + "."; + } + runs += has_shoes ? 1 : 0; + } + has_shoes = has_shoes || checkpoint.id == "00.10"; + } + if (!has_shoes || runs == 0){ + return "No checkpoint 00.10, where the player gets the Running Shoes, or no route after it."; + } + return true; +} + +// A trainer who stands still but turns to look more than one way (Mt. +// Moon B2F's Grunt at (37, 21) looks around): whether they see the player +// depends on where they look as the player passes. +bool looks_around(const MapObject& object){ + return object.trainer && !object.wander && object.trainer->looks.size() > 1; +} + +// The trainer on `map` who would see the player on `tile`, other than the +// `beaten`. Wanderers and those who look around are left out: no route +// can plan around one (see wanderer_stops() and held_past_lookers()). +const MapObject* trainer_seeing(const MapData& map, const TilePos& tile, const std::set& beaten){ + for (const MapObject& object : map.objects){ + if (object.trainer && !object.wander && !looks_around(object) && !beaten.contains(&object) && trainer_may_see(map, object, tile)){ + return &object; + } + } + return nullptr; +} + +// The tile a STEP from `pose` ends on: the next one; for a step into a +// trainer's line, the first within TRAINER_LINE_TILES that a trainer +// other than the `beaten` sees, where step_until_seen() stops, else the +// last. +TilePos step_end(const StoryPose& pose, const StoryStep& step, const std::set& beaten){ + const MapData* map = find_map_named(pose.map); + if (step.variant != TRAINER_LINE_VARIANT || map == nullptr){ + return pose.tile.next(step.direction); + } + TilePos tile = pose.tile; + step_until_seen( + TRAINER_LINE_TILES, + [&]{ tile = tile.next(step.direction); }, + [&]{ return trainer_seeing(*map, tile, beaten) != nullptr; } + ); + return tile; +} + +// Whether every floor tile of `map` has wild encounters, as in a cave: +// every open tile is tall grass, but for warps. No route keeps out of +// its grass. +bool wild_everywhere(const MapData& map){ + for (int y = 0; y < map.tiles.height; y++){ + for (int x = 0; x < map.tiles.width; x++){ + const TilePos tile{x, y}; + if (map.tiles.at(tile) == TileKind::OPEN && warp_at(map, tile) == nullptr){ + return false; + } + } + } + return true; +} + +// The legs a ROUTE or LEGS `step` from `pose` walks; none if it plans none. +std::vector legs_walked(const StoryPose& pose, const StoryStep& step){ + const MapPosition start{pose.map, pose.tile}; + std::optional> route; + if (step.kind == StoryStepKind::LEGS){ + route = fixed_route(story_maps(), start, step.legs); + }else if (step.target){ + const MapData* map = find_map_named(pose.map); + const std::optional facing = pose.facing ? pose.facing : map ? wall_beside(*map, pose.tile) : std::nullopt; + const StoryPose target = one_tile(*step.target); + route = plan_overworld_route(story_maps(), start, RouteGoal{{target.map, target.tile}, target.facing}, default_route_costs(), facing); + } + return route ? *route : std::vector{}; +} + +// Whether a wandering trainer may see `tile`. +bool wanderer_sees(const MapPosition& tile){ + const MapData* map = find_map_named(tile.map); + if (map == nullptr){ + return false; + } + for (const MapObject& object : map->objects){ + if (object.wander && trainer_may_see(*map, object, tile.point)){ + return true; + } + } + return false; +} + +// Whether they look the player's way can't be planned for, so the walk +// watches for a wanderer after each leg that crosses their sight, fights +// them, and goes on from where they stopped the player, if it can tell +// (run_route()'s trainer_stop(): the leg has one tile in any trainer's +// sight and is off the grass). For each leg of `step` from `pose` that +// crosses a wanderer's sight, that tile, or nothing if it can't tell. +std::vector> wanderer_stops(const StoryPose& pose, const StoryStep& step){ + std::vector> stops; + MapPosition from{pose.map, pose.tile}; + for (const RouteStep& leg : legs_walked(pose, step)){ + const std::optional c = trainer_crossing(story_maps(), from, leg); + if (c && std::any_of(c->seen.begin(), c->seen.end(), wanderer_sees)){ + stops.push_back(c->seen.size() == 1 && !leg.wild && !leg.warps ? std::optional(c->seen[0]) : std::nullopt); + } + from = leg.end; + } + return stops; +} + +// Where a leg of a ROUTE or LEGS `step` from `pose` crosses the sight of a +// trainer who looks around, the leg ends on a wall: if the trainer looks +// the player's way, they walk up beside the leg, and the walker holds it +// on to its wall after the battle. 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){ + MapPosition from{pose.map, pose.tile}; + for (const RouteStep& leg : legs_walked(pose, step)){ + const MapData* map = find_map_named(from.map); + bool crosses = false; + for (const MapPosition& tile : leg.action == RouteAction::WALK ? leg_tiles(story_maps(), from, leg) : std::vector{}){ + for (const MapObject& object : map ? map->objects : std::vector{}){ + crosses |= looks_around(object) && trainer_may_see(*map, object, tile.point); + } + } + if (crosses && !leg.to_wall && leg.tiles != 1){ + return "crosses a trainer's sight on " + to_string(leg) + ", not a leg to a wall."; + } + from = leg.end; + } + return ""; +} + +// The tiles a ROUTE or LEGS `step` from `pose` walks onto, in order. +std::vector tiles_walked(const StoryPose& pose, const StoryStep& step){ + std::vector tiles; + MapPosition from{pose.map, pose.tile}; + for (const RouteStep& leg : legs_walked(pose, step)){ + if (leg.action == RouteAction::WALK){ + const std::vector passed = leg_tiles(story_maps(), from, leg); + tiles.insert(tiles.end(), passed.begin() + 1, passed.end()); + } + from = leg.end; + tiles.push_back(from); + } + return tiles; +} + +// Why the player shouldn't walk onto `tile`, or empty: tall grass, a warp +// to a map not loaded (Route 4's ways into Mt. Moon), a trainer's line. +std::string tile_problem(const MapPosition& tile, const std::set& beaten){ + const MapData* map = find_map_named(tile.map); + if (map == nullptr){ + return "off the story's maps at " + to_string(tile); + } + if (is_tall_grass(*map, tile.point) && !wild_everywhere(*map)){ + return "in the grass at " + to_string(tile); + } + const MapWarp* warp = warp_at(*map, tile.point); + if (warp != nullptr && !warp->to){ + return "onto a warp to a map not loaded at " + to_string(tile); + } + const MapObject* trainer = trainer_seeing(*map, tile.point, beaten); + if (trainer != nullptr){ + return "in the line of the trainer at " + to_string(TilePos{trainer->x, trainer->y}) + " at " + to_string(tile); + } + return ""; +} + +// Moves `pose` past `step`, as check_checkpoint() does. +UnitTestResult advance(StoryPose& pose, const StoryStep& step){ + if (step.kind == StoryStepKind::ROUTE){ + return check_route(pose, step); + } + if (step.kind == StoryStepKind::LEGS){ + return check_legs(pose, step); + } + if (step.kind == StoryStepKind::STEP){ + pose = StoryPose{pose.map, step_end(pose, step, {}), step.direction}; + } + if (step.pose_after){ + pose = one_tile(*step.pose_after); + } + return true; +} + +// `checkpoint`'s routes and legs keep to tiles with no tile_problem(); a +// step into a trainer's line is followed by the battle, which beats them. +UnitTestResult check_lines(const StoryCheckpoint& checkpoint, std::set& beaten){ + StoryPose pose = one_tile(*checkpoint.start); + for (size_t c = 0; c < checkpoint.steps.size(); c++){ + const StoryStep& step = checkpoint.steps[c]; + const std::string where = checkpoint.id + "/" + step.name + ": "; + const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS; + // A GRIND's or a capture's patch is grass: the route there may end on it. + const bool to_patch = step.target && c + 1 < checkpoint.steps.size() + && (parse_grind_variant(checkpoint.steps[c + 1].variant) || parse_capture_variant(checkpoint.steps[c + 1].variant)); + for (const MapPosition& tile : walks ? tiles_walked(pose, step) : std::vector{}){ + 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"))){ + return where + problem; + } + } + const std::vector> stops = walks ? wanderer_stops(pose, step) : std::vector>{}; + if (std::count(stops.begin(), stops.end(), std::nullopt) > 0){ + return where + "crosses a wandering trainer's sight where the walk can't tell where they'd stop the player."; + } + if (const std::string problem = walks ? held_past_lookers(pose, step) : ""; !problem.empty()){ + return where + problem; + } + const MapData* map = find_map_named(pose.map); + const MapObject* trainer = step.kind == StoryStepKind::STEP && map ? trainer_seeing(*map, step_end(pose, step, beaten), beaten) : nullptr; + if (trainer != nullptr && (c + 1 == checkpoint.steps.size() || checkpoint.steps[c + 1].kind != StoryStepKind::BATTLE)){ + return where + "steps into a trainer's line with no battle after it."; + } + if (trainer != nullptr){ + beaten.insert(trainer); + } + if (UnitTestResult result = advance(pose, step); result.result != UnitTestResult::PASSED){ + return checkpoint.id + " " + result.message; + } + } + return true; +} + +// From the Running Shoes on, routes and legs keep out of the grass (but +// in caves, which are all grass, and onto a GRIND's or a capture's patch), off warps to maps not loaded and out of +// trainers' lines, but for the three Route 3 trainers, Mt. Moon's Grunt +// at (12, 20), Nugget Bridge's five and Route 25's four the table steps +// in front of to fight; where they cross a wandering trainer's sight, the walk can place +// the player if the trainer stops them; and where they cross the sight of +// one who looks around, on a leg to a wall. +UnitTestResult test_trainer_lines(){ + std::set beaten; + bool has_shoes = false; + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (has_shoes){ + if (UnitTestResult result = check_lines(checkpoint, beaten); result.result != UnitTestResult::PASSED){ + return result; + } + } + has_shoes = has_shoes || checkpoint.id == "00.10"; + } + if (beaten.size() != 13){ + return "Expected to fight Colton, Greg and James on Route 3, the Grunt at (12, 20) in Mt. Moon, Nugget Bridge's five" + " and Franklin, Kelsey, Nob and Haley on Route 25; the table fights " + + std::to_string(beaten.size()) + " trainers."; + } + return true; +} + +// 00.12's walk east over Route 3 crosses Lass Robin's sight (she wanders +// x 40, y 10..12, looking north and south, sight 3) on (40, 9) only: if +// she sees the player, the walk goes on from there (console B, +// 2026-11-02, she stopped "east 11 (to wall)" there). +UnitTestResult test_robin_crossing(){ + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.id != "00.12" || checkpoint.steps.empty() || checkpoint.steps[0].name != "route4_nurse"){ + continue; + } + const std::vector> expected{MapPosition{"Route3", {40, 9}}}; + if (wanderer_stops(one_tile(*checkpoint.start), checkpoint.steps[0]) != expected){ + return "Expected 00.12/route4_nurse to cross Lass Robin's sight once, the walk placing her stop on Route3 (40, 9)."; + } + return true; + } + return "No step 00.12/route4_nurse."; +} + +// A scene the table starts on a step onto its trigger, and the walks of +// its checkpoint before that step, which keep off the trigger's tiles: +// the maps have no triggers, so a route over one would start the scene +// part-way. +struct TriggerCase{ + std::string checkpoint; + std::string step; + std::string map; + // The trigger's tiles: row `y` from `x0` to `x1`. + int y = 0; + int x0 = 0; + int x1 = 0; + // The tile the step lands on. + int onto_x = 0; +}; + +const StoryCheckpoint* find_checkpoint(const std::string& id){ + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.id == id){ + return &checkpoint; + } + } + return nullptr; +} + +UnitTestResult check_trigger(const TriggerCase& c){ + const StoryCheckpoint* checkpoint = find_checkpoint(c.checkpoint); + if (checkpoint == nullptr){ + return "No checkpoint " + c.checkpoint + "."; + } + const MapPosition onto{c.map, {c.onto_x, c.y}}; + StoryPose pose = one_tile(*checkpoint->start); + for (const StoryStep& step : checkpoint->steps){ + const std::string where = c.checkpoint + "/" + step.name + ": "; + if (step.name == c.step && step.kind == StoryStepKind::STEP && onto == MapPosition{pose.map, pose.tile.next(step.direction)}){ + return true; + } + if (step.name == c.step){ + return where + "expected a step onto " + to_string(onto) + ", from " + describe(pose) + "."; + } + const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS; + for (const MapPosition& tile : walks ? tiles_walked(pose, step) : std::vector{}){ + if (tile.map == c.map && tile.point.y == c.y && tile.point.x >= c.x0 && tile.point.x <= c.x1){ + return where + "walks over the trigger at " + to_string(tile) + "."; + } + } + if (UnitTestResult result = advance(pose, step); result.result != UnitTestResult::PASSED){ + return c.checkpoint + " " + result.message; + } + } + return "No step " + c.checkpoint + "/" + c.step + "."; +} + +// 00.15b meets the rival on the middle of his three trigger tiles, row 6 +// at x 22..24 north of Cerulean (VAR_MAP_SCENE_CERULEAN_CITY_RIVAL 0), +// and 00.16 the Rocket on the east one of his two, (10..11, 15) on Nugget +// Bridge (VAR_MAP_SCENE_ROUTE24 0), each from the tile below. +UnitTestResult test_cerulean_triggers(){ + const TriggerCase CASES[] = { + {.checkpoint = "00.15b", .step = "rival_trigger", .map = "CeruleanCity", .y = 6, .x0 = 22, .x1 = 24, .onto_x = 23}, + {.checkpoint = "00.16", .step = "rocket_trigger", .map = "Route24", .y = 15, .x0 = 10, .x1 = 11, .onto_x = 11}, + }; + for (const TriggerCase& c : CASES){ + if (UnitTestResult result = check_trigger(c); result.result != UnitTestResult::PASSED){ + return result; + } + } + return true; +} + +// 00.16 steps beside Nugget Bridge's five trainers from the south, one +// step at a time, each battle right after its step: Cale, Ali, Timmy, +// Reli and Ethan, by where they stand. +UnitTestResult test_nugget_bridge_order(){ + const StoryCheckpoint* checkpoint = find_checkpoint("00.16"); + if (checkpoint == nullptr){ + return "No checkpoint 00.16."; + } + const std::vector& steps = checkpoint->steps; + std::string met; + StoryPose pose = one_tile(*checkpoint->start); + 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; + 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."; + } + met += trainer ? " " + to_string(TilePos{trainer->x, trainer->y}) : ""; + if (UnitTestResult result = advance(pose, steps[c]); result.result != UnitTestResult::PASSED){ + return "00.16 " + result.message; + } + } + const std::string expected = " (12, 31) (10, 28) (12, 25) (10, 22) (12, 19)"; + if (met != expected){ + return "00.16: expected the five bridge trainers south to north," + expected + "; met" + met + "."; + } + return true; +} + +// Every checkpoint that starts from a save has a place the save card +// names, so a save left elsewhere is caught before it runs; and the +// places of the starts that matter differ. +UnitTestResult test_save_places(){ + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.start && save_card_place(checkpoint.start->map).empty()){ + return checkpoint.id + " starts on " + checkpoint.start->map + ", which has no save card place."; + } + } + if (save_card_place("ViridianForest") != "viridian-forest" || save_card_place("ViridianCity_Mart") != "viridian-city" + || save_card_place("PewterCity_PokemonCenter_1F") != "pewter-city" || save_card_place("PalletTown_ProfessorOaksLab") != "pallet-town" + ){ + return "Wrong save card places."; + } + if (save_card_place("Route3") != "route-3" || save_card_place("Route4_PokemonCenter_1F") != "route-4"){ + return "Wrong save card places on Route 3 and 4."; + } + if (save_card_place("CeruleanCity_PokemonCenter_1F") != "cerulean-city"){ + return "Wrong save card place in Cerulean City."; + } + // Not Route 2's or Route 4's: a map name is a place's or a building's in it. + if (save_card_place("Route24") != "route-24" || save_card_place("Route25_SeaCottage") != "route-25"){ + return "Wrong save card places on Route 24 and 25."; + } + if (!save_card_place("Route22").empty() || !save_card_place("Route2").empty()){ + return "A place for a map the table doesn't use."; + } + return true; +} + +// A reload's save checked against the checkpoint's start: a save left in +// Pewter City (a new game whose save didn't happen) can't start 00.02 in +// the bedroom. The check takes no checks level: Fast runs it too. +UnitTestResult test_save_place_error(){ + struct Case{ + std::string start_map; + std::string place; + bool error; + }; + const Case CASES[] = { + {.start_map = "PalletTown_PlayersHouse_2F", .place = "pewter-city", .error = true}, + {.start_map = "PalletTown_PlayersHouse_2F", .place = "pallet-town", .error = false}, + // A card that can't be read is let pass. + {.start_map = "PalletTown_PlayersHouse_2F", .place = "", .error = false}, + {.start_map = "ViridianForest", .place = "viridian-city", .error = true}, + // A map with no known place isn't checked. + {.start_map = "Route22", .place = "pewter-city", .error = false}, + }; + for (const Case& c : CASES){ + const std::string error = save_place_error(c.start_map, c.place); + if (error.empty() == c.error){ + return "A save in \"" + c.place + "\" for a start on " + c.start_map + ": expected " + + (c.error ? "an error" : "no error") + ", got \"" + error + "\"."; + } + } + return true; +} + +const char* save_action_name(SaveAction action){ + switch (action){ + case SaveAction::WAIT: + return "WAIT"; + case SaveAction::ANSWER_YES: + return "ANSWER_YES"; + case SaveAction::NEXT_PAGE: + return "NEXT_PAGE"; + case SaveAction::CHECK_OVERWORLD: + return "CHECK_OVERWORLD"; + case SaveAction::FAIL_CANCELLED: + return "FAIL_CANCELLED"; + case SaveAction::FAIL_ERROR: + return "FAIL_ERROR"; + } + return "?"; +} + +// save_game()'s answers to the save dialogs of pokefirered's start_menu.c, +// screen by screen from the first YES/NO. +UnitTestResult test_save_dialog(){ + using S = SaveScreen; + using A = SaveAction; + struct Case{ + std::string name; + std::vector> screens; + }; + const std::vector CASES = { + {.name = "first save", .screens = {{S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::TEXT, A::WAIT}, {S::NOTHING, A::WAIT}, {S::NOTHING, A::CHECK_OVERWORLD}}}, + {.name = "over this game's save", .screens = {{S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::NOTHING, A::WAIT}, {S::NOTHING, A::CHECK_OVERWORLD}}}, + // WARNING!, four pages, then the YES/NO that starts on NO. + {.name = "a new game over another game's save", .screens = {{S::YES_NO, A::ANSWER_YES}, {S::PAGED_TEXT, A::NEXT_PAGE}, {S::NOTHING, A::WAIT}, {S::PAGED_TEXT, A::NEXT_PAGE}, {S::PAGED_TEXT, A::NEXT_PAGE}, {S::TEXT, A::WAIT}, {S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::NOTHING, A::WAIT}, {S::NOTHING, A::CHECK_OVERWORLD}}}, + {.name = "cancelled: the START menu is back", .screens = {{S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::START_MENU, A::FAIL_CANCELLED}}}, + {.name = "Save error.", .screens = {{S::YES_NO, A::ANSWER_YES}, {S::TEXT, A::WAIT}, {S::PAGED_TEXT, A::NEXT_PAGE}, {S::NOTHING, A::WAIT}, {S::NOTHING, A::FAIL_ERROR}}}, + }; + for (const Case& c : CASES){ + SaveDialog dialog; + for (size_t i = 0; i < c.screens.size(); i++){ + const SaveAction action = dialog.next(c.screens[i].first); + if (action != c.screens[i].second){ + return c.name + ", screen " + std::to_string(i + 1) + ": expected " + save_action_name(c.screens[i].second) + + ", got " + save_action_name(action) + "."; + } + } + } + return true; +} + +UnitTestResult test_lint(){ + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + const std::vector problems = Pokemon::lint_story_checkpoint(checkpoint); + if (!problems.empty()){ + return problems[0]; + } + } + return true; +} + +UnitTestResult test_unique_names(){ + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + for (size_t i = 0; i < checkpoint.steps.size(); i++){ + for (size_t j = i + 1; j < checkpoint.steps.size(); j++){ + if (checkpoint.steps[i].name == checkpoint.steps[j].name){ + return checkpoint.id + " has two steps named " + checkpoint.steps[i].name + "."; + } + } + } + } + return true; +} +// The intro's names: the player's keyboard first, then the rival's list, +// where a preset is picked; a keyboard after the player's means the list +// was missed. +UnitTestResult test_intro_names(){ + struct Case{ + const char* what; + IntroNameScreen screen; + size_t keyboards_seen; + size_t presets_picked; + IntroNameStep expected; + }; + const std::vector cases{ + {.what = "the first keyboard", .screen = IntroNameScreen::KEYBOARD, .keyboards_seen = 0, .presets_picked = 0, .expected = IntroNameStep::PLAYER_KEYBOARD}, + {.what = "the list after the player's keyboard", .screen = IntroNameScreen::RIVAL_LIST, .keyboards_seen = 1, .presets_picked = 0, .expected = IntroNameStep::RIVAL_PRESET}, + {.what = "a list read before the player's keyboard", .screen = IntroNameScreen::RIVAL_LIST, .keyboards_seen = 0, .presets_picked = 0, .expected = IntroNameStep::NONE}, + {.what = "the second keyboard", .screen = IntroNameScreen::KEYBOARD, .keyboards_seen = 1, .presets_picked = 0, .expected = IntroNameStep::RIVAL_KEYBOARD}, + {.what = "a keyboard after a preset was picked", .screen = IntroNameScreen::KEYBOARD, .keyboards_seen = 1, .presets_picked = 1, .expected = IntroNameStep::RIVAL_KEYBOARD}, + {.what = "the list again after a NO", .screen = IntroNameScreen::RIVAL_LIST, .keyboards_seen = 1, .presets_picked = 1, .expected = IntroNameStep::RIVAL_PRESET}, + {.what = "the list after every try", .screen = IntroNameScreen::RIVAL_LIST, .keyboards_seen = 1, .presets_picked = RIVAL_PRESET_TRIES, .expected = IntroNameStep::NONE}, + {.what = "a message box", .screen = IntroNameScreen::OTHER, .keyboards_seen = 1, .presets_picked = 0, .expected = IntroNameStep::NONE}, + }; + for (const Case& c : cases){ + const IntroNameStep step = intro_name_step(c.screen, c.keyboards_seen, c.presets_picked); + if (step != c.expected){ + return std::string(c.what) + ": expected step " + std::to_string(static_cast(c.expected)) + + ", got " + std::to_string(static_cast(step)) + "."; + } + } + // A black screen is no list. + ImageRGB32 black(1920, 1080); + black.fill(0xff000000); + if (rival_name_list_cursor(black).has_value()){ + return "Read the rival's name list on a black screen."; + } + return true; +} + +// 00.12c stores the Magikarp Gift RNG bought; with Gift RNG off there is +// none, and the deposit is skipped instead of refused. +UnitTestResult test_magikarp_deposit_skippable(){ + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.id != "00.12c"){ + continue; + } + for (const StoryStep& step : checkpoint.steps){ + const std::optional transfer = parse_pc_variant(step.variant); + if (transfer && transfer->party_slot == 1 && transfer->if_missing == PcMissing::SKIP){ + return true; + } + } + return "00.12c: expected a deposit of the second slot, skipped if the party hasn't got it."; + } + return "No checkpoint 00.12c."; +} + +// set_options()'s RIGHT presses from each value an old save can leave: +// on console B a save with FAST and SET became SLOW and SHIFT when the +// presses assumed MID and SHIFT. +UnitTestResult test_option_presses(){ + const std::vector& speed = option_menu_values(OptionMenuItem::TEXT_SPEED); + const std::vector& scene = option_menu_values(OptionMenuItem::BATTLE_SCENE); + const std::vector& style = option_menu_values(OptionMenuItem::BATTLE_STYLE); + struct Case{ + const std::vector& values; + std::string current; + std::string target; + std::optional expected; + }; + const std::vector cases{ + {.values = speed, .current = "slow", .target = "fast", .expected = 2}, + {.values = speed, .current = "mid", .target = "fast", .expected = 1}, + {.values = speed, .current = "fast", .target = "fast", .expected = 0}, + {.values = scene, .current = "on", .target = "off", .expected = 1}, + {.values = scene, .current = "off", .target = "off", .expected = 0}, + {.values = style, .current = "shift", .target = "set", .expected = 1}, + {.values = style, .current = "set", .target = "set", .expected = 0}, + // RIGHT wraps from the last value to the first. + {.values = speed, .current = "fast", .target = "slow", .expected = 1}, + // Unread, or another row's value: no guess. + {.values = speed, .current = "", .target = "fast", .expected = std::nullopt}, + {.values = style, .current = "fast", .target = "set", .expected = std::nullopt}, + {.values = scene, .current = "set", .target = "off", .expected = std::nullopt}, + }; + for (const Case& c : cases){ + const std::optional presses = option_right_presses(c.values, c.current, c.target); + if (presses != c.expected){ + auto text = [](const std::optional& n){ + return n ? std::to_string(*n) + " RIGHT" : std::string("no presses"); + }; + return "\"" + c.current + "\" to " + c.target + ": expected " + text(c.expected) + ", got " + text(presses) + "."; + } + } + return true; +} + +const AutoStoryTestCase AUTO_STORY_CASES[] = { + {.input = {.name = "every route plans, and checkpoints chain", .body = test_routes_and_chain}, .expectation = {.description = "verifies route planning validity across all chained checkpoints"},}, + {.input = {.name = "step names are unique per checkpoint", .body = test_unique_names}, .expectation = {.description = "ensures step identifier uniqueness within checkpoints"},}, + {.input = {.name = "every save has a place on the save card", .body = test_save_places}, .expectation = {.description = "validates save location identifiers on save card"},}, + {.input = {.name = "a reload's save is checked against the start", .body = test_save_place_error}, .expectation = {.description = "detects mismatch between checkpoint initial state and save point"},}, + {.input = {.name = "save dialogs: every YES/NO is YES, a cancel fails", .body = test_save_dialog}, .expectation = {.description = "verifies save confirmation prompt interactions"},}, + {.input = {.name = "every checkpoint lints clean", .body = test_lint}, .expectation = {.description = "validates semantic lint rules across story step sequence"},}, + {.input = {.name = "Pewter Gym walks", .body = test_pewter_gym_walks}, .expectation = {.description = "verifies Pewter Gym route planning and obstacle avoidance"},}, + {.input = {.name = "Pewter: to the aide's trigger", .body = test_pewter_aide_walk}, .expectation = {.description = "plans path to Running Shoes aide trigger tile"},}, + {.input = {.name = "routes run after the Running Shoes", .body = test_runs_after_shoes}, .expectation = {.description = "switches movement speed to running once shoes acquired"},}, + {.input = {.name = "intro: names, the rival's from the list", .body = test_intro_names}, .expectation = {.description = "verifies character and rival naming inputs"},}, + {.input = {.name = "options: RIGHT presses from the shown value", .body = test_option_presses}, .expectation = {.description = "calculates Dpad RIGHT inputs for setting game options"},}, + {.input = {.name = "Route 3: only the planned trainers' lines", .body = test_trainer_lines}, .expectation = {.description = "verifies route 3 trainer interaction sightlines"},}, + {.input = {.name = "Route 3: Lass Robin stops the walk on (40, 9)", .body = test_robin_crossing}, .expectation = {.description = "verifies trainer interruption on route 3"},}, + {.input = {.name = "00.12c: the Magikarp's deposit is skipped without Gift RNG", .body = test_magikarp_deposit_skippable}, .expectation = {.description = "skips magikarp deposit when gift rng is disabled"},}, + {.input = {"Cerulean: the rival's and the Rocket's triggers", test_cerulean_triggers}, .expectation = {},}, + {.input = {"Nugget Bridge: its five trainers, south to north", test_nugget_bridge_order}, .expectation = {},},}; + +} // namespace + +void add_tests_AutoStory(UnitTestDatabase& database){ + add_tests_AutoStoryRunPlan(database); + for (const auto& scenario : AUTO_STORY_CASES){ + database.add(scenario); + } +} + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index f2aa35410d..d2dc208485 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1928,6 +1928,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMart_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryMember.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryParty_Tests.cpp