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