diff --git a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp index c4f3752ab1..621d4421ef 100644 --- a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp @@ -6,12 +6,14 @@ #include "Common/Cpp/TestRunners/UnitTestDatabase.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_Actions_Tests.h" +#include "Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.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_StorySim_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_StorySimulation_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_StoryTable_Tests.h" #include "Programs/AutoStory/PokemonBDSP_AutoStory_Walk_Tests.h" @@ -34,6 +36,8 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStory_StoryTable(database); add_tests_AutoStory_Walk(database); add_tests_AutoStory_PlannedRoute(database); + add_tests_AutoStory_GridSim(database); + add_tests_AutoStory_StorySim(database); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_CoordEvents.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_CoordEvents.cpp new file mode 100644 index 0000000000..c3fe3fea36 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_CoordEvents.cpp @@ -0,0 +1,109 @@ +/* BDSP AutoStory - Coord events + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Json/JsonArray.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "Common/Cpp/Json/JsonValue.h" +#include "PokemonBDSP_AutoStory_Maps.h" +#include "PokemonBDSP_AutoStory_CoordEvents.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using Pokemon::TilePos; + + +namespace{ + +int read_int(const JsonObject& object, const std::string& key, const std::string& where){ + const JsonValue* value = object.get_value(key); + if (value == nullptr || !value->is_integer()){ + throw ParseException(where + key + " is missing or not an integer."); + } + return (int)value->to_integer_default(); +} + +std::string read_string(const JsonObject& object, const std::string& key, const std::string& where){ + const JsonValue* value = object.get_value(key); + const std::string* text = value == nullptr ? nullptr : value->to_string(); + if (text == nullptr){ + throw ParseException(where + key + " is missing or not a string."); + } + return *text; +} + +CoordEvent read_event(const JsonObject& object, const std::string& where){ + CoordEvent event{ + .name = read_string(object, "name", where), + .tiles = {}, + .var = read_string(object, "var", where), + .value = read_int(object, "value", where), + .source = read_string(object, "source", where), + }; + const int x = read_int(object, "x", where); + const int y = read_int(object, "y", where); + const int width = read_int(object, "width", where); + const int length = read_int(object, "length", where); + if (width < 1 || length < 1){ + throw ParseException(where + "width and length must be at least 1."); + } + for (int row = y; row < y + length; row++){ + for (int col = x; col < x + width; col++){ + event.tiles.push_back({col, row}); + } + } + return event; +} + +std::vector read_events(const std::string& name, const std::string& json){ + std::vector events; + const JsonValue root = parse_json(json); + const JsonObject* object = root.to_object(); + const JsonValue* list = object == nullptr ? nullptr : object->get_value("coord_events"); + if (list == nullptr){ + return events; + } + const JsonArray* array = list->to_array(); + if (array == nullptr){ + throw ParseException(name + ".json: coord_events is not an array."); + } + for (size_t c = 0; c < array->size(); c++){ + const std::string where = std::format("{}.json: coord_events[{}].", name, c); + const JsonObject* item = (*array)[c].to_object(); + if (item == nullptr){ + throw ParseException(where + " is not an object."); + } + events.push_back(read_event(*item, where)); + } + return events; +} + +} // namespace + + +const std::vector& story_coord_events(std::string_view map){ + static const std::map, std::less<>> by_map = []{ + std::map, std::less<>> out; + for (const auto& [name, json] : bdsp_map_json()){ + out.emplace(name, read_events(name, json)); + } + return out; + }(); + if (auto iter = by_map.find(map); iter != by_map.end()){ + return iter->second; + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::format("No BDSP story map named \"{}\".", map)); +} + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_CoordEvents.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_CoordEvents.h new file mode 100644 index 0000000000..51f2d8514a --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_CoordEvents.h @@ -0,0 +1,51 @@ +/* BDSP AutoStory - Coord events + * + * From: https://github.com/PokemonAutomation/ + * + * The scene triggers of the story maps: stepping onto one of an event's + * tiles while its story variable holds `value` starts its script. They are + * the decomp's coord events (zone_event), read from a map's + * "coord_events", which the shared map schema leaves to the game: + * + * "coord_events": [{"name": "catch_tutorial", "x": 21, "y": 25, "width": 1, "length": 5, + * "var": "VAR_ROUTE_202_STATE", "value": 0, "source": "..."}, ...] + * + * A trigger seen on the console that the decomp does not have names the run + * in `source`. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_CoordEvents_H +#define PokemonAutomation_PokemonBDSP_AutoStory_CoordEvents_H + +#include +#include +#include +#include "Pokemon/Navigation/Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Coordinate-triggered event parsed from BDSP map data. +struct CoordEvent{ + std::string name; + // The map's own tiles: `width` columns from x, `length` rows from y. + std::vector tiles; + std::string var; + int value = 0; + std::string source; +}; + +// The coord events of the map `map` (a JSON map's name), none if it lists +// none. Throws InternalProgramError for an unknown map, and ParseException +// for an event that does not parse. +const std::vector& story_coord_events(std::string_view map); + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.cpp new file mode 100644 index 0000000000..785c6a9638 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.cpp @@ -0,0 +1,446 @@ +/* BDSP AutoStory Grid Simulation Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The grid simulation's moves against the console's measurements, then the + * route walker (walk_route()) on it: every route of the story table from + * where the table has the player, and the routes to doors that a turn on + * the stick missed on the console (r49, r57). + * + */ + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Pokemon/AutoStory/Pokemon_TilePose.h" +#include "Data/PokemonBDSP_AutoStory_Maps.h" +#include "Data/PokemonBDSP_AutoStory_PlanCosts.h" +#include "Utils/PokemonBDSP_AutoStory_GridSim.h" +#include "Utils/PokemonBDSP_AutoStory_PlannedRoute.h" +#include "PokemonBDSP_AutoStory_StoryExecutor.h" +#include "PokemonBDSP_AutoStory_StoryPlaces.h" +#include "PokemonBDSP_AutoStory_StoryTable.h" +#include "PokemonBDSP_AutoStory_GridSim_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ + +using namespace std::chrono_literals; +using Pokemon::Direction; +using Pokemon::Pose; +using Pokemon::StoryStep; +using Pokemon::StoryStepKind; +using Pokemon::TilePos; + + +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; +} + +SimPos at_tile(int x, int y, int part_x = 0, int part_y = 0){ + return {x * SIM_TILE + part_x, y * SIM_TILE + part_y}; +} + + +// One input to the simulation. +enum class SimInputKind{ + DPAD, + STICK, + HOLD, +}; +struct SimInput{ + SimInputKind kind; + Direction direction; + Milliseconds time; +}; + +// Inputs from `start`, and where the player must end and how the last +// input ended. +struct MoveCase{ + std::string name; + TilePos start; + std::vector inputs; + SimPos end; + SimStop stop; +}; + +SimMove apply(GridSim& sim, const SimInput& input){ + switch (input.kind){ + case SimInputKind::DPAD: + return sim.tap(input.direction, input.time, false); + case SimInputKind::STICK: + return sim.tap(input.direction, input.time, true); + case SimInputKind::HOLD: + return sim.hold(input.direction, input.time, SimPace::WALK); + } + return {}; +} + +// A room with a door in row 3 at x 3, a ledge south at x 7, someone +// standing at (5, 1), and a trainer east of row 2 who looks west 3 tiles. +UnitTestResult test_moves(Logger&){ + StoryWorld world = make_world({ + "#########", + "#.......#", + "#.......#", + "###D###v#", + "#.......#", + "#########", + }); + world.block({5, 1}); + const SimWorld setup{.world = &world, .trainers = {{.name = "watcher", .pos = {8, 2}, .facing = Direction::WEST, .range = 3}}}; + constexpr Direction EAST = Direction::EAST; + constexpr Direction SOUTH = Direction::SOUTH; + constexpr Direction WEST = Direction::WEST; + const std::vector cases{ + {.name = "a 50 ms D-pad tap only turns", .start = {2, 1}, .inputs = {{SimInputKind::DPAD, EAST, 50ms}}, .end = at_tile(2, 1), .stop = SimStop::DONE}, + {.name = "a 160 ms D-pad tap steps a tile", .start = {2, 1}, .inputs = {{SimInputKind::DPAD, EAST, 160ms}}, .end = at_tile(3, 1), .stop = SimStop::DONE}, + {.name = "a 100 ms stick tap steps 0.6 tile across", .start = {2, 1}, .inputs = {{SimInputKind::STICK, EAST, 100ms}}, .end = at_tile(2, 1, 625), .stop = SimStop::DONE}, + {.name = "a 100 ms stick tap steps 0.4 tile down", .start = {2, 1}, .inputs = {{SimInputKind::STICK, SOUTH, 100ms}}, .end = at_tile(2, 1, 0, 416), .stop = SimStop::DONE}, + {.name = "the D-pad keeps a stick tap's offset", .start = {2, 1}, + .inputs = {{SimInputKind::STICK, EAST, 100ms}, {SimInputKind::DPAD, EAST, 160ms}}, .end = at_tile(3, 1, 625), .stop = SimStop::DONE}, + {.name = "a held walk stops at someone standing", .start = {2, 1}, .inputs = {{SimInputKind::HOLD, EAST, 5000ms}}, .end = at_tile(4, 1), .stop = SimStop::BLOCKED}, + {.name = "a door warps from its column", .start = {3, 2}, .inputs = {{SimInputKind::HOLD, SOUTH, 3000ms}}, .end = at_tile(3, 3), .stop = SimStop::WARP}, + {.name = "part of a tile off the door's column, the frame stops us", .start = {3, 2}, + .inputs = {{SimInputKind::STICK, WEST, 100ms}, {SimInputKind::HOLD, SOUTH, 3000ms}}, .end = at_tile(2, 2, 375), .stop = SimStop::BLOCKED}, + {.name = "a trainer's sight stops a walk", .start = {1, 2}, .inputs = {{SimInputKind::HOLD, EAST, 5000ms}}, .end = at_tile(5, 2), .stop = SimStop::DIALOG}, + {.name = "a ledge hops two tiles", .start = {7, 2}, .inputs = {{SimInputKind::DPAD, SOUTH, 160ms}}, .end = at_tile(7, 4), .stop = SimStop::DONE}, + }; + for (const MoveCase& item : cases){ + GridSim sim(setup, item.start, Direction::NORTH, false); + SimMove last; + for (const SimInput& input : item.inputs){ + last = apply(sim, input); + } + if (sim.player().pos != item.end || last.stop != item.stop){ + return std::format("{}: expected {} ({}), got {} ({}).", item.name, to_string(item.end), sim_stop_name(item.stop), + to_string(sim.player().pos), sim_stop_name(last.stop)); + } + } + return true; +} + + +// A turn on the stick, as before r49: each turn tap of the D-pad becomes a +// 100 ms stick tap, which moves the player part of a tile. +class StickTurnIO : public RouteIO{ +public: + explicit StickTurnIO(RouteIO& io) + : m_io(io) + {} + virtual WalkResult walk_to_wall(const RouteWallWalk& walk) override{ + return m_io.walk_to_wall(walk); + } + virtual WalkResult walk_taps(Direction direction, size_t tiles, const std::string& label) override{ + return m_io.walk_taps(direction, tiles, label); + } + virtual void press(const RouteInput& input) override{ + if (input.kind != RouteInputKind::DPAD || input.hold >= 100ms){ + m_io.press(input); + return; + } + RouteInput stick = input; + stick.kind = RouteInputKind::STICK; + stick.hold = 100ms; + m_io.press(stick); + } + virtual bool dialog_within(Milliseconds wait, Color color) override{ + return m_io.dialog_within(wait, color); + } + virtual bool holding_against_wall(Direction direction, const std::string& label) override{ + return m_io.holding_against_wall(direction, label); + } + virtual void look(const std::function& at) override{ + m_io.look(at); + } + virtual void note(const std::string& text, RouteNote kind) override{ + m_io.note(text, kind); + } + +private: + RouteIO& m_io; +}; + + +// A route walked on the simulation: where the walker believes it ended, +// where the player is, and why it failed. +struct SimWalk{ + std::vector believed; + SimPos actual; + std::string failure; + std::vector notes; +}; + +// One route to walk: on `world`, from `from` (the walker's belief) with the +// player on `start`, to any of `targets`. +struct SimRoute{ + std::string label; + const StoryWorld* world; + std::vector from; + TilePos start; + std::vector targets; + RobustPlanCosts costs; + bool can_run; +}; + +SimWalk walk_on_sim(const SimRoute& route, bool stick_turns){ + SimWalk out; + GridSim sim({.world = route.world, .trainers = story_trainers(*route.world)}, route.start, Direction::NORTH, route.can_run); + GridSimRouteIO sim_io(sim, out.notes); + StickTurnIO stick_io(sim_io); + RouteIO& io = stick_turns ? static_cast(stick_io) : sim_io; + RouteTrainers trainers{story_trainers(*route.world), [&sim](const std::string&){ sim.end_dialogue(); }}; + const std::vector& targets = route.targets; + try{ + out.believed = walk_route(io, *route.world, route.from, trainers, { + .goal = [&targets](const TilePos& pos){ return std::find(targets.begin(), targets.end(), pos) != targets.end(); }, + .label = route.label, + .avoid = {}, + .costs = route.costs, + .speed = route.can_run ? MoveSpeed::RUN : MoveSpeed::WALK, + }); + }catch (const GridSimFailure& e){ + out.failure = e.message; + } + out.actual = sim.player().pos; + return out; +} + +// Why a walk did not end where it should: on the grid, on one of the +// targets, and on a tile the walker believes. Empty if it did. +std::string walk_error(const SimRoute& route, const SimWalk& walk){ + const auto has = [](const std::vector& tiles, const TilePos& pos){ + return std::find(tiles.begin(), tiles.end(), pos) != tiles.end(); + }; + const TilePos tile = nearest_tile(walk.actual); + if (!walk.failure.empty()){ + return std::format("{} from {}: failed at {}: {}", route.label, describe(*route.world, route.start), + to_string(walk.actual), walk.failure); + } + if (!on_grid(walk.actual) || !has(route.targets, tile) || !has(walk.believed, tile)){ + return std::format("{} from {}: ended at {} ({}), off the grid or off the target.", route.label, + describe(*route.world, route.start), to_string(walk.actual), describe(*route.world, tile)); + } + return ""; +} + + +std::vector world_tiles(const StoryWorld& world, const Pose& pose){ + std::vector tiles; + for (const TilePos& pos : Pokemon::tiles_of(pose)){ + tiles.push_back(story_to_world(world, pose.map, pos)); + } + return tiles; +} + +// Where the table has the player after `step`, from `at`: a route's +// target, a walk's last tile, a step's next tile; a scene or an event's +// end is the table's pose_after, or unknown. +std::optional pose_after_step(const std::optional& at, const StoryStep& step){ + std::optional next; + if (step.kind == StoryStepKind::ROUTE){ + next = step.target; + }else if (at && step.kind == StoryStepKind::STEP){ + std::vector tiles; + for (const TilePos& pos : Pokemon::tiles_of(*at)){ + tiles.push_back(pos.next(step.direction)); + } + next = Pokemon::tile_pose(at->map, tiles, step.direction); + }else if (at && step.kind == StoryStepKind::LEGS && step.variant.empty()){ + std::vector tiles = Pokemon::tiles_of(*at); + for (const Pokemon::StoryLeg& leg : step.legs){ + for (TilePos& pos : tiles){ + for (int i = 0; i < leg.tiles; i++){ + pos = pos.next(leg.direction); + } + } + } + next = Pokemon::tile_pose(at->map, tiles, step.legs.back().direction); + }else if (step.kind != StoryStepKind::GAME && step.kind != StoryStepKind::SHOP && + step.kind != StoryStepKind::MASH && step.kind != StoryStepKind::LEGS + ){ + next = at; + } + return step.pose_after ? step.pose_after : next; +} + +// Every ROUTE step of the table that starts from a known pose, from its +// first and its last tile, at the pace StoryExecutor walks it. +std::vector table_routes(){ + std::vector routes; + for (const StoryCheckpoint& row : story_checkpoints()){ + std::optional at = row.start; + bool shoes = row.id > std::string(RUNNING_SHOES_ROW); + for (const StoryStep& step : row.steps){ + const StoryWorld* world = at && step.kind == StoryStepKind::ROUTE ? story_plan_world(at->map, step.target->map) : nullptr; + const std::vector from = world ? world_tiles(*world, *at) : std::vector{}; + std::vector starts; + if (!from.empty()){ + starts.push_back(from.front()); + } + if (from.size() > 1){ + starts.push_back(from.back()); + } + for (const TilePos& start : starts){ + routes.push_back({.label = row.id + " " + step.name, .world = world, .from = from, .start = start, + .targets = world_tiles(*world, *step.target), .costs = *story_route_costs(step.variant), .can_run = shoes}); + } + at = pose_after_step(at, step); + shoes = shoes || (row.id == RUNNING_SHOES_ROW && step.name == RUNNING_SHOES_STEP); + } + } + return routes; +} + +UnitTestResult test_table_routes(Logger& logger){ + std::string errors; + size_t walked = 0; + for (const SimRoute& route : table_routes()){ + const SimWalk walk = walk_on_sim(route, false); + walked++; + const std::string error = walk_error(route, walk); + logger.log(std::format("{} from {}: {}", route.label, describe(*route.world, route.start), error.empty() ? "on target" : error)); + if (!error.empty()){ + errors += "\n " + error; + } + } + if (walked < 10){ + return std::format("Expected the table's routes; found {}.", walked); + } + return errors.empty() ? UnitTestResult(true) : UnitTestResult("Routes that did not end on their target:" + errors); +} + + +// A route to the tile below a door, then a walk north into it. +struct DoorRoute{ + std::string name; + std::string map; + TilePos from; + std::string door; +}; + +// Whether the route ends below the door and a walk north goes in. +std::string door_route_error(const DoorRoute& item, bool stick_turns){ + const StoryWorld& world = *story_plan_world(item.map, item.map); + const TilePos from = story_to_world(world, item.map, item.from); + const TilePos below = story_to_world(world, item.map, map_point(item.map, item.door).next(Direction::SOUTH)); + const SimRoute route{.label = item.name, .world = &world, .from = {from}, .start = from, .targets = {below}, + .costs = town_plan_costs(), .can_run = true}; + const SimWalk walk = walk_on_sim(route, stick_turns); + const std::string error = walk_error(route, walk); + if (!error.empty()){ + return error; + } + GridSim sim({.world = &world}, below, Direction::NORTH, true); + if (sim.hold(Direction::NORTH, 3000ms, SimPace::RUN).stop != SimStop::WARP){ + return item.name + ": the walk north did not go through the door."; + } + return ""; +} + +// r49 put us a tile west of Sandgem's Center door after a stick "turn", +// and r57 walked the Jubilife School's row a column short: a turn tap on +// the stick moves part of a tile, and every D-pad tap after keeps it. With +// D-pad turns both routes end below their doors; with stick turns they +// must miss, or the simulation would not have caught it. +UnitTestResult test_door_routes(Logger& logger){ + const std::vector routes{ + {.name = "Sandgem: below the Center's door", .map = "SandgemTown", .from = map_point("SandgemTown", "front_row_east_end"), + .door = "pokemon_center_door"}, + {.name = "Jubilife: below the School's door", .map = "JubilifeCity", .from = map_point("JubilifeCity", "pokemon_center_door").next(Direction::SOUTH), + .door = "trainers_school_door"}, + }; + for (const DoorRoute& item : routes){ + const std::string dpad = door_route_error(item, false); + if (!dpad.empty()){ + return "D-pad turns: " + dpad; + } + const std::string stick = door_route_error(item, true); + logger.log(item.name + ", stick turns: " + stick); + if (stick.empty()){ + return item.name + ": with turns on the stick, expected the route to miss the door; it went in."; + } + } + return true; +} + + +struct AutoStoryGridSimInput{ + std::string name; + std::function body; +}; + +struct AutoStoryGridSimExpectation{ +}; + +struct AutoStoryGridSimCase{ + AutoStoryGridSimInput input; + AutoStoryGridSimExpectation expectation; +}; + +class Test_GridSim : public UnitTest{ +public: + explicit Test_GridSim(AutoStoryGridSimCase test_case) + : UnitTest("PokemonBDSP::AutoStory - Grid sim: " + 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: + AutoStoryGridSimCase m_case; +}; + +} // namespace + + +const AutoStoryGridSimCase AUTO_STORY_GRID_SIM_CASES[] = { + { + .input = {"taps, holds, stick offsets, doors, people and trainers as measured", test_moves}, + .expectation = {}, + }, + { + .input = {"the table's routes end on their targets", test_table_routes}, + .expectation = {}, + }, + { + .input = {"a stick-tap turn misses the Sandgem Center and Jubilife School doors", test_door_routes}, + .expectation = {}, + }, +}; + +void add_tests_AutoStory_GridSim(UnitTestDatabase& database){ + for (const auto& item : AUTO_STORY_GRID_SIM_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.h new file mode 100644 index 0000000000..d80dd04f8b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.h @@ -0,0 +1,28 @@ +/* BDSP AutoStory Grid Simulation Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_GridSim_Tests_H +#define PokemonAutomation_PokemonBDSP_AutoStory_GridSim_Tests_H + +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + + +// The grid simulation (Utils/PokemonBDSP_AutoStory_GridSim.h): the moves +// measured on the console, the story table's routes walked by the route +// walker on it, and walks that missed doors on the console missing them here. +void add_tests_AutoStory_GridSim(UnitTestDatabase& database); + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.cpp index b57062b2e7..5b8f8ef3dd 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.cpp @@ -95,38 +95,24 @@ void save_failure_screenshot(VideoStream& stream, size_t attempt){ stream.log("Failure screenshot: " + path); } -// A trainer who saw us on a route: Route 204's Twins battle as a pair -// (01.10), everyone else alone. -void fight_story_trainer(VideoStream& stream, ProControllerContext& context, const std::string& trainer){ - if (trainer.find("Twins") != std::string::npos){ - talk_or_fight(stream, context, trainer, TalkExpect::BATTLE, BattleFormat::DOUBLES); - return; - } - fight_route_trainer(stream, context, trainer); } -} // namespace - StoryExecutor::StoryExecutor(StoryEnv& env) : m_env(env) + , m_io(env.env.console, env.context) + , m_walks(m_io) {} void StoryExecutor::begin_attempt(const Pokemon::StoryCheckpoint& data){ m_checkpoint = data.id; - // A row starts from its save: after the shoes' row, the save has them. - m_can_run = data.id > std::string(RUNNING_SHOES_ROW); - m_trainers.clear(); + m_walks.begin_attempt(data.id); } Outcome StoryExecutor::run(RunState& state, const StoryStep& step){ stream().log(std::format("[{}] {} {}: {}", m_checkpoint, Pokemon::story_step_kind_name(step.kind), step.name, step.why), COLOR_BLUE); try{ - Outcome outcome = run_guarded(state, step); - if (outcome.succeeded() && m_checkpoint == RUNNING_SHOES_ROW && step.name == RUNNING_SHOES_STEP){ - m_can_run = true; - } - return outcome; + return run_guarded(state, step); }catch (OperationFailedException& e){ if (m_stop_rows.contains(m_checkpoint)){ throw; @@ -148,46 +134,6 @@ Outcome StoryExecutor::run_guarded(RunState& state, const StoryStep& step){ return Pokemon::StoryStepExecutor::run(state, step); } -Pose StoryExecutor::walk_to(const Pose& from, const Pose& goal, const StoryStep& step){ - const StoryWorld* maps = story_plan_world(from.map, goal.map); - if (maps == nullptr){ - fail_step(stream(), std::format("{}: no maps join {} and {}.", step.name, from.map, goal.map)); - } - const std::optional costs = story_route_costs(step.variant); - if (!costs){ - throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown route costs: " + step.variant); - } - std::vector start; - for (const TilePos& pos : Pokemon::tiles_of(from)){ - start.push_back(story_to_world(*maps, from.map, pos)); - } - std::vector targets; - for (const TilePos& pos : Pokemon::tiles_of(goal)){ - targets.push_back(story_to_world(*maps, goal.map, pos)); - } - // The ones fought stay off m_trainers until the next attempt. - std::vector& sights = m_trainers.try_emplace(maps, story_trainers(*maps)).first->second; - RouteTrainers trainers{ - sights, - [this](const std::string& trainer){ fight_story_trainer(stream(), context(), trainer); } - }; - const RouteWalkPlan plan{ - [&](const TilePos& pos){ return std::find(targets.begin(), targets.end(), pos) != targets.end(); }, - label_of(step), {}, *costs, m_can_run ? MoveSpeed::RUN : MoveSpeed::WALK - }; - const std::vector at = walk_route(stream(), context(), run_from_wild_battle, *maps, start, trainers, plan); - sights = std::move(trainers.sights); - std::vector local; - for (const TilePos& pos : at){ - const std::optional tile = story_to_local(*maps, goal.map, pos); - if (!tile){ - fail_step(stream(), std::format("{}: the walk ended off {}.", step.name, goal.map)); - } - local.push_back(*tile); - } - return Pokemon::tile_pose(goal.map, local, Pokemon::facing_of(goal)); -} - Outcome StoryExecutor::new_game(RunState&, const StoryStep&){ play_new_game_intro(m_env.env, context(), m_env.player_name); return Pokemon::outcome_ok(); @@ -200,287 +146,19 @@ Outcome StoryExecutor::options(RunState&, const StoryStep&){ } Outcome StoryExecutor::route(RunState& state, const StoryStep& step){ - if (Pokemon::tiles_of(state.pose).empty()){ - return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); - } - return Pokemon::outcome_ok(walk_to(state.pose, *step.target, step)); -} - -namespace{ - -// A LEGS step "to_dialogue": taps its one leg until a dialogue starts on the -// way, wherever it starts (Dawn's at Sandgem's edge, r35/r36: an exact tile -// and banner raced her scene). The scene's lines are the next step's. -Outcome leg_to_dialogue(VideoStream& stream, ProControllerContext& context, const StoryStep& step, const std::string& checkpoint){ - const Pokemon::StoryLeg& leg = step.legs.front(); - route_turn(stream, context, leg.direction); - const auto [x, y] = dpad_of(leg.direction); - const WalkResult walk = walk_tiles_dpad(stream, context, run_from_wild_battle, {x, y}, (size_t)leg.tiles, step.name); - stream.log(std::format("[{}] {}: taps {} to a dialogue: {} tiles, {}.", checkpoint, step.name, direction_name(leg.direction), walk.moved_chunks, walk_stop_name(walk.stop))); - if (walk.stop == WalkStop::DIALOG){ - return Pokemon::outcome_ok(); - } - // A scene can lock us before its first line: Dawn walks over first (r37). - ShortDialogWatcher dialog(COLOR_CYAN); - if (walk.stop == WalkStop::BLOCKED && wait_until(stream, context, 10000ms, {{dialog}}) == 0){ - return Pokemon::outcome_ok(); - } - return Pokemon::outcome_failed("no_dialogue", step.name); -} - -// A LEGS step "to_map": runs its one leg until the location banner of -// pose_after's map shows, wherever it shows. A straight run along an open -// row needs no tile count (r41, r42: Sandgem's row 11 took us into the lab). -Outcome leg_to_map(VideoStream& stream, ProControllerContext& context, const StoryStep& step, const std::string& checkpoint){ - const Pokemon::StoryLeg& leg = step.legs.front(); - const std::string map = step.pose_after ? step.pose_after->map : ""; - route_turn(stream, context, leg.direction); - const auto [x, y] = dpad_of(leg.direction); - const WalkResult walk = walk_until_dpad(stream, context, run_from_wild_battle, {x, y}, - Milliseconds(250) * leg.tiles + 2000ms, step.name, { - .arrived = [&](const ImageViewRGB32& screen){ return location_banner_matches(read_location_banner(screen), map); }, - .pace = DpadPace::RUN, - }); - stream.log(std::format("[{}] {}: ran {} to {}'s banner: {}.", checkpoint, step.name, direction_name(leg.direction), map, walk_stop_name(walk.stop))); - return walk.stop == WalkStop::ARRIVED ? Pokemon::outcome_ok() : Pokemon::outcome_failed("no_banner", map); + return m_walks.route(state, step); } -} // namespace - Outcome StoryExecutor::legs(RunState& state, const StoryStep& step){ - if (step.variant == "to_wall"){ - for (const Pokemon::StoryLeg& leg : step.legs){ - walk_until_stopped(stream(), context(), leg.direction, (size_t)leg.tiles, label_of(step)); - } - return Pokemon::outcome_ok(); - } - if (step.variant == "to_dialogue"){ - return leg_to_dialogue(stream(), context(), step, m_checkpoint); - } - if (step.variant == "to_map"){ - return leg_to_map(stream(), context(), step, m_checkpoint); - } - const bool one_short_ok = step.variant == "one_short_ok"; - if (!one_short_ok && !step.variant.empty()){ - return Pokemon::outcome_failed("unknown_variant", step.variant); - } - std::vector at = Pokemon::tiles_of(state.pose); - for (const Pokemon::StoryLeg& leg : step.legs){ - // Facing another way, a tap only turns (r256): turn first. - route_turn(stream(), context(), leg.direction); - const auto [x, y] = dpad_of(leg.direction); - const size_t tiles = (size_t)leg.tiles; - const WalkResult walk = walk_tiles_dpad(stream(), context(), run_from_wild_battle, {x, y}, tiles, step.name); - stream().log(std::format("[{}] {}: taps {} moved {} of {} tiles.", m_checkpoint, step.name, direction_name(leg.direction), walk.moved_chunks, tiles)); - if (walk.moved_chunks + (one_short_ok ? 1 : 0) < tiles){ - return Pokemon::outcome_failed("short_leg", std::format("{} {} moved {}", direction_name(leg.direction), tiles, walk.moved_chunks)); - } - // Where we may be: the end, or a tile short of it. - std::vector next; - for (const TilePos& start : at){ - TilePos end = start; - for (size_t i = 0; i < tiles; i++){ - if (one_short_ok && i + 1 == tiles){ - next.push_back(end); - } - end = end.next(leg.direction); - } - next.push_back(end); - } - at = std::move(next); - } - return Pokemon::outcome_ok(Pokemon::tile_pose(state.pose.map, at, step.legs.back().direction)); + return m_walks.legs(state, step); } -namespace{ - -// One rule for scenes and map changes: v2's long B mashes, with an end -// check. Press through the text, and keep pressing B while the screen is -// quiet: a scene can pause while someone walks off (Dawn and Barry after -// Rowan leaves, r23), fade dimly, not to black, to another map (r25), or -// wait on a box the detector misses (r26). B never starts a talk on the -// overworld. Done once the overworld has shown, with no text and a still -// screen, for 5 s of presses: after r31's fade home a scripted walk-in ran -// 5 s with no text before Mom spoke. -// -// A step or scene that ends on another map (`fade`) must fade on the way, -// and only the quiet after the fade counts: r30's scene went quiet for 5 s -// before the fade home, and r28 "went out" a tile beside the door and -// walked a whole route in the house. No fade is a map mismatch. -constexpr double FADE_DARKNESS = 40; // brightness 0 to 255; r24's dim fade home goes darker -void settle(VideoStream& stream, ProControllerContext& context, Button button, const std::string& label, bool fade){ - const WallClock none = WallClock::max(); - const WallClock deadline = current_time() + 120s; - ShortDialogDetector dialog; - OverworldDetector overworld; - WallClock quiet_since = none; - WallClock calm_since = none; // overworld, no text, still or not - double darkest = 255; - VideoSnapshot previous; - while (current_time() < deadline){ - context.wait_for_all_requests(); - const auto frame = stream.video().snapshot(); - const WallClock now = current_time(); - if (frame){ - darkest = std::min(darkest, image_average(*frame.frame).sum() / 3); - } - const bool talking = frame && dialog.detect(frame); - const bool still = frame && previous && walk_screen_change(*previous.frame, *frame.frame) < WALK_TAP_BLOCKED_CHANGE; - previous = frame; - // Cloud shadows drift over Floaroma, so its screen is never still: r83 - // got Mew and then waited out the 120 s on a calm overworld. 15 s of - // overworld with no text is three times r31's silent walk-in. - const bool calm = !talking && overworld.detect(frame) && (!fade || darkest < FADE_DARKNESS); - calm_since = calm ? (calm_since == none ? now : calm_since) : none; - if (calm && now - calm_since >= 15000ms){ - stream.log(std::format("[Story] {}: settled on 15 s of overworld without text (the screen kept moving); darkest frame {:.0f}.", label, darkest)); - return; - } - if (!talking && still && overworld.detect(frame) && (!fade || darkest < FADE_DARKNESS)){ - quiet_since = quiet_since == none ? now : quiet_since; - if (now - quiet_since >= 5000ms){ - stream.log(std::format("[Story] {}: settled; darkest frame {:.0f}.", label, darkest)); - return; - } - }else{ - quiet_since = none; - } - pbf_press_button(context, talking ? button : BUTTON_B, 80ms, 1200ms); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - fade && darkest >= FADE_DARKNESS - ? label + ": no fade within 120 s, so we are not where the step goes (map mismatch)." - : label + ": not back on a quiet overworld within 120 s.", - stream); -} - -} // namespace - -namespace{ - -// Holds the D-pad toward a door (with B after the shoes) until the screen goes -// dark or a line shows, up to 12 s. Returns whether it went dark. A line -// stops it: holding on under Mom's "Oh, Kosmos!" wasted 12 s (r45). The walker's black-screen watch -// missed the lab's fade into Sandgem (r44, "dialog"), and settle's samples -// 1.3 s apart can fall either side of it, so watch at settle's own darkness. -DpadPosition dpad_toward(Direction direction){ - return - direction == Direction::NORTH ? DPAD_UP : - direction == Direction::SOUTH ? DPAD_DOWN : - direction == Direction::WEST ? DPAD_LEFT : DPAD_RIGHT; -} - -bool run_into_door(VideoStream& stream, ProControllerContext& context, Direction direction, bool run){ - const DpadPosition dpad = dpad_toward(direction); - BlackScreenWatcher dark(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 3 * FADE_DARKNESS, 60); - ShortDialogWatcher dialog(COLOR_YELLOW); - const int ret = run_until(stream, context, [&](ProControllerContext& context){ - if (run){ - ssf_press_button(context, BUTTON_B, 0ms, 12000ms, 0ms); - } - pbf_press_dpad(context, dpad, 12000ms, 0ms); - }, {{dark}, {dialog}}); - return ret == 0; -} - -// What the screen showed after a tap toward a door or a cave mouth. -enum class TapLook{ - NOTHING, - DARK, - BANNER, -}; - -// Looks for `map`'s location banner (and, if `dark_too`, the fade) for up -// to `wait`. -TapLook look_after_tap(VideoStream& stream, ProControllerContext& context, const std::string& map, Milliseconds wait, bool dark_too){ - const WallClock deadline = current_time() + wait; - while (true){ - context.wait_for_all_requests(); - const VideoSnapshot screen = stream.video().snapshot(); - if (screen && location_banner_matches(read_location_banner(*screen.frame), map)){ - return TapLook::BANNER; - } - if (dark_too && screen && image_average(*screen.frame).sum() / 3 < FADE_DARKNESS){ - return TapLook::DARK; - } - if (current_time() >= deadline){ - return TapLook::NOTHING; - } - context.wait_for(100ms); - } -} - -// A STEP "to_banner": D-pad taps one at a time toward a door or cave mouth, -// each followed by a look, until the fade (then no more taps) and -// pose_after's map's location banner. A held walk can carry on past a -// fade that goes unseen: out of the Gym it walked on south (r219g), and -// through the Oreburgh Gate's west mouth on west into a pocket by Route -// 203's pond (r75). Taps during the fade are eaten. No banner fails the -// step right there. -Outcome tap_to_banner(VideoStream& stream, ProControllerContext& context, const StoryStep& step, const std::string& checkpoint){ - const std::string map = step.pose_after ? story_banner_name(step.pose_after->map) : ""; - for (size_t tap = 1; tap <= 30; tap++){ - pbf_press_dpad(context, dpad_toward(step.direction), 160ms, 0ms); - TapLook look = look_after_tap(stream, context, map, 1500ms, true); - const bool faded = look == TapLook::DARK; - if (faded){ - look = look_after_tap(stream, context, map, 8000ms, false); - } - if (look == TapLook::BANNER){ - stream.log(std::format("[{}] {}: {}'s banner after {} taps {}.", checkpoint, step.name, map, tap, direction_name(step.direction))); - context.wait_for(1000ms); - return Pokemon::outcome_ok(); - } - if (faded){ - break; - } - } - return Pokemon::outcome_failed("no_banner", map); -} - -} // namespace - Outcome StoryExecutor::step(RunState& state, const StoryStep& step){ - route_turn(stream(), context(), step.direction); - if (step.variant == "to_banner"){ - return tap_to_banner(stream(), context(), step, m_checkpoint); - } - // A step through a door: walk (or run) on until the screen goes dark, - // from next to the door or from anywhere in its line (r43: tapping 8 tiles - // down the lab took 7 s), then settle for any scene on arrival (Mom's, - // 00.01; Dawn's tour, 00.04). Without the fade, settle must see it itself - // (r33 was outside, "no fade"). - if (step.pose_after && step.pose_after->map != state.pose.map){ - const bool faded = run_into_door(stream(), context(), step.direction, m_can_run); - stream().log(std::format("[{}] {}: {} through the door: {}.", m_checkpoint, step.name, direction_name(step.direction), faded ? "faded" : "no fade yet")); - settle(stream(), context(), BUTTON_B, label_of(step), !faded); - return Pokemon::outcome_ok(); - } - const auto [x, y] = dpad_of(step.direction); - const WalkResult walk = walk_tiles_dpad(stream(), context(), run_from_wild_battle, {x, y}, 1, step.name); - stream().log(std::format("[{}] {}: a step {}, {}.", m_checkpoint, step.name, direction_name(step.direction), walk_stop_name(walk.stop))); - std::vector at; - for (const TilePos& pos : Pokemon::tiles_of(state.pose)){ - at.push_back(pos.next(step.direction)); - } - return Pokemon::outcome_ok(Pokemon::tile_pose(state.pose.map, at, step.direction)); + return m_walks.step(state, step); } Outcome StoryExecutor::talk(RunState& state, const StoryStep& step){ - // Face whom we talk to: a route ends facing its last leg, not its goal - // (r40 reached Mom facing north). They block the tile, so the tap only turns. - if (const std::optional facing = Pokemon::facing_of(state.pose)){ - route_turn(stream(), context(), *facing); - } - pbf_press_button(context(), BUTTON_A, 80ms, 200ms); - context().wait_for_all_requests(); - // Roark's first line in the mine comes after the camera zooms in on - // us, which can take some seconds (r98; the old event waited 8 s). - ShortDialogWatcher dialog(COLOR_YELLOW); - if (wait_until(stream(), context(), 8000ms, {{dialog}}) != 0){ - fail_step(stream(), label_of(step) + ": nobody answered."); - } - return Pokemon::outcome_ok(); + return m_walks.talk(state, step); } Outcome StoryExecutor::answer(RunState&, const StoryStep& step){ @@ -491,15 +169,7 @@ Outcome StoryExecutor::answer(RunState&, const StoryStep& step){ } Outcome StoryExecutor::mash(RunState& state, const StoryStep& step){ - // The step before may have played the scene already (a door step settles - // its arrival scene), so its first line is not required. - ShortDialogWatcher dialog(COLOR_CYAN); - if (wait_until(stream(), context(), 15000ms, {{dialog}}) != 0){ - stream().log(label_of(step) + ": no line within 15 s; the step before played it.", COLOR_ORANGE); - } - settle(stream(), context(), step.variant == "a" ? BUTTON_A : BUTTON_B, label_of(step), - step.pose_after && step.pose_after->map != state.pose.map); - return Pokemon::outcome_ok(); + return m_walks.mash(state, step); } Outcome StoryExecutor::battle(RunState&, const StoryStep& step){ @@ -542,7 +212,7 @@ Outcome StoryExecutor::heal(RunState&, const StoryStep& step){ // way can leave the route unsure of the column (r59, Jubilife). Below // Sandgem's door the lineup read 0.7 tile off and stepped us off it (r48). route_turn(stream(), context(), Direction::NORTH); - const bool in = run_into_door(stream(), context(), Direction::NORTH, m_can_run); + const bool in = m_io.run_into_door(Direction::NORTH, m_walks.can_run()); stream().log(std::format("[{}] {}: north into the Center: {}.", m_checkpoint, step.name, in ? "faded" : "no door; lining up by sight")); if (in){ pbf_wait(context(), 1500ms); @@ -564,9 +234,9 @@ Outcome StoryExecutor::heal(RunState&, const StoryStep& step){ // Out the same way: south until the fade. The walk out's indoor check // read "not a room" a step from the counter and stopped inside (r50, r59). route_turn(stream(), context(), Direction::SOUTH); - const bool out = run_into_door(stream(), context(), Direction::SOUTH, m_can_run); + const bool out = m_io.run_into_door(Direction::SOUTH, m_walks.can_run()); stream().log(std::format("[{}] {}: south out of the Center: {}.", m_checkpoint, step.name, out ? "faded" : "no fade yet")); - settle(stream(), context(), BUTTON_B, label_of(step), !out); + m_io.settle(BUTTON_B, label_of(step), !out); // r143a's heal said it had walked out while still inside. context().wait_for_all_requests(); VideoSnapshot screen = stream().video().snapshot(); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.h index e2f1f1a0a7..9ae892480c 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.h @@ -34,6 +34,8 @@ #include "Pokemon/AutoStory/Pokemon_StorySegments.h" #include "Pokemon/AutoStory/Pokemon_StorySteps.h" #include "PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RoutePlanner.h" +#include "PokemonBDSP_AutoStory_StoryStepIO.h" +#include "PokemonBDSP_AutoStory_StoryWalkSteps.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -111,11 +113,6 @@ class StoryExecutor : public Pokemon::StoryStepExecutor{ const std::string& checkpoint() const{ return m_checkpoint; } - // Walks from the tiles of `from` on its map to any of `goal`'s tiles, - // fighting the trainers who see us this attempt. Returns where it left - // us, as a pose on `goal`'s map. - Pokemon::Pose walk_to(const Pokemon::Pose& from, const Pokemon::Pose& goal, const Pokemon::StoryStep& step); - // The intro of a new game, from the title screen to the bedroom. virtual Pokemon::Outcome new_game(Pokemon::RunState& state, const Pokemon::StoryStep& step) override; // verify_tutorial_settings(): fast text, Autosave off. @@ -156,13 +153,10 @@ class StoryExecutor : public Pokemon::StoryStepExecutor{ Pokemon::Outcome run_guarded(Pokemon::RunState& state, const Pokemon::StoryStep& step); StoryEnv& m_env; + ConsoleStoryStepIO m_io; + // The steps that walk and talk (ROUTE, LEGS, STEP, TALK, MASH). + StoryWalkSteps m_walks; std::string m_checkpoint; - // Whether the player has the Running Shoes: the pace routes are timed at. - bool m_can_run = false; - // The trainers who can still see us on each story_plan_world() world, - // in its tiles, from its first walk this attempt; all of them again at - // each attempt. - std::map> m_trainers; std::map m_failures; std::map m_run_counts; std::set m_stop_rows; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySim_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySim_Tests.cpp new file mode 100644 index 0000000000..0d3d718354 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySim_Tests.cpp @@ -0,0 +1,920 @@ +/* BDSP AutoStory Story Sim Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Rows of the story table run as the program runs them (the segment + * runner, make_story_segment()), with the program's own walking steps + * (StoryWalkSteps: ROUTE, LEGS, STEP, TALK, MASH) on the grid simulation. + * After each walking step the simulated player must stand, on the grid, + * on a tile the table gives; a step through a door must have warped. + * + * Events, battles, shops and saves are stubs: a dialogue they open is + * closed, and the table's pose_after puts the player where they end. A + * heal checks that a walk north from where we stand enters a door. A scene + * starts when the player steps onto one of the maps' coord events while + * its story variable holds (story_vars()). Rooms with no + * map (the Trainers' School) are passed over. Each row must start where + * the row before left the player. + * + * A tap or hold through a door or cave mouth stops on its tile, as the + * console stops at the fade; a hold that does not see the fade goes on + * onto the step's pose_after and on along it, and a location banner reads + * only a few tiles into its map (SIM_BANNER_TILES). The exit tests run + * r75's and r81's old open-ended walks that way. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Pokemon/AutoStory/Pokemon_StorySegments.h" +#include "Pokemon/AutoStory/Pokemon_TilePose.h" +#include "Data/PokemonBDSP_AutoStory_CoordEvents.h" +#include "Data/PokemonBDSP_AutoStory_Maps.h" +#include "Utils/PokemonBDSP_AutoStory_GridSim.h" +#include "Utils/PokemonBDSP_AutoStory_RouteWalk.h" +#include "PokemonBDSP_AutoStory_Events.h" +#include "PokemonBDSP_AutoStory_StoryExecutor.h" +#include "PokemonBDSP_AutoStory_StoryPlaces.h" +#include "PokemonBDSP_AutoStory_StoryStepIO.h" +#include "PokemonBDSP_AutoStory_StoryTable.h" +#include "PokemonBDSP_AutoStory_StoryWalkSteps.h" +#include "PokemonBDSP_AutoStory_StorySim_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ + +using namespace std::chrono_literals; +using Pokemon::Direction; +using Pokemon::Outcome; +using Pokemon::Pose; +using Pokemon::RunState; +using Pokemon::StoryStep; +using Pokemon::StoryStepKind; +using Pokemon::TilePos; + + +// Which detections the simulation misses on purpose (#162): the `fade`th +// door fade (run_into_door(), from 0) or the `banner`th map banner +// (run_until_map()) of the run goes unseen, though the player did go +// through. The console missed both (r75, r81). +// The counts and flags below are the run's: how many fades and banners it +// looked for, whether a miss happened since the executor last looked, and +// whether a missed fade is still unseen. +struct SimMisses{ + std::optional fade; + std::optional banner; + size_t fades = 0; + size_t banners = 0; + bool missed = false; + bool fade_unseen = false; +}; + +// How many tiles a held walk goes on into a map before its location +// banner reads: "it shows a tile or a few past the border" (the table's +// route_201_by_the_path(), measured on the console). Standing still, it +// reads at once. Through a cave mouth the fade comes first, and a hold +// whose fade went unseen goes on the same way (r75, r81). +constexpr size_t SIM_BANNER_TILES = 3; + +// What a run on the simulation did. +struct SimRun{ + // The first failure, as "row outcome", or "". + std::string error; + // The rows and steps where an injected miss happened and where the run + // first failed, as "row step": "" if none. + std::string missed_step; + std::string failed_step; + // Walking steps that end where nothing says, as "row step". + std::vector unchecked; + // Each row run, with the fades and banners looked for before it. + struct RowStart{ + std::string row; + size_t fades; + size_t banners; + }; + std::vector rows; + // Where the player stood after the last step, if placed. + const StoryWorld* world = nullptr; + TilePos end; +}; + +// StoryStepIO on the simulation. A walk through a door or a cave mouth +// stops on its tile, as the console stops at the fade; a hold that misses +// the fade carries on, onto `far_side()` (the step's pose_after: the +// sim's maps have no warps' destinations) and on along it. +class SimStoryStepIO : public StoryStepIO{ +public: + SimStoryStepIO(GridSim& sim, std::vector& notes, SimMisses& misses, std::function far_side) + : m_sim(sim) + , m_route(sim, notes) + , m_misses(misses) + , m_far_side(std::move(far_side)) + {} + + virtual RouteIO& route() override{ + return m_route; + } + virtual void fight(const std::string&) override{ + m_sim.end_dialogue(); + } + virtual void walk_until_stopped(Direction direction, size_t tiles, const std::string& label) override; + virtual bool run_until_map(Direction direction, Milliseconds limit, const std::string& map, const std::string&) override; + virtual bool run_into_door(Direction direction, bool run) override{ + const bool warped = m_sim.hold(direction, 12000ms, run ? SimPace::RUN : SimPace::WALK).stop == SimStop::WARP; + if (warped && fade_missed()){ + m_misses.fade_unseen = true; + return false; + } + return warped; + } + // A scene may take us to another map (Mom walks us out, 00.04): with a + // dialogue open the stub scene plays it. Otherwise only a door fades. + // A fade missed on the way in stays missed: it was over before settle() + // looked, which then fails as it does on the console after 120 s. + virtual void settle(Button, const std::string& label, bool fade) override{ + if (fade && ((!m_sim.player().warped && !m_sim.player().dialog) || m_misses.fade_unseen)){ + m_route.note(label + ": no fade, so we are not where the step goes (map mismatch).", RouteNote::FAILURE); + } + m_misses.fade_unseen = false; + m_sim.end_dialogue(); + } + virtual std::optional tap_to_banner(Direction direction, const std::string& map, size_t max_taps) override; + +private: + // Whether the run misses this fade or this banner (SimMisses). + bool fade_missed(){ + const bool miss = m_misses.fade == m_misses.fades++; + m_misses.missed |= miss; + return miss; + } + bool banner_missed(){ + const bool miss = m_misses.banner == m_misses.banners++; + m_misses.missed |= miss; + return miss; + } + bool on_map(const std::string& map) const{ + return story_to_local(m_sim.world(), map, nearest_tile(m_sim.player().pos)).has_value(); + } + + GridSim& m_sim; + GridSimRouteIO m_route; + SimMisses& m_misses; + std::function m_far_side; +}; + +// A hold until something stops us. A door stops it at the fade, which +// fails the step; a fade missed lets the hold go on past the mouth (r81's +// timed walk south out of the Ravaged Path, onto Route 204's cliff). +void SimStoryStepIO::walk_until_stopped(Direction direction, size_t tiles, const std::string& label){ + m_sim.tap(direction, ROUTE_TURN_TAP, false); + Milliseconds limit = ROUTE_START_DELAY + 800ms + route_ms_per_tile(direction) * (int64_t)(2 * tiles); + SimMove move = m_sim.hold(direction, limit, SimPace::WALK); + if (move.stop == SimStop::WARP && fade_missed() && m_far_side()){ + limit -= std::min(limit, move.time); + move = m_sim.hold(direction, limit, SimPace::WALK); + } + if (move.stop == SimStop::DIALOG || move.stop == SimStop::WARP){ + m_route.note(label + ": " + sim_stop_name(move.stop) + " instead of a stop.", RouteNote::FAILURE); + } +} + +// The D-pad, a tile at a time, until `map`'s banner reads; through a door +// the walk goes on beyond it (the fade eats no tiles of a hold). +bool SimStoryStepIO::run_until_map(Direction direction, Milliseconds limit, const std::string& map, const std::string&){ + const bool miss = banner_missed(); + size_t on = 0; + for (Milliseconds ran = 0ms; ran < limit; ran += ROUTE_RUN_MS_PER_TILE_ACROSS){ + SimMove move = m_sim.tap(direction, ROUTE_MS_PER_TILE_ACROSS, false); + const bool crossed = move.stop == SimStop::WARP && m_far_side(); + const bool stopped = !crossed && (move.distance == 0 || move.stop != SimStop::DONE); + on = on_map(map) ? on + 1 : 0; + if (on > 0 && (on > SIM_BANNER_TILES || stopped) && !miss){ + return true; + } + if (stopped){ + return false; + } + } + return false; +} + +// D-pad taps, each looked at, until the fade and then the banner (the +// console's ConsoleStoryStepIO::tap_to_banner()). Each look stands still, +// so a banner on the far side reads at once. A fade seen ends the taps, +// banner or not; with the fade missed the next look still sees the banner. +std::optional SimStoryStepIO::tap_to_banner(Direction direction, const std::string& map, size_t max_taps){ + bool crossed = false; + for (size_t tap = 1; tap <= max_taps; tap++){ + const SimMove move = m_sim.tap(direction, 160ms, false); + if (move.stop == SimStop::DIALOG){ + return std::nullopt; + } + bool faded = false; + if (move.stop == SimStop::WARP){ + faded = !fade_missed(); + crossed = m_far_side(); + } + if (crossed && on_map(map) && !banner_missed()){ + return tap; + } + if (faded){ + return std::nullopt; + } + } + return std::nullopt; +} + + +// A scene's trigger the simulation fires from row `row` on, once the step +// `after` has run ("" from the row's start), on `map`'s `tiles`. +struct RowTrigger{ + std::string row; + std::string after; + std::string map; + SimTrigger trigger; +}; + +// When a story variable holds the value its coord events wait for: from +// row `row` on, once the step `after` has run ("" from the row's start). +struct VarState{ + std::string var; + int value; + std::string row; + std::string after; +}; + +// The story variables whose coord events the table walks onto, from the +// row (and step) the table's reasons give, with the maps' coord events +// (Diamond's zone events; Platinum's for the houses): Barry's doorstep and +// Mom's warning in 00.01, Barry's Route 201 warning, then its pull-back +// row 19 once he has warned us (r14, r16); the lake's entrance in 00.02; +// Rowan's stop and Dawn at Sandgem's edge in 00.03; Dawn's catching +// tutorial on Route 202 from 00.04's start, Mom's call once she has healed +// us; the Poketch president on Jubilife's row 40 in 00.05b; Barry on Route +// 203 in 01.01; the Gate's Hiker and Oreburgh's welcome in 01.02; Barry's +// bump in Oreburgh after Roark in 01.05; and Rowan in Jubilife in 01.07. +// Left out: the Twinleaf guitarist (spent upstairs), Platinum's rival +// steps around our bedroom's start, Dawn at Jubilife's steps (BDSP starts +// her below them: an event), and Floaroma's (10..12, 29) (Diamond only). +std::vector story_vars(){ + return { + {.var = "VAR_TWINLEAF_TOWN_RIVAL_TRIGGER_STATE", .value = 0, .row = "00.01", .after = ""}, + {.var = "VAR_PLAYER_HOUSE_STATE", .value = 1, .row = "00.01", .after = ""}, + {.var = "VAR_FOLLOWER_RIVAL_STATE", .value = 0, .row = "00.01", .after = ""}, + {.var = "VAR_FOLLOWER_RIVAL_STATE", .value = 1, .row = "00.01", .after = "barry_warns_us"}, + {.var = "VAR_VERITY_LAKEFRONT_STATE", .value = 0, .row = "00.02", .after = ""}, + {.var = "VAR_FOLLOWER_RIVAL_STATE", .value = 2, .row = "00.03", .after = ""}, + {.var = "VAR_SANDGEM_TOWN_STATE", .value = 0, .row = "00.03", .after = ""}, + {.var = "VAR_ROUTE_202_STATE", .value = 0, .row = "00.04", .after = ""}, + {.var = "CONSOLE_MOMS_CALL", .value = 0, .row = "00.04", .after = "mom_heals_us"}, + {.var = "VAR_POKETCH_CAMPAIGN_STATE", .value = 1, .row = "00.05b", .after = ""}, + {.var = "VAR_ROUTE_203_RIVAL_STATE", .value = 0, .row = "01.01", .after = ""}, + {.var = "VAR_OREBURGH_GATE_1F_HIKER_STATE", .value = 1, .row = "01.02", .after = ""}, + {.var = "VAR_OREBURGH_CITY_STATE", .value = 0, .row = "01.02", .after = ""}, + {.var = "VAR_OREBURGH_CITY_STATE", .value = 2, .row = "01.05", .after = ""}, + {.var = "VAR_JUBILIFE_CITY_STATE", .value = 3, .row = "01.07", .after = ""}, + }; +} + +// The maps' coord events (story_coord_events()) whose variable holds. +std::optional var_state(const CoordEvent& event){ + for (const VarState& state : story_vars()){ + if (state.var == event.var && state.value == event.value){ + return state; + } + } + return std::nullopt; +} + +RowTrigger row_trigger(const std::string& map, const CoordEvent& event, const VarState& state){ + return {.row = state.row, .after = state.after, .map = map, .trigger = {.name = event.name, .tiles = event.tiles}}; +} + +std::vector story_triggers(){ + std::vector triggers; + for (const auto& [map, json] : bdsp_map_json()){ + for (const CoordEvent& event : story_coord_events(map)){ + const std::optional state = var_state(event); + if (state){ + triggers.push_back(row_trigger(map, event, *state)); + } + } + } + return triggers; +} + + +// Someone the maps do not have, who stands on `tile` of `map` and is +// talked to there or walked round: from row `row` on if `until` is "", +// else in `row` until its step `until` has run. +struct SimPerson{ + std::string name; + std::string map; + TilePos tile; + std::string row; + std::string until; +}; +std::vector sim_people(){ + return { + // Mom stands a row above the decomp's (7, 8) from 00.04 on (r43). + {.name = "mom", .map = "PlayerHouse1F", .tile = {7, 7}, .row = "00.04", .until = ""}, + // Barry stands in the Center's door until his lines (01.03; Roark + // ignored A until then, r101 and r104). + {.name = "barry", .map = "OreburghCity", .tile = {47, 20}, .row = "01.03", .until = "barrys_lines"}, + }; +} + +// A house's stairs, entered by a step `side` from the map's "stairs" +// point into what the map draws as a wall (the table's 00.01: r8 and r9 +// for ours, r11 for Barry's). +struct SimStairs{ + std::string map; + Direction side; +}; +std::vector sim_stairs(){ + return { + {.map = "PlayerHouse2F", .side = Direction::EAST}, + {.map = "RivalHouse1F", .side = Direction::EAST}, + {.map = "RivalHouse2F", .side = Direction::WEST}, + }; +} + + +// The program's walking steps on the simulation, the rest stubbed. +class SimExecutor : public Pokemon::StoryStepExecutor{ +public: + explicit SimExecutor(Logger& logger, SimMisses misses = {}) + : m_logger(logger) + , m_misses(misses) + , m_sim({}, {0, 0}, Direction::NORTH, false) + , m_io(m_sim, m_notes, m_misses, [this]{ return cross(); }) + , m_walks(m_io) + {} + + const SimMisses& misses() const{ + return m_misses; + } + const SimRun& result() const{ + return m_result; + } + virtual void begin_attempt(const Pokemon::StoryCheckpoint& data) override{ + m_row = data.id; + m_start_mismatch = data.start ? off_pose(*data.start) : ""; + m_ran.clear(); + m_result.rows.push_back({.row = data.id, .fades = m_misses.fades, .banners = m_misses.banners}); + m_walks.begin_attempt(data.id); + arm(""); + } + virtual Outcome run(RunState& state, const StoryStep& step) override; + +protected: + virtual Outcome route(RunState& state, const StoryStep& step) override{ return m_walks.route(state, step); } + virtual Outcome legs(RunState& state, const StoryStep& step) override{ return m_walks.legs(state, step); } + virtual Outcome step(RunState& state, const StoryStep& step) override{ return m_walks.step(state, step); } + virtual Outcome talk(RunState& state, const StoryStep& step) override{ return m_walks.talk(state, step); } + virtual Outcome mash(RunState& state, const StoryStep& step) override{ return m_walks.mash(state, step); } + virtual Outcome heal(RunState& state, const StoryStep& step) override; + virtual Outcome new_game(RunState&, const StoryStep&) override{ return stub(); } + virtual Outcome options(RunState&, const StoryStep&) override{ return stub(); } + virtual Outcome answer(RunState&, const StoryStep&) override{ return stub(); } + virtual Outcome battle(RunState&, const StoryStep&) override{ return stub(); } + virtual Outcome train(RunState&, const StoryStep&) override{ return stub(); } + virtual Outcome shop(RunState&, const StoryStep&) override{ return stub(); } + virtual Outcome save(RunState&, const StoryStep&) override{ return stub(); } + virtual Outcome game(RunState&, const StoryStep&) override{ return stub(); } + +private: + // A step the simulation does not play: its dialogue opened and closed. + Outcome stub(){ + m_sim.end_dialogue(); + return Pokemon::outcome_ok(); + } + const StoryWorld* sim_world(const std::string& map) const; + SimWorld setup(const StoryWorld* world) const; + void arm(const std::string& after); + bool sync(const Pose& pose, const StoryWorld* on = nullptr); + bool cross(); + std::string off_pose(const Pose& pose) const; + std::string check(const StoryStep& step, const Outcome& outcome); + Outcome run_step(RunState& state, const StoryStep& step); + + Logger& m_logger; + std::vector m_notes; + SimMisses m_misses; + GridSim m_sim; + SimStoryStepIO m_io; + StoryWalkSteps m_walks; + // The planner's worlds with sim_people() on them, by world and who. + mutable std::map, std::unique_ptr> m_peopled; + // The steps of this row that have run. + std::set m_ran; + std::string m_row; + std::vector m_armed; + // Where the step running now ends, if it says: a door's far side. + std::optional m_far; + // Why the player is not on this row's start, or "". + std::string m_start_mismatch; + // Whether the player has been put on a map yet. + bool m_placed = false; + // Whether the step running now is on a room with no map. + bool m_passed_over = false; + SimRun m_result; +}; + +// The world the player walks on for `map`: the planner's, with what the +// console showed that the maps lack. Nothing for a room with no map. +const StoryWorld* SimExecutor::sim_world(const std::string& map) const{ + const StoryWorld* world = story_plan_world(map, map); + std::vector here; + std::string who; + for (const SimPerson& person : sim_people()){ + const bool now = person.until.empty() ? m_row >= person.row : m_row == person.row && !m_ran.contains(person.until); + if (world != nullptr && now && story_plan_world(person.map, person.map) == world){ + here.push_back(person); + who += person.name + ";"; + } + } + if (here.empty()){ + return world; + } + std::unique_ptr& peopled = m_peopled[{world, who}]; + if (!peopled){ + peopled = std::make_unique(*world); + for (const SimPerson& person : here){ + peopled->block(story_to_world(*world, person.map, person.tile)); + } + } + return peopled.get(); +} + +SimWorld SimExecutor::setup(const StoryWorld* world) const{ + // B runs once the shoes' scene has given them: the scene ends at home. + SimWorld out{.world = world, .trainers = story_trainers(*world), .can_run = m_walks.can_run()}; + for (const SimStairs& stairs : sim_stairs()){ + if (sim_world(stairs.map) == world){ + out.doors.push_back(story_to_world(*world, stairs.map, map_point(stairs.map, "stairs").next(stairs.side))); + } + } + for (const RowTrigger& armed : m_armed){ + if (sim_world(armed.map) != world){ + continue; + } + SimTrigger trigger{.name = armed.trigger.name, .tiles = {}}; + for (const TilePos& pos : armed.trigger.tiles){ + trigger.tiles.push_back(story_to_world(*world, armed.map, pos)); + } + out.triggers.push_back(std::move(trigger)); + } + return out; +} + +// Arms the triggers of this row that start after `after`. +void SimExecutor::arm(const std::string& after){ + size_t armed = 0; + for (const RowTrigger& trigger : story_triggers()){ + if (trigger.row == m_row && trigger.after == after){ + m_armed.push_back(trigger); + armed++; + } + } + const SimPlayer& player = m_sim.player(); + if (armed > 0 && m_placed && on_grid(player.pos) && !player.dialog){ + m_sim.place(setup(&m_sim.world()), nearest_tile(player.pos), player.facing); + } +} + +// Puts the player on `pose`, on the world `on` if given, unless they stand +// on one of its tiles already. Returns whether the pose is on a map the +// simulation has. +bool SimExecutor::sync(const Pose& pose, const StoryWorld* on){ + const std::vector tiles = Pokemon::tiles_of(pose); + const StoryWorld* world = on != nullptr ? on : pose.map.empty() ? nullptr : sim_world(pose.map); + if (world == nullptr || tiles.empty()){ + return false; + } + const SimPlayer& player = m_sim.player(); + const std::optional local = !m_placed || &m_sim.world() != world + ? std::nullopt : story_to_local(*world, pose.map, nearest_tile(player.pos)); + if (local && on_grid(player.pos) && std::find(tiles.begin(), tiles.end(), *local) != tiles.end()){ + return true; + } + m_sim.place(setup(world), story_to_world(*world, pose.map, tiles.front()), Pokemon::facing_of(pose)); + m_placed = true; + return true; +} + +// Through a door or a cave mouth onto the step's far side, the player's +// facing kept: a hold goes on from there. +bool SimExecutor::cross(){ + const StoryWorld* world = m_far ? sim_world(m_far->map) : nullptr; + const std::vector tiles = m_far ? Pokemon::tiles_of(*m_far) : std::vector{}; + if (world == nullptr || tiles.empty()){ + return false; + } + m_sim.place(setup(world), story_to_world(*world, m_far->map, tiles.front()), std::nullopt); + return true; +} + +// Why the player does not stand on one of `pose`'s tiles, or "" if they +// do or the simulation cannot tell (no map, or not placed yet). +std::string SimExecutor::off_pose(const Pose& pose) const{ + const std::vector tiles = Pokemon::tiles_of(pose); + if (!m_placed || tiles.empty() || sim_world(pose.map) == nullptr){ + return ""; + } + const SimPlayer& player = m_sim.player(); + const TilePos at = nearest_tile(player.pos); + // In world tiles: a pose a leg worked out from the map it started on + // may lie past that map's edge, on the next map (Twinleaf's exit). + // story_to_world() throws for a map not on the player's world. + try{ + const auto there = [&](const TilePos& tile){ return story_to_world(m_sim.world(), pose.map, tile) == at; }; + if (on_grid(player.pos) && std::ranges::any_of(tiles, there)){ + return ""; + } + }catch (const InternalProgramError&){} + return std::format("ended at {} ({}), not on {}.", to_string(player.pos), describe(m_sim.world(), at), Pokemon::to_string(pose)); +} + +Outcome SimExecutor::heal(RunState& state, const StoryStep& step){ + if (sync(state.pose) && m_sim.hold(Direction::NORTH, 3000ms, SimPace::RUN).stop != SimStop::WARP){ + return Pokemon::outcome_failed("no_door", step.name + ": no door north of " + to_string(m_sim.player().pos)); + } + return Pokemon::outcome_ok(); +} + +// Why a walking step did not end where the table says, or "". A step +// through a door the simulation did not cross is not checked: the sim's +// maps have no warps' destinations. +std::string SimExecutor::check(const StoryStep& step, const Outcome& outcome){ + const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS || step.kind == StoryStepKind::STEP; + const std::optional expected = step.pose_after ? step.pose_after : outcome.pose; + if (!walks || !m_placed){ + return ""; + } + if (!expected || Pokemon::tiles_of(*expected).empty()){ + m_result.unchecked.push_back(m_row + " " + step.name); + return ""; + } + const SimPlayer& player = m_sim.player(); + if (player.warped && !story_to_local(m_sim.world(), expected->map, nearest_tile(player.pos))){ + return ""; + } + return off_pose(*expected); +} + +Outcome SimExecutor::run_step(RunState& state, const StoryStep& step){ + if (!m_start_mismatch.empty()){ + return Pokemon::outcome_failed("sim_mismatch", "the row before " + std::exchange(m_start_mismatch, "")); + } + m_far = step.pose_after; + if (story_events().contains(step.variant)){ + return stub(); + } + // A scene goes on from where the step before left us. A route walks + // on the world it plans on (Jubilife alone has no Route 204 north of it). + const bool walks = step.kind == StoryStepKind::ROUTE || step.kind == StoryStepKind::LEGS || + step.kind == StoryStepKind::STEP || step.kind == StoryStepKind::TALK; + const StoryWorld* on = step.kind == StoryStepKind::ROUTE && step.target && !state.pose.map.empty() + ? story_plan_world(state.pose.map, step.target->map) : nullptr; + // A world of one map is sim_world()'s, with its people. + if (on != nullptr && on == story_plan_world(state.pose.map, state.pose.map)){ + on = nullptr; + } + if (walks && !sync(state.pose, on)){ + m_logger.log(std::format("[{}] {}: no map for {}; passed over.", m_row, step.name, Pokemon::to_string(state.pose))); + m_passed_over = true; + return Pokemon::outcome_ok(); + } + return Pokemon::StoryStepExecutor::run(state, step); +} + +Outcome SimExecutor::run(RunState& state, const StoryStep& step){ + Outcome outcome; + m_passed_over = false; + try{ + outcome = run_step(state, step); + }catch (const GridSimFailure& e){ + outcome = Pokemon::outcome_failed("sim_failure", e.message); + } + const std::string mismatch = outcome.succeeded() && !m_passed_over ? check(step, outcome) : ""; + if (!mismatch.empty()){ + outcome = Pokemon::outcome_failed("sim_mismatch", step.name + ": " + mismatch); + } + // What the simulation does not play (scenes, events, battles) ends + // where the table says it does. + if (outcome.succeeded() && step.pose_after){ + sync(*step.pose_after); + } + m_logger.log(std::format("[{}] {}: {}, the player at {}.", m_row, step.name, Pokemon::to_string(outcome), to_string(m_sim.player().pos))); + if (std::exchange(m_misses.missed, false) && m_result.missed_step.empty()){ + m_result.missed_step = m_row + " " + step.name; + } + if (!outcome.succeeded() && m_result.error.empty()){ + // The walker's last notes, to see why. + for (size_t i = m_notes.size() - std::min(m_notes.size(), 40); i < m_notes.size(); i++){ + m_logger.log(std::format("[{}] note: {}", m_row, m_notes[i])); + } + m_result.error = m_row + " " + Pokemon::to_string(outcome); + m_result.failed_step = m_row + " " + step.name; + } + m_result.world = m_placed ? &m_sim.world() : nullptr; + m_result.end = nearest_tile(m_sim.player().pos); + m_ran.insert(step.name); + arm(step.name); + return outcome; +} + + +// Runs `range` of `rows` (the table unless given) on `executor`, stopping +// at the first failure. +Pokemon::SegmentReport run_rows_on( + SimExecutor& executor, Logger& logger, const Pokemon::SegmentRange& range, + const std::vector& rows = story_checkpoints() +){ + std::set saved; + Pokemon::Segment segment = make_story_segment(rows, executor, saved); + Pokemon::SegmentPolicy policy; + policy.stop_at_first_failure = true; + Pokemon::SegmentHooks hooks; + hooks.reload = [](RunState&){ return Pokemon::outcome_ok(); }; + hooks.log = [&logger](const std::string& line){ logger.log(line); }; + RunState state; + return Pokemon::run_segment(state, segment, range, policy, hooks); +} + +// Runs `range` of the table on the simulation, stopping at the first failure. +UnitTestResult run_rows(Logger& logger, const Pokemon::SegmentRange& range){ + SimExecutor executor(logger); + const Pokemon::SegmentReport report = run_rows_on(executor, logger, range); + for (const std::string& step : executor.result().unchecked){ + logger.log("No end to check after " + step + "."); + } + if (!report.completed){ + return std::format("stopped at {} {}: {}", report.failed_checkpoint, report.failed_event, executor.result().error); + } + return true; +} + +// The table with row `row`'s step of `step`'s name replaced by `step`. +std::vector table_with(const std::string& row, const StoryStep& step){ + std::vector rows = story_checkpoints(); + for (StoryCheckpoint& checkpoint : rows){ + for (StoryStep& old : checkpoint.steps){ + if (checkpoint.id == row && old.name == step.name){ + old = step; + return rows; + } + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No step " + step.name + " in " + row); +} + +bool ends_on(const SimRun& run, const Pose& pose){ + const std::vector tiles = Pokemon::tiles_of(pose); + const std::optional local = run.world ? story_to_local(*run.world, pose.map, run.end) : std::nullopt; + return local && std::find(tiles.begin(), tiles.end(), *local) != tiles.end(); +} +std::string end_of(const SimRun& run){ + return run.world ? describe(*run.world, run.end) : "nowhere"; +} + + +UnitTestResult run_exit_test(Logger& logger, const std::string& row_id, const StoryStep& old_step, const SimMisses& misses, const Pose& overshoot){ + SimExecutor executor(logger, misses); + const std::vector rows = table_with(row_id, old_step); + run_rows_on(executor, logger, {.from = row_id, .to = row_id}, rows); + const SimRun& run = executor.result(); + logger.log(std::format("The old {}: {}, ended on {}.", old_step.name, run.error, end_of(run))); + if (run.failed_step != row_id + " " + old_step.name || !ends_on(run, overshoot)){ + return std::format("the old {} should end on {} and fail there; it ended on {}: {}", + old_step.name, Pokemon::to_string(overshoot), end_of(run), run.error.empty() ? "no failure" : run.error); + } + SimExecutor clean(logger); + if (!run_rows_on(clean, logger, {.from = row_id, .to = row_id}).completed){ + return "the table's row fails without misses: " + clean.result().error; + } + std::vector runs; + for (size_t i = 0; i < clean.misses().fades; i++){ + runs.push_back({.fade = i, .banner = std::nullopt}); + } + for (size_t i = 0; i < clean.misses().banners; i++){ + runs.push_back({.fade = std::nullopt, .banner = i}); + } + for (const SimMisses& m : runs){ + SimExecutor sim(logger, m); + run_rows_on(sim, logger, {.from = row_id, .to = row_id}); + if (ends_on(sim.result(), overshoot)){ + return std::format("the table's {} ended on {} with a miss in {}.", old_step.name, end_of(sim.result()), + sim.result().missed_step); + } + } + return true; +} + +// r75: out of the Oreburgh Gate as a run west until Route 203's banner +// (the old event west_until_transition()). The banner reads a few tiles +// in: the run ends west of the Gate's warp tile (55, 9), in the pocket by +// the pond (Route 203's x 49 to 54, rows 6 to 13). +StoryStep r75_old_gate_exit(){ + StoryStep step; + step.kind = StoryStepKind::LEGS; + step.name = "out_of_oreburgh_gate"; + step.legs = {{.direction = Direction::WEST, .tiles = 8}}; + step.variant = "to_map"; + step.pose_after = Pokemon::tile_pose("Route203", {TilePos{55, 9}}, Direction::WEST); + return step; +} +Pose r75_pond_pocket(){ + std::vector tiles; + for (int y = 6; y <= 13; y++){ + for (int x = 49; x <= 54; x++){ + tiles.push_back({x, y}); + } + } + return Pokemon::tile_pose("Route203", tiles); +} +// r81: out of the Ravaged Path as a timed walk south (the old event's +// 4 s walk_dir()), its fade missed: on past cave_exit (20, 26) to the +// cliff, column 20 down to row 30. +StoryStep r81_old_cave_exit(){ + StoryStep step; + step.kind = StoryStepKind::LEGS; + step.name = "out_of_the_ravaged_path"; + step.legs = {{.direction = Direction::SOUTH, .tiles = 4}}; + step.variant = "to_wall"; + step.pose_after = Pokemon::tile_pose("Route204North", {TilePos{20, 26}}, Direction::SOUTH); + return step; +} +Pose r81_below_the_mouth(){ + return Pokemon::tile_pose("Route204North", {TilePos{20, 27}, TilePos{20, 28}, TilePos{20, 29}, TilePos{20, 30}}); +} + + +UnitTestResult test_coord_events(Logger&){ + for (const auto& [map, json] : bdsp_map_json()){ + const StoryWorld* world = story_plan_world(map, map); + for (const CoordEvent& event : world == nullptr ? std::vector{} : story_coord_events(map)){ + const auto open = [&](const TilePos& pos){ + const TilePos tile = story_to_world(*world, map, pos); + return standable(*world, tile) || world->tile(tile) == Pokemon::TileKind::DOOR; + }; + if (!std::ranges::any_of(event.tiles, open)){ + return std::format("{} {}: no tile is open ground or a door.", map, event.name); + } + } + } + const size_t armed = story_triggers().size(); + return armed == story_vars().size() ? UnitTestResult(true) + : UnitTestResult(std::format("Expected {} coord events armed by the story's variables, found {}.", story_vars().size(), armed)); +} + +std::string miss(Logger& logger, const SimRun& clean, bool fade, size_t index){ + size_t k = 0; + while (k + 1 < clean.rows.size() && (fade ? clean.rows[k + 1].fades : clean.rows[k + 1].banners) <= index){ + k++; + } + const SimRun::RowStart& start = clean.rows[k]; + SimMisses misses; + (fade ? misses.fade : misses.banner) = index - (fade ? start.fades : start.banners); + SimExecutor executor(logger, misses); + const Pokemon::SegmentReport report = run_rows_on(executor, logger, + {.from = start.row, .to = clean.rows[std::min(k + 1, clean.rows.size() - 1)].row}); + const SimRun& run = executor.result(); + const std::string what = std::format("{} {}", fade ? "fade" : "banner", index); + logger.log(std::format("Missed {} in {}: {}.", what, run.missed_step, + report.completed ? "got over it" : "stopped in " + run.failed_step + ": " + run.error)); + if (report.completed || run.failed_step == run.missed_step){ + return ""; + } + return std::format("{} missed in {}, failed later in {}: {}", what, run.missed_step, run.failed_step, run.error); +} + +UnitTestResult test_story_sim_misses(Logger& logger){ + Pokemon::SegmentRange range{.from = "00.00", .to = "01.11"}; + SimExecutor clean(logger); + if (!run_rows_on(clean, logger, range).completed){ + return "the run fails without misses: " + clean.result().error; + } + logger.log(std::format("{} fades and {} banners to miss.", clean.misses().fades, clean.misses().banners)); + std::vector unseen; + for (size_t i = 0; i < clean.misses().fades + clean.misses().banners; i++){ + const std::string line = miss(logger, clean.result(), i < clean.misses().fades, i < clean.misses().fades ? i : i - clean.misses().fades); + if (!line.empty()){ + unseen.push_back(line); + } + } + std::string out = "Misses that went unseen:"; + for (const std::string& line : unseen){ + out += "\n " + line; + } + return unseen.empty() ? UnitTestResult(true) : UnitTestResult(out); +} + +UnitTestResult test_story_sim_whole_table(Logger& logger){ + return run_rows(logger, {.from = "00.00", .to = "01.11"}); +} + +UnitTestResult test_story_sim_each_row(Logger& logger){ + for (const StoryCheckpoint& row : story_checkpoints()){ + const auto res = run_rows(logger, {.from = row.id, .to = row.id}); + if (res.result != UnitTestResult::PASSED){ + return std::format("Row {} failed: {}", row.id, res.message); + } + } + return true; +} + +UnitTestResult test_exit_gate(Logger& logger){ + return run_exit_test(logger, "01.05", r75_old_gate_exit(), SimMisses{}, r75_pond_pocket()); +} + +UnitTestResult test_exit_ravaged_path(Logger& logger){ + return run_exit_test(logger, "01.09", r81_old_cave_exit(), SimMisses{.fade = 0, .banner = std::nullopt}, r81_below_the_mouth()); +} + +struct AutoStoryStorySimInput{ + std::string name; + std::function body; +}; + +struct AutoStoryStorySimExpectation{ +}; + +struct AutoStoryStorySimCase{ + AutoStoryStorySimInput input; + AutoStoryStorySimExpectation expectation; +}; + +class Test_AutoStoryStorySim : public UnitTest{ +public: + explicit Test_AutoStoryStorySim(AutoStoryStorySimCase test_case) + : UnitTest("PokemonBDSP::AutoStory - Story sim: " + 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: + AutoStoryStorySimCase m_case; +}; + +} // namespace + + +const AutoStoryStorySimCase AUTO_STORY_STORY_SIM_CASES[] = { + { + .input = {"the maps' coord events are on open ground or doors", test_coord_events}, + .expectation = {}, + }, + { + .input = {"detector misses from 00.00 to 01.11", test_story_sim_misses}, + .expectation = {}, + }, + { + .input = {"00.00 to 01.11: the whole table, from the new game to Floaroma", test_story_sim_whole_table}, + .expectation = {}, + }, + { + .input = {"each row from 00.00 to 01.11 in isolation", test_story_sim_each_row}, + .expectation = {}, + }, + { + .input = {"r75, out of the Oreburgh Gate", test_exit_gate}, + .expectation = {}, + }, + { + .input = {"r81, out of the Ravaged Path", test_exit_ravaged_path}, + .expectation = {}, + }, +}; + +void add_tests_AutoStory_StorySim(UnitTestDatabase& database){ + for (const auto& item : AUTO_STORY_STORY_SIM_CASES){ + database.add(item); + } +} + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySim_Tests.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySim_Tests.h new file mode 100644 index 0000000000..bc1ed57d3a --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySim_Tests.h @@ -0,0 +1,28 @@ +/* BDSP AutoStory Story Sim Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_StorySim_Tests_H +#define PokemonAutomation_PokemonBDSP_AutoStory_StorySim_Tests_H + +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + + +// The story table's rows run by the program's own walking steps +// (StoryWalkSteps) on the grid simulation: each walk must end where the +// table says the player is. +void add_tests_AutoStory_StorySim(UnitTestDatabase& database); + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryStepIO.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryStepIO.cpp new file mode 100644 index 0000000000..275717d800 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryStepIO.cpp @@ -0,0 +1,216 @@ +/* BDSP AutoStory - Story step IO + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_Superscalar.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h" +#include "PokemonBDSP_AutoStory_Talks.h" +#include "PokemonBDSP_AutoStory_Walks.h" +#include "PokemonBDSP_AutoStory_WildBattle.h" +#include "PokemonBDSP_AutoStory_StoryPlaces.h" +#include "PokemonBDSP_AutoStory_StoryStepIO.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; +using Pokemon::Direction; + + +namespace{ + +// One rule for scenes and map changes: v2's long B mashes, with an end +// check. Press through the text, and keep pressing B while the screen is +// quiet: a scene can pause while someone walks off (Dawn and Barry after +// Rowan leaves, r23), fade dimly, not to black, to another map (r25), or +// wait on a box the detector misses (r26). B never starts a talk on the +// overworld. Done once the overworld has shown, with no text and a still +// screen, for 5 s of presses: after r31's fade home a scripted walk-in ran +// 5 s with no text before Mom spoke. +// +// A step or scene that ends on another map (`fade`) must fade on the way, +// and only the quiet after the fade counts: r30's scene went quiet for 5 s +// before the fade home, and r28 "went out" a tile beside the door and +// walked a whole route in the house. No fade is a map mismatch. +constexpr double FADE_DARKNESS = 40; // brightness 0 to 255; r24's dim fade home goes darker +void settle(VideoStream& stream, ProControllerContext& context, Button button, const std::string& label, bool fade){ + const WallClock none = WallClock::max(); + const WallClock deadline = current_time() + 120s; + ShortDialogDetector dialog; + OverworldDetector overworld; + WallClock quiet_since = none; + WallClock calm_since = none; // overworld, no text, still or not + double darkest = 255; + VideoSnapshot previous; + while (current_time() < deadline){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + const WallClock now = current_time(); + if (frame){ + darkest = std::min(darkest, image_average(*frame.frame).sum() / 3); + } + const bool talking = frame && dialog.detect(frame); + const bool still = frame && previous && walk_screen_change(*previous.frame, *frame.frame) < WALK_TAP_BLOCKED_CHANGE; + previous = frame; + // Cloud shadows drift over Floaroma, so its screen is never still: r83 + // got Mew and then waited out the 120 s on a calm overworld. 15 s of + // overworld with no text is three times r31's silent walk-in. + const bool calm = !talking && overworld.detect(frame) && (!fade || darkest < FADE_DARKNESS); + calm_since = calm ? (calm_since == none ? now : calm_since) : none; + if (calm && now - calm_since >= 15000ms){ + stream.log(std::format("[Story] {}: settled on 15 s of overworld without text (the screen kept moving); darkest frame {:.0f}.", label, darkest)); + return; + } + if (!talking && still && overworld.detect(frame) && (!fade || darkest < FADE_DARKNESS)){ + quiet_since = quiet_since == none ? now : quiet_since; + if (now - quiet_since >= 5000ms){ + stream.log(std::format("[Story] {}: settled; darkest frame {:.0f}.", label, darkest)); + return; + } + }else{ + quiet_since = none; + } + pbf_press_button(context, talking ? button : BUTTON_B, 80ms, 1200ms); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + fade && darkest >= FADE_DARKNESS + ? label + ": no fade within 120 s, so we are not where the step goes (map mismatch)." + : label + ": not back on a quiet overworld within 120 s.", + stream); +} + +// Holds the D-pad toward a door (with B after the shoes) until the screen goes +// dark or a line shows, up to 12 s. Returns whether it went dark. A line +// stops it: holding on under Mom's "Oh, Kosmos!" wasted 12 s (r45). The walker's black-screen watch +// missed the lab's fade into Sandgem (r44, "dialog"), and settle's samples +// 1.3 s apart can fall either side of it, so watch at settle's own darkness. +DpadPosition dpad_toward(Direction direction){ + return + direction == Direction::NORTH ? DPAD_UP : + direction == Direction::SOUTH ? DPAD_DOWN : + direction == Direction::WEST ? DPAD_LEFT : DPAD_RIGHT; +} + +bool run_into_door(VideoStream& stream, ProControllerContext& context, Direction direction, bool run){ + const DpadPosition dpad = dpad_toward(direction); + BlackScreenWatcher dark(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 3 * FADE_DARKNESS, 60); + ShortDialogWatcher dialog(COLOR_YELLOW); + const int ret = run_until(stream, context, [&](ProControllerContext& context){ + if (run){ + ssf_press_button(context, BUTTON_B, 0ms, 12000ms, 0ms); + } + pbf_press_dpad(context, dpad, 12000ms, 0ms); + }, {{dark}, {dialog}}); + return ret == 0; +} + +// What the screen showed after a tap toward a door or a cave mouth. +enum class TapLook{ + NOTHING, + DARK, + BANNER, +}; + +// Looks for the location banner `banner` (and, if `dark_too`, the fade) +// for up to `wait`. +TapLook look_after_tap(VideoStream& stream, ProControllerContext& context, const std::string& banner, Milliseconds wait, bool dark_too){ + const WallClock deadline = current_time() + wait; + while (true){ + context.wait_for_all_requests(); + const VideoSnapshot screen = stream.video().snapshot(); + if (screen && location_banner_matches(read_location_banner(*screen.frame), banner)){ + return TapLook::BANNER; + } + if (dark_too && screen && image_average(*screen.frame).sum() / 3 < FADE_DARKNESS){ + return TapLook::DARK; + } + if (current_time() >= deadline){ + return TapLook::NOTHING; + } + context.wait_for(100ms); + } +} + +} // namespace + + +ConsoleStoryStepIO::ConsoleStoryStepIO(VideoStream& stream, ProControllerContext& context) + : m_stream(stream) + , m_context(context) + , m_escape(run_from_wild_battle) + , m_route(stream, context, m_escape) +{} + +void ConsoleStoryStepIO::fight(const std::string& trainer){ + if (trainer.find("Twins") != std::string::npos){ + talk_or_fight(m_stream, m_context, trainer, TalkExpect::BATTLE, BattleFormat::DOUBLES); + return; + } + fight_route_trainer(m_stream, m_context, trainer); +} + +void ConsoleStoryStepIO::walk_until_stopped(Direction direction, size_t tiles, const std::string& label){ + PokemonBDSP::walk_until_stopped(m_stream, m_context, direction, tiles, label); +} + +bool ConsoleStoryStepIO::run_until_map(Direction direction, Milliseconds limit, const std::string& map, const std::string& label){ + const auto [x, y] = dpad_of(direction); + const WalkResult walk = walk_until_dpad(m_stream, m_context, run_from_wild_battle, {x, y}, limit, label, { + .arrived = [&](const ImageViewRGB32& screen){ return location_banner_matches(read_location_banner(screen), map); }, + .pace = DpadPace::RUN, + }); + m_stream.log(std::format("[Story] {}: ran {} to {}'s banner: {}.", label, direction_name(direction), map, walk_stop_name(walk.stop))); + return walk.stop == WalkStop::ARRIVED; +} + +bool ConsoleStoryStepIO::run_into_door(Direction direction, bool run){ + return PokemonBDSP::run_into_door(m_stream, m_context, direction, run); +} + +// A held walk can carry on past a fade that goes unseen: out of the Gym it +// walked on south (r219g), and through the Oreburgh Gate's west mouth on +// west into a pocket by Route 203's pond (r75). Taps during the fade are +// eaten. +std::optional ConsoleStoryStepIO::tap_to_banner(Direction direction, const std::string& map, size_t max_taps){ + const std::string& banner = story_banner_name(map); + for (size_t tap = 1; tap <= max_taps; tap++){ + pbf_press_dpad(m_context, dpad_toward(direction), 160ms, 0ms); + TapLook look = look_after_tap(m_stream, m_context, banner, 1500ms, true); + const bool faded = look == TapLook::DARK; + if (faded){ + look = look_after_tap(m_stream, m_context, banner, 8000ms, false); + } + if (look == TapLook::BANNER){ + m_context.wait_for(1000ms); + return tap; + } + if (faded){ + break; + } + } + return std::nullopt; +} + +void ConsoleStoryStepIO::settle(Button button, const std::string& label, bool fade){ + PokemonBDSP::settle(m_stream, m_context, button, label, fade); +} + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryStepIO.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryStepIO.h new file mode 100644 index 0000000000..a6175834ac --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryStepIO.h @@ -0,0 +1,87 @@ +/* BDSP AutoStory - Story step IO + * + * From: https://github.com/PokemonAutomation/ + * + * What the story's walking steps (StoryWalkSteps) do on the console beyond + * the route walker's RouteIO: fights, walks until stopped, runs into doors + * and the settle after a scene. Tests run the steps on a simulation; + * ConsoleStoryStepIO is the console. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_StoryStepIO_H +#define PokemonAutomation_PokemonBDSP_AutoStory_StoryStepIO_H + +#include +#include +#include "Common/Cpp/Time.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "NintendoSwitch/Controllers/NintendoSwitch_ControllerButtons.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteIO.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Abstract interface for story step input/output operations. +class StoryStepIO{ +public: + virtual ~StoryStepIO() = default; + + // The route walker's console: walks, presses, dialogue watches, the log. + virtual RouteIO& route() = 0; + // Fights a trainer whose dialogue has opened (fight_route_trainer()): + // Route 204's Twins as a pair (01.10), everyone else alone. + virtual void fight(const std::string& trainer) = 0; + // walk_until_stopped(): `direction` until something stops us, for at + // most `tiles` tiles' time. Fails on a door or a dialogue. + virtual void walk_until_stopped(Pokemon::Direction direction, size_t tiles, const std::string& label) = 0; + // Runs `direction` on the D-pad until the location banner of `map` (a + // JSON map's name) shows, for at most `limit`. Returns whether it showed. + virtual bool run_until_map(Pokemon::Direction direction, Milliseconds limit, const std::string& map, const std::string& label) = 0; + // Holds the D-pad toward a door (running with `run`) until the screen + // goes dark or a line shows. Returns whether it went dark. + virtual bool run_into_door(Pokemon::Direction direction, bool run) = 0; + // D-pad taps `direction` one at a time, each looked at, toward a door + // or cave mouth, until the fade (then no more taps) and the location + // banner of `map` (a JSON map's name), for at most `max_taps`. Returns + // the taps it took, or nothing if no banner showed. + virtual std::optional tap_to_banner(Pokemon::Direction direction, const std::string& map, size_t max_taps) = 0; + // Presses through any text until the overworld is quiet; with `fade`, a + // fade must come first (a map change). Fails otherwise. + virtual void settle(Button button, const std::string& label, bool fade) = 0; +}; + + +// The console. +class ConsoleStoryStepIO : public StoryStepIO{ +public: + ConsoleStoryStepIO(VideoStream& stream, ProControllerContext& context); + + virtual RouteIO& route() override{ + return m_route; + } + virtual void fight(const std::string& trainer) override; + virtual void walk_until_stopped(Pokemon::Direction direction, size_t tiles, const std::string& label) override; + virtual bool run_until_map(Pokemon::Direction direction, Milliseconds limit, const std::string& map, const std::string& label) override; + virtual bool run_into_door(Pokemon::Direction direction, bool run) override; + virtual std::optional tap_to_banner(Pokemon::Direction direction, const std::string& map, size_t max_taps) override; + virtual void settle(Button button, const std::string& label, bool fade) override; + +private: + VideoStream& m_stream; + ProControllerContext& m_context; + WildBattleEscape m_escape; + ConsoleRouteIO m_route; +}; + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp new file mode 100644 index 0000000000..067a6281af --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp @@ -0,0 +1,264 @@ +/* BDSP AutoStory - Story walk steps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "Common/Cpp/Color.h" +#include "Common/Cpp/Exceptions.h" +#include "Pokemon/AutoStory/Pokemon_TilePose.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PlannedRoute.h" +#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h" +#include "PokemonBDSP_AutoStory_StoryExecutor.h" +#include "PokemonBDSP_AutoStory_StoryPlaces.h" +#include "PokemonBDSP_AutoStory_StoryTable.h" +#include "PokemonBDSP_AutoStory_StoryWalkSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; +using Pokemon::Direction; +using Pokemon::Outcome; +using Pokemon::Pose; +using Pokemon::RunState; +using Pokemon::StoryStep; +using Pokemon::TilePos; + + +namespace{ + +// "to_catch_grass" as "to catch grass", for the logs. +std::string label_of(const StoryStep& step){ + std::string label = step.name; + std::replace(label.begin(), label.end(), '_', ' '); + return label; +} + +// Fails the step (fail_step() on the console). +[[noreturn]] void fail(RouteIO& io, const std::string& message){ + io.note(message, RouteNote::FAILURE); + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "RouteIO::note() returned from a failure: " + message); +} + +} // namespace + + +void StoryWalkSteps::begin_attempt(const std::string& row){ + m_row = row; + // A row starts from its save: after the shoes' row, the save has them. + m_can_run = row > std::string(RUNNING_SHOES_ROW); + m_trainers.clear(); +} + +void StoryWalkSteps::turn(Direction direction){ + m_io.route().note(std::string("[Route] Turning ") + direction_name(direction) + ".", RouteNote::LOG); + m_io.route().press(route_turn_tap(direction)); +} + +void StoryWalkSteps::log(const std::string& text){ + m_io.route().note(text, RouteNote::LOG); +} + +Pose StoryWalkSteps::walk_to(const Pose& from, const Pose& goal, const StoryStep& step){ + const StoryWorld* maps = story_plan_world(from.map, goal.map); + if (maps == nullptr){ + fail(m_io.route(), std::format("{}: no maps join {} and {}.", step.name, from.map, goal.map)); + } + const std::optional costs = story_route_costs(step.variant); + if (!costs){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown route costs: " + step.variant); + } + std::vector start; + for (const TilePos& pos : Pokemon::tiles_of(from)){ + start.push_back(story_to_world(*maps, from.map, pos)); + } + std::vector targets; + for (const TilePos& pos : Pokemon::tiles_of(goal)){ + targets.push_back(story_to_world(*maps, goal.map, pos)); + } + // The ones fought stay off m_trainers until the next attempt. + std::vector& sights = m_trainers.try_emplace(maps, story_trainers(*maps)).first->second; + RouteTrainers trainers{ + sights, + [this](const std::string& trainer){ m_io.fight(trainer); } + }; + const RouteWalkPlan plan{ + [&](const TilePos& pos){ return std::find(targets.begin(), targets.end(), pos) != targets.end(); }, + label_of(step), {}, *costs, m_can_run ? MoveSpeed::RUN : MoveSpeed::WALK + }; + const std::vector at = walk_route(m_io.route(), *maps, start, trainers, plan); + sights = std::move(trainers.sights); + std::vector local; + for (const TilePos& pos : at){ + const std::optional tile = story_to_local(*maps, goal.map, pos); + if (!tile){ + fail(m_io.route(), std::format("{}: the walk ended off {}.", step.name, goal.map)); + } + local.push_back(*tile); + } + return Pokemon::tile_pose(goal.map, local, Pokemon::facing_of(goal)); +} + +Outcome StoryWalkSteps::route(RunState& state, const StoryStep& step){ + if (Pokemon::tiles_of(state.pose).empty()){ + return Pokemon::outcome_failed("no_pose", Pokemon::to_string(state.pose)); + } + return Pokemon::outcome_ok(walk_to(state.pose, *step.target, step)); +} + +// A LEGS step "to_dialogue": taps its one leg until a dialogue starts on the +// way, wherever it starts (Dawn's at Sandgem's edge, r35/r36: an exact tile +// and banner raced her scene). The scene's lines are the next step's. +Outcome StoryWalkSteps::leg_to_dialogue(const StoryStep& step){ + const Pokemon::StoryLeg& leg = step.legs.front(); + turn(leg.direction); + const WalkResult walk = m_io.route().walk_taps(leg.direction, (size_t)leg.tiles, step.name); + log(std::format("[{}] {}: taps {} to a dialogue: {} tiles, {}.", m_row, step.name, direction_name(leg.direction), walk.moved_chunks, walk_stop_name(walk.stop))); + if (walk.stop == WalkStop::DIALOG){ + return Pokemon::outcome_ok(); + } + // A scene can lock us before its first line: Dawn walks over first (r37). + if (walk.stop == WalkStop::BLOCKED && m_io.route().dialog_within(10000ms, COLOR_CYAN)){ + return Pokemon::outcome_ok(); + } + return Pokemon::outcome_failed("no_dialogue", step.name); +} + +// A LEGS step "to_map": runs its one leg until the location banner of +// pose_after's map shows, wherever it shows. A straight run along an open +// row needs no tile count (r41, r42: Sandgem's row 11 took us into the lab). +Outcome StoryWalkSteps::leg_to_map(const StoryStep& step){ + const Pokemon::StoryLeg& leg = step.legs.front(); + const std::string map = step.pose_after ? step.pose_after->map : ""; + turn(leg.direction); + const bool arrived = m_io.run_until_map(leg.direction, Milliseconds(250) * leg.tiles + 2000ms, map, step.name); + return arrived ? Pokemon::outcome_ok() : Pokemon::outcome_failed("no_banner", map); +} + +// A STEP "to_banner": D-pad taps toward a door or cave mouth until the fade +// and pose_after's map's location banner. No banner fails the step right +// there. +Outcome StoryWalkSteps::tap_to_banner(const StoryStep& step){ + const std::string map = step.pose_after ? step.pose_after->map : ""; + const std::optional taps = m_io.tap_to_banner(step.direction, map, 30); + if (!taps){ + return Pokemon::outcome_failed("no_banner", map); + } + log(std::format("[{}] {}: {}'s banner after {} taps {}.", m_row, step.name, map, *taps, direction_name(step.direction))); + return Pokemon::outcome_ok(); +} + +// Each leg in D-pad taps, turning first; "one_short_ok" lets a leg end a +// tile short. +Outcome StoryWalkSteps::tap_legs(const Pose& from, const StoryStep& step){ + const bool one_short_ok = step.variant == "one_short_ok"; + std::vector at = Pokemon::tiles_of(from); + for (const Pokemon::StoryLeg& leg : step.legs){ + // Facing another way, a tap only turns (r256): turn first. + turn(leg.direction); + const size_t tiles = (size_t)leg.tiles; + const WalkResult walk = m_io.route().walk_taps(leg.direction, tiles, step.name); + log(std::format("[{}] {}: taps {} moved {} of {} tiles.", m_row, step.name, direction_name(leg.direction), walk.moved_chunks, tiles)); + if (walk.moved_chunks + (one_short_ok ? 1 : 0) < tiles){ + return Pokemon::outcome_failed("short_leg", std::format("{} {} moved {}", direction_name(leg.direction), tiles, walk.moved_chunks)); + } + // Where we may be: the end, or a tile short of it. + std::vector next; + for (const TilePos& start : at){ + TilePos end = start; + for (size_t i = 0; i < tiles; i++){ + if (one_short_ok && i + 1 == tiles){ + next.push_back(end); + } + end = end.next(leg.direction); + } + next.push_back(end); + } + at = std::move(next); + } + return Pokemon::outcome_ok(Pokemon::tile_pose(from.map, at, step.legs.back().direction)); +} + +Outcome StoryWalkSteps::legs(RunState& state, const StoryStep& step){ + if (step.variant == "to_wall"){ + for (const Pokemon::StoryLeg& leg : step.legs){ + m_io.walk_until_stopped(leg.direction, (size_t)leg.tiles, label_of(step)); + } + return Pokemon::outcome_ok(); + } + if (step.variant == "to_dialogue"){ + return leg_to_dialogue(step); + } + if (step.variant == "to_map"){ + return leg_to_map(step); + } + if (step.variant != "one_short_ok" && !step.variant.empty()){ + return Pokemon::outcome_failed("unknown_variant", step.variant); + } + return tap_legs(state.pose, step); +} + +Outcome StoryWalkSteps::step(RunState& state, const StoryStep& step){ + turn(step.direction); + if (step.variant == "to_banner"){ + return tap_to_banner(step); + } + // A step through a door: walk (or run) on until the screen goes dark, + // from next to the door or from anywhere in its line (r43: tapping 8 tiles + // down the lab took 7 s), then settle for any scene on arrival (Mom's, + // 00.01; Dawn's tour, 00.04). Without the fade, settle must see it itself + // (r33 was outside, "no fade"). + if (step.pose_after && step.pose_after->map != state.pose.map){ + const bool faded = m_io.run_into_door(step.direction, m_can_run); + log(std::format("[{}] {}: {} through the door: {}.", m_row, step.name, direction_name(step.direction), faded ? "faded" : "no fade yet")); + m_io.settle(BUTTON_B, label_of(step), !faded); + return Pokemon::outcome_ok(); + } + const WalkResult walk = m_io.route().walk_taps(step.direction, 1, step.name); + log(std::format("[{}] {}: a step {}, {}.", m_row, step.name, direction_name(step.direction), walk_stop_name(walk.stop))); + std::vector at; + for (const TilePos& pos : Pokemon::tiles_of(state.pose)){ + at.push_back(pos.next(step.direction)); + } + return Pokemon::outcome_ok(Pokemon::tile_pose(state.pose.map, at, step.direction)); +} + +Outcome StoryWalkSteps::talk(RunState& state, const StoryStep& step){ + // Face whom we talk to: a route ends facing its last leg, not its goal + // (r40 reached Mom facing north). They block the tile, so the tap only turns. + if (const std::optional facing = Pokemon::facing_of(state.pose)){ + turn(*facing); + } + m_io.route().press({.kind = RouteInputKind::BUTTON_A, .direction = Direction::NORTH, .hold = 80ms, .release = 200ms, + .count = 1, .end = RouteInputEnd::DONE}); + // Roark's first line in the mine comes after the camera zooms in on + // us, which can take some seconds (r98; the old event waited 8 s). + if (!m_io.route().dialog_within(8000ms, COLOR_YELLOW)){ + fail(m_io.route(), label_of(step) + ": nobody answered."); + } + return Pokemon::outcome_ok(); +} + +Outcome StoryWalkSteps::mash(RunState& state, const StoryStep& step){ + // The step before may have played the scene already (a door step settles + // its arrival scene), so its first line is not required. + if (!m_io.route().dialog_within(15000ms, COLOR_CYAN)){ + m_io.route().note(label_of(step) + ": no line within 15 s; the step before played it.", RouteNote::WARNING); + } + m_io.settle(step.variant == "a" ? BUTTON_A : BUTTON_B, label_of(step), + step.pose_after && step.pose_after->map != state.pose.map); + if (m_row == RUNNING_SHOES_ROW && step.name == RUNNING_SHOES_STEP){ + m_can_run = true; + } + return Pokemon::outcome_ok(); +} + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.h new file mode 100644 index 0000000000..fa89abaf14 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.h @@ -0,0 +1,75 @@ +/* BDSP AutoStory - Story walk steps + * + * From: https://github.com/PokemonAutomation/ + * + * The story steps that walk and talk (ROUTE, LEGS, STEP, TALK, MASH), on a + * StoryStepIO: StoryExecutor runs them on the console, the story's + * simulation tests on a simulated player. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_StoryWalkSteps_H +#define PokemonAutomation_PokemonBDSP_AutoStory_StoryWalkSteps_H + +#include +#include +#include +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RoutePlanner.h" +#include "PokemonBDSP_AutoStory_StoryStepIO.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Executes walking and movement-related story steps via StoryStepIO. +class StoryWalkSteps{ +public: + explicit StoryWalkSteps(StoryStepIO& io) + : m_io(io) + {} + + // A new attempt of the row `row`: every trainer can see us again, and + // the pace is the row's save's (a run after the Running Shoes). + void begin_attempt(const std::string& row); + // Whether the player has the Running Shoes: the pace routes are timed at. + bool can_run() const{ + return m_can_run; + } + + // Walks the planned route to `step.target`. Variant: the plan's costs. + Pokemon::Outcome route(Pokemon::RunState& state, const Pokemon::StoryStep& step); + // See StoryExecutor::legs(). + Pokemon::Outcome legs(Pokemon::RunState& state, const Pokemon::StoryStep& step); + // One D-pad step, or a run through a door when the step changes map. + Pokemon::Outcome step(Pokemon::RunState& state, const Pokemon::StoryStep& step); + // A at what we face; a dialogue must open. + Pokemon::Outcome talk(Pokemon::RunState& state, const Pokemon::StoryStep& step); + // Text to a quiet overworld; the Running Shoes' scene gives the shoes. + Pokemon::Outcome mash(Pokemon::RunState& state, const Pokemon::StoryStep& step); + +private: + Pokemon::Pose walk_to(const Pokemon::Pose& from, const Pokemon::Pose& goal, const Pokemon::StoryStep& step); + Pokemon::Outcome tap_legs(const Pokemon::Pose& from, const Pokemon::StoryStep& step); + Pokemon::Outcome leg_to_dialogue(const Pokemon::StoryStep& step); + Pokemon::Outcome leg_to_map(const Pokemon::StoryStep& step); + Pokemon::Outcome tap_to_banner(const Pokemon::StoryStep& step); + void turn(Pokemon::Direction direction); + void log(const std::string& text); + + StoryStepIO& m_io; + std::string m_row; + bool m_can_run = false; + // The trainers who can still see us on each story_plan_world() world, + // in its tiles, from its first walk this attempt; all of them again at + // each attempt. + std::map> m_trainers; +}; + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp new file mode 100644 index 0000000000..fd90d56444 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp @@ -0,0 +1,364 @@ +/* BDSP AutoStory - Grid simulation + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "PokemonBDSP_AutoStory_RouteWalk.h" +#include "PokemonBDSP_AutoStory_GridSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; +using Pokemon::Direction; +using Pokemon::TileKind; +using Pokemon::TilePos; + + +namespace{ + +int floor_div(int value, int by){ + return value >= 0 ? value / by : -((-value + by - 1) / by); +} + +bool across(Direction direction){ + return direction == Direction::EAST || direction == Direction::WEST; +} +// +1 toward the east or south, -1 toward the west or north. +int sign_of(Direction direction){ + return direction == Direction::EAST || direction == Direction::SOUTH ? 1 : -1; +} +int& along(SimPos& pos, Direction direction){ + return across(direction) ? pos.x : pos.y; +} +int along(const SimPos& pos, Direction direction){ + return across(direction) ? pos.x : pos.y; +} +int sideways(const SimPos& pos, Direction direction){ + return across(direction) ? pos.y : pos.x; +} +TilePos tile_at(Direction direction, int along_tile, int side_tile){ + return across(direction) ? TilePos{along_tile, side_tile} : TilePos{side_tile, along_tile}; +} + +// The tiles a player at `pos` moves into with their next tile toward +// `direction`: one on the grid, two off it. `pos` is on the grid along +// `direction`. +std::vector tiles_ahead(const SimPos& pos, Direction direction){ + const int next = along(pos, direction) / SIM_TILE + sign_of(direction); + const int side = sideways(pos, direction); + std::vector tiles{tile_at(direction, next, floor_div(side, SIM_TILE))}; + if (side % SIM_TILE != 0){ + tiles.push_back(tile_at(direction, next, floor_div(side, SIM_TILE) + 1)); + } + return tiles; +} + +bool is_door(const StoryWorld& world, const TilePos& pos){ + const std::optional tile = world.tile(pos); + return tile && *tile == TileKind::DOOR && !world.occupied(pos); +} + +Milliseconds per_tile(Direction direction, SimPace pace, bool can_run){ + return route_ms_per_tile(direction, pace == SimPace::RUN && can_run ? MoveSpeed::RUN : MoveSpeed::WALK); +} + +} // namespace + + +SimPos sim_pos(const TilePos& tile){ + return {tile.x * SIM_TILE, tile.y * SIM_TILE}; +} +bool on_grid(const SimPos& pos){ + return pos.x % SIM_TILE == 0 && pos.y % SIM_TILE == 0; +} +TilePos nearest_tile(const SimPos& pos){ + return {floor_div(pos.x + SIM_TILE / 2, SIM_TILE), floor_div(pos.y + SIM_TILE / 2, SIM_TILE)}; +} +std::string to_string(const SimPos& pos){ + return std::format("({:g}, {:g})", pos.x / (double)SIM_TILE, pos.y / (double)SIM_TILE); +} + +const char* sim_stop_name(SimStop stop){ + switch (stop){ + case SimStop::DONE: + return "done"; + case SimStop::BLOCKED: + return "blocked"; + case SimStop::DIALOG: + return "dialog"; + case SimStop::WARP: + return "warp"; + case SimStop::LIMIT: + return "limit"; + } + return "?"; +} + + +GridSim::GridSim(SimWorld world, const TilePos& start, Direction facing, bool can_run) + : m_world(std::move(world)) +{ + m_player.pos = sim_pos(start); + m_player.facing = facing; + m_player.can_run = can_run; +} + +void GridSim::place(SimWorld world, const TilePos& start, std::optional facing){ + m_world = std::move(world); + m_player.pos = sim_pos(start); + if (facing){ + m_player.facing = *facing; + } + m_player.dialog = false; + m_player.trainer.reset(); + m_player.warped.reset(); + if (m_world.can_run){ + m_player.can_run = *m_world.can_run; + } +} + +// Moves into the next tile toward `direction`, `part` of the way (a whole +// tile or less), from a position on the grid along it; adds what it moved +// to `moved`. A ledge hops two whole tiles and a door warps, however far +// the press goes. Nothing if the player may go on, else why the move ended. +std::optional GridSim::enter_next(Direction direction, int part, int& moved){ + const StoryWorld& world = *m_world.world; + const std::vector ahead = tiles_ahead(m_player.pos, direction); + if (ahead.size() == 1){ + const TilePos from = nearest_tile(m_player.pos); + const std::optional landing = PokemonBDSP::step(world, from, direction); + if (landing && *landing != ahead[0]){ + part = 2 * SIM_TILE; + }else if (!landing && (is_door(world, ahead[0]) || std::find(m_world.doors.begin(), m_world.doors.end(), ahead[0]) != m_world.doors.end())){ + m_player.pos = sim_pos(ahead[0]); + m_player.warped = ahead[0]; + moved += SIM_TILE; + return SimStop::WARP; + }else if (!landing){ + return SimStop::BLOCKED; + } + } + // Off the grid, both tiles must be open: a door frame or a corner stops us. + for (const TilePos& pos : ahead){ + if (ahead.size() > 1 && !standable(world, pos)){ + return SimStop::BLOCKED; + } + } + along(m_player.pos, direction) += sign_of(direction) * part; + moved += part; + return std::nullopt; +} + +// On reaching a whole tile: a scene's trigger or a trainer's sight stops us. +std::optional GridSim::arrive(){ + const TilePos tile = nearest_tile(m_player.pos); + for (const SimTrigger& trigger : m_world.triggers){ + const bool fired = std::find(m_player.fired.begin(), m_player.fired.end(), trigger.name) != m_player.fired.end(); + if (!fired && std::find(trigger.tiles.begin(), trigger.tiles.end(), tile) != trigger.tiles.end()){ + m_player.fired.push_back(trigger.name); + m_player.dialog = true; + return SimStop::DIALOG; + } + } + for (const TrainerSight& trainer : m_world.trainers){ + const bool fought = std::find(m_player.fought.begin(), m_player.fought.end(), trainer.name) != m_player.fought.end(); + const std::vector sight = sight_tiles(*m_world.world, trainer); + if (!fought && std::find(sight.begin(), sight.end(), tile) != sight.end()){ + m_player.dialog = true; + m_player.trainer = trainer.name; + return SimStop::DIALOG; + } + } + return std::nullopt; +} + +// Moves up to `distance` toward `direction`. +SimMove GridSim::advance(Direction direction, int distance){ + m_player.facing = direction; + if (m_player.dialog){ + return {.stop = SimStop::DIALOG}; + } + int moved = 0; + while (moved < distance){ + const int offset = along(m_player.pos, direction) % SIM_TILE; + if (offset != 0){ + // Part of a tile to the next whole one: no new tile is entered. + const int to_whole = sign_of(direction) > 0 ? SIM_TILE - offset : offset; + const int part = std::min(to_whole, distance - moved); + along(m_player.pos, direction) += sign_of(direction) * part; + moved += part; + }else if (const std::optional stop = enter_next(direction, std::min(SIM_TILE, distance - moved), moved)){ + return {.stop = *stop, .distance = moved}; + } + if (along(m_player.pos, direction) % SIM_TILE == 0){ + if (const std::optional stop = arrive()){ + return {.stop = *stop, .distance = moved}; + } + } + } + return {.stop = SimStop::DONE, .distance = moved}; +} + +SimMove GridSim::tap(Direction direction, Milliseconds hold, bool stick){ + const Milliseconds tile_time = route_ms_per_tile(direction, MoveSpeed::WALK); + if (!stick && hold < 100ms){ + m_player.facing = direction; + return {.stop = m_player.dialog ? SimStop::DIALOG : SimStop::DONE, .time = hold}; + } + // The stick moves as far as it is held; the D-pad whole tiles. + int distance = (int)(hold.count() * SIM_TILE / tile_time.count()); + if (!stick){ + distance = std::max(1, (int)((hold + tile_time / 2) / tile_time)) * SIM_TILE; + } + SimMove move = advance(direction, distance); + move.time = hold; + return move; +} + +SimMove GridSim::hold(Direction direction, Milliseconds limit, SimPace pace){ + const Milliseconds tile_time = per_tile(direction, pace, m_player.can_run); + const int64_t tiles = limit < ROUTE_START_DELAY ? 0 : (limit - ROUTE_START_DELAY) / tile_time; + int moved = 0; + SimStop stop = SimStop::LIMIT; + while (moved < tiles * SIM_TILE){ + const SimMove step = advance(direction, SIM_TILE); + moved += step.distance; + if (step.stop != SimStop::DONE){ + stop = step.stop; + break; + } + } + if (stop == SimStop::LIMIT){ + return {.stop = stop, .distance = moved, .time = limit}; + } + // A stop is seen once the screen has held still a while. + const Milliseconds seen = stop == SimStop::BLOCKED ? 800ms : 0ms; + return {.stop = stop, .distance = moved, .time = ROUTE_START_DELAY + tile_time * moved / SIM_TILE + seen}; +} + +bool GridSim::press_a(){ + if (m_player.dialog){ + return true; + } + const StoryWorld& world = *m_world.world; + const TilePos faced = nearest_tile(m_player.pos).next(m_player.facing); + const std::optional tile = world.tile(faced); + m_player.dialog = world.occupied(faced) != StoryWorld::NOBODY || (tile && *tile == TileKind::SIGN); + for (const TrainerSight& trainer : m_world.trainers){ + if (m_player.dialog && trainer.pos == faced){ + m_player.trainer = trainer.name; + } + } + return m_player.dialog; +} + +void GridSim::end_dialogue(){ + if (m_player.trainer){ + m_player.fought.push_back(*m_player.trainer); + } + m_player.trainer.reset(); + m_player.dialog = false; +} + + +bool sim_can_step(const GridSim& sim, Direction direction){ + GridSim probe = sim; + const SimMove move = probe.tap(direction, ROUTE_MS_PER_TILE_ACROSS, false); + return move.distance > 0; +} + + +namespace{ + +WalkStop walk_stop(SimStop stop){ + switch (stop){ + case SimStop::BLOCKED: + return WalkStop::BLOCKED; + case SimStop::DIALOG: + return WalkStop::DIALOG; + case SimStop::WARP: + return WalkStop::TRANSITION; + case SimStop::DONE: + case SimStop::LIMIT: + return WalkStop::LIMIT; + } + return WalkStop::LIMIT; +} + +} // namespace + +WalkResult GridSimRouteIO::walk_to_wall(const RouteWallWalk& walk){ + const SimPace pace = walk.options.pace == DpadPace::RUN ? SimPace::RUN : SimPace::WALK; + const SimMove move = m_sim.hold(walk.direction, walk.limit, pace); + m_notes.push_back(std::format("[Sim] {}: held {} to {} at {}.", walk.label, direction_name(walk.direction), + sim_stop_name(move.stop), to_string(m_sim.player().pos))); + return {.stop = walk_stop(move.stop), .walked = move.time, .wild_battles = 0, .moved_chunks = (size_t)(move.distance / SIM_TILE)}; +} + +// A tap a tile, as walk_tiles_dpad() taps: two taps in a row that move +// nothing end the walk. +WalkResult GridSimRouteIO::walk_taps(Direction direction, size_t tiles, const std::string& label){ + WalkResult result{.stop = WalkStop::LIMIT, .walked = 0ms, .wild_battles = 0, .moved_chunks = 0}; + size_t still = 0; + for (size_t tap = 0; tap < tiles && result.stop == WalkStop::LIMIT; tap++){ + const SimMove move = m_sim.tap(direction, ROUTE_MS_PER_TILE_ACROSS, false); + result.walked += 800ms; + still = move.distance > 0 ? 0 : still + 1; + result.moved_chunks += move.distance > 0 ? 1 : 0; + if (move.stop == SimStop::DIALOG || move.stop == SimStop::WARP){ + result.stop = walk_stop(move.stop); + }else if (still >= 2){ + result.stop = WalkStop::BLOCKED; + } + } + m_notes.push_back(std::format("[Sim] {}: {} taps {}, {} moved, {} at {}.", label, tiles, direction_name(direction), + result.moved_chunks, walk_stop_name(result.stop), to_string(m_sim.player().pos))); + return result; +} + +void GridSimRouteIO::press(const RouteInput& input){ + for (size_t press = 0; press < input.count; press++){ + switch (input.kind){ + case RouteInputKind::DPAD: + m_sim.tap(input.direction, input.hold, false); + break; + case RouteInputKind::STICK: + m_sim.tap(input.direction, input.hold, true); + break; + case RouteInputKind::BUTTON_A: + m_sim.press_a(); + break; + case RouteInputKind::WAIT: + case RouteInputKind::PAUSE: + break; + } + } +} + +bool GridSimRouteIO::dialog_within(Milliseconds, Color){ + return m_sim.player().dialog; +} + +bool GridSimRouteIO::holding_against_wall(Direction direction, const std::string&){ + return !sim_can_step(m_sim, direction); +} + +void GridSimRouteIO::look(const std::function&){} + +void GridSimRouteIO::note(const std::string& text, RouteNote kind){ + m_notes.push_back(text); + if (kind == RouteNote::FAILURE){ + throw GridSimFailure{text}; + } +} + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h new file mode 100644 index 0000000000..727aac0518 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h @@ -0,0 +1,190 @@ +/* BDSP AutoStory - Grid simulation + * + * From: https://github.com/PokemonAutomation/ + * + * A player walking on a story world, by the movement measured on the + * console, so walks that go wrong only on the Switch go wrong in tests: + * + * - A D-pad tap of 40 to 80 ms only turns; 160 ms steps a whole tile. + * - The D-pad held walks whole tiles until something stops it. + * - A stick tap moves part of a tile (100 ms about 0.6 tile across), and + * later D-pad moves keep that offset: the player is off the tile grid. + * - Walls, signs and people on their tiles stop the player. Off the grid + * the player covers two tiles, and both must be open: a door frame or + * a tree corner stops a player a part of a tile off. + * - Walking onto a door warps, but only from its exact column or row. + * - A trainer whose sight line the player steps into stops the walk with + * their dialogue; so does a scene's trigger tile. + * + * Positions are in thousandths of a tile, so parts of tiles add up exactly. + * GridSimRouteIO is the walker's RouteIO on a GridSim. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_GridSim_H +#define PokemonAutomation_PokemonBDSP_AutoStory_GridSim_H + +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RoutePlanner.h" +#include "PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryWorld.h" +#include "PokemonBDSP_AutoStory_RouteIO.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// A tile, in the simulation's units. +inline constexpr int SIM_TILE = 1000; + +// A world position in thousandths of a tile: the player covers the square +// from (x, y) to (x + SIM_TILE, y + SIM_TILE). +struct SimPos{ + int x = 0; + int y = 0; + bool operator==(const SimPos& other) const = default; +}; +// Converts a TilePos to its fixed-point fractional simulation coordinate. +SimPos sim_pos(const Pokemon::TilePos& tile); +// Whether the player stands exactly on a tile. +bool on_grid(const SimPos& pos); +// The tile the player mostly stands on. +Pokemon::TilePos nearest_tile(const SimPos& pos); +// "(12.375, 5)". +std::string to_string(const SimPos& pos); + +// A scene that starts when the player steps onto one of `tiles` (a coord +// event): its dialogue opens and the player stops. It fires once. +struct SimTrigger{ + std::string name; + std::vector tiles; +}; + +// Where the simulated player walks: the world (which must outlive the +// simulation), its trainers and its scene triggers, in world tiles; and +// the tiles that warp like doors though the maps draw them as walls (a +// house's stairs, entered from beside them); and, if the caller knows, +// whether B runs from here on (the Running Shoes, 00.03). +struct SimWorld{ + const StoryWorld* world = nullptr; + std::vector trainers = {}; + std::vector triggers = {}; + std::vector doors = {}; + std::optional can_run = std::nullopt; +}; + +// Reason why simulated player movement stopped. +enum class SimStop{ + DONE, // the input finished + BLOCKED, // stopped by a wall, a person, or a door off its column + DIALOG, // a trainer saw us or a scene started; the player is frozen + WARP, // stepped onto a door + LIMIT, // a held walk ran out of time +}; +// Returns the debug string for a SimStop reason. +const char* sim_stop_name(SimStop stop); + +// What one input did. +struct SimMove{ + SimStop stop = SimStop::DONE; + int distance = 0; // thousandths of a tile + Milliseconds time = Milliseconds(0); +}; + +// The player as the simulation has them. +struct SimPlayer{ + SimPos pos; + Pokemon::Direction facing = Pokemon::Direction::NORTH; + // Whether B runs: the Running Shoes (00.03). + bool can_run = false; + // A dialogue is open: a trainer's, a scene's or a talk's. + bool dialog = false; + // The trainer whose dialogue is open, if one is. + std::optional trainer; + // The door the last warp stepped onto. + std::optional warped; + std::vector fought = {}; + std::vector fired = {}; +}; + +// How long a held walk takes per tile in `direction`. +enum class SimPace{ + WALK, + RUN, // B held: a run once the player has the Running Shoes +}; + +// Simulates grid-based player movement and collisions in BDSP. +class GridSim{ +public: + GridSim(SimWorld world, const Pokemon::TilePos& start, Pokemon::Direction facing, bool can_run); + + // One press toward `direction`: the D-pad or the stick, held for `hold`. + SimMove tap(Pokemon::Direction direction, Milliseconds hold, bool stick); + // Holds toward `direction` until something stops the player or `limit` + // runs out. Moves whole tiles, from wherever the player stands. + SimMove hold(Pokemon::Direction direction, Milliseconds limit, SimPace pace); + // A: talks to a person or a sign on the faced tile. Returns whether a + // dialogue is open. + bool press_a(); + // The open dialogue ends: a trainer's battle is won and they see us no more. + void end_dialogue(); + // Puts the player on `start` of `world`: after a warp or a scene. + void place(SimWorld world, const Pokemon::TilePos& start, std::optional facing); + + const SimPlayer& player() const{ + return m_player; + } + const StoryWorld& world() const{ + return *m_world.world; + } + +private: + SimMove advance(Pokemon::Direction direction, int distance); + std::optional enter_next(Pokemon::Direction direction, int part, int& moved); + std::optional arrive(); + + SimWorld m_world; + SimPlayer m_player; +}; + +// Whether the player could take a step toward `direction` from where they stand. +bool sim_can_step(const GridSim& sim, Pokemon::Direction direction); + + +// The walker's RouteIO on a GridSim. A failure throws GridSimFailure. +class GridSimRouteIO : public RouteIO{ +public: + // Every line noted goes to `notes`, which must outlive this. + GridSimRouteIO(GridSim& sim, std::vector& notes) + : m_sim(sim) + , m_notes(notes) + {} + + virtual WalkResult walk_to_wall(const RouteWallWalk& walk) override; + virtual WalkResult walk_taps(Pokemon::Direction direction, size_t tiles, const std::string& label) override; + virtual void press(const RouteInput& input) override; + virtual bool dialog_within(Milliseconds wait, Color color) override; + virtual bool holding_against_wall(Pokemon::Direction direction, const std::string& label) override; + virtual void look(const std::function& at) override; + virtual void note(const std::string& text, RouteNote kind) override; + +private: + GridSim& m_sim; + std::vector& m_notes; +}; + +// What GridSimRouteIO throws for RouteNote::FAILURE. +struct GridSimFailure{ + std::string message; +}; + + + +} // namespace PokemonBDSP +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 5db22e18b2..23895f6d37 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1569,6 +1569,8 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryTable.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySimulation_Tests.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySimulation_Tests.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_GridSim.h Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Indoors.cpp Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Indoors.h Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp @@ -1692,8 +1694,14 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Settings.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryExecutor.h + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryStepIO.cpp + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryStepIO.h + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.cpp + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryWalkSteps.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryPlaces.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryPlaces.h + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySim_Tests.cpp + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StorySim_Tests.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryTable_Tests.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryTable_Tests.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Talks.cpp @@ -1702,6 +1710,8 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Walks.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_WildBattle.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_WildBattle.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_CoordEvents.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_CoordEvents.h Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Maps.cpp Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Maps.h Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RoutePlanner.cpp @@ -1712,6 +1722,8 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Maps_Tests.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_PlannedRoute_Tests.h + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.cpp + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_GridSim_Tests.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_RoutePlanner_Tests.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryMaps_Tests.cpp