diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp index df4bdb0714..928a5c4bd0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp @@ -68,6 +68,15 @@ AutoStory::AutoStory() LockMode::LOCK_WHILE_RUNNING, "Kosmos", "" ) + , STOP_FOR_SID_HELPER( + "Stop for SID Helper:
Off by default. At 00.01, stop on the player's keyboard with the name typed and OK not pressed, " + "where the SID Helper program starts. After SID Helper, start AutoStory at 00.01b, with the game as SID Helper left it " + "(the Trainer Card, or HOME): it closes the card, sets the options and saves in the bedroom.
" + "SID Helper needs the text speed and button settings already saved. On a cartridge with that save, choose NEW GAME " + "by hand and start AutoStory on the first message.", + LockMode::LOCK_WHILE_RUNNING, + false + ) , NOTIFICATIONS({ &NOTIFICATION_PROGRAM_FINISH, &NOTIFICATION_ERROR_FATAL, @@ -76,6 +85,7 @@ AutoStory::AutoStory() PA_ADD_OPTION(RUN); PA_ADD_OPTION(NEW_GAME_FROM_TITLE); PA_ADD_OPTION(PLAYER_NAME); + PA_ADD_OPTION(STOP_FOR_SID_HELPER); PA_ADD_OPTION(NOTIFICATIONS); } @@ -156,6 +166,11 @@ void load_verify_save( "Verify mode reloads the checkpoint's save between runs: " + checkpoint.id + " starts a new game and has none." ); } + if (checkpoint.live_start){ + throw UserSetupError(env.console, + "Verify mode reloads the checkpoint's save between runs: " + checkpoint.id + " starts from the game as it is, with no save." + ); + } env.console.log("Verify mode: loading the save for " + checkpoint.id + "."); soft_reset(env.console, context); story.has_save = true; @@ -212,6 +227,50 @@ static void check_setup( } } +static void throw_if_failed(const Pokemon::SegmentReport& report){ + if (report.completed){ + return; + } + throw OperationFailedException( + ErrorReportMode::NO_ERROR_REPORT, + std::format( + "AutoStory stopped at {} {}: {}", + report.failed_checkpoint, report.failed_event, Pokemon::to_string(report.failure) + ) + ); +} + +// Gets the game to where checkpoint `first` starts: a new game from the +// title screen, the game as another program left it, or the checkpoint's +// save loaded. +static void prepare_start( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + const std::vector& checkpoints, size_t first, StoryEnv& story +){ + const StoryCheckpoint& checkpoint = checkpoints[first]; + if (!checkpoint.start){ + return; + } + if (checkpoint.live_start){ + env.console.log(checkpoint.id + " starts from the game as it is: no save to load."); + return; + } + env.console.log("Loading the save for " + checkpoint.id + "."); + soft_reset(env.console, context); + story.has_save = true; + check_start_save(env, story, checkpoints[first]); + read_lead_moves(story, checkpoints[first].menu); + read_bag_supplies(story, checkpoints[first].menu); +} + +// The run stopped for an RNG program, as its option asked: the user's +// turn, with what to do next. +static void stop_for_handoff(SingleSwitchProgramEnvironment& env, EventNotificationOption& notification, const std::string& message){ + env.console.log(message, COLOR_BLUE); + env.console.overlay().add_log(message); + send_program_finished_notification(env, notification, message); +} + void AutoStory::program(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ if (const std::optional verify = RUN.VERIFY.policy()){ verify_story_checkpoint(env, context, RUN, *verify, NOTIFICATION_PROGRAM_FINISH); @@ -222,40 +281,34 @@ void AutoStory::program(SingleSwitchProgramEnvironment& env, ProControllerContex const size_t last = RUN.end_index(); const std::string player_name = PLAYER_NAME; check_setup(env.console, checkpoints, first, last, NEW_GAME_FROM_TITLE, player_name); + const StoryRunPlan plan = plan_story_run(checkpoints, first, last, StoryHandoffs{.sid_helper = STOP_FOR_SID_HELPER}); + if (plan.stop == StoryHandoff::SID_HELPER){ + play_new_game_to_player_name(env.console, context, player_name); + stop_for_handoff(env, NOTIFICATION_PROGRAM_FINISH, + "Stopped for SID Helper: the player's name is typed and OK is not pressed. " + "Run SID Helper now, then start AutoStory at 00.01b." + ); + return; + } StoryEnv story{env.console, context, checkpoints[first].supplies, checkpoints[first].supplies, false, RUN.checks()}; story.player_name = player_name; - if (!(first == 0 && NEW_GAME_FROM_TITLE)){ - env.console.log("Loading the save for " + checkpoints[first].id + "."); - soft_reset(env.console, context); - story.has_save = true; - check_start_save(env, story, checkpoints[first]); - read_lead_moves(story, checkpoints[first].menu); - read_bag_supplies(story, checkpoints[first].menu); - } + prepare_start(env, context, checkpoints, first, story); StoryExecutor executor(story); executor.set_checks(RUN.checks()); executor.set_language(RUN.language()); - Pokemon::Segment segment = make_story_segment(checkpoints, executor); + Pokemon::Segment segment = make_story_segment(plan.rows, executor); Pokemon::SegmentHooks hooks = story_hooks(env, story); Pokemon::RunState state; const Pokemon::SegmentReport report = run_segment( - state, segment, Pokemon::SegmentRange{checkpoints[first].id, checkpoints[last].id}, RUN.policy(), hooks + state, segment, Pokemon::SegmentRange{plan.rows.front().id, plan.rows.back().id}, RUN.policy(), hooks ); Pokemon::record_story_stats(env.current_stats(), report); env.update_stats(); - if (!report.completed){ - throw OperationFailedException( - ErrorReportMode::NO_ERROR_REPORT, - std::format( - "AutoStory stopped at {} {}: {}", - report.failed_checkpoint, report.failed_event, Pokemon::to_string(report.failure) - ) - ); - } - env.console.log("AutoStory: done through " + checkpoints[last].id + "."); + throw_if_failed(report); + env.console.log("AutoStory: done through " + plan.rows.back().id + "."); send_program_finished_notification(env, NOTIFICATION_PROGRAM_FINISH); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.h index e349fc9d9d..3915332642 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.h @@ -47,6 +47,8 @@ class AutoStory : public SingleSwitchProgramInstance{ Pokemon::StoryRunOptions RUN; BooleanCheckBoxOption NEW_GAME_FROM_TITLE; StringOption PLAYER_NAME; + // The RNG programs the run stops for (StoryHandoff). + BooleanCheckBoxOption STOP_FOR_SID_HELPER; EventNotificationsOption NOTIFICATIONS; }; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp index debf1311a6..c3c5d3461c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.cpp @@ -16,6 +16,8 @@ #include "CommonTools/Async/InferenceRoutines.h" #include "CommonTools/Images/SolidColorTest.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Inference/NintendoSwitch_HomeMenuDetector.h" +#include "NintendoSwitch/Programs/NintendoSwitch_GameEntry.h" #include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_BattleDialogs.h" #include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" #include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h" @@ -179,10 +181,18 @@ void open_start_menu_row(ConsoleHandle& console, ProControllerContext& context, // GREEN. class NewGameIntro{ public: - NewGameIntro(ConsoleHandle& console, ProControllerContext& context, std::string player_name) + // Where the intro stops: the bedroom, or the player's keyboard with the + // name typed and OK not pressed (for SID Helper). + enum class End{ + BEDROOM, + PLAYER_NAME_TYPED, + }; + + NewGameIntro(ConsoleHandle& console, ProControllerContext& context, std::string player_name, End end) : m_console(console) , m_context(context) , m_player_name(std::move(player_name)) + , m_end(end) {} void run(){ @@ -192,7 +202,7 @@ class NewGameIntro{ m_last_press = m_last_dialog; while (current_time() < deadline){ if (step()){ - m_console.log("New game: the bedroom."); + m_console.log(m_end == End::BEDROOM ? "New game: the bedroom." : "New game: the player's name is typed, OK not pressed."); return; } } @@ -231,6 +241,12 @@ class NewGameIntro{ pbf_wait(m_context, POLL); return false; } + if (m_end == End::PLAYER_NAME_TYPED && m_keyboards_seen == 0 && NamingScreenDetector(COLOR_RED).detect(screen)){ + m_keyboards_seen++; + clear_stray_keys(); + type_player_name(false); + return true; + } if (names(screen)){ m_last_dialog = current_time(); m_last_press = m_last_dialog; @@ -320,10 +336,15 @@ class NewGameIntro{ return false; } - void enter_name(bool player){ + // B deletes whatever a stray A typed; the cursor stays on its key. + void clear_stray_keys(){ for (int c = 0; c < 8; c++){ press(m_context, BUTTON_B, 340ms); } + } + + void enter_name(bool player){ + clear_stray_keys(); if (!player || m_player_name.empty()){ m_console.log("New game: the keyboard, confirming an empty name."); press(m_context, BUTTON_PLUS, 500ms); @@ -331,19 +352,30 @@ class NewGameIntro{ m_context.wait_for_all_requests(); return; } - if (!type_name(m_console, m_context, m_player_name, NamingScreenKind::PLAYER)){ + type_player_name(true); + // Past the keyboard's fade, so it isn't taken for the rival's. + pbf_wait(m_context, 2000ms); + m_context.wait_for_all_requests(); + } + + void type_player_name(bool confirm){ + if (m_player_name.empty()){ + return; + } + const bool typed = confirm + ? type_name(m_console, m_context, m_player_name, NamingScreenKind::PLAYER) + : type_name_without_confirming(m_console, m_context, m_player_name, NamingScreenKind::PLAYER); + if (!typed){ OperationFailedExceptionWithScreenshot::fire( ErrorReportMode::SEND_ERROR_REPORT, "play_new_game_intro(): the keyboard closed before the name was typed.", m_console ); } - // Past the keyboard's fade, so it isn't taken for the rival's. - pbf_wait(m_context, 2000ms); - m_context.wait_for_all_requests(); } ConsoleHandle& m_console; ProControllerContext& m_context; const std::string m_player_name; + const End m_end; WallClock m_last_dialog; WallClock m_last_press; bool m_dialog_seen = false; @@ -851,7 +883,23 @@ void buy_items( void play_new_game_intro(ConsoleHandle& console, ProControllerContext& context, const std::string& player_name){ console.log("New game: from the title screen."); - NewGameIntro(console, context, player_name).run(); + NewGameIntro(console, context, player_name, NewGameIntro::End::BEDROOM).run(); +} + +void play_new_game_to_player_name(ConsoleHandle& console, ProControllerContext& context, const std::string& player_name){ + console.log("New game: from the title screen to the player's keyboard, for SID Helper."); + NewGameIntro(console, context, player_name, NewGameIntro::End::PLAYER_NAME_TYPED).run(); +} + +void close_trainer_card(ConsoleHandle& console, ProControllerContext& context){ + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + if (screen && HomeMenuDetector(console, COLOR_RED).detect(screen)){ + console.log("After SID Helper: on HOME, back to the game."); + resume_game_from_home(console, context); + } + // B closes the card, then the START menu. + mash_until_overworld(console, context, 20000ms); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h index 2850e1576c..7065c8e1f7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h @@ -219,6 +219,15 @@ void buy_items( // (naming_text_error()). void play_new_game_intro(ConsoleHandle& console, ProControllerContext& context, const std::string& player_name); +// play_new_game_intro() up to the player's keyboard: `player_name` typed, +// OK not pressed, which is where SID Helper starts. Empty types nothing. +void play_new_game_to_player_name(ConsoleHandle& console, ProControllerContext& context, const std::string& player_name); + +// From the Trainer Card SID Helper leaves open, or from HOME (its "go +// home when done"): back to the game, the card and the START menu closed. +// Returns on the overworld. +void close_trainer_card(ConsoleHandle& console, ProControllerContext& context); + } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index f575125826..60796919a0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -443,6 +443,34 @@ Outcome StoryExecutor::ready(RunState& state, const StoryStep& step){ return get_ready(m_env, state, step); } +Outcome StoryExecutor::game(RunState&, const StoryStep& step){ + if (step.variant == CLOSE_TRAINER_CARD){ + close_trainer_card(m_env.console, m_env.context); + return Pokemon::outcome_ok(); + } + return Pokemon::unsupported_step(step); +} + + +StoryRunPlan plan_story_run( + const std::vector& checkpoints, size_t first, size_t last, const StoryHandoffs& handoffs +){ + StoryRunPlan plan; + for (size_t c = first; c <= last && c < checkpoints.size(); c++){ + const StoryCheckpoint& row = checkpoints[c]; + if (row.live_start && c != first){ + continue; + } + if (row.handoff == StoryHandoff::SID_HELPER && handoffs.sid_helper){ + plan.rows.clear(); + plan.stop = StoryHandoff::SID_HELPER; + return plan; + } + plan.rows.emplace_back(row); + } + return plan; +} + std::string check_save_place(StoryEnv& env, const std::string& start_map){ if (save_card_place(start_map).empty()){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index e8e5817f76..053cadbdeb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -56,18 +56,57 @@ struct StoryPose{ // has the Running Shoes (00.10 on). inline const std::string RUN_VARIANT = "run"; +// The GAME step that closes the Trainer Card SID Helper leaves open. +inline const std::string CLOSE_TRAINER_CARD = "close_trainer_card"; + // How `step`, a ROUTE or LEGS, moves the player: RUN_VARIANT runs. MoveSpeed move_speed(const StoryStep& step); +// An upstream RNG program the user may run part-way through the story. +// Each is off unless its option is on. +enum class StoryHandoff{ + NONE, + // The checkpoint's new game stops on the player's keyboard, the name + // typed and OK not pressed, which is where SID Helper starts. + SID_HELPER, +}; + // A shared checkpoint with what FRLG adds to it. struct StoryCheckpoint : public Pokemon::StoryCheckpoint{ // The healing items at its start, for a run that loads its save. Supplies supplies; // The START menu at its end, when it saves. StartMenu menu = StartMenu::FULL; + // Starts from the game as another program left it, loading no save: + // the way back into the story after an RNG program. A run that starts + // before it passes over it. + bool live_start = false; + // The RNG program the user may run at this checkpoint. + StoryHandoff handoff = StoryHandoff::NONE; +}; + +// The handoffs a run stops for (AutoStory's options). +struct StoryHandoffs{ + bool sid_helper = false; }; +// What a run from table row `first` to `last` does. +struct StoryRunPlan{ + // The checkpoints to run, in order. + std::vector rows; + // NONE: the run ends after `rows`. SID_HELPER: `rows` is empty; the run + // plays the new game to the player's keyboard and stops there. + StoryHandoff stop = StoryHandoff::NONE; +}; + +// The rows of `checkpoints` from `first` to `last`, both inclusive, as a +// run with `handoffs` takes them: live-start rows only when the run +// starts there, and a run that reaches a handoff that is on stops for it. +StoryRunPlan plan_story_run( + const std::vector& checkpoints, size_t first, size_t last, const StoryHandoffs& handoffs +); + // The maps the story plans on: every map to Brock, with the objects the // game hides after the Hall of Fame removed, as the lab's frlg_route does. @@ -175,6 +214,8 @@ class StoryExecutor : public Pokemon::StoryStepExecutor{ // The Viridian Mart, before the Boulder Badge: Potions, Antidotes and Parlyz Heals. virtual Pokemon::Outcome shop(Pokemon::RunState& state, const StoryStep& step) override; virtual Pokemon::Outcome ready(Pokemon::RunState& state, const StoryStep& step) override; + // CLOSE_TRAINER_CARD: back from SID Helper (close_trainer_card()). + virtual Pokemon::Outcome game(Pokemon::RunState& state, const StoryStep& step) override; private: StoryEnv& m_env; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp index 6410a0710c..53b2da73ad 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -166,6 +166,7 @@ class FakeGame : public Pokemon::StoryStepExecutor{ virtual Outcome mash(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } virtual Outcome battle(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } virtual Outcome heal(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } + virtual Outcome game(RunState&, const StoryStep&) override{ return Pokemon::outcome_ok(); } // The Viridian Mart's list as the game shows it. After a purchase the // cursor stays on the row bought (console B, 2026-11-01), so each // order moves it from there, as buy_items() does since ad5d40885. @@ -220,9 +221,11 @@ class FakeGame : public Pokemon::StoryStepExecutor{ }; -// One simulated run, segment built as the program builds it: each -// checkpoint saves at its end (hooks.save stands for save_game()). +// One simulated run, segment built as the program builds it: the rows a +// run takes with every handoff off, each saving at its end (hooks.save +// stands for save_game()). struct Simulation{ + std::vector rows = plan_story_run(story_checkpoints(), 0, story_checkpoints().size() - 1, StoryHandoffs{}).rows; FakeGame game; std::vector lines; // Where each save stood: the end of its checkpoint. @@ -234,7 +237,7 @@ struct Simulation{ game.set_language(Language::Japanese); Pokemon::Segment segment; segment.id = "00"; - for (const StoryCheckpoint& data : story_checkpoints()){ + for (const StoryCheckpoint& data : rows){ segment.checkpoints.push_back(Pokemon::CheckpointSpec{ .id = data.id, .title = data.title, .saves = true, .build = [this, &data]{ return Pokemon::build_story_checkpoint(data, game); }, @@ -303,15 +306,15 @@ UnitTestResult test_whole_story(){ return "Expected the whole story through:" + joined(sim.lines); } std::vector ids; - for (const StoryCheckpoint& data : story_checkpoints()){ + for (const StoryCheckpoint& data : sim.rows){ ids.push_back(data.id); } if (sim.begun() != ids || sim.saves.size() != ids.size()){ return "Expected each checkpoint once, in order, each saving at its end:" + joined(sim.game.trace); } // Each checkpoint starts where the save before it stands. - for (size_t c = 1; c < story_checkpoints().size(); c++){ - const StoryCheckpoint& data = story_checkpoints()[c]; + for (size_t c = 1; c < sim.rows.size(); c++){ + const StoryCheckpoint& data = sim.rows[c]; const std::optional start = story_pose(*data.start); const std::optional saved = story_pose(sim.saves[c - 1].second); const bool same = start && saved && start->map == saved->map && start->tile == saved->tile diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 1bc03b8b6c..9905a6cf5d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -146,12 +146,37 @@ StoryStep shop(std::string name, std::vector items, std::str } +StoryStep game_step(std::string variant, Pokemon::Pose pose_after, std::string why){ + StoryStep step = make(StoryStepKind::GAME, variant, std::move(why)); + step.variant = std::move(variant); + step.pose_after = std::move(pose_after); + return step; +} + +StoryStep options_step(){ + return make(StoryStepKind::OPTIONS, "options", "fast text; no switch prompt between a trainer's Pokemon"); +} + + StoryCheckpoint new_game(){ - return row("00.01", "new game to the bedroom", std::nullopt, {}, StartMenu::BEFORE_POKEMON, { + StoryCheckpoint checkpoint = row("00.01", "new game to the bedroom", std::nullopt, {}, StartMenu::BEFORE_POKEMON, { // new_game.c: the bedroom at (6, 6), facing the TV. new_game_intro(at(BEDROOM, 6, 6, NORTH), "Oak's speech, BOY, the player's name (default if none is set), rival GREEN"), - make(StoryStepKind::OPTIONS, "options", "fast text; no switch prompt between a trainer's Pokemon"), + options_step(), + }); + checkpoint.handoff = StoryHandoff::SID_HELPER; + return checkpoint; +} + +// SID Helper confirms the name, finishes the intro and reads the Trainer +// Card from the START menu, without moving the player or saving. +StoryCheckpoint after_sid_helper(){ + StoryCheckpoint checkpoint = row("00.01b", "after SID Helper: close the Trainer Card, save in the bedroom", at(BEDROOM, 6, 6, NORTH), {}, StartMenu::BEFORE_POKEMON, { + game_step(CLOSE_TRAINER_CARD, at(BEDROOM, 6, 6, NORTH), "the Trainer Card SID Helper left open, or HOME"), + options_step(), }); + checkpoint.live_start = true; + return checkpoint; } StoryCheckpoint leave_house(){ @@ -284,7 +309,7 @@ StoryCheckpoint running_shoes(){ const std::vector& story_checkpoints(){ static const std::vector checkpoints{ - new_game(), leave_house(), oaks_trigger(), starter(), parcel(), pokedex(), shopping(), forest(), brock(), + new_game(), after_sid_helper(), leave_house(), oaks_trigger(), starter(), parcel(), pokedex(), shopping(), forest(), brock(), running_shoes(), }; return checkpoints; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h index d8a3f8b15d..37531a70c7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h @@ -21,7 +21,8 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -// Checkpoints 00.01 to 00.10, in order. +// Checkpoints 00.01 to 00.10, in order, with 00.01b, the way back in after +// SID Helper, after 00.01. const std::vector& story_checkpoints(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp index fa4b926b71..8b0ab92acb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include "Common/Cpp/Exceptions.h" @@ -211,8 +212,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 10 || checkpoints[0].start){ - return "Expected 10 checkpoints, the first from a new game."; + if (checkpoints.size() != 11 || checkpoints[0].start){ + return "Expected 11 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -527,6 +528,113 @@ UnitTestResult test_intro_names(){ return true; } + +size_t row_index(std::string_view id){ + const std::vector& checkpoints = story_checkpoints(); + for (size_t c = 0; c < checkpoints.size(); c++){ + if (checkpoints[c].id == id){ + return c; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No checkpoint " + std::string(id)); +} + +std::string joined_ids(const std::vector& rows){ + std::string text; + for (const StoryCheckpoint& row : rows){ + text += (text.empty() ? "" : " ") + row.id; + } + return text; +} + +const char* handoff_name(StoryHandoff handoff){ + switch (handoff){ + case StoryHandoff::NONE: + return "none"; + case StoryHandoff::SID_HELPER: + return "SID Helper"; + } + return "?"; +} + +struct RunPlanScenario{ + std::string name = ""; + std::string first = ""; + // The table's last row if empty. + std::string last = ""; + StoryHandoffs handoffs = {}; + std::string expected_ids = ""; + StoryHandoff expected_stop = StoryHandoff::NONE; +}; + +class Test_RunPlan : public UnitTest{ +public: + Test_RunPlan(RunPlanScenario scenario) + : UnitTest("PokemonFRLG::AutoStory - run plan: " + scenario.name) + , m_scenario(std::move(scenario)) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + const size_t last = m_scenario.last.empty() ? story_checkpoints().size() - 1 : row_index(m_scenario.last); + const StoryRunPlan plan = plan_story_run(story_checkpoints(), row_index(m_scenario.first), last, m_scenario.handoffs); + const std::string ids = joined_ids(plan.rows); + if (ids != m_scenario.expected_ids || plan.stop != m_scenario.expected_stop){ + return "Expected rows \"" + m_scenario.expected_ids + "\", stop " + handoff_name(m_scenario.expected_stop) + + "; got \"" + ids + "\", stop " + handoff_name(plan.stop) + "."; + } + return true; + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + const RunPlanScenario m_scenario; +}; + +void add_tests_RunPlan(UnitTestDatabase& database){ + const std::string TODAY = "00.01 00.02 00.03 00.04 00.05 00.06 00.07 00.08 00.09 00.10"; + const RunPlanScenario scenarios[] = { + {.name = "handoffs off: the story as before", .first = "00.01", .expected_ids = TODAY}, + {.name = "handoffs off: a range", .first = "00.02", .last = "00.04", .expected_ids = "00.02 00.03 00.04"}, + { + .name = "SID Helper: 00.01 stops on the keyboard", + .first = "00.01", + .handoffs = {.sid_helper = true}, + .expected_ids = "", + .expected_stop = StoryHandoff::SID_HELPER, + }, + { + .name = "SID Helper: back in at 00.01b", + .first = "00.01b", + .last = "00.03", + .handoffs = {.sid_helper = true}, + .expected_ids = "00.01b 00.02 00.03", + }, + {.name = "00.01b runs when started at, option off", .first = "00.01b", .last = "00.02", .expected_ids = "00.01b 00.02"}, + {.name = "SID Helper: a later start passes over it", .first = "00.02", .last = "00.03", .handoffs = {.sid_helper = true}, .expected_ids = "00.02 00.03"}, + }; + for (const RunPlanScenario& scenario : scenarios){ + database.add(scenario); + } +} + +// The way back in after SID Helper loads no save and starts where 00.01 +// leaves the player, so 00.02 follows it unchanged. +UnitTestResult test_after_sid_helper_row(){ + const StoryCheckpoint& sid = story_checkpoints()[row_index("00.01b")]; + if (!sid.live_start || story_checkpoints()[0].handoff != StoryHandoff::SID_HELPER){ + return "Expected 00.01 to be SID Helper's handoff and 00.01b to start from the live game."; + } + for (const StoryCheckpoint& checkpoint : story_checkpoints()){ + if (checkpoint.live_start != (checkpoint.id == "00.01b")){ + return checkpoint.id + ": only 00.01b starts from the live game."; + } + } + return true; +} + // set_options()'s RIGHT presses from each value an old save can leave: // on console B a save with FAST and SET became SLOW and SHIFT when the // presses assumed MID and SHIFT. @@ -571,6 +679,8 @@ UnitTestResult test_option_presses(){ void add_tests_AutoStory(UnitTestDatabase& database){ + add_tests_RunPlan(database); + database.add("00.01b: back in after SID Helper", test_after_sid_helper_row); database.add("every route plans, and checkpoints chain", test_routes_and_chain); database.add("step names are unique per checkpoint", test_unique_names); database.add("every save has a place on the save card", test_save_places);