From d9945ed7fd1a6a8e92f17d5a07e25bf48eb1c5ca Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 2 Nov 2026 13:26:45 -0800 Subject: [PATCH 1/3] FRLG AutoStory: stop for SID Helper, and come back in after it A "Stop for SID Helper" option (off by default) makes 00.01 stop on the player's keyboard with the name typed and OK not pressed, and end the program with what to do next. A new checkpoint, 00.01b, starts from the game as SID Helper leaves it (the Trainer Card, or HOME), loading no save: it closes the card, sets the options and saves in the bedroom at (6, 6) facing north, where 00.02 starts. A row that starts from the live game is a field on FRLG's StoryCheckpoint (live_start); plan_story_run() picks the rows a run takes, passing over 00.01b unless the run starts there, so with the option off a run takes the same rows as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonFRLG_AutoStory.cpp | 108 +++++++++++++---- .../AutoStory/PokemonFRLG_AutoStory.h | 2 + .../PokemonFRLG_AutoStoryActions.cpp | 67 +++++++++-- .../AutoStory/PokemonFRLG_AutoStoryActions.h | 9 ++ .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 28 +++++ .../AutoStory/PokemonFRLG_AutoStoryEvents.h | 41 +++++++ .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 15 ++- .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 31 ++++- .../AutoStory/PokemonFRLG_AutoStoryTable.h | 3 +- .../AutoStory/PokemonFRLG_AutoStory_Tests.cpp | 113 +++++++++++++++++- 10 files changed, 369 insertions(+), 48 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp index 4f7e55d34c..e172376f39 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp @@ -67,6 +67,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, @@ -75,6 +84,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); } @@ -150,52 +160,98 @@ Pokemon::SegmentHooks story_hooks(SingleSwitchProgramEnvironment& env, StoryEnv& } +// The options' problems, before anything is pressed. +static void check_setup( + ConsoleHandle& console, const std::vector& checkpoints, size_t first, size_t last, + bool new_game_from_title, const std::string& player_name +){ + if (first > last || last >= checkpoints.size()){ + throw UserSetupError(console, "The start checkpoint must not come after the end checkpoint."); + } + if (first == 0 && !new_game_from_title){ + throw UserSetupError(console, "Checkpoint 00.01 starts a new game: start on the title screen with no save."); + } + if (first == 0){ + check_player_name(console, player_name); + } +} + +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_save_place(env, context, checkpoints, first, story.language); +} + +// 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){ const std::vector& checkpoints = story_checkpoints(); const size_t first = RUN.start_index(); const size_t last = RUN.end_index(); - if (first > last || last >= checkpoints.size()){ - throw UserSetupError(env.console, "The start checkpoint must not come after the end checkpoint."); - } - if (first == 0 && !NEW_GAME_FROM_TITLE){ - throw UserSetupError(env.console, "Checkpoint 00.01 starts a new game: start on the title screen with no save."); - } const std::string player_name = PLAYER_NAME; - if (first == 0){ - check_player_name(env.console, 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_save_place(env, context, checkpoints, first, story.language); - } + 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 6de8818900..183f78af7e 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" @@ -139,10 +141,18 @@ void open_start_menu_row(ConsoleHandle& console, ProControllerContext& context, // second the rival's, who always keeps the default name. 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(){ @@ -152,7 +162,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; } } @@ -192,7 +202,13 @@ class NewGameIntro{ return false; } if (NamingScreenDetector(COLOR_RED).detect(screen)){ - enter_name(); + const bool player = m_keyboards_seen++ == 0; + if (player && m_end == End::PLAYER_NAME_TYPED){ + clear_stray_keys(); + type_player_name(false); + return true; + } + enter_name(player); m_last_dialog = current_time(); return false; } @@ -231,11 +247,15 @@ class NewGameIntro{ return false; } - void enter_name(){ - const bool player = m_keyboards_seen++ == 0; + // 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); @@ -243,19 +263,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; size_t m_keyboards_seen = 0; WallClock m_last_dialog; WallClock m_last_press; @@ -529,7 +560,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 77c699a90a..578cc57441 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryActions.h @@ -135,6 +135,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 5860ca649b..8253d4f1cb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -400,6 +400,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; +} + Pokemon::Checkpoint build_checkpoint(const StoryCheckpoint& data, StoryExecutor& executor){ executor.set_menu(data.menu); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index f4cb9095a2..d44562786b 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. @@ -147,6 +186,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 56f3269033..e7593b3442 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -135,6 +135,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. @@ -186,9 +187,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. @@ -200,7 +203,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); }, @@ -269,15 +272,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 856ad57476..c63059357f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -134,12 +134,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(){ @@ -280,7 +305,7 @@ StoryCheckpoint first_run(){ 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(), first_run(), }; 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 0e45e21787..a4279103e4 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.11, in order. +// Checkpoints 00.01 to 00.11, 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 c62094cae2..d949e0ddc3 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp @@ -204,8 +204,8 @@ UnitTestResult check_checkpoint(const StoryCheckpoint& checkpoint, StoryPose& po UnitTestResult test_routes_and_chain(){ const std::vector& checkpoints = story_checkpoints(); - if (checkpoints.size() != 11 || checkpoints[0].start){ - return "Expected 11 checkpoints, the first from a new game."; + if (checkpoints.size() != 12 || checkpoints[0].start){ + return "Expected 12 checkpoints, the first from a new game."; } StoryPose pose; for (size_t c = 0; c < checkpoints.size(); c++){ @@ -403,10 +403,119 @@ UnitTestResult test_unique_names(){ return true; } + +size_t row_index(const std::string& 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 " + 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 00.11"; + 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; +} + } 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); From 8ba4155038c9ebb3eeadada8cec840635c80bf27 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 2 Nov 2026 20:41:03 -0800 Subject: [PATCH 2/3] FRLG AutoStory tests: take the checkpoint id as std::string_view in row_index() Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Programs/AutoStory/PokemonFRLG_AutoStory_Tests.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 972777ccd7..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" @@ -528,14 +529,14 @@ UnitTestResult test_intro_names(){ } -size_t row_index(const std::string& id){ +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 " + id); + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No checkpoint " + std::string(id)); } std::string joined_ids(const std::vector& rows){ From 886eddddcb3a1f269d435f4b947be29bacbee390 Mon Sep 17 00:00:00 2001 From: dayfine Date: Tue, 3 Nov 2026 05:20:36 -0800 Subject: [PATCH 3/3] FRLG AutoStory: verify mode refuses a live-start checkpoint A live-start row starts from the game as an RNG program left it, with no save to reload between verify runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp index df3d8aff50..745c16d81c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStory.cpp @@ -162,6 +162,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;