diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.cpp new file mode 100644 index 0000000000..d5e37c567f --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.cpp @@ -0,0 +1,45 @@ +/* BDSP Transition Watchers + * + * From: https://github.com/PokemonAutomation/ + * + * Adapted from PokemonAutomation/Arduino-Source#1717 by Developer-Butters. + */ + +#include "PokemonBDSP_TransitionWatchers.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +WhiteScreenOverWatcher::WhiteScreenOverWatcher(Color color, const ImageFloatBox& box) + : VisualInferenceCallback("WhiteScreenOverWatcher") + , m_on(color, box, WhiteScreenWatcher::FinderType::PRESENT, std::chrono::milliseconds(100)) + , m_off(color, box, WhiteScreenWatcher::FinderType::GONE, std::chrono::milliseconds(100)) +{} +void WhiteScreenOverWatcher::make_overlays(VideoOverlaySet& items) const{ + m_on.make_overlays(items); +} +bool WhiteScreenOverWatcher::process_frame(const ImageViewRGB32& frame, WallClock timestamp){ + if (m_white_is_over.load(std::memory_order_acquire)){ + return true; + } + if (!m_has_been_white){ + m_has_been_white = m_on.process_frame(frame, timestamp); + return false; + } + bool is_over = m_off.process_frame(frame, timestamp); + if (!is_over){ + return false; + } + m_white_is_over.store(true, std::memory_order_release); + return true; +} +bool WhiteScreenOverWatcher::white_is_over(const ImageViewRGB32& frame){ + return m_white_is_over.load(std::memory_order_acquire); +} + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h new file mode 100644 index 0000000000..2dd7e59d7c --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h @@ -0,0 +1,51 @@ +/* BDSP Transition Watchers + * + * From: https://github.com/PokemonAutomation/ + * + * Adapted from PokemonAutomation/Arduino-Source#1717 by Developer-Butters. + */ + +#ifndef PokemonAutomation_PokemonBDSP_TransitionWatchers_H +#define PokemonAutomation_PokemonBDSP_TransitionWatchers_H + +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// AutoStory-specific transition handling; shared watchers retain upstream behavior. +// A white screen (RGB sum over 500, stddev sum under 10) in `box`, +// present or gone for `duration`. +class WhiteScreenWatcher : public DetectorToFinder{ +public: + WhiteScreenWatcher(Color color, const ImageFloatBox& box, FinderType finder_type, std::chrono::milliseconds duration) + : DetectorToFinder("WhiteScreenWatcher", finder_type, duration, color, box, 500, 10) + {} +}; + +// The screen in `box` went white for 100 ms, then stopped being white for +// 100 ms: an encounter's flash. +class WhiteScreenOverWatcher : public VisualInferenceCallback{ +public: + WhiteScreenOverWatcher(Color color, const ImageFloatBox& box); + + bool white_is_over(const ImageViewRGB32& frame); + + virtual void make_overlays(VideoOverlaySet& items) const override; + + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override; + +private: + WhiteScreenWatcher m_on; + WhiteScreenWatcher m_off; + bool m_has_been_white = false; + std::atomic m_white_is_over = false; +}; + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp index 847692b532..145096b969 100644 --- a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp @@ -6,10 +6,12 @@ #include "Common/Cpp/TestRunners/UnitTestDatabase.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_Maps_Tests.h" +#include "Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_Segment00_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_StoryMaps_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_StoryWorld_Tests.h" +#include "Programs/AutoStory/PokemonBDSP_AutoStory_Walk_Tests.h" #include "PokemonBDSP_Tests.h" namespace PokemonAutomation{ @@ -24,6 +26,8 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStory_RoutePlanner(database); add_tests_AutoStory_StoryMaps(database); add_tests_AutoStory_Segment00(database); + add_tests_AutoStory_Walk(database); + add_tests_AutoStory_PlannedRoute(database); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.cpp new file mode 100644 index 0000000000..effcc50af0 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.cpp @@ -0,0 +1,136 @@ +/* BDSP AutoStory - Overworld Detector + * + * From: https://github.com/PokemonAutomation/ + * + * Adapted from PokemonAutomation/Arduino-Source#1717 by Developer-Butters. + */ + +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" + +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/Images/SolidColorTest.h" + +#include "PokemonBDSP_AutoStory_OverworldDetector.h" + +using namespace std::chrono_literals; + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// --------------------------------------------------------------------------- +// OverworldDetector +// --------------------------------------------------------------------------- + +OverworldDetector::OverworldDetector(Color color) + : m_color(color) + , m_battle_menu(BattleType::STANDARD, color){ +} + +void OverworldDetector::make_overlays(VideoOverlaySet& items) const { + m_dialog.make_overlays(items); + m_battle_menu.make_overlays(items); + m_menu.make_overlays(items); +} + +bool OverworldDetector::detect(const ImageViewRGB32& screen){ + // We are in the overworld when none of the overlay screens are present. + if (m_dialog.detect(screen)){ + return false; + } + if (m_battle_menu.detect(screen)){ + return false; + } + if (m_menu.detect(screen)){ + return false; + } + return true; +} + + +// --------------------------------------------------------------------------- +// OverworldWatcher +// --------------------------------------------------------------------------- + +OverworldWatcher::OverworldWatcher(Color color) + : OverworldDetector(color) + , VisualInferenceCallback("OverworldWatcher"){ +} + +void OverworldWatcher::make_overlays(VideoOverlaySet& items) const { + OverworldDetector::make_overlays(items); +} + +bool OverworldWatcher::process_frame(const ImageViewRGB32& frame, WallClock /*timestamp*/){ + return detect(frame); +} + + +// --------------------------------------------------------------------------- +// PoketchButtonDetector +// --------------------------------------------------------------------------- + +PoketchButtonDetector::PoketchButtonDetector(Color color) + : m_color(color) + , m_button(0.968, 0.170, 0.022, 0.120){ +} + +void PoketchButtonDetector::make_overlays(VideoOverlaySet& items) const { + items.add(m_color, m_button); +} + +bool PoketchButtonDetector::detect(const ImageViewRGB32& screen){ + ImageStats stats = image_stats(extract_box_reference(screen, m_button)); + // The button is a glossy red with a highlight, so allow a wide stddev. + return is_solid(stats, {0.74, 0.10, 0.16}, 0.12, 80); +} + +PoketchButtonWatcher::PoketchButtonWatcher(Color color) + : PoketchButtonDetector(color) + , VisualInferenceCallback("PoketchButtonWatcher"){ +} + +void PoketchButtonWatcher::make_overlays(VideoOverlaySet& items) const { + PoketchButtonDetector::make_overlays(items); +} + +bool PoketchButtonWatcher::process_frame(const ImageViewRGB32& frame, WallClock){ + return detect(frame); +} + + +static bool look_for_poketch_button(VideoStream& stream, ProControllerContext& context){ + context.wait_for_all_requests(); + PoketchButtonWatcher watcher; + return wait_until(stream, context, 2s, {{watcher}}) == 0; +} + +bool ensure_poketch_visible(VideoStream& stream, ProControllerContext& context){ + for (size_t attempt = 0; attempt < 3; attempt++){ + if (look_for_poketch_button(stream, context)){ + return true; + } + stream.log("[AutoStory] Poketch button not found. Attempt " + std::to_string(attempt + 1) + " of 3.", + COLOR_ORANGE); + + if (attempt == 0){ + // Most likely hidden: one tap shows it. + pbf_press_button(context, BUTTON_R, 205ms, 0ms); + }else{ + // Long press hides it, then a tap shows it at the normal size. + pbf_press_button(context, BUTTON_R, 1548ms, 0ms); + pbf_wait(context, 1418ms); + pbf_press_button(context, BUTTON_R, 143ms, 0ms); + } + } + + // Check the result of the last press sequence. + return look_for_poketch_button(stream, context); +} + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h new file mode 100644 index 0000000000..f2717765b6 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h @@ -0,0 +1,101 @@ +/* BDSP AutoStory - Overworld Detector + * + * From: https://github.com/PokemonAutomation/ + * + * Adapted from PokemonAutomation/Arduino-Source#1717 by Developer-Butters. + * + * Detects that the player is in the free-roaming overworld state: + * no dialog box, no battle menu, and no in-game menu open. + * + * BDSP does not ship a standalone OverworldDetector in PokemonBDSP/Inference/, + * so this composite detector is defined here for AutoStory use. + * + * Reuses: + * - ShortDialogDetector (PokemonBDSP_DialogDetector.h) — absence required + * - BattleMenuDetector (PokemonBDSP_BattleMenuDetector.h) — absence required + * - MenuDetector (PokemonBDSP_MenuDetector.h) — absence required + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_OverworldDetector_H +#define PokemonAutomation_PokemonBDSP_AutoStory_OverworldDetector_H + +#include "Common/Cpp/Color.h" + +#include "CommonFramework/Tools/VideoStream.h" + +#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" +#include "CommonTools/VisualDetector.h" + +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Static (single-frame) version — use when you already have a frame. +class OverworldDetector : public StaticScreenDetector { +public: + OverworldDetector(Color color = COLOR_CYAN); + + virtual void make_overlays(VideoOverlaySet& items) const override; + + // Returns true when none of: short dialog, battle menu, or in-game menu + // are present on the given frame. + virtual bool detect(const ImageViewRGB32& screen) override; + +private: + Color m_color; + ShortDialogDetector m_dialog; + BattleMenuDetector m_battle_menu; + MenuDetector m_menu; +}; + + +// Watcher (callback) version — use with run_until<> or wait_until(). +class OverworldWatcher : public OverworldDetector, public VisualInferenceCallback { +public: + OverworldWatcher(Color color = COLOR_CYAN); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override; +}; + + +// Detects the big red button on the right edge of the Poketch overlay. +// Present only while the Poketch is shown at its normal size. +class PoketchButtonDetector : public StaticScreenDetector { +public: + PoketchButtonDetector(Color color = COLOR_RED); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + +private: + Color m_color; + ImageFloatBox m_button; +}; + +// Inference watcher tracking the visibility of the Poketch button on screen. +class PoketchButtonWatcher : public PoketchButtonDetector, public VisualInferenceCallback { +public: + PoketchButtonWatcher(Color color = COLOR_RED); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override; +}; + +// Tries up to 3 times to get the Poketch button on screen using R presses. +// Returns true if the button was seen. +bool ensure_poketch_visible(VideoStream& stream, ProControllerContext& context); + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.cpp new file mode 100644 index 0000000000..ca7f599636 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.cpp @@ -0,0 +1,414 @@ +/* BDSP AutoStory Planned Route Tests + * + * From: https://github.com/PokemonAutomation/ + * + * walk_route() (Utils/PokemonBDSP_AutoStory_PlannedRoute.h) on a script + * of console outcomes: each case lists the walks, presses and screen reads + * the walker must make, in order, and what the console answers. A call the + * script does not expect fails the case. + * + */ + +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Data/PokemonBDSP_AutoStory_Maps.h" +#include "Data/PokemonBDSP_AutoStory_StoryMaps.h" +#include "Utils/PokemonBDSP_AutoStory_PlannedRoute.h" +#include "Utils/PokemonBDSP_AutoStory_RouteIO.h" +#include "PokemonBDSP_AutoStory_PlannedRoute_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ + +using namespace std::chrono_literals; +using Pokemon::Direction; +using Pokemon::TilePos; + + +// One call the walker is expected to make, and what the console answers: +// `walk` for a walk, `answer` for a dialogue watch or the holding check. +struct ScriptStep{ + std::string call; + WalkResult walk; + bool answer; +}; + +ScriptStep walk_step(std::string call, WalkStop stop, Milliseconds walked, size_t moved){ + return {.call = std::move(call), .walk = {.stop = stop, .walked = walked, .wild_battles = 0, .moved_chunks = moved}, .answer = false}; +} +ScriptStep answer_step(std::string call, bool answer){ + return {.call = std::move(call), .walk = {.stop = WalkStop::LIMIT, .walked = 0ms, .wild_battles = 0, .moved_chunks = 0}, .answer = answer}; +} +ScriptStep press_step(std::string call){ + return answer_step(std::move(call), false); +} + +// What the console did on a scripted walk: the steps called so far, every +// line logged, and the legs walked (to a wall, or a number of taps). +struct Script{ + std::vector steps; + size_t next = 0; + std::vector notes = {}; + std::vector legs = {}; +}; + +// Thrown by ScriptedRouteIO: a call the script did not expect, or the +// walker failing the program. +struct ScriptMismatch{ + std::string message; +}; +struct RouteFailed{ + std::string message; +}; + +std::string describe(const RouteInput& input){ + const char* kind = + input.kind == RouteInputKind::DPAD ? "D-pad" : + input.kind == RouteInputKind::STICK ? "stick" : + input.kind == RouteInputKind::BUTTON_A ? "A" : + input.kind == RouteInputKind::WAIT ? "wait" : "pause"; + const bool directed = input.kind == RouteInputKind::DPAD || input.kind == RouteInputKind::STICK; + return std::format("{}{}{} {} ms x{}", kind, directed ? " " : "", directed ? Pokemon::direction_name(input.direction) : "", + input.hold.count(), input.count); +} + +// The console as a script: each call must be the script's next step. +// look() sees no screen. +class ScriptedRouteIO : public RouteIO{ +public: + explicit ScriptedRouteIO(Script& script) + : m_script(script) + {} + + virtual WalkResult walk_to_wall(const RouteWallWalk& walk) override{ + m_script.legs.push_back({.direction = walk.direction, .tiles = 0, .ends_at_wall = true}); + return expect("wall: " + walk.label).walk; + } + virtual WalkResult walk_taps(Direction direction, size_t tiles, const std::string& label) override{ + m_script.legs.push_back({.direction = direction, .tiles = tiles, .ends_at_wall = false}); + return expect(std::format("{} taps: {}", tiles, label)).walk; + } + virtual void press(const RouteInput& input) override{ + expect(describe(input)); + } + virtual bool dialog_within(Milliseconds wait, Color) override{ + return expect(std::format("dialog within {} ms", wait.count())).answer; + } + virtual bool holding_against_wall(Direction direction, const std::string&) override{ + return expect(std::string("holding ") + Pokemon::direction_name(direction)).answer; + } + virtual void look(const std::function&) override{} + virtual void note(const std::string& text, RouteNote kind) override{ + m_script.notes.push_back(text); + if (kind == RouteNote::FAILURE){ + throw RouteFailed{text}; + } + } + +private: + const ScriptStep& expect(const std::string& call){ + if (m_script.next >= m_script.steps.size()){ + throw ScriptMismatch{"a call after the script's end: \"" + call + "\""}; + } + const ScriptStep& step = m_script.steps[m_script.next++]; + if (step.call != call){ + throw ScriptMismatch{std::format("step {}: expected \"{}\", got \"{}\"", m_script.next, step.call, call)}; + } + return step; + } + + Script& m_script; +}; + + +// How a scripted walk ended: where it arrived, or why it failed. +struct ScriptedWalk{ + std::vector at; + std::string failure; + std::vector fights; +}; + +ScriptedWalk walk_script(const StoryWorld& maps, const TilePos& from, const TilePos& goal, std::vector sights, Script& script){ + ScriptedWalk out; + RouteTrainers trainers{ + .sights = std::move(sights), + .fight = [&out](const std::string& trainer){ out.fights.push_back(trainer); }, + }; + ScriptedRouteIO io(script); + try{ + out.at = walk_route(io, maps, {from}, trainers, { + .goal = [goal](const TilePos& pos){ return pos == goal; }, + .label = "T", + .avoid = {}, + .costs = route_plan_costs(), + .speed = MoveSpeed::WALK, + }); + }catch (const RouteFailed& e){ + out.failure = e.message; + } + return out; +} + +// The script's calls all made, or which are left. +std::string script_left(const Script& script){ + if (script.next == script.steps.size()){ + return ""; + } + return std::format("{} of {} steps called; next \"{}\".", script.next, script.steps.size(), script.steps[script.next].call); +} + +StoryWorld make_world(const std::vector& rows){ + Pokemon::MapData map; + map.name = "Test"; + map.tiles = Pokemon::Grid{(int)rows[0].size(), (int)rows.size(), {}}; + for (const std::string& row : rows){ + for (char glyph : row){ + map.tiles.cells.push_back(*Pokemon::tile_from_glyph(glyph)); + } + } + StoryWorld world; + world.add("Test", map, {0, 0}); + return world; +} + + +// r54: a trainer's line froze the check walk at a leg's wall; after the +// battle the walk on reached the wall, but the check still judged the +// frozen walk's DIALOG and failed. The walk on is what counts. +UnitTestResult test_wall_leg_trainer_on_the_way(Logger&){ + Script script{.steps = { + walk_step("wall: T: east 4 (to wall)", WalkStop::LIMIT, 2050ms, 0), + walk_step("wall: T: east 4 (to wall): at the wall?", WalkStop::DIALOG, 400ms, 0), + walk_step("wall: T: east 4 (to wall): on to the wall", WalkStop::BLOCKED, 1200ms, 0), + answer_step("dialog within 500 ms", false), + }}; + try{ + const ScriptedWalk walk = walk_script(make_world({"#.....#"}), {1, 0}, {5, 0}, {}, script); + if (!walk.failure.empty()){ + return "failed after the walk on reached the wall: " + walk.failure; + } + if (walk.fights != std::vector{"a trainer on the way"}){ + return std::format("fought {} times, expected once.", walk.fights.size()); + } + if (walk.at != std::vector{{5, 0}}){ + return "did not arrive at (5, 0)."; + } + }catch (const ScriptMismatch& e){ + return e.message; + } + const std::string left = script_left(script); + return left.empty() ? UnitTestResult(true) : UnitTestResult(left); +} + + +// A leg to a wall that ran out of time: a check walk that also runs out +// of time counts as at the wall only if holding on there changes the +// screen less than walking (r127a ran along the wrong row). +struct WallCheckCase{ + std::string name; + std::vector check; + bool fails; +}; + +UnitTestResult test_wall_leg_limit(Logger&){ + const std::vector cases{ + {.name = "the check stops at the wall", .check = { + walk_step("wall: T: east 4 (to wall): at the wall?", WalkStop::BLOCKED, 1050ms, 0), + answer_step("dialog within 500 ms", false), + }, .fails = false}, + {.name = "the check runs out of time, holding on reads a wall", .check = { + walk_step("wall: T: east 4 (to wall): at the wall?", WalkStop::LIMIT, 2000ms, 0), + answer_step("holding east", true), + answer_step("dialog within 500 ms", false), + }, .fails = false}, + {.name = "the check runs out of time, holding on still moves", .check = { + walk_step("wall: T: east 4 (to wall): at the wall?", WalkStop::LIMIT, 2000ms, 0), + answer_step("holding east", false), + }, .fails = true}, + }; + for (const WallCheckCase& item : cases){ + Script script{.steps = {walk_step("wall: T: east 4 (to wall)", WalkStop::LIMIT, 2050ms, 0)}}; + script.steps.insert(script.steps.end(), item.check.begin(), item.check.end()); + try{ + const ScriptedWalk walk = walk_script(make_world({"#.....#"}), {1, 0}, {5, 0}, {}, script); + if (walk.failure.empty() == item.fails){ + return item.name + (item.fails ? ": went on as at the wall." : ": failed: " + walk.failure); + } + }catch (const ScriptMismatch& e){ + return item.name + ": " + e.message; + } + const std::string left = script_left(script); + if (!left.empty()){ + return item.name + ": " + left; + } + } + return true; +} + + +// Whether walking `legs` from `from` ever steps into `person`'s tile: a +// leg to a wall that stops against them, or a tap toward them. +bool walks_into(const StoryWorld& world, TilePos from, const std::vector& legs, const TilePos& person){ + for (const TileLeg& leg : legs){ + for (size_t tap = 0; leg.ends_at_wall || tap < leg.tiles; tap++){ + if (from.next(leg.direction) == person){ + return true; + } + const std::optional next = step(world, from, leg.direction); + if (!next){ + break; + } + from = *next; + } + } + return false; +} + +// Route 202 alone, at its own tiles, and Logan's sight: he stands at +// (26, 4) and looks south. +StoryWorld route202_world(){ + StoryWorld world; + world.add("Route 202", story_map("Route202"), {0, 0}); + return world; +} +std::vector logan_sight(){ + std::vector sights; + for (const TrainerSight& sight : route202_trainer_sights()){ + if (sight.name == "logan"){ + sights.push_back(sight); + } + } + return sights; +} + +// Whether the replan after the battle starts from each of `tiles`. +std::string check_replan_from(const Script& script, const std::vector& tiles){ + const auto replan = std::find_if(script.notes.begin(), script.notes.end(), [](const std::string& note){ + return note.find("planning again") != std::string::npos; + }); + if (replan == script.notes.end()){ + return "never planned again after the battle."; + } + for (const std::string& tile : tiles){ + if (replan->find(tile) == std::string::npos){ + return "the replan does not start from " + tile + ": " + *replan; + } + } + return ""; +} + +// r55: Logan (Route 202, (26, 4), looking south) stopped an open leg west +// along row 5. "5 taps" were 4, under his nose at (26, 5); the replan from +// the tap count alone walked into him. The replan must start from both the +// tile the taps give and the tile where the leg enters his sight, and walk +// on from either without bumping into him. +UnitTestResult test_open_leg_known_trainer(Logger&){ + const StoryWorld world = route202_world(); + // West along row 5 from (30, 5) to (24, 5): his line is the 4th tap, + // and his dialogue comes after 3 taps that moved. + Script script{.steps = { + press_step("D-pad west 50 ms x1"), + walk_step("6 taps: T: west 6 (open)", WalkStop::DIALOG, 1500ms, 3), + walk_step("wall: T: east 4 (to wall)", WalkStop::BLOCKED, 2000ms, 0), + answer_step("dialog within 500 ms", false), + press_step("D-pad west 50 ms x1"), + walk_step("6 taps: T: west 6 (open)", WalkStop::LIMIT, 1500ms, 6), + answer_step("dialog within 500 ms", false), + }}; + ScriptedWalk walk; + try{ + walk = walk_script(world, {30, 5}, {24, 5}, logan_sight(), script); + }catch (const ScriptMismatch& e){ + return e.message; + } + if (!walk.failure.empty() || walk.fights != std::vector{"logan"}){ + return std::format("expected to fight Logan once and go on; fought {}, failure \"{}\".", walk.fights.size(), walk.failure); + } + // The taps say (27, 5); his sight line crosses the row at (26, 5). + const std::string replan = check_replan_from(script, {"Route 202 (27, 5)", "Route 202 (26, 5)"}); + if (!replan.empty()){ + return replan; + } + const std::vector after_battle(script.legs.begin() + 1, script.legs.end()); + const TilePos logan = map_point("Route202", "trainer_logan"); + for (const TilePos& from : {TilePos{27, 5}, TilePos{26, 5}}){ + if (walks_into(world, from, after_battle, logan)){ + return "the replan walks into Logan from " + Pokemon::to_string(from) + "."; + } + } + if (walk.at != std::vector{{24, 5}}){ + return "did not arrive at (24, 5)."; + } + const std::string left = script_left(script); + return left.empty() ? UnitTestResult(true) : UnitTestResult(left); +} + + +struct PlannedRouteInput{ + std::string name; + std::function body; +}; + +struct PlannedRouteExpectation{ +}; + +struct PlannedRouteCase{ + PlannedRouteInput input; + PlannedRouteExpectation expectation; +}; + +class Test_PlannedRoute : public UnitTest{ +public: + explicit Test_PlannedRoute(PlannedRouteCase test_case) + : UnitTest("PokemonBDSP::AutoStory - Planned route: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + try{ + return m_case.input.body(logger); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + PlannedRouteCase m_case; +}; + +} // namespace + + +const PlannedRouteCase PLANNED_ROUTE_CASES[] = { + { + .input = {"a trainer on a wall leg, then on to the wall", test_wall_leg_trainer_on_the_way}, + .expectation = {}, + }, + { + .input = {"a wall leg out of time needs the holding check", test_wall_leg_limit}, + .expectation = {}, + }, + { + .input = {"a known trainer stops an open leg", test_open_leg_known_trainer}, + .expectation = {}, + }, +}; + +void add_tests_AutoStory_PlannedRoute(UnitTestDatabase& database){ + for (const auto& item : PLANNED_ROUTE_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.h new file mode 100644 index 0000000000..740e5c27e8 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.h @@ -0,0 +1,28 @@ +/* BDSP AutoStory Planned Route Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_PlannedRoute_Tests_H +#define PokemonAutomation_PokemonBDSP_AutoStory_PlannedRoute_Tests_H + +#include "Common/Cpp/TestRunners/UnitTest.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + + +// walk_route() on a script of console outcomes (a ScriptedRouteIO): what +// the walker does after a trainer stops a leg, and when a leg to a wall +// counts as at its wall. +void add_tests_AutoStory_PlannedRoute(UnitTestDatabase& database); + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Walk_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Walk_Tests.cpp new file mode 100644 index 0000000000..154c0b21cc --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Walk_Tests.cpp @@ -0,0 +1,381 @@ +/* BDSP AutoStory Walk Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "Utils/PokemonBDSP_AutoStory_Indoors.h" +#include "Utils/PokemonBDSP_AutoStory_LegBattle.h" +#include "Utils/PokemonBDSP_AutoStory_Location.h" +#include "Utils/PokemonBDSP_AutoStory_RouteWalk.h" +#include "Utils/PokemonBDSP_AutoStory_Walk.h" +#include "PokemonBDSP_AutoStory_Walk_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ + +// A turn must not step: on the console D-pad taps of 40 to 80 ms only +// turned, a 160 ms D-pad tap stepped a whole tile, and a 100 ms stick tap +// stepped about 0.6 tile, off the tile grid (r49, r248a). So route_turn() +// taps the D-pad, once, for at most 80 ms. +UnitTestResult test_route_turn_tap(Logger&){ + constexpr Milliseconds LONGEST_TURN(80); + for (Pokemon::Direction direction : {Pokemon::Direction::NORTH, Pokemon::Direction::SOUTH, Pokemon::Direction::EAST, Pokemon::Direction::WEST}){ + const RouteInput tap = route_turn_tap(direction); + const std::string name = std::string("turning ") + Pokemon::direction_name(direction); + if (tap.kind != RouteInputKind::DPAD){ + return name + ": not a D-pad tap; a stick tap moves part of a tile."; + } + if (tap.direction != direction || tap.count != 1){ + return name + ": expected one tap that way."; + } + if (tap.hold > LONGEST_TURN){ + return name + ": a " + std::to_string(tap.hold.count()) + " ms tap; over 80 ms may step."; + } + } + if (ROUTE_TURN_TAP > LONGEST_TURN){ + return "ROUTE_TURN_TAP is " + std::to_string(ROUTE_TURN_TAP.count()) + " ms; over 80 ms may step."; + } + return true; +} + +// WarpFadeWatch on synthetic frames 33 ms apart, each a brightness and a +// texture (stddev a channel). Two scenes differ here when their +// brightness does. +struct FadeFrame{ + double brightness; + double stddev; +}; +struct WarpFadeCase{ + std::string name; + WarpFadeRule rule; + std::vector frames; + // The frame (1-based) the fade shows on, 0 for none, and how. + size_t seen_at; + WarpFade fade; +}; + +UnitTestResult test_warp_fade_watch(Logger&){ + constexpr WarpFadeRule DOOR{.partial_dark = true, .dark_for = Milliseconds(50), .white_for = Milliseconds(60)}; + constexpr WarpFadeRule TAP{}; + constexpr FadeFrame STREET{104, 20}; + constexpr FadeFrame ROOM{130, 20}; + constexpr FadeFrame FALL{55, 10}; + constexpr FadeFrame BLACK{7, 1}; + const std::vector cases{ + {.name = "a door's fade, its hold seen", .rule = DOOR, .frames = {STREET, FALL, BLACK, BLACK, BLACK, ROOM}, .seen_at = 4, .fade = WarpFade::BLACK}, + {.name = "a tap's fade, its hold seen", .rule = TAP, .frames = {STREET, FALL, BLACK, ROOM}, .seen_at = 3, .fade = WarpFade::BLACK}, + {.name = "a short fade, its hold between two frames", .rule = TAP, .frames = {STREET, FALL, ROOM}, .seen_at = 3, .fade = WarpFade::BLACK}, + {.name = "a door's short fade, its hold between two frames", .rule = DOOR, .frames = {STREET, FALL, ROOM}, .seen_at = 3, .fade = WarpFade::BLACK}, + {.name = "a dip back to the same street", .rule = TAP, .frames = {STREET, FALL, STREET}, .seen_at = 0, .fade = WarpFade::NONE}, + {.name = "a dim cave floor (46) is no fade", .rule = TAP, .frames = {STREET, {46, 20}, {46, 20}, {46, 20}}, .seen_at = 0, .fade = WarpFade::NONE}, + {.name = "a cave mouth's dim fade (55)", .rule = TAP, .frames = {STREET, {80, 10}, {55, 1}, ROOM}, .seen_at = 3, .fade = WarpFade::BLACK}, + {.name = "a cave exit's white, its hold between two frames", .rule = TAP, .frames = {STREET, {180, 10}, ROOM}, .seen_at = 3, .fade = WarpFade::WHITE}, + {.name = "a cave exit's white", .rule = DOOR, .frames = {STREET, {250, 1}, {250, 1}, {250, 1}}, .seen_at = 4, .fade = WarpFade::WHITE}, + }; + for (const WarpFadeCase& item : cases){ + WarpFadeWatch watch(item.rule, [](const Pokemon::TransitionSample& before, const Pokemon::TransitionSample& after){ + return after.middle.average.r != before.middle.average.r ? Pokemon::SceneChange::DIFFERENT : Pokemon::SceneChange::SAME; + }); + size_t seen_at = 0; + WarpFade fade = WarpFade::NONE; + for (size_t i = 0; i < item.frames.size() && seen_at == 0; i++){ + const FadeFrame& frame = item.frames[i]; + fade = watch.push({ + .time = WallClock() + Milliseconds(33 * (int64_t)i), + .middle = ImageStats(FloatPixel(frame.brightness, frame.brightness, frame.brightness), FloatPixel(frame.stddev, frame.stddev, frame.stddev), 1), + }); + seen_at = fade == WarpFade::NONE ? 0 : i + 1; + } + if (seen_at != item.seen_at || fade != item.fade){ + return item.name + ": " + warp_fade_name(fade) + " at frame " + std::to_string(seen_at) + ", expected " + + warp_fade_name(item.fade) + " at " + std::to_string(item.seen_at) + "."; + } + } + return true; +} + +// indoors() decides when leave_door_south() is out: r257 walked on south +// past Sandgem's road into a house after leaving the Center, because the +// evening tint made the outside as warm as a room. Synthetic screens: the +// evening outside (warm all over), a room (warm, black along the bottom), +// a fade (black all over), and warm with a thin dark edge (a shadow). +struct IndoorsCase{ + std::string name; + uint32_t fill; + double dark_bottom; // how much of the screen's height, from the bottom, is black + bool indoors; +}; + +UnitTestResult test_indoors_any_light(Logger&){ + const std::vector cases{ + {.name = "evening outside, orange all over", .fill = 0xffe0a040, .dark_bottom = 0, .indoors = false}, + {.name = "a room: orange, black below its floor", .fill = 0xffe0a040, .dark_bottom = 0.10, .indoors = true}, + {.name = "a fade: black all over", .fill = 0xff000000, .dark_bottom = 0, .indoors = false}, + {.name = "orange with a thin shadow at the bottom", .fill = 0xffe0a040, .dark_bottom = 0.01, .indoors = false}, + {.name = "daytime grass", .fill = 0xff60b040, .dark_bottom = 0.10, .indoors = false}, + }; + for (const IndoorsCase& item : cases){ + ImageRGB32 screen(1920, 1080); + const size_t dark_from = (size_t)(1080 * (1 - item.dark_bottom)); + for (size_t y = 0; y < 1080; y++){ + for (size_t x = 0; x < 1920; x++){ + screen.pixel(x, y) = y >= dark_from ? 0xff000000 : item.fill; + } + } + if (indoors(screen) != item.indoors){ + return item.name + (item.indoors ? ": read as outside." : ": read as inside."); + } + } + return true; +} + +// door_fade_look(): out of a door is a dark look, then a bright one that is +// not a room. r259 turned round in Sandgem's lit Center, read "not a room" +// once at full brightness, and walked east inside it as if outside. +struct DoorLook{ + double brightness; + bool room; +}; +struct DoorCase{ + std::string name; + std::vector looks; + // The look (1-based) that reads out, or 0 for none. + size_t out_at; +}; + +UnitTestResult test_door_fade(Logger&){ + const std::vector cases{ + {.name = "r259: a bright look in the Center that misses the room", .looks = {{458, true}, {440, false}, {457, true}}, .out_at = 0}, + {.name = "the fade, then the town", .looks = {{458, true}, {300, true}, {60, false}, {310, false}}, .out_at = 4}, + {.name = "the fade, then the room again", .looks = {{458, true}, {60, false}, {420, true}}, .out_at = 0}, + {.name = "the fade, still dim", .looks = {{60, false}, {150, false}, {320, false}}, .out_at = 3}, + }; + for (const DoorCase& item : cases){ + DoorFadeWatch watch; + size_t out_at = 0; + for (size_t i = 0; i < item.looks.size() && out_at == 0; i++){ + if (door_fade_look(watch, item.looks[i].brightness, item.looks[i].room)){ + out_at = i + 1; + } + } + if (out_at != item.out_at){ + return item.name + ": out at look " + std::to_string(out_at) + ", expected " + std::to_string(item.out_at) + "."; + } + } + return true; +} + + +UnitTestResult test_location_banner(Logger&){ + struct Case{ + const char* read; + const char* expected; + bool match; + }; + const Case cases[] = { + {"sandgemtown", "Sandgem Town", true}, + {"andgemltown", "Sandgem Town", true}, // r109's OCR + {"Route 202", "Route 202", true}, + {"route2o2", "Route 202", false}, // a digit read as a letter is not trusted + {"route201", "Route 202", false}, + {"jubilifecity", "Sandgem Town", false}, + {"twinleaftown", "Sandgem Town", false}, + {"", "Sandgem Town", false}, + }; + for (const Case& c : cases){ + if (location_banner_matches(c.read, c.expected) != c.match){ + return std::string("Banner \"") + c.read + "\" for " + c.expected + + (c.match ? " should match." : " should not match."); + } + } + return true; +} + + +UnitTestResult test_indoors(Logger& logger){ + struct Case{ const char* file; bool inside; }; + const Case cases[] = { + {"PokemonBDSP/AutoStory/JubilifeCenterInside-20261030-c6.png", true}, + {"PokemonBDSP/AutoStory/JubilifeCenterInside-20261030-c4.png", true}, + {"PokemonBDSP/AutoStory/JubilifeCenterInsideUpper-20261030-r151a.png", true}, + {"PokemonBDSP/AutoStory/SandgemCenterInside-20261030-053555.png", true}, + {"PokemonBDSP/AutoStory/JubilifeSchoolInside-20261030.png", true}, + {"PokemonBDSP/AutoStory/JubilifeCenterDoorstep-20261030-c5.png", false}, + {"PokemonBDSP/AutoStory/JubilifeSchoolDoorstep-20261030.png", false}, + {"PokemonBDSP/AutoStory/JubilifeSchoolDoorstep2-20261030.png", false}, + {"PokemonBDSP/AutoStory/JubilifeCenterFrontDoor-20261030-q2.png", false}, + {"PokemonBDSP/AutoStory/SandgemCenterBelowSign-20261030-053527.png", false}, + {"PokemonBDSP/AutoStory/SandgemMartDoorUpRight-20261030-023443.png", false}, + }; + for (const Case& c : cases){ + const std::string path = UNIT_TEST_RESOURCE_PATH() + c.file; + if (!Filesystem::is_regular_file(path)){ + return UnitTestResult(UnitTestResult::SKIPPED, "Screenshot not found: " + path); + } + const bool inside = indoors(ImageRGB32(path)); + logger.log(std::string(c.file) + ": " + (inside ? "indoors" : "outside")); + if (inside != c.inside){ + return std::string(c.file) + ": expected " + (c.inside ? "indoors" : "outside") + "."; + } + } + return true; +} + + +UnitTestResult test_location_banner_showing(Logger& logger){ + struct Case{ const char* file; bool banner; }; + const Case cases[] = { + {"PokemonBDSP/AutoStory/JubilifeCenterDoorstep-20261030-c5.png", true}, + {"PokemonBDSP/AutoStory/JubilifeSchoolDoorstep-20261030.png", true}, + {"PokemonBDSP/AutoStory/JubilifeCenterInside-20261030-c6.png", false}, + {"PokemonBDSP/AutoStory/JubilifeCenterInside-20261030-c4.png", false}, + {"PokemonBDSP/AutoStory/JubilifeCenterInsideUpper-20261030-r151a.png", false}, + {"PokemonBDSP/AutoStory/JubilifeCenterFrontDoor-20261030-q2.png", false}, + }; + for (const Case& c : cases){ + const std::string path = UNIT_TEST_RESOURCE_PATH() + c.file; + if (!Filesystem::is_regular_file(path)){ + return UnitTestResult(UnitTestResult::SKIPPED, "Screenshot not found: " + path); + } + const bool banner = location_banner_showing(ImageRGB32(path)); + logger.log(std::string(c.file) + ": banner " + (banner ? "shows" : "none")); + if (banner != c.banner){ + return std::string(c.file) + ": expected " + (c.banner ? "a banner" : "no banner") + "."; + } + } + return true; +} + + +struct AutoStoryWalkInput{ + std::string name; + std::function body; +}; + +struct AutoStoryWalkExpectation{ +}; + +struct AutoStoryWalkCase{ + AutoStoryWalkInput input; + AutoStoryWalkExpectation expectation; +}; + +// place_leg_battle() on synthetic walks of 10 taps, their events recorded +// as record_tap_battle() records the console's. +struct LegBattleCase{ + std::string name; + size_t battles; + size_t tap; // the tap (from 1) that set the battle off + size_t moved_after; // taps that moved after it was run from + bool settled; // false: a white fade with no battle yet + std::optional expected; +}; +UnitTestResult test_leg_battle_place(Logger& logger){ + const LegBattleCase CASES[] = { + {"mid-leg, the taps went on", 1, 3, 7, true, TileSpan{9, 10}}, + {"seen after the hold", 1, 6, 0, true, TileSpan{5, 7}}, + {"on the first tap", 1, 1, 0, true, TileSpan{0, 2}}, + {"before the walk", 1, 0, 0, true, TileSpan{0, 1}}, + {"two battles", 2, 3, 0, true, std::nullopt}, + {"unsettled: a white fade, no battle yet", 1, 4, 0, false, std::nullopt}, + }; + for (const LegBattleCase& item : CASES){ + WalkResult walk{.stop = WalkStop::LIMIT, .walked = Milliseconds(8000), .wild_battles = item.battles}; + walk.moved_after_battle = item.moved_after; + walk.events.record({}, walk.started); + if (item.settled){ + record_tap_battle(walk, item.tap); + }else{ + walk.events.record({Pokemon::ScreenEvent::FADE_WHITE}, walk.started + Milliseconds(800 * (item.tap - 1) + 500)); + } + const LegBattlePlace place = place_leg_battle(walk, 10); + logger.log(leg_battle_line(item.name, walk, place)); + const bool same = place.moved.has_value() == item.expected.has_value() && + (!place.moved || (place.moved->min == item.expected->min && place.moved->max == item.expected->max)); + if (!same){ + return item.name + ": " + leg_battle_line(item.name, walk, place); + } + } + return true; +} + +class Test_AutoStoryWalk : public UnitTest{ +public: + explicit Test_AutoStoryWalk(AutoStoryWalkCase test_case) + : UnitTest("PokemonBDSP::AutoStory - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + try{ + return m_case.input.body(logger); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + AutoStoryWalkCase m_case; +}; + +} // namespace + + +const AutoStoryWalkCase AUTO_STORY_WALK_CASES[] = { + { + .input = {"a turn is a D-pad tap too short to step", test_route_turn_tap}, + .expectation = {}, + }, + { + .input = {"indoors() under the evening tint", test_indoors_any_light}, + .expectation = {}, + }, + { + .input = {"out of a door needs its fade", test_door_fade}, + .expectation = {}, + }, + { + .input = {"a warp's fade, followed frame by frame, its hold seen or not", test_warp_fade_watch}, + .expectation = {}, + }, + { + .input = {"location banner match", test_location_banner}, + .expectation = {}, + }, + { + .input = {"indoors", test_indoors}, + .expectation = {}, + }, + { + .input = {"location banner showing", test_location_banner_showing}, + .expectation = {}, + }, + { + .input = {"a wild battle on an open leg's taps, placed by its screen events", test_leg_battle_place}, + .expectation = {}, + }, +}; + +void add_tests_AutoStory_Walk(UnitTestDatabase& database){ + for (const auto& item : AUTO_STORY_WALK_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Walk_Tests.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Walk_Tests.h new file mode 100644 index 0000000000..6532456aa1 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Walk_Tests.h @@ -0,0 +1,27 @@ +/* BDSP AutoStory Walk Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Walk_Tests_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Walk_Tests_H + +#include "Common/Cpp/TestRunners/UnitTest.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + + +// The walker's screen reads: indoors() on synthetic screens and +// screenshots, the door's fade, and the location banner. +void add_tests_AutoStory_Walk(UnitTestDatabase& database); + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Indoors.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Indoors.cpp new file mode 100644 index 0000000000..2ae32da5c3 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Indoors.cpp @@ -0,0 +1,68 @@ +/* BDSP AutoStory - Indoors + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Common/Cpp/Color.h" +#include "PokemonBDSP_AutoStory_Indoors.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ + +// Near-black pixels in one edge strip of the screen, out of all its pixels. +struct EdgeDark{ + size_t dark = 0; + size_t count = 0; +}; +void add_pixel(EdgeDark& edge, bool dark){ + edge.dark += dark; + edge.count++; +} +bool mostly_dark(const EdgeDark& edge){ + return edge.count > 0 && edge.dark * 100 >= edge.count * 15; +} + +} // namespace + +bool indoors(const ImageViewRGB32& screen){ + const size_t width = screen.width(), height = screen.height(); + const size_t strip_h = height * 12 / 100, strip_w = width * 12 / 100; + size_t warm = 0; + size_t count = 0; + EdgeDark top, bottom, left, right; + for (size_t y = 0; y < height; y += 8){ + for (size_t x = 0; x < width; x += 8){ + const Color pixel(screen.pixel(x, y)); + count++; + warm += pixel.red() > 150 && pixel.red() > pixel.blue() + 70 && pixel.green() > pixel.blue() + 20; + const bool dark = pixel.red() + pixel.green() + pixel.blue() < 60; + if (y < strip_h){ + add_pixel(top, dark); + } + if (y >= height - strip_h){ + add_pixel(bottom, dark); + } + if (x < strip_w){ + add_pixel(left, dark); + } + if (x >= width - strip_w){ + add_pixel(right, dark); + } + } + } + if (count == 0 || warm * 4 <= count){ + return false; + } + return mostly_dark(top) || mostly_dark(bottom) || mostly_dark(left) || mostly_dark(right); +} + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Indoors.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Indoors.h new file mode 100644 index 0000000000..b86d80d934 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Indoors.h @@ -0,0 +1,33 @@ +/* BDSP AutoStory - Indoors + * + * From: https://github.com/PokemonAutomation/ + * + * Whether the screen shows a room: the walker leaves a building by it. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Indoors_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Indoors_H + +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Whether the screen shows a building's inside: its warm walls and floor +// fill much of the view (Pokemon Centers, the Trainers' School: 42..65% of +// the screen), and the room floats in black, which shows along at least one +// edge (15% or more of a strip 12% deep). Warmth alone is not enough: the +// evening tint makes outside 50..72% warm (r256, r257, Sandgem), and +// Oreburgh's ground 58..69% by day. Of 1412 lab screenshots, the rule calls +// every outside one outside, at all hours, and the rooms rooms. A fade is +// dark everywhere but not warm, so it is not inside. +bool indoors(const ImageViewRGB32& screen); + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_LegBattle.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_LegBattle.cpp new file mode 100644 index 0000000000..aaba129f77 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_LegBattle.cpp @@ -0,0 +1,128 @@ +/* BDSP AutoStory - Leg Battle + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "PokemonBDSP_AutoStory_LegBattle.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using Pokemon::Direction; +using Pokemon::InterruptKind; +using Pokemon::ScreenEvent; +using Pokemon::TilePos; + + +const LegBattleTiming& leg_battle_timing(){ + static const LegBattleTiming TIMING{ + .lags = { + {ScreenEvent::FADE_WHITE, Milliseconds(500)}, + {ScreenEvent::BATTLE_STARTED, Milliseconds(4500)}, + }, + }; + return TIMING; +} + +namespace{ + +// The tile of the leg (from 1) that a step begun at `step` reached: the +// taps begin `tap_time` apart from `started`. At most `tiles`. +size_t tile_reached(WallClock step, WallClock started, Milliseconds tap_time, size_t tiles){ + if (step < started || tap_time.count() <= 0){ + return 0; + } + const auto since = std::chrono::duration_cast(step - started); + return std::min(tiles, (size_t)(since / tap_time) + 1); +} + +// Why a battle on the leg was not placed. +std::string not_placed(const WalkResult& walk, const LegBattlePlace& place){ + if (walk.wild_battles != 1){ + return std::format("{} wild battles", walk.wild_battles); + } + switch (place.verdict.kind){ + case InterruptKind::UNSETTLED: + return "a fade too recent to tell a battle from a warp"; + case InterruptKind::WILD_ENCOUNTER: + return "no timed event"; + case InterruptKind::NONE: + return "no events"; + default: + return std::string("not a wild battle"); + } +} + +} // namespace + +LegBattlePlace place_leg_battle(const WalkResult& walk, size_t tiles, const LegBattleTiming& timing){ + LegBattlePlace out; + const WallClock end = walk.events.last_frame().value_or(walk.started); + out.verdict = Pokemon::classify_interrupt(walk.events, {walk.started, end}, timing.interrupts); + if (walk.wild_battles != 1 || out.verdict.kind != InterruptKind::WILD_ENCOUNTER){ + return out; + } + const std::optional step = Pokemon::interrupt_step_time(out.verdict, timing.lags); + if (!step){ + return out; + } + const size_t reached = tile_reached(*step, walk.started, timing.tap_time, tiles); + const size_t at = std::min(tiles, reached + walk.moved_after_battle); + out.moved = TileSpan{at > timing.slack ? at - timing.slack : 0, std::min(tiles, at + timing.slack)}; + return out; +} + +std::string leg_battle_line(const std::string& label, const WalkResult& walk, const LegBattlePlace& place){ + std::string out = std::format("{}: battle on the leg: {}", label, Pokemon::describe(place.verdict, walk.started)); + if (place.moved){ + return out + std::format("; {} to {} tiles in.", place.moved->min, place.moved->max); + } + return out + "; not placed (" + not_placed(walk, place) + "): any tile of the leg."; +} + +std::vector tiles_moved( + const StoryWorld& world, const std::vector& from, + Direction direction, TileSpan span +){ + std::vector out; + for (TilePos at : from){ + for (size_t count = 0; count <= span.max; count++){ + const auto next = count < span.max ? step(world, at, direction) : std::nullopt; + const bool kept = std::find(out.begin(), out.end(), at) != out.end(); + if ((count >= span.min || !next) && !kept){ + out.push_back(at); + } + if (!next){ + break; + } + at = *next; + } + } + return out; +} + +void record_tap_battle(WalkResult& walk, size_t tap, const LegBattleTiming& timing){ + const int64_t taps_before = tap == 0 ? -1 : (int64_t)tap - 1; + const WallClock step = walk.started + timing.tap_time * taps_before; + WallClock last = walk.events.last_frame().value_or(walk.started); + std::vector lags = timing.lags; + std::sort(lags.begin(), lags.end(), [](const Pokemon::EventLag& x, const Pokemon::EventLag& y){ + return x.lag < y.lag; + }); + for (const Pokemon::EventLag& lag : lags){ + last = std::max(last, step + lag.lag); + walk.events.record({lag.event}, last); + } + walk.events.record({}, last + timing.escape_time); +} + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_LegBattle.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_LegBattle.h new file mode 100644 index 0000000000..457b6239dd --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_LegBattle.h @@ -0,0 +1,96 @@ +/* BDSP AutoStory - Leg Battle + * + * From: https://github.com/PokemonAutomation/ + * + * Where a wild battle on an open leg's taps left the player. The walk + * records what its watchers saw, timed (WalkResult::events); the shared + * classifier (Pokemon::classify_interrupt()) says what stopped the leg, + * and a wild battle is placed by when its first timed event showed: the + * tap that set it off, and the taps that moved after it was run from. + * Without that the walker took the player to be anywhere on the leg. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_LegBattle_H +#define PokemonAutomation_PokemonBDSP_AutoStory_LegBattle_H + +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "Pokemon/AutoStory/Pokemon_Interrupts.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.h" +#include "PokemonBDSP_AutoStory_Walk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// BDSP's timing for a battle on an open leg's taps, as data. +struct LegBattleTiming{ + Pokemon::InterruptKnobs interrupts = {}; + // How long after its tap went in each of a wild battle's events shows. + // NOT MEASURED on BDSP's console yet: estimates from the walk's own + // waits (a tap is held 160 ms, a step takes about 270 ms; the battle's + // box shows within WHITE_FLASH_BATTLE_WAIT). Measure them from a lab-B + // walk log before trusting a placement to the tile. + std::vector lags = {}; + // A tap a tile: walk_tiles_dpad()'s chunk. + Milliseconds tap_time = Milliseconds(800); + // Tiles either side of the placed one the player may be on, for the + // unmeasured lags. + size_t slack = 1; + // How long running from a battle takes, for the sim's events. + Milliseconds escape_time = Milliseconds(6000); +}; +// BDSP's timing. +const LegBattleTiming& leg_battle_timing(); + +// Tiles moved along a leg, from `min` to `max`. +struct TileSpan{ + size_t min = 0; + size_t max = 0; +}; + +// What stopped an open leg's taps, and how far along the leg it left the +// player. +struct LegBattlePlace{ + Pokemon::InterruptVerdict verdict = {}; + // Set for one wild battle whose step the events time. + std::optional moved = {}; +}; + +// For `walk`, the taps of an open leg of `tiles` tiles that a wild battle +// interrupted: what its events show stopped the player, and for one wild +// battle, the tiles moved: the tile its tap reached plus the taps that +// moved after it, give or take `timing.slack`. Not placed for more than +// one battle, for an UNSETTLED verdict (a fade too recent to tell a +// battle from a warp) or any other kind, or for a walk with no events. +LegBattlePlace place_leg_battle(const WalkResult& walk, size_t tiles, const LegBattleTiming& timing = leg_battle_timing()); + +// "