From 49808cce72d3fd22e2b1511d855612165c5d83d3 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 18:53:15 -0700 Subject: [PATCH 01/19] Pokemon navigation: tile grids and map sets on the shared planner BDSP's hand-traced tile grids, now on the shared Direction and TilePos. plan_tile_route() runs Pokemon::plan_legs() on the grid's step rules (a ledge hop is one press of two tiles); plan_robust_route() keeps its own search over sets of possible tiles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Pokemon/Navigation/Pokemon_MapSet.cpp | 102 ++++ .../Pokemon/Navigation/Pokemon_MapSet.h | 63 +++ .../Pokemon/Navigation/Pokemon_TileGrid.cpp | 499 ++++++++++++++++++ .../Pokemon/Navigation/Pokemon_TileGrid.h | 177 +++++++ 4 files changed, 841 insertions(+) create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapSet.cpp create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapSet.h create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_TileGrid.cpp create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_TileGrid.h diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapSet.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapSet.cpp new file mode 100644 index 0000000000..533dc4f12f --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapSet.cpp @@ -0,0 +1,102 @@ +/* Map Set + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Common/Cpp/Exceptions.h" +#include "Pokemon_MapSet.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +void MapSet::add(std::string name, const TileGrid& grid, const TilePos& offset){ + for (const Placed& placed : m_maps){ + if (placed.name == name){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Map placed twice: " + name); + } + // Map images show a border of trees past their edges, so two maps + // may share blocked tiles, but nothing else. + for (int y = 0; y < grid.height(); y++){ + for (int x = 0; x < grid.width(); x++){ + const TilePos other{offset.x + x - placed.offset.x, offset.y + y - placed.offset.y}; + if (!placed.grid.in_bounds(other)){ + continue; + } + if (grid.at({x, y}) != TileType::BLOCKED || placed.grid.at(other) != TileType::BLOCKED){ + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + "Map " + name + " overlaps " + placed.name + " at its tile " + to_string(TilePos{x, y}) + "." + ); + } + } + } + } + m_maps.push_back(Placed{std::move(name), grid, offset}); + + // Rebuild the joined grid. + TilePos low = m_maps[0].offset; + TilePos high = low; + for (const Placed& placed : m_maps){ + low.x = std::min(low.x, placed.offset.x); + low.y = std::min(low.y, placed.offset.y); + high.x = std::max(high.x, placed.offset.x + placed.grid.width()); + high.y = std::max(high.y, placed.offset.y + placed.grid.height()); + } + m_origin = low; + std::vector rows(high.y - low.y, std::string(high.x - low.x, tile_char(TileType::UNKNOWN))); + for (const Placed& placed : m_maps){ + const std::vector map_rows = placed.grid.rows(); + for (size_t y = 0; y < map_rows.size(); y++){ + rows[placed.offset.y - low.y + y].replace(placed.offset.x - low.x, map_rows[y].size(), map_rows[y]); + } + } + m_world.emplace(rows); +} + +const TileGrid& MapSet::world() const{ + if (!m_world){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No maps placed."); + } + return *m_world; +} + +TilePos MapSet::to_world(const std::string& map, const TilePos& local) const{ + for (const Placed& placed : m_maps){ + if (placed.name == map){ + return { + placed.offset.x - m_origin.x + local.x, + placed.offset.y - m_origin.y + local.y, + }; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Unknown map: " + map); +} + +std::optional MapSet::to_local(const TilePos& world) const{ + for (const Placed& placed : m_maps){ + const TilePos local{ + world.x + m_origin.x - placed.offset.x, + world.y + m_origin.y - placed.offset.y, + }; + if (placed.grid.in_bounds(local)){ + return MapTile{placed.name, local}; + } + } + return std::nullopt; +} + +std::string MapSet::describe(const TilePos& world) const{ + const std::optional local = to_local(world); + if (!local){ + return "world " + to_string(world); + } + return local->map + " " + to_string(local->pos); +} + + + +} +} diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapSet.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapSet.h new file mode 100644 index 0000000000..468213c481 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_MapSet.h @@ -0,0 +1,63 @@ +/* Map Set + * + * From: https://github.com/PokemonAutomation/ + * + * Several tile grids placed side by side on one world grid, so one plan + * can cross from a route into a town. + */ + +#ifndef PokemonAutomation_Pokemon_MapSet_H +#define PokemonAutomation_Pokemon_MapSet_H + +#include +#include +#include +#include "Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +struct MapTile{ + std::string map; + TilePos pos; +}; + + +class MapSet{ +public: + // Places `grid` with its tile (0,0) at `offset`. Offsets are relative to + // each other and may be negative. Maps may overlap only where both are + // blocked; the map placed first holds those tiles. Throws + // InternalProgramError if the name is taken or the maps conflict. + void add(std::string name, const TileGrid& grid, const TilePos& offset); + + // The maps joined on one grid. Tiles no map covers are UNKNOWN. World + // tile (0,0) is the top-left corner of all the maps together. + const TileGrid& world() const; + + // Throws InternalProgramError for an unknown map name. + TilePos to_world(const std::string& map, const TilePos& local) const; + + // The map that holds a world tile, or nothing between maps. + std::optional to_local(const TilePos& world) const; + + // "Route 201 (45,3)", or the world tile if no map holds it. + std::string describe(const TilePos& world) const; + +private: + struct Placed{ + std::string name; + TileGrid grid; + TilePos offset; + }; + std::vector m_maps; + TilePos m_origin; // offset of world tile (0,0) + std::optional m_world; +}; + + + +} +} +#endif diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_TileGrid.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_TileGrid.cpp new file mode 100644 index 0000000000..c596747789 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_TileGrid.cpp @@ -0,0 +1,499 @@ +/* Tile Grid + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Pokemon_LegPlanner.h" +#include "Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +char tile_char(TileType type){ + switch (type){ + case TileType::BLOCKED: return '#'; + case TileType::WALKABLE: return '.'; + case TileType::TALL_GRASS: return '"'; + case TileType::LEDGE: return 'v'; + case TileType::NPC: return 'N'; + case TileType::SIGN: return 'S'; + case TileType::DOOR: return 'D'; + case TileType::UNKNOWN: return '?'; + } + return '?'; +} + + + + +TileGrid::TileGrid(const std::vector& rows) + : m_width(rows.empty() ? 0 : (int)rows[0].size()) + , m_height((int)rows.size()) +{ + m_tiles.reserve((size_t)m_width * m_height); + for (size_t y = 0; y < rows.size(); y++){ + const std::string& row = rows[y]; + if ((int)row.size() != m_width){ + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + "Tile grid row " + std::to_string(y) + " has " + std::to_string(row.size()) + + " tiles, expected " + std::to_string(m_width) + "." + ); + } + for (size_t x = 0; x < row.size(); x++){ + switch (row[x]){ + case '#': m_tiles.push_back(TileType::BLOCKED); break; + case '.': m_tiles.push_back(TileType::WALKABLE); break; + case '"': m_tiles.push_back(TileType::TALL_GRASS); break; + case 'v': m_tiles.push_back(TileType::LEDGE); break; + case 'N': m_tiles.push_back(TileType::NPC); break; + case 'S': m_tiles.push_back(TileType::SIGN); break; + case 'D': m_tiles.push_back(TileType::DOOR); break; + case '?': m_tiles.push_back(TileType::UNKNOWN); break; + default: + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + std::string("Unknown tile '") + row[x] + "' at " + + to_string(TilePos{(int)x, (int)y}) + "." + ); + } + } + } +} + +std::vector TileGrid::rows() const{ + std::vector rows; + for (int y = 0; y < m_height; y++){ + std::string row; + for (int x = 0; x < m_width; x++){ + row += tile_char(at({x, y})); + } + rows.push_back(std::move(row)); + } + return rows; +} + +bool TileGrid::in_bounds(const TilePos& pos) const{ + return 0 <= pos.x && pos.x < m_width && 0 <= pos.y && pos.y < m_height; +} + +TileType TileGrid::at(const TilePos& pos) const{ + if (!in_bounds(pos)){ + return TileType::UNKNOWN; + } + return m_tiles[(size_t)pos.y * m_width + pos.x]; +} + +void TileGrid::set(const TilePos& pos, TileType type){ + if (in_bounds(pos)){ + m_tiles[(size_t)pos.y * m_width + pos.x] = type; + } +} + +bool TileGrid::standable(const TilePos& pos) const{ + const TileType type = at(pos); + return type == TileType::WALKABLE || type == TileType::TALL_GRASS; +} + +std::optional TileGrid::step(const TilePos& from, Direction direction) const{ + const TilePos next = from.next(direction); + if (standable(next)){ + return next; + } + if (at(next) == TileType::LEDGE && direction == Direction::SOUTH){ + const TilePos landing = next.next(direction); + if (standable(landing)){ + return landing; + } + } + return std::nullopt; +} + +bool TileGrid::at_wall(const TilePos& pos, Direction direction) const{ + const TileType next = at(pos.next(direction)); + return next != TileType::DOOR && next != TileType::UNKNOWN && !step(pos, direction).has_value(); +} + + + +WallHit walk_to_wall(const TileGrid& grid, const TilePos& from, Direction direction){ + WallHit hit; + hit.stop = from; + while (true){ + const TilePos before = hit.stop; + const std::optional next = grid.step(before, direction); + if (!next){ + hit.wall = before.next(direction); + hit.anchored = grid.at_wall(before, direction); + return hit; + } + hit.tiles += std::abs(next->x - before.x) + std::abs(next->y - before.y); + hit.stop = *next; + } +} + + + +std::string to_string(const TileLeg& leg){ + return std::string(direction_name(leg.direction)) + " " + std::to_string(leg.tiles) + + (leg.ends_at_wall ? " (to wall)" : " (open)"); +} + + +namespace{ + +// The grid's step rules for the shared leg planner. A ledge hop is one +// press that moves two tiles. +class TileGridModel{ +public: + using State = TilePos; + using StateHash = std::hash; + + TileGridModel(const TileGrid& grid, const TilePlanCosts& costs) + : m_grid(grid) + , m_costs(costs) + {} + + std::optional> press(const State& pos, Direction direction) const{ + const std::optional next = m_grid.step(pos, direction); + if (!next){ + return std::nullopt; + } + Step step; + step.next = *next; + step.facing = direction; + const int tiles = std::abs(next->x - pos.x) + std::abs(next->y - pos.y); + step.extra_cost = m_costs.per_tile * (tiles - 1); + if (m_grid.at(*next) == TileType::TALL_GRASS){ + step.extra_cost += m_costs.per_grass_tile; + } + return step; + } + bool held_at(const State& pos, Direction direction) const{ + return m_grid.at_wall(pos, direction); + } + +private: + const TileGrid& m_grid; + const TilePlanCosts& m_costs; +}; + +} + +std::optional> plan_tile_route( + const TileGrid& grid, + const TilePos& from, const TilePos& to, + const TilePlanCosts& costs +){ + if (!grid.standable(from) || !grid.standable(to)){ + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + "Route ends must be standable: " + to_string(from) + " -> " + to_string(to) + "." + ); + } + if (from == to){ + return std::vector(); + } + LegCosts leg_costs; + leg_costs.per_tile = costs.per_tile; + leg_costs.per_turn = costs.per_leg; + leg_costs.per_open_stop = costs.leg_not_at_wall; + const std::optional>> legs = plan_legs( + TileGridModel(grid, costs), from, std::nullopt, + std::function([&](const TilePos& pos){ return pos == to; }), + std::nullopt, leg_costs + ); + if (!legs){ + return std::nullopt; + } + std::vector ret; + for (const Leg& leg : *legs){ + const size_t tiles = std::abs(leg.end.x - leg.start.x) + std::abs(leg.end.y - leg.start.y); + ret.push_back(TileLeg{leg.direction, tiles, leg.to_wall}); + } + return ret; +} + +std::vector sight_tiles(const TileGrid& grid, const TrainerSight& trainer){ + std::vector tiles; + TilePos pos = trainer.pos; + for (int i = 0; i < trainer.range; i++){ + pos = pos.next(trainer.facing); + if (!grid.standable(pos)){ + break; + } + tiles.push_back(pos); + } + return tiles; +} + +std::vector> legs_crossing_sight( + const TileGrid& grid, + const TilePos& from, + const std::vector& legs, + const std::vector& trainers +){ + std::vector> sights; + for (const TrainerSight& trainer : trainers){ + sights.push_back(sight_tiles(grid, trainer)); + } + std::vector> crossings; + TilePos pos = from; + for (const TileLeg& leg : legs){ + const std::vector tiles = trace_legs(grid, pos, {leg}); + std::vector seen_by; + for (size_t t = 0; t < trainers.size(); t++){ + for (size_t i = 1; i < tiles.size(); i++){ + if (std::find(sights[t].begin(), sights[t].end(), tiles[i]) != sights[t].end()){ + seen_by.push_back(trainers[t].name); + break; + } + } + } + crossings.push_back(std::move(seen_by)); + pos = tiles.back(); + } + return crossings; +} + +std::vector trace_legs( + const TileGrid& grid, + const TilePos& from, + const std::vector& legs +){ + std::vector tiles{from}; + for (const TileLeg& leg : legs){ + size_t travelled = 0; + while (travelled < leg.tiles){ + const TilePos pos = tiles.back(); + const std::optional next = grid.step(pos, leg.direction); + if (!next){ + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + "Leg " + to_string(leg) + " is stopped at " + to_string(pos) + "." + ); + } + travelled += std::abs(next->x - pos.x) + std::abs(next->y - pos.y); + tiles.push_back(*next); + } + if (travelled != leg.tiles){ + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + "Leg " + to_string(leg) + " overshoots on a ledge hop." + ); + } + } + return tiles; +} + + + +std::string to_string(const PlannedLeg& leg){ + std::string out = to_string(leg.leg) + " to"; + for (const TilePos& pos : leg.after){ + out += " " + to_string(pos); + } + return out; +} + +namespace{ + +// Where `from` ends up after `tiles` steps in `direction`, stopping early at +// anything that stops the player. Nothing if a step would leave the map or +// enter a door: the plan cannot tell what happens then. +std::optional step_tiles( + const TileGrid& grid, TilePos from, Direction direction, int tiles, int& grass +){ + int travelled = 0; + while (travelled < tiles){ + const std::optional next = grid.step(from, direction); + if (!next){ + if (!grid.at_wall(from, direction)){ + return std::nullopt; + } + break; + } + travelled += std::abs(next->x - from.x) + std::abs(next->y - from.y); + if (grid.at(*next) == TileType::TALL_GRASS){ + grass++; + } + from = *next; + } + return from; +} + +using Spread = std::vector; + +bool spread_less(const TilePos& a, const TilePos& b){ + return a.y != b.y ? a.y < b.y : a.x < b.x; +} +void normalize(Spread& spread){ + std::sort(spread.begin(), spread.end(), spread_less); + spread.erase(std::unique(spread.begin(), spread.end()), spread.end()); +} +struct SpreadLess{ + bool operator()(const Spread& a, const Spread& b) const{ + return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(), spread_less); + } +}; + +} + +std::optional> plan_robust_route( + const TileGrid& grid, + const TilePos& from, const TilePos& to, + const RobustPlanCosts& costs +){ + if (!grid.standable(from) || !grid.standable(to)){ + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + "Route ends must be standable: " + to_string(from) + " -> " + to_string(to) + "." + ); + } + return plan_robust_route(grid, std::vector{from}, [&](const TilePos& pos){ return pos == to; }, costs); +} + +std::optional> plan_robust_route( + const TileGrid& grid, + const std::vector& from, const std::function& goal, + const RobustPlanCosts& costs +){ + for (const TilePos& pos : from){ + if (!grid.standable(pos)){ + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + "Route starts must be standable: " + to_string(pos) + "." + ); + } + } + // Only the start may be wider than costs.max_spread: every leg must bring + // the player within it. + const size_t max_spread = costs.max_spread; + auto reached = [&](const Spread& spread){ + return std::all_of(spread.begin(), spread.end(), goal); + }; + + struct Node{ + long long cost; + Spread parent; + PlannedLeg leg; + bool done = false; + }; + std::map nodes; + using Entry = std::pair; + auto entry_greater = [](const Entry& a, const Entry& b){ return a.first > b.first; }; + std::priority_queue, decltype(entry_greater)> queue(entry_greater); + + Spread start = from; + normalize(start); + nodes[start] = Node{0, {}, {}}; + queue.emplace(0, start); + + auto relax = [&](const Spread& spread, long long cost, Spread next, PlannedLeg leg){ + normalize(next); + if (next.size() > max_spread || next == spread){ + return; + } + auto iter = nodes.find(next); + if (iter != nodes.end() && iter->second.cost <= cost){ + return; + } + leg.after = next; + nodes[next] = Node{cost, spread, std::move(leg)}; + queue.emplace(cost, std::move(next)); + }; + + while (!queue.empty()){ + const Entry top = queue.top(); + queue.pop(); + const long long cost = top.first; + const Spread& spread = top.second; + Node& node = nodes[spread]; + if (node.done || node.cost != cost){ + continue; + } + node.done = true; + if (reached(spread)){ + std::vector legs; + for (Spread at = spread; at != start; ){ + const Node& step = nodes[at]; + legs.push_back(step.leg); + at = step.parent; + } + std::reverse(legs.begin(), legs.end()); + return legs; + } + + for (Direction direction : ALL_DIRECTIONS){ + // To the wall: every position walks until stopped. + { + Spread next; + size_t longest = 0; + int grass = 0; + bool ok = true; + for (const TilePos& pos : spread){ + const WallHit hit = walk_to_wall(grid, pos, direction); + if (!hit.anchored){ + ok = false; + break; + } + int leg_grass = 0; + step_tiles(grid, pos, direction, (int)hit.tiles, leg_grass); + grass = std::max(grass, leg_grass); + longest = std::max(longest, hit.tiles); + next.push_back(hit.stop); + } + if (ok && longest > 0){ + const long long step = (long long)costs.per_leg + + (long long)costs.per_tile * longest + (long long)costs.per_grass_tile * grass; + relax(spread, cost + step, std::move(next), PlannedLeg{TileLeg{direction, longest, true}, {}}); + } + } + // Open: about `tiles` tiles, give or take `open_error`. + for (int tiles = 1; tiles <= costs.max_open_tiles; tiles++){ + Spread next; + int grass = 0; + bool ok = true; + for (const TilePos& pos : spread){ + const int error = costs.open_error + tiles / costs.open_error_per; + for (int n = std::max(1, tiles - error); n <= tiles + error; n++){ + int leg_grass = 0; + const std::optional end = step_tiles(grid, pos, direction, n, leg_grass); + if (!end){ + ok = false; + break; + } + grass = std::max(grass, leg_grass); + next.push_back(*end); + } + if (!ok){ + break; + } + } + if (!ok){ + break; + } + const long long step = (long long)costs.per_leg + costs.leg_not_at_wall + + (long long)costs.per_tile * tiles + (long long)costs.per_grass_tile * grass; + relax(spread, cost + step, std::move(next), PlannedLeg{TileLeg{direction, (size_t)tiles, false}, {}}); + } + } + } + + return std::nullopt; +} + + + +} +} diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_TileGrid.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_TileGrid.h new file mode 100644 index 0000000000..e7ac5ae15a --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_TileGrid.h @@ -0,0 +1,177 @@ +/* Tile Grid + * + * From: https://github.com/PokemonAutomation/ + * + * An overworld map as a grid of tiles, and a planner that turns a walk from + * one tile to another into straight legs. Timed stick holds drift by a tile; + * a leg that ends against a wall does not, because the player stops there + * however long the stick is held. The planner prefers such legs. + */ + +#ifndef PokemonAutomation_Pokemon_TileGrid_H +#define PokemonAutomation_Pokemon_TileGrid_H + +#include +#include +#include +#include +#include +#include "Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// Legend of the ASCII rows a grid is parsed from. +enum class TileType{ + BLOCKED, // '#': trees, walls, items on the ground + WALKABLE, // '.': open ground and paths + TALL_GRASS, // '"': walkable, with wild encounters + LEDGE, // 'v': crossed only southward, hopping to the tile past it + NPC, // 'N': a standing NPC or trainer; blocks like a wall + SIGN, // 'S': a sign; blocks like a wall + DOOR, // 'D': walking into it enters a building + UNKNOWN, // '?': off the map; an exit or ground we have no map of +}; +char tile_char(TileType type); + +class TileGrid{ +public: + // Every row must have the same length and use only the legend's characters. + // Throws InternalProgramError otherwise. + explicit TileGrid(const std::vector& rows); + + // The grid as the ASCII rows it parses from. + std::vector rows() const; + + int width() const{ return m_width; } + int height() const{ return m_height; } + bool in_bounds(const TilePos& pos) const; + + // Out-of-bounds tiles read as UNKNOWN. + TileType at(const TilePos& pos) const; + + // Changes one tile, e.g. to block one found in the way on the console. + // Out-of-bounds tiles are left alone. + void set(const TilePos& pos, TileType type); + + // Whether the player can stand on this tile. + bool standable(const TilePos& pos) const; + + // Where one step from `from` lands, or nothing if the player is stopped. + // A south step onto a ledge hops to the tile past it. + std::optional step(const TilePos& from, Direction direction) const; + + // Whether a step from `pos` is refused by a tile that holds the player + // in place. Doors and unknown tiles are not walls: walking into a door + // enters the building, and unknown ground may be an exit. + bool at_wall(const TilePos& pos, Direction direction) const; + +private: + int m_width = 0; + int m_height = 0; + std::vector m_tiles; +}; + + +// Where the player stops when walking in one direction until stopped. +struct WallHit{ + TilePos stop; + size_t tiles = 0; // tiles travelled; a ledge hop counts 2 + TilePos wall; // the first tile the player could not enter + bool anchored = false; // the player is held at `stop` (see TileGrid::at_wall()) +}; +WallHit walk_to_wall(const TileGrid& grid, const TilePos& from, Direction direction); + + +// A straight run in one direction. +struct TileLeg{ + Direction direction; + size_t tiles = 0; // tiles travelled; a ledge hop counts 2 + bool ends_at_wall = false; // the next tile in `direction` stops the player +}; +std::string to_string(const TileLeg& leg); + +struct TilePlanCosts{ + int per_tile = 1; + int per_grass_tile = 4; // extra for each tall-grass tile entered + int per_leg = 8; + int leg_not_at_wall = 50; // extra for a leg that stops in the open +}; + +// Cheapest legs from `from` to `to`, moving 4 ways, or nothing if `to` +// cannot be reached. Both tiles must be standable. +std::optional> plan_tile_route( + const TileGrid& grid, + const TilePos& from, const TilePos& to, + const TilePlanCosts& costs = TilePlanCosts() +); + +// A trainer sees the tiles in a straight line in front of them, up to +// `range` tiles or the first tile the player cannot stand on. +struct TrainerSight{ + std::string name; + TilePos pos; + Direction facing; + int range = 4; +}; +std::vector sight_tiles(const TileGrid& grid, const TrainerSight& trainer); + +// For each leg, the trainers whose sight line it enters. +std::vector> legs_crossing_sight( + const TileGrid& grid, + const TilePos& from, + const std::vector& legs, + const std::vector& trainers +); + +// The tiles a list of legs visits, starting with `from`. Throws +// InternalProgramError if a leg is stopped before its end. +std::vector trace_legs( + const TileGrid& grid, + const TilePos& from, + const std::vector& legs +); + + +// A leg of a plan that allows for open legs going a tile short or long. A +// leg to a wall is walked until the player stops; an open leg is walked for +// `leg.tiles` tiles' worth of time. `after` lists every tile the player may +// stand on once the leg is done. +struct PlannedLeg{ + TileLeg leg; + std::vector after; +}; +std::string to_string(const PlannedLeg& leg); + +struct RobustPlanCosts : TilePlanCosts{ + // An open leg of n tiles may end open_error + n / open_error_per tiles + // short or long: on the console 7 tiles went 9. + int open_error = 1; + int open_error_per = 5; + int max_open_tiles = 12; // longest open leg to consider + size_t max_spread = 5; // most tiles the player may be on after a leg +}; + +// Cheapest legs that take the player from any of the tiles in `from` to tiles +// that are all `goal`, even if every open leg ends up to `open_error` tiles +// off: the walls of later legs must bring the possible positions together. +// Legs that would leave the map or enter a door are not used. Nothing if no +// such plan exists. +std::optional> plan_robust_route( + const TileGrid& grid, + const std::vector& from, const std::function& goal, + const RobustPlanCosts& costs = RobustPlanCosts() +); +// The same, from one tile to exactly `to`. +std::optional> plan_robust_route( + const TileGrid& grid, + const TilePos& from, const TilePos& to, + const RobustPlanCosts& costs = RobustPlanCosts() +); + + + +} +} +#endif From 04aa818c9999fa805de468f4b72286c324c716d7 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 18:53:15 -0700 Subject: [PATCH 02/19] BDSP AutoStory: story maps from Route 201 to Route 204 Tile grids traced from the DP maps, joined into map sets, with named tiles and trainer sight lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonBDSP_AutoStory_JubilifeCity.cpp | 112 ++++++++++++++ .../Data/PokemonBDSP_AutoStory_JubilifeCity.h | 43 ++++++ .../PokemonBDSP_AutoStory_OreburghMine.cpp | 77 ++++++++++ .../Data/PokemonBDSP_AutoStory_OreburghMine.h | 39 +++++ .../PokemonBDSP_AutoStory_RavagedPath.cpp | 111 ++++++++++++++ .../Data/PokemonBDSP_AutoStory_RavagedPath.h | 47 ++++++ .../Data/PokemonBDSP_AutoStory_Route201.cpp | 74 +++++++++ .../Data/PokemonBDSP_AutoStory_Route201.h | 40 +++++ .../Data/PokemonBDSP_AutoStory_Route202.cpp | 106 +++++++++++++ .../Data/PokemonBDSP_AutoStory_Route202.h | 49 ++++++ .../Data/PokemonBDSP_AutoStory_Route204.cpp | 81 ++++++++++ .../Data/PokemonBDSP_AutoStory_Route204.h | 39 +++++ .../PokemonBDSP_AutoStory_SandgemTown.cpp | 77 ++++++++++ .../Data/PokemonBDSP_AutoStory_SandgemTown.h | 35 +++++ .../Data/PokemonBDSP_AutoStory_StoryMaps.cpp | 140 ++++++++++++++++++ .../Data/PokemonBDSP_AutoStory_StoryMaps.h | 70 +++++++++ 16 files changed, 1140 insertions(+) create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.cpp new file mode 100644 index 0000000000..c27c00b3e2 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.cpp @@ -0,0 +1,112 @@ +/* BDSP AutoStory - Jubilife City tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_JubilifeCity.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace JubilifeCity{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map of Jubilife City (1024 x 772 px): 16 px tiles +// from the top-left corner; the last 4 px rows are left out. Road and paved +// plaza are walkable; buildings, roofs, trees, planters, lamps, signs, fences +// and fountains are blocked. Tiles were classified by colour and only the +// ones on the 00.05 route checked by eye, so some open plaza next to a lamp +// or a pattern line may read as blocked; the planner goes around those, and +// anything the map lacks is found by the route walker. +// The DP layout matches BDSP (console B, 2026-10-28): the Trainers' School +// and the Pokemon Center either side of the main road, the Poke Mart and the +// fountain park north of them, the TV station at the top of the main road +// and the Poketch Company in the north-west. +// NPCs are the 00.05 story people, placed from the console: they stand still. +const TileGrid& grid(){ + // 0 1 2 3 4 5 6 + // 0123456789012345678901234567890123456789012345678901234567890123 + static const TileGrid grid({ + R"(################################################################)", // 0 + R"(################################################################)", // 1 + R"(##########################################......################)", // 2 + R"(##########################################......################)", // 3 + R"(##########################################......################)", // 4 + R"(###########################...##########.......#################)", // 5 + R"(###########################...##########........################)", // 6 + R"(########.##########.#######...##########..#.....################)", // 7 + R"(########.##########.#######...##########.......#################)", // 8 + R"(########.##########.#######....#########..#....#################)", // 9 + R"(########.##########.#######...#####D####..#....#################)", // 10 + R"(########..#########.#######.......N.......#....#################)", // 11 + R"(########............#######...............#....########....#####)", // 12 + R"(########...##N......#######..........##...#....########....#####)", // 13 + R"(######.#####.......#######.....................#######...#######)", // 14 + R"(##........................................#....#...........#####)", // 15 + R"(##...#..................................................#..#####)", // 16 + R"(##...#...###..............###...........................#..#####)", // 17 + R"(##...#..#####.################............#............#########)", // 18 + R"(##############################..###########....#.###...#########)", // 19 + R"(##############################..###########....######..#########)", // 20 + R"(##############################..###########....#######..####.###)", // 21 + R"(##############################..##########.....#################)", // 22 + R"(##############################..###########....#......N#########)", // 23 + R"(########.#####################..#.....##.......#........########)", // 24 + R"(########.#####################.......####............#..####.###)", // 25 + R"(########.#####.#########.######.##########..N.....###..#########)", // 26 + R"(##############..########.######.###########....#######.#########)", // 27 + R"(########.#####.############################....#################)", // 28 + R"(########.#####.############################....#################)", // 29 + R"(#################################.##.##D###....####D############)", // 30 + R"(########.#####..#########.#####...........#....#........########)", // 31 + R"(#############....#######..####.####.....................####.###)", // 32 + R"(#############....##.......########...................#..####.###)", // 33 + R"(##############............###############..........N.N.#########)", // 34 + R"(##############............###############.#............#########)", // 35 + R"(#############.............##############.......#....############)", // 36 + R"(########.#####.###...###.################.#....#...#############)", // 37 + R"(########.#####.###...###.################.#....#...#############)", // 38 + R"(########.#####.###...###.################.#....#...#############)", // 39 + R"(########.#.#.#...........##########.####..#....#...#########.###)", // 40 + R"(#########................##########.....###........#########.###)", // 41 + R"(########.........#.......##########.......#....#...#########.###)", // 42 + R"(########.#.#.#.#..........#########.......#....#...#########.###)", // 43 + R"(#########################################........###############)", // 44 + R"(########################################..........##############)", // 45 + R"(################################################################)", // 46 + R"(################################################################)", // 47 + }); + return grid; +} + +// The top of the stairs from Route 202, where Dawn's scene leaves us: the +// bottom of the main road (columns 43..46), above the steps at row 46. +const std::vector& after_dawn(){ + static const std::vector tiles{{44, 44}, {45, 44}, {46, 44}, {44, 45}, {45, 45}, {46, 45}}; + return tiles; +} + +const TilePos TRAINERS_SCHOOL_DOOR{39, 30}; +const TilePos POKEMON_CENTER_DOOR{51, 30}; +const TilePos TV_STATION_DOOR{35, 10}; + +// On the main road just north of the school and the Center. Talked to from +// the east, facing west (also works from the south). +const TilePos POKETCH_PRESIDENT{44, 26}; +// Below the Poke Mart's planters, where the road meets the plaza. +const TilePos CLOWN_MART{54, 23}; +// West of the TV station door. +const TilePos CLOWN_TV_STATION{34, 11}; +// East of the Poketch Company's sign. Talked to from the south on the +// console; from the east a walk west along row 13 stops right beside him. +const TilePos CLOWN_POKETCH_COMPANY{13, 13}; + + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.h new file mode 100644 index 0000000000..e858f08a8c --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.h @@ -0,0 +1,43 @@ +/* BDSP AutoStory - Jubilife City tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Jubilife City as a tile grid, read from Bulbapedia's DP map, with the + * places and people of checkpoint 00.05. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_JubilifeCity_H +#define PokemonAutomation_PokemonBDSP_AutoStory_JubilifeCity_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace JubilifeCity{ + + +const Pokemon::TileGrid& grid(); + +// Where we may stand after Dawn's scene at the top of the stairs. +const std::vector& after_dawn(); + +// Doors: walk north from the tile below to enter. +extern const Pokemon::TilePos TRAINERS_SCHOOL_DOOR; +extern const Pokemon::TilePos POKEMON_CENTER_DOOR; +extern const Pokemon::TilePos TV_STATION_DOOR; + +// The Poketch Company president and the three coupon clowns. +extern const Pokemon::TilePos POKETCH_PRESIDENT; +extern const Pokemon::TilePos CLOWN_MART; +extern const Pokemon::TilePos CLOWN_TV_STATION; +extern const Pokemon::TilePos CLOWN_POKETCH_COMPANY; + + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.cpp new file mode 100644 index 0000000000..116ee89633 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.cpp @@ -0,0 +1,77 @@ +/* BDSP AutoStory - Oreburgh Mine tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_OreburghMine.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace OreburghMine{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map Oreburgh_Mine_B2F_DP.png with +// devtools/maps/levels.py (--profile cave --height 512 --offset 0,4 +// --objects on --edits oreburgh_mine_b2f_edits.txt). The archive image is +// squashed to 512x395; stretched back to 512x512 its rocks sit on 16 px +// tiles both ways. Rocks, boulders, crates, the coal block, the workers and +// their Machop are blocked. Checked on the console (2026-10-28): the +// corridor's small rocks zigzag a row at a time; the coal block's top edge +// is drawn over the room's second row, which is walkable and is how to pass +// the worker on the first row; Roark stands a row above the bottom row. +const TileGrid& grid(){ + static const TileGrid grid({ + // 0 1 2 3 + R"(################################)", // 0 + R"(################################)", // 1 + R"(################################)", // 2 + R"(###############.################)", // 3 + R"(#############.....##############)", // 4 + R"(#############.....##############)", // 5 + R"(#############.....##############)", // 6 + R"(#############.....##############)", // 7 + R"(#############...################)", // 8 + R"(#############...################)", // 9 + R"(#############.....##############)", // 10 + R"(#############..#..##############)", // 11 + R"(###############...##############)", // 12 + R"(#############...################)", // 13 + R"(#############.....##############)", // 14 + R"(###############...##############)", // 15 + R"(########...####.....##.....#.###)", // 16 + R"(##............................##)", // 17 + R"(##.#..###################.....##)", // 18 + R"(##....###################.....##)", // 19 + R"(##...######################...##)", // 20 + R"(#####.#####################...##)", // 21 + R"(##.########################...##)", // 22 + R"(##..########################..##)", // 23 + R"(##..########################..##)", // 24 + R"(##....#####################...##)", // 25 + R"(#####.#####################...##)", // 26 + R"(####..####################....##)", // 27 + R"(####..####################..####)", // 28 + R"(####....#..#.....#N#........####)", // 29 + R"(######......................####)", // 30 + R"(################################)", // 31 + }); + return grid; +} + +const std::vector& corridor_top(){ + static const std::vector tiles{{14, 4}, {15, 4}, {16, 4}}; + return tiles; +} + +const TilePos ROARK{18, 29}; +const TilePos CORRIDOR_EXIT_START{14, 11}; + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.h new file mode 100644 index 0000000000..48764f46ba --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.h @@ -0,0 +1,39 @@ +/* BDSP AutoStory - Oreburgh Mine tiles + * + * From: https://github.com/PokemonAutomation/ + * + * The Oreburgh Mine's coal room (Bulbapedia's "B2F") as a tile grid, with + * Roark, for checkpoint 01.03. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_OreburghMine_H +#define PokemonAutomation_PokemonBDSP_AutoStory_OreburghMine_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace OreburghMine{ + + +const Pokemon::TileGrid& grid(); + +// Where the walk in from the entrance hall leaves us: the top of the rocky +// corridor, below its timber frame. +const std::vector& corridor_top(); + +// Roark, between two rocks a row above the room's bottom row. Talked to from +// the tile below him, facing north. +extern const Pokemon::TilePos ROARK; +// The corridor's corner a wall-to-wall route ends on, below a straight +// walk north out into the entrance hall. +extern const Pokemon::TilePos CORRIDOR_EXIT_START; + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.cpp new file mode 100644 index 0000000000..1da12ad29c --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.cpp @@ -0,0 +1,111 @@ +/* BDSP AutoStory - Ravaged Path tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_RavagedPath.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace RavagedPath{ + +using namespace Pokemon; + + +// Traced by hand from Bulbapedia's Ravaged_Path_DP.png. The archive image +// is squashed to 528x672; stretched back to 528x864 its tiles sit on 16 px +// with an 8 px offset both ways (rocks and the two door lights centre on +// that grid). Only the lower corridor and the room below its stairs are +// traced; '?' marks where the untraced west chambers continue (they end in +// water). Rocks are '#'; see rocks(). Checked against the hand run of +// 2026-10-29: in from the south door and up the stairs to the corridor, two +// rocks across it east of the stairs, and the way out a tile west of the +// corridor's east end, walking south. +// +// Not yet checked on the console tile by tile: the rows are measured from +// the stretched image, so a row may be off by one. +const TileGrid& grid(){ + static const TileGrid grid({ + // 0 1 2 3 + R"(#################################)", // 0 + R"(#################################)", // 1 + R"(#################################)", // 2 + R"(#################################)", // 3 + R"(#################################)", // 4 + R"(#################################)", // 5 + R"(#################################)", // 6 + R"(#################################)", // 7 + R"(#################################)", // 8 + R"(#################################)", // 9 + R"(#################################)", // 10 + R"(#################################)", // 11 + R"(#################################)", // 12 + R"(#################################)", // 13 + R"(#################################)", // 14 + R"(#################################)", // 15 + R"(#################################)", // 16 + R"(#################################)", // 17 + R"(#################################)", // 18 + R"(#################################)", // 19 + R"(#################################)", // 20 + R"(#################################)", // 21 + R"(#################################)", // 22 + R"(#################################)", // 23 + R"(#################################)", // 24 + R"(#################################)", // 25 + R"(#################################)", // 26 + R"(#################################)", // 27 + R"(#################################)", // 28 + R"(#################################)", // 29 + R"(#################################)", // 30 + R"(#################################)", // 31 + R"(#################################)", // 32 + R"(#################################)", // 33 + R"(#################################)", // 34 + R"(#################################)", // 35 + R"(############??###################)", // 36 + R"(############??###################)", // 37 + R"(###########?.####################)", // 38 + R"(###########?..###################)", // 39 + R"(###########?.....#...############)", // 40 + R"(###########?#....#...############)", // 41 + R"(##################....#.......###)", // 42 + R"(##################....#.......###)", // 43 + R"(##################.....#......###)", // 44 + R"(##################...#######D####)", // 45 + R"(##################...############)", // 46 + R"(##################...############)", // 47 + R"(##################...############)", // 48 + R"(##################...############)", // 49 + R"(##################...############)", // 50 + R"(##################...############)", // 51 + R"(###################D#############)", // 52 + R"(#################################)", // 53 + }); + return grid; +} + +const std::vector& rocks(){ + static const std::vector tiles{ + {13, 38}, {17, 40}, {12, 41}, {22, 42}, {22, 43}, {23, 44}, + }; + return tiles; +} + +const TilePos ENTRANCE{19, 51}; + +const std::vector& before_rocks(){ + static const std::vector tiles{{21, 42}, {21, 43}}; + return tiles; +} + +const TilePos EAST_END{29, 44}; +const TilePos ABOVE_EXIT{28, 44}; + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.h new file mode 100644 index 0000000000..4f03dacf39 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.h @@ -0,0 +1,47 @@ +/* BDSP AutoStory - Ravaged Path tiles + * + * From: https://github.com/PokemonAutomation/ + * + * The Ravaged Path (the cave on Route 204) as a tile grid, for checkpoint + * 01.05: from its south door, where Route 204's south half enters, east + * along its lower corridor to the door out onto Route 204's north half. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_RavagedPath_H +#define PokemonAutomation_PokemonBDSP_AutoStory_RavagedPath_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace RavagedPath{ + + +// With every Rock Smash rock in place (blocked). +const Pokemon::TileGrid& grid(); + +// The Rock Smash rocks of the traced part, blocked in grid(). Each breaks +// for good once smashed, so a plan after a smash opens that tile. +const std::vector& rocks(); + +// Where walking in from Route 204's south half leaves us: the tile above the +// south door, at the foot of the room below the stairs. +extern const Pokemon::TilePos ENTRANCE; +// The tiles west of the two rocks across the lower corridor: from either, +// facing east, A offers Rock Smash, and the corridor is open to its east end. +const std::vector& before_rocks(); +// The corridor's east end on its bottom row, where walls stop a walk east +// and south: a route can end here, and nowhere else near the way out. +extern const Pokemon::TilePos EAST_END; +// The tile above the door out onto Route 204's north half, a tile west of +// EAST_END: walk south. +extern const Pokemon::TilePos ABOVE_EXIT; + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp new file mode 100644 index 0000000000..9fa493693b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp @@ -0,0 +1,74 @@ +/* BDSP AutoStory - Route 201 tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_Route201.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route201{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map of Route 201 (1040 x 320 px). As on the +// Route 202 map, the tile grid starts 8 px in from the left edge: tile +// (x, y) covers pixels [8 + 16x, 24 + 16x) x [16y, 16y + 16). The trees sit +// about 3 px lower on their tiles than on the Route 202 map; tall grass and +// the NPC at the Potion girl fit a grid with no vertical offset. +// +// Trees follow the Route 202 rules: tiles under trunks or mostly under a +// canopy are blocked. Ledges: row 10 west of the tall grass, and (53..56,7) +// in the north-east. On the console the north-east ledge is one row higher +// than it looks on the DP map, as on Route 202; the west one is unchecked. West edge, rows 9..12: towards Lake Verity. South gap +// (14..17, 19): Twinleaf Town. East edge, rows 3..6: Sandgem Town. +// Route 201 has no trainers. Its other NPCs wander, so they are left off: +// a planned wall must stay put. The Potion giver only turns. +const TileGrid& grid(){ + // 0 1 2 3 4 5 6 + // 0123456789012345678901234567890123456789012345678901234567890123 + static const TileGrid grid({ + R"(################################################################)", // 0 + R"(################################################################)", // 1 + R"(################################################################)", // 2 + R"(##########################################"N"""""""""...........)", // 3 + R"(##########################################"""""""""""..S........)", // 4 + R"(##########################################"""""""""##...........)", // 5 + R"(##..####..####....######..##....##..####.."""""""""##...........)", // 6 + R"(##..####..####....######..##....##..####.."""""""""##vvvv.######)", // 7 + R"(##........####............................"""""""""##.....######)", // 8 + R"(....####.................................."""""""""##.....######)", // 9 + R"(vvvv####vvvvvvvvvvvvvvvvvvvvvvvv........S."""""""""##.....######)", // 10 + R"(....####............""""""""""............................######)", // 11 + R"(....................""""""""""............................######)", // 12 + R"(########.....S......##########............................######)", // 13 + R"(##############....############################............######)", // 14 + R"(##############....############################""""""""""""######)", // 15 + R"(##############....##############################################)", // 16 + R"(##############....##############################################)", // 17 + R"(##############....##############################################)", // 18 + R"(##############....##############################################)", // 19 + }); + return grid; +} + + +// On the DP map she is at (44,3), pixel (718,57). On the console (by hand, +// then through the Lab Console) she stands at (43,3), in the top grass row, +// and only turns: walking west along row 3 from the trees at its east end +// stops at (44,3), facing her. +const TilePos POTION_GIVER{43, 3}; +const TilePos POTION_GIVER_TALK_FROM{44, 3}; +const TilePos POTION_GIVER_ROW_EAST{53, 3}; +const TilePos POTION_GIVER_ROW_WEST_END{42, 3}; +const TilePos POTION_GIVER_ROW_EAST_END{52, 3}; + + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h new file mode 100644 index 0000000000..a2fdc38dfd --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h @@ -0,0 +1,40 @@ +/* BDSP AutoStory - Route 201 tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Route 201 (Twinleaf Town to Sandgem Town) as a tile grid, read from + * Bulbapedia's DP map; BDSP keeps DP's tile layout. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Route201_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Route201_H + +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route201{ + + +const Pokemon::TileGrid& grid(); + +// The girl who gives Potions, in the top row of the north-east tall grass, +// (42..52, 3), one tile from its west end. +extern const Pokemon::TilePos POTION_GIVER; +// East of her, where a walk west along her row stops: talk to her from here, +// facing west. +extern const Pokemon::TilePos POTION_GIVER_TALK_FROM; +// The tile east of her row, against the trees: walk west from here to meet her. +extern const Pokemon::TilePos POTION_GIVER_ROW_EAST; +// The first and last tiles of her row. +extern const Pokemon::TilePos POTION_GIVER_ROW_WEST_END; +extern const Pokemon::TilePos POTION_GIVER_ROW_EAST_END; + + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp new file mode 100644 index 0000000000..945b743a56 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp @@ -0,0 +1,106 @@ +/* BDSP AutoStory - Route 202 tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_Route202.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route202{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map of Route 202 (592 x 416 px). Its tile grid +// starts 8 px in from the left edge, so tile (x, y) covers pixels +// [8 + 16x, 24 + 16x) x [16y, 16y + 16). The half tiles at both side edges +// are trees and are left out. +// +// Trees: each tile under a trunk or a canopy that hides most of the ground +// is blocked. The thin top of a canopy that only overhangs the row above a +// tree is not. +// Ledges: rows 9 and 18. On the DP map the south ledge looks like row 19, +// but on the console the player stands at (22,19) right under it, beside +// the trees at (21,19), and the sign at (24,20) is one row below. +// The Poke Ball item at (3,6) is blocked. +// The east tree wall comes in to column 31 at rows 15 to 17: walking east +// along row 16 stops at column 30 on the console (r75), and column 30 is +// open north to row 4. +// NPCs stand on the tile under their feet; the sprites rise into the row +// above. The girl at (22,15), pixel (365,235), is a trainer. +const TileGrid& grid(){ + // 0 1 2 3 + // 012345678901234567890123456789012345 + static const TileGrid grid({ + R"(###########........#################)", // 0 + R"(###########........#################)", // 1 + R"(###########........#################)", // 2 + R"(###########........#################)", // 3 + R"(###""""""........S..""""".N....#####)", // 4 + R"(###""""""..........."""""......#####)", // 5 + R"(####"""""..........."""""......#####)", // 6 + R"(###"""""".N...########"""......#####)", // 7 + R"(#########.....########.........#####)", // 8 + R"(#########vvvvv########vvvvvv...""###)", // 9 + R"(#######.......########"""""""""""###)", // 10 + R"(#######N..........####"""""""""""###)", // 11 + R"(###...............####"""""""""""###)", // 12 + R"(###............S..####"""""""""""###)", // 13 + R"(###...............####"""""""""""###)", // 14 + R"(###....##.........####N."""""""#####)", // 15 + R"(###""""##..............."""""""#####)", // 16 + R"(###""""##....................""#####)", // 17 + R"(###""""##vvvvv########vvvvvvvvv#####)", // 18 + R"(###""""##.....########.........#####)", // 19 + R"(###""""""......""""""...S......#####)", // 20 + R"(###""""""......""""""..........#####)", // 21 + R"(###""""""......""""""..........#####)", // 22 + R"(#####################..........#####)", // 23 + R"(#####################..........#####)", // 24 + R"(#########################......#####)", // 25 + }); + return grid; +} + + +// Where the post-catch save stands: the bottom row of the tall-grass patch at +// (15..20, 20..22), nearest the Sandgem Town entrance at the bottom right. +// On the console the sign at (24,20) is 5 columns right and 2 rows up, and +// walking east stops at the trees after 11 tiles. +const TilePos CATCH_SPOT{19, 22}; +const TilePos POTION_SAVE_SPOT{30, 19}; + +// Blue-cap trainer, feet at about (128,186), facing south. +const TilePos TRAINER_A{7, 11}; +// Blue-cap trainer, feet at about (175,125), facing east. +const TilePos TRAINER_B{10, 7}; +// Blue-cap trainer, feet at about (430,77), facing south. +const TilePos TRAINER_C{26, 4}; +// The girl at pixel (365,235), facing south. On the console she challenged +// us at (22,17), walking east along row 17. +const TilePos TRAINER_D{22, 15}; + +// Facing is read from the sprites. The range is a guess; DP trainers on +// early routes see a few tiles. +const std::vector& trainer_sights(){ + static const std::vector sights{ + {"trainer_a", TRAINER_A, Direction::SOUTH, 4}, + {"trainer_b", TRAINER_B, Direction::EAST, 4}, + {"trainer_c", TRAINER_C, Direction::SOUTH, 4}, + {"trainer_d", TRAINER_D, Direction::SOUTH, 4}, + }; + return sights; +} + +// Pixel (256,8): the path in the middle of the 8-tile gap at (11..18, 0). +const TilePos EXIT_JUBILIFE{15, 0}; + + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h new file mode 100644 index 0000000000..52e7219d0f --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h @@ -0,0 +1,49 @@ +/* BDSP AutoStory - Route 202 tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Route 202 (Sandgem Town to Jubilife City) as a tile grid. BDSP keeps the + * tile layout of DP, so the grid is read from Bulbapedia's DP map. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Route202_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Route202_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route202{ + + +const Pokemon::TileGrid& grid(); + +// Where the player stands in the tall grass after the catching tutorial. +extern const Pokemon::TilePos CATCH_SPOT; +// Under the south ledge, against the east trees: where 00.04 saves once it +// has the Potions. Two walls pin it. +extern const Pokemon::TilePos POTION_SAVE_SPOT; + +// The three trainers, south to north. Which one is Tristan, Natalie or +// Logan is not known from the map. +extern const Pokemon::TilePos TRAINER_A; +extern const Pokemon::TilePos TRAINER_B; +extern const Pokemon::TilePos TRAINER_C; +extern const Pokemon::TilePos TRAINER_D; + +// Their sight lines, in this map's tiles. The story wants these battles. +const std::vector& trainer_sights(); + +// A tile in the gap of the north edge; walking north from it leaves for +// Jubilife City. +extern const Pokemon::TilePos EXIT_JUBILIFE; + + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.cpp new file mode 100644 index 0000000000..0cfa65ba3b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.cpp @@ -0,0 +1,81 @@ +/* BDSP AutoStory - Route 204 tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_Route204.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route204{ + +using namespace Pokemon; + + +// Traced by hand from Bulbapedia's Sinnoh_Route_204_DP.png (512x848, 16 px +// tiles, not squashed), rows 0 to 27 of 53: the north half, above the +// cliff the Ravaged Path runs under. Checked against the hand run of +// 2026-10-29: out of the cave onto the sand below the cliff, west to the +// stairs, up them into a pocket closed at the top by a ledge (row 12, 'v', +// only crossed southward), back down to the sand, east and north through +// the tall grass to the flower beds, the twins below the right-hand bed, +// and north between the beds' fences into Floaroma. +// +// Not yet checked tile by tile on the console: the fence and tree edges +// fall on tile boundaries in the image, so a column may be off by one. +// '?' is the way down the cliff to the south half (not traced). +const TileGrid& north_grid(){ + static const TileGrid grid({ + // 0 1 2 3 + R"(#########....###################)", // 0 + R"(#########....###################)", // 1 + R"(#########....#######.###########)", // 2 + R"(#########....#######.###########)", // 3 + R"(#########....#######.###########)", // 4 + R"(#########....#######.###########)", // 5 + R"(#########.....NN.....###########)", // 6 + R"(#...............S....###########)", // 7 + R"(#...#####............###########)", // 8 + R"(##########...........###########)", // 9 + R"(##########..#####...N..#########)", // 10 + R"(##########..######.....#########)", // 11 + R"(##########vv#######""""#########)", // 12 + R"(##########..#######""""#########)", // 13 + R"(##########..#######""""#########)", // 14 + R"(##########..#######""""#########)", // 15 + R"(##########.......N.""""#########)", // 16 + R"(##########...........###########)", // 17 + R"(##########..###......###########)", // 18 + R"(##########..###....#.###########)", // 19 + R"(##########..####################)", // 20 + R"(##########..####################)", // 21 + R"(##########..########D###########)", // 22 + R"(##########....N......###########)", // 23 + R"(####..................##########)", // 24 + R"(####..................##########)", // 25 + R"(##########???###################)", // 26 + R"(################################)", // 27 + }); + return grid; +} + +const TilePos CAVE_EXIT{20, 23}; + +bool in_floaroma_path(const TilePos& pos){ + return pos.x >= 9 && pos.x <= 12 && pos.y >= 6 && pos.y <= 8; +} + +std::vector north_trainer_sights(){ + return { + {"Twins (left)", {14, 6}, Direction::SOUTH, 3}, + {"Twins (right)", {15, 6}, Direction::SOUTH, 3}, + }; +} + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.h new file mode 100644 index 0000000000..5b5af38615 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.h @@ -0,0 +1,39 @@ +/* BDSP AutoStory - Route 204 tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Route 204's north half as a tile grid, for checkpoint 01.05: from the + * door out of the Ravaged Path up to the path into Floaroma Town. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Route204_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Route204_H + +#include +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace Route204{ + + +const Pokemon::TileGrid& north_grid(); + +// Where leaving the Ravaged Path puts us: the tile below its door. +extern const Pokemon::TilePos CAVE_EXIT; + +// Whether a tile is in the path north into Floaroma, between the flower +// beds' fences: walking north from here leaves the route. +bool in_floaroma_path(const Pokemon::TilePos& pos); + +// The twins below the right-hand flower bed: a double battle when they see +// us walk past below them. +std::vector north_trainer_sights(); + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp new file mode 100644 index 0000000000..646d9e9ecc --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp @@ -0,0 +1,77 @@ +/* BDSP AutoStory - Sandgem Town tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_SandgemTown.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace SandgemTown{ + +using namespace Pokemon; + + +// Read from Bulbapedia's DP map of Sandgem Town (Sandgem_Town_DP.png, +// 560 x 368 px). The tile grid starts 8 px in from the left edge, as on the +// route maps: tile (x, y) covers pixels [8 + 16x, 24 + 16x) x [16y, 16y + 16). +// +// Buildings are blocked; each door is the tile the player walks into, on +// the building's front row. Top left: Professor Rowan's lab, with a fence +// running west along row 6 to the map edge. Top middle: Pokemon Center. +// Top right: Poke Mart. Exits: west, rows 7..9 (Route 201); north, +// (20..29, 0) (Route 202); south, (20..25, 22) (the beach). +// +// Checked on the console: walking south from Route 202 (30,22) stops at +// (29,2) above the Poke Mart, and walking west from there stops at (20,2). +// The lab's sign stands on row 7, in front of the fence: walking west along +// row 7 stops at it. +const TileGrid& grid(){ + // 0 1 2 3 + // 0123456789012345678901234567890123 + static const TileGrid grid({ + R"(####################..........####)", // 0 + R"(####################..........####)", // 1 + R"(####################..........####)", // 2 + R"(####################......########)", // 3 + R"(####################......########)", // 4 + R"(####################......########)", // 5 + R"(########D###..###D##S....S#D######)", // 6 + R"(.....S........................####)", // 7 + R"(..............................####)", // 8 + R"(..............................####)", // 9 + R"(######.........#####..........####)", // 10 + R"(######.........#####..........####)", // 11 + R"(##########.....#####..........####)", // 12 + R"(##########.....#####..........####)", // 13 + R"(##########.....#####..........####)", // 14 + R"(#######D##....S#D###..S.......####)", // 15 + R"(######........................####)", // 16 + R"(######........................####)", // 17 + R"(######....................########)", // 18 + R"(####################......########)", // 19 + R"(####################......########)", // 20 + R"(####################......########)", // 21 + R"(####################......########)", // 22 + }); + return grid; +} + + +// Pixel (144,110): the lab's door, between its two front windows. +const TilePos LAB_DOOR{8, 6}; +// Pixel (288,104): the Pokemon Center's glass door. +const TilePos POKEMON_CENTER_DOOR{17, 6}; +// On the console, walking south from (20,2) stops at (20,5), above it. +const TilePos POKEMON_CENTER_SIGN{20, 6}; +// Pixel (448,104): the Poke Mart's door. +const TilePos POKE_MART_DOOR{27, 6}; + + + +} +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h new file mode 100644 index 0000000000..6e4b21f43e --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h @@ -0,0 +1,35 @@ +/* BDSP AutoStory - Sandgem Town tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Sandgem Town as a tile grid, read from Bulbapedia's DP map; BDSP keeps + * DP's tile layout. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_SandgemTown_H +#define PokemonAutomation_PokemonBDSP_AutoStory_SandgemTown_H + +#include "Pokemon/Navigation/Pokemon_TileGrid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ +namespace SandgemTown{ + + +const Pokemon::TileGrid& grid(); + +extern const Pokemon::TilePos LAB_DOOR; +extern const Pokemon::TilePos POKEMON_CENTER_DOOR; +// The sign east of the Pokemon Center's door. Walking north from row 10 in +// its column stops on the tile below it. +extern const Pokemon::TilePos POKEMON_CENTER_SIGN; +extern const Pokemon::TilePos POKE_MART_DOOR; + + + +} +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp new file mode 100644 index 0000000000..862839fca9 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp @@ -0,0 +1,140 @@ +/* BDSP AutoStory - Story maps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_JubilifeCity.h" +#include "PokemonBDSP_AutoStory_OreburghMine.h" +#include "PokemonBDSP_AutoStory_RavagedPath.h" +#include "PokemonBDSP_AutoStory_Route204.h" +#include "PokemonBDSP_AutoStory_Route201.h" +#include "PokemonBDSP_AutoStory_Route202.h" +#include "PokemonBDSP_AutoStory_SandgemTown.h" +#include "PokemonBDSP_AutoStory_StoryMaps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace Pokemon; + + +const char* const MAP_ROUTE_201 = "Route 201"; +const char* const MAP_SANDGEM_TOWN = "Sandgem Town"; +const char* const MAP_ROUTE_202 = "Route 202"; +const char* const MAP_JUBILIFE_CITY = "Jubilife City"; +const char* const MAP_OREBURGH_MINE = "Oreburgh Mine"; +const char* const MAP_RAVAGED_PATH = "Ravaged Path"; +const char* const MAP_ROUTE_204 = "Route 204"; + + +// Offsets come from how the Bulbapedia maps join: +// - Route 201 to Sandgem Town: the 16 px strip at the right of the Route +// 201 image matches the left of the Sandgem image, which puts Sandgem's +// tile column 0 on Route 201's column 64. Rows are 3 px apart, so the +// row comes from the edges: Sandgem's lab fence (row 6) lines up with +// the fence post at the right of Route 201's row 3, and Route 201's +// open rows 4..6 meet Sandgem's open rows 7..9. +// - Sandgem Town to Route 202: the 8 px strip at the bottom of the Route +// 202 image matches the top of the Sandgem image 16 px to the right, so +// Route 202's column x + 1 sits above Sandgem's column x. Route 202's +// open bottom row (25..30, 25) then meets Sandgem's (24..29, 0), with +// trees beside both. Sandgem's row 0 is taken as the row below Route +// 202's row 25; the 8 px overlap leaves a half-row doubt. +const MapSet& story_maps(){ + static const MapSet maps = []{ + MapSet maps; + maps.add(MAP_ROUTE_201, Route201::grid(), {0, 0}); + maps.add(MAP_SANDGEM_TOWN, SandgemTown::grid(), {64, -3}); + maps.add(MAP_ROUTE_202, Route202::grid(), {63, -29}); + return maps; + }(); + return maps; +} + +const MapSet& jubilife_maps(){ + static const MapSet maps = []{ + MapSet maps; + maps.add(MAP_JUBILIFE_CITY, JubilifeCity::grid(), {0, 0}); + return maps; + }(); + return maps; +} + +const MapSet& oreburgh_mine_maps(){ + static const MapSet maps = []{ + MapSet maps; + maps.add(MAP_OREBURGH_MINE, OreburghMine::grid(), {0, 0}); + return maps; + }(); + return maps; +} + +MapSet ravaged_path_maps(const std::vector& smashed){ + TileGrid grid = RavagedPath::grid(); + for (const TilePos& rock : smashed){ + grid.set(rock, TileType::WALKABLE); + } + MapSet maps; + maps.add(MAP_RAVAGED_PATH, grid, {0, 0}); + return maps; +} + +const MapSet& route204_north_maps(){ + static const MapSet maps = []{ + MapSet maps; + maps.add(MAP_ROUTE_204, Route204::north_grid(), {0, 0}); + return maps; + }(); + return maps; +} + +bool story_map_plannable(const std::string& map){ + return map == MAP_ROUTE_201 || map == MAP_ROUTE_202; +} + +bool story_at_jubilife_exit(const TilePos& world){ + const std::optional tile = story_maps().to_local(world); + return tile && tile->map == MAP_ROUTE_202 && tile->pos.y <= 3; +} + +std::vector story_potion_giver_area(){ + const MapSet& maps = story_maps(); + std::vector area; + const TilePos west = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); + const TilePos east = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST); + for (int y = west.y; y <= west.y + 1; y++){ + for (int x = west.x; x <= east.x; x++){ + if (maps.world().standable({x, y})){ + area.push_back({x, y}); + } + } + } + return area; +} + +std::vector story_after_training_area(){ + const TilePos catch_spot = story_maps().to_world(MAP_ROUTE_202, Route202::CATCH_SPOT); + std::vector area; + for (int y = catch_spot.y - 2; y <= catch_spot.y; y++){ + for (int x = catch_spot.x - 1; x <= catch_spot.x + 1; x++){ + area.push_back({x, y}); + } + } + return area; +} + +std::vector story_trainer_sights(){ + std::vector sights = Route202::trainer_sights(); + for (TrainerSight& sight : sights){ + sight.pos = story_maps().to_world(MAP_ROUTE_202, sight.pos); + } + return sights; +} + + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h new file mode 100644 index 0000000000..2b08afb0c0 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h @@ -0,0 +1,70 @@ +/* BDSP AutoStory - Story maps + * + * From: https://github.com/PokemonAutomation/ + * + * The maps the story walks through, joined on one grid. + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_StoryMaps_H +#define PokemonAutomation_PokemonBDSP_AutoStory_StoryMaps_H + +#include "Pokemon/Navigation/Pokemon_MapSet.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +extern const char* const MAP_ROUTE_201; +extern const char* const MAP_SANDGEM_TOWN; +extern const char* const MAP_ROUTE_202; +extern const char* const MAP_JUBILIFE_CITY; +extern const char* const MAP_OREBURGH_MINE; +extern const char* const MAP_RAVAGED_PATH; +extern const char* const MAP_ROUTE_204; + +// Route 201, Sandgem Town and Route 202. +const Pokemon::MapSet& story_maps(); + +// Jubilife City on its own: 00.05 starts there after Dawn's scene, so it is +// not joined to Route 202. +const Pokemon::MapSet& jubilife_maps(); +// The Oreburgh Mine's coal room on its own (01.03). +const Pokemon::MapSet& oreburgh_mine_maps(); + +// The Ravaged Path on its own (01.05), with the rocks in `smashed` open. +Pokemon::MapSet ravaged_path_maps(const std::vector& smashed = {}); + +// Route 204's north half on its own (01.05). +const Pokemon::MapSet& route204_north_maps(); + +// Whether a plan may cross this map. Routes are traced from the DP maps and +// their tree walls carried over to BDSP; towns changed more in the remake, so +// a town is crossed by screen-driven legs until the console has confirmed its +// grid. +bool story_map_plannable(const std::string& map); + +// The open rows at the top of Route 202, in world tiles: walking north from +// any of them enters Jubilife City. +bool story_at_jubilife_exit(const Pokemon::TilePos& world); + +// Where we may stand after talking to the Potion giver and the training +// battle next to her: the standable tiles of the top two rows of her grass +// and the tile east of them, in world tiles. +std::vector story_potion_giver_area(); + +// Where we may stand after training in the catch grass, in world tiles: the +// training walks up and down the catch spot's column, between the tree walls +// north and south of the grass, so a battle may stop us on any of its three +// rows. A column either side allows for where the catch left us. +std::vector story_after_training_area(); + +// Route 202's trainers, in world tiles of story_maps(). +std::vector story_trainer_sights(); + + + +} +} +} +#endif From dcc877811742ed4c5ccd479841f79517c245640a Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 18:53:15 -0700 Subject: [PATCH 03/19] BDSP AutoStory: walk by the screen, walk planned routes, Pokemon Center walk_until() stops on what the screen shows; walk_route() walks a plan on the story maps, tracking every tile the player may be on and planning again after a trainer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonBDSP_AutoStory_PokemonCenter.cpp | 190 +++++++ .../PokemonBDSP_AutoStory_PokemonCenter.h | 33 ++ .../Utils/PokemonBDSP_AutoStory_RouteWalk.cpp | 491 ++++++++++++++++++ .../Utils/PokemonBDSP_AutoStory_RouteWalk.h | 106 ++++ .../Utils/PokemonBDSP_AutoStory_Walk.cpp | 365 +++++++++++++ .../Utils/PokemonBDSP_AutoStory_Walk.h | 110 ++++ 6 files changed, 1295 insertions(+) create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp new file mode 100644 index 0000000000..d238089cc7 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp @@ -0,0 +1,190 @@ +/* BDSP AutoStory - Pokemon Center + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP_AutoStory_RouteWalk.h" +#include "PokemonBDSP_AutoStory_Walk.h" +#include "PokemonBDSP_AutoStory_PokemonCenter.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; +using namespace Pokemon; + + +namespace{ + +// How many tiles the 1080p view spans across: a tile is about 125 px. +constexpr double SCREEN_WIDTH_TILES = 1920.0 / 125; + +// Where a building's lit doorway is across the screen, 0 to 1: the warm +// white columns in the band where doors stand, taking the run of them +// nearest the middle (a second building's door may show further off). +// Nothing if no doorway shows. +std::optional lit_doorway_x(const ImageViewRGB32& screen){ + const ImageRGB32 small = screen.scale_to(480, 270); + const size_t width = small.width(); + std::vector counts(width, 0); + for (size_t y = 81; y < 157; y++){ + for (size_t x = 0; x < width; x++){ + const uint32_t pixel = small.pixel(x, y); + const int r = (pixel >> 16) & 0xff, g = (pixel >> 8) & 0xff, b = pixel & 0xff; + if (r > 235 && g > 215 && b > 120 && b < 215 && r - b > 40){ + counts[x]++; + } + } + } + std::optional best; + double best_distance = 0; + for (size_t x = 0; x < width; ){ + if (counts[x] < 4){ + x++; + continue; + } + double mass = 0, moment = 0; + size_t gap = 0; + for (; x < width && gap <= 3; x++){ + if (counts[x] >= 4){ + mass += (double)counts[x]; + moment += (double)counts[x] * (double)x; + gap = 0; + }else{ + gap++; + } + } + const double center = moment / mass / (double)width; + const double distance = std::abs(center - 0.5); + if (distance < 0.35 && (!best || distance < best_distance)){ + best = center; + best_distance = distance; + } + } + return best; +} + +} + + +void heal_at_pokemon_center( + VideoStream& stream, ProControllerContext& context, + const std::string& label +){ + // Movement is not locked to tiles, and the door takes us in only when we + // are lined up with it. Its doorway is lit: find it on screen, where the + // camera keeps the player in the middle, move across by its offset, and + // walk in once it is straight ahead. + route_status(stream, label + ": lining up with the door."); + bool inside = false; + VideoSnapshot last_outside; + auto scene_changed_since = [&](const VideoSnapshot& before){ + context.wait_for_all_requests(); + VideoSnapshot now = stream.video().snapshot(); + return before && now && walk_screen_change(*before.frame, *now.frame) > WALK_SCENE_CHANGE; + }; + for (size_t tries = 0; tries < 12 && !inside; tries++){ + context.wait_for_all_requests(); + VideoSnapshot outside = stream.video().snapshot(); + if (!outside){ + continue; + } + const std::optional doorway = lit_doorway_x(*outside.frame); + if (!doorway){ + // The door may have taken us in after all, more slowly than we watched. + pbf_wait(context, 1500ms); + if (scene_changed_since(last_outside)){ + stream.log("[Route] " + label + ": the scene changed; inside."); + inside = true; + break; + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": no lit doorway on screen.", stream); + } + last_outside = outside; + const double tiles = (*doorway - 0.5) * SCREEN_WIDTH_TILES; + stream.log("[Route] " + label + ": doorway " + std::to_string(tiles) + " tiles across."); + if (std::abs(tiles) > 0.25){ + const Direction side = tiles > 0 ? Direction::EAST : Direction::WEST; + route_turn(stream, context, side); + const Milliseconds hold = std::max(Milliseconds(40), + Milliseconds((int64_t)(std::abs(tiles) * ROUTE_MS_PER_TILE_ACROSS.count()))); + pbf_move_left_joystick(context, {tiles > 0 ? 1.0 : -1.0, 0}, hold, 300ms); + continue; + } + inside = walk_until(stream, context, 0, 1, 1500ms, label + ": into the door").stop == WalkStop::TRANSITION; + // The door's fade is not always black enough to read as a transition, + // and may finish after the walk; a whole new scene says we are in. + pbf_wait(context, 2000ms); + if (!inside && scene_changed_since(outside)){ + stream.log("[Route] " + label + ": the scene changed; inside."); + inside = true; + } + } + if (!inside){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": door not found.", stream); + } + pbf_wait(context, 1500ms); + + // The nurse's counter is straight ahead of the door. + route_status(stream, label + ": talking to the nurse."); + if (walk_to_talk(stream, context, 0, 1, 6000ms, label + ": to the nurse").stop != WalkStop::DIALOG){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": nurse not reached.", stream); + } + // A through her lines; the cursor starts on "Yes". Stop once no dialogue + // has shown for a while, so A does not start the talk over. + size_t quiet = 0; + for (size_t presses = 0; presses < 30 && quiet < 2; presses++){ + ShortDialogWatcher dialog(COLOR_YELLOW); + if (wait_until(stream, context, 1500ms, {{dialog}}) == 0){ + quiet = 0; + pbf_press_button(context, BUTTON_A, 80ms, 700ms); + }else{ + quiet++; + } + } + context.wait_for_all_requests(); + + route_status(stream, label + ": healed; walking out."); + // Her last line may still be up, and it would stop the walk. + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + VideoSnapshot counter = stream.video().snapshot(); + bool faded_out = false; + bool scene_changed = false; + for (size_t tries = 0; tries < 3 && !faded_out && !scene_changed; tries++){ + const WalkResult walk = walk_until(stream, context, 0, -1, 6000ms, label + ": out the door"); + faded_out = walk.stop == WalkStop::TRANSITION; + if (walk.stop == WalkStop::DIALOG){ + pbf_mash_button(context, BUTTON_B, 2000ms); + } + context.wait_for_all_requests(); + VideoSnapshot now = stream.video().snapshot(); + scene_changed = counter && now && walk_screen_change(*counter.frame, *now.frame) > WALK_SCENE_CHANGE; + } + if (!faded_out && !scene_changed){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": did not get back out.", stream); + } + pbf_wait(context, 1500ms); + context.wait_for_all_requests(); +} + + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h new file mode 100644 index 0000000000..bbd593e63b --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h @@ -0,0 +1,33 @@ +/* BDSP AutoStory - Pokemon Center + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_PokemonCenter_H +#define PokemonAutomation_PokemonBDSP_AutoStory_PokemonCenter_H + +#include +#include "CommonFramework/Tools/VideoStream.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Heals the party at a Pokemon Center. Starts in front of the Center with its +// lit doorway on screen, within a few tiles of it: lines up with the doorway +// by sight, walks in, talks to the nurse, accepts, and walks back out. Ends +// on the tile below the door. +void heal_at_pokemon_center( + VideoStream& stream, ProControllerContext& context, + const std::string& label +); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp new file mode 100644 index 0000000000..c4befcac87 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -0,0 +1,491 @@ +/* BDSP AutoStory - Route Walk + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "Common/Cpp/PrettyPrint.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/OCR/OCR_RawTesseractOCR.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" +#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP_AutoStory_Walk.h" +#include "PokemonBDSP_AutoStory_RouteWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; +using namespace Pokemon; + + +namespace{ + +bool across(Direction direction){ + return direction == Direction::EAST || direction == Direction::WEST; +} + +Milliseconds ms_per_tile(Direction direction){ + return across(direction) ? ROUTE_MS_PER_TILE_ACROSS : ROUTE_MS_PER_TILE_UP_DOWN; +} + +// The left stick for a direction. Row 0 is the top of a map, and the stick's +// y is positive upward. +void stick_for(Direction direction, double& x, double& y){ + x = 0; + y = 0; + switch (direction){ + case Direction::NORTH: y = 1; break; + case Direction::EAST: x = 1; break; + case Direction::SOUTH: y = -1; break; + case Direction::WEST: x = -1; break; + } +} + +std::string describe(const MapSet& maps, const std::vector& tiles){ + std::string out; + for (const TilePos& pos : tiles){ + out += (out.empty() ? "" : ", ") + maps.describe(pos); + } + return out; +} + +// The tiles the player passes when walking `tiles` tiles from `from`, the +// first one after `from` first. Stops early where the player is stopped. +std::vector leg_path(const TileGrid& grid, TilePos from, Direction direction, size_t tiles){ + std::vector path; + size_t travelled = 0; + while (travelled < tiles){ + const std::optional next = grid.step(from, direction); + if (!next){ + break; + } + travelled += std::abs(next->x - from.x) + std::abs(next->y - from.y); + from = *next; + path.push_back(from); + } + return path; +} + +// Lower-case letters and digits only: "Route 202" and "ROUTE 2O2 " compare +// by "route202". +std::string normalize_name(const std::string& text){ + std::string out; + for (unsigned char ch : text){ + if (std::isalnum(ch)){ + out.push_back((char)std::tolower(ch)); + } + } + return out; +} + +// The location banner: a white box at the top left that names a map for a +// few seconds after the player enters it. Left of the centred name it is plain +// white (251 on console B, against 27-75 for the trees there otherwise). +const ImageFloatBox LOCATION_BANNER_WHITE{0.035, 0.035, 0.085, 0.050}; +const ImageFloatBox LOCATION_BANNER_TEXT{0.100, 0.025, 0.300, 0.080}; + +// The banner's map name, normalized, or empty when no banner shows. +std::string read_location_banner(const ImageViewRGB32& screen){ + const ImageStats white = image_stats(extract_box_reference(screen, LOCATION_BANNER_WHITE)); + if (white.average.sum() < 690 || white.stddev.sum() > 30){ + return ""; + } + return normalize_name(OCR::tesseract_ocr_read(Language::English, + extract_box_reference(screen, LOCATION_BANNER_TEXT))); +} + +// The map all of `tiles` are on, or empty if they are on several or none. +std::string map_of(const MapSet& maps, const std::vector& tiles){ + std::string name; + for (const TilePos& pos : tiles){ + const std::optional tile = maps.to_local(pos); + if (!tile || (!name.empty() && tile->map != name)){ + return ""; + } + name = tile->map; + } + return name; +} + +} + + +void route_status(VideoStream& stream, const std::string& text){ + stream.log("[Route] " + text, COLOR_BLUE); + add_wrapped_log(stream.overlay(), text, COLOR_WHITE); +} + +Milliseconds route_open_hold(Direction direction, size_t tiles){ + const Milliseconds step = ms_per_tile(direction); + return step * tiles - step / 2; +} + +void route_turn(VideoStream& stream, ProControllerContext& context, Direction direction){ + double x, y; + stick_for(direction, x, y); + stream.log(std::string("[Route] Turning ") + direction_name(direction) + "."); + pbf_move_left_joystick(context, {x, y}, ROUTE_TURN_TAP, 300ms); + context.wait_for_all_requests(); +} + + +std::vector walk_route( + VideoStream& stream, ProControllerContext& context, + const MapSet& maps, + std::vector from, + const std::function& goal, + std::vector& trainers, + const RouteTrainerFight& fight, + const std::string& label +){ + // Our own copy, so things found in the way can be added to it. + TileGrid grid = maps.world(); + // Which way the player faces, once a leg has set it. + std::optional facing; + for (size_t plans = 0; plans < 8; plans++){ + const std::optional> legs = plan_robust_route(grid, from, goal); + if (!legs){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": no plan from " + describe(maps, from) + ".", stream); + } + route_status(stream, label + ": " + std::to_string(legs->size()) + " legs planned"); + std::string text = "[Route] " + label + ": plan from " + describe(maps, from) + ":"; + for (const PlannedLeg& leg : *legs){ + text += "\n " + to_string(leg.leg) + " to " + describe(maps, leg.after); + } + stream.log(text); + + bool replanned = false; + size_t leg_number = 0; + for (const PlannedLeg& planned : *legs){ + const TileLeg& leg = planned.leg; + leg_number++; + route_status(stream, "Leg " + std::to_string(leg_number) + "/" + std::to_string(legs->size()) + ": " + + to_string(leg) + " to " + describe(maps, planned.after)); + double x, y; + stick_for(leg.direction, x, y); + const std::string leg_label = label + ": " + to_string(leg); + + // Note any location banner the walk passes, to check map changes. + std::string banner; + auto watch_banner = [&](const ImageViewRGB32& screen){ + if (banner.empty()){ + banner = read_location_banner(screen); + } + return false; + }; + + WalkResult result; + if (leg.ends_at_wall){ + // Long enough to reach the wall from the farthest tile, with room + // for a slow start; past the wall the player stands still. + const Milliseconds limit = ROUTE_START_DELAY + ms_per_tile(leg.direction) * leg.tiles * 5 / 4 + 1000ms; + result = walk_until(stream, context, x, y, limit, leg_label, watch_banner); + // A wild battle may end the walk early. Walking to a wall again + // ends in the same place, so finish the leg. + const bool interrupted = result.wild_battles > 0; + for (size_t again = 0; again < 3 && result.wild_battles > 0 && + (result.stop == WalkStop::LIMIT || result.stop == WalkStop::BLOCKED); again++ + ){ + stream.log("[Route] " + leg_label + ": a wild battle on the way; walking to the wall again."); + result = walk_until(stream, context, x, y, limit, leg_label, watch_banner); + } + // A wall well before the nearest one the map has: something the + // map does not know is in the way. Work out where we stopped from + // the time walked, put it on our map and plan again from there. + size_t nearest = SIZE_MAX; + for (const TilePos& pos : from){ + nearest = std::min(nearest, walk_to_wall(grid, pos, leg.direction).tiles); + } + auto tiles_moved = [&](const WalkResult& walk){ + const Milliseconds moving = walk.walked - ROUTE_START_DELAY - + (walk.stop == WalkStop::BLOCKED ? 800ms : 0ms); + return moving.count() <= 0 ? (size_t)0 : (size_t)(moving / ms_per_tile(leg.direction)); + }; + size_t walked_tiles = tiles_moved(result); + // Someone walking about may stand in the way for a moment: wait + // and walk to the wall again before calling it an obstacle. + for (size_t again = 0; !interrupted && again < 2 && + result.stop == WalkStop::BLOCKED && walked_tiles + 2 < nearest; again++ + ){ + stream.log("[Route] " + leg_label + ": stopped after about " + std::to_string(walked_tiles) + + " tiles, short of the wall; waiting for the way to clear.", COLOR_ORANGE); + pbf_wait(context, 2500ms); + context.wait_for_all_requests(); + result = walk_until(stream, context, x, y, limit, leg_label, watch_banner); + walked_tiles += tiles_moved(result); + } + if (!interrupted && result.stop == WalkStop::BLOCKED && walked_tiles + 2 < nearest){ + // Someone who stays put: talk to them. A trainer we walked up + // to from the side starts the battle this way. + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + { + ShortDialogWatcher dialog(COLOR_YELLOW); + if (wait_until(stream, context, 2000ms, {{dialog}}) == 0){ + route_status(stream, leg_label + ": someone is in the way; talking to them."); + fight("someone in the way"); + } + } + std::vector stopped; + for (const TilePos& pos : from){ + const std::vector path = leg_path(grid, pos, leg.direction, SIZE_MAX); + for (size_t k = walked_tiles == 0 ? 0 : walked_tiles - 1; k <= walked_tiles + 1 && k <= path.size(); k++){ + stopped.push_back(k == 0 ? pos : path[k - 1]); + } + } + std::sort(stopped.begin(), stopped.end(), [](const TilePos& a, const TilePos& b){ + return a.y != b.y ? a.y < b.y : a.x < b.x; + }); + stopped.erase(std::unique(stopped.begin(), stopped.end()), stopped.end()); + if (stopped.empty()){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": stopped early, and no tile fits.", stream); + } + if (stopped.size() == 1){ + const TilePos blocker = stopped[0].next(leg.direction); + grid.set(blocker, TileType::BLOCKED); + stream.log("[Route] " + leg_label + ": something the map lacks is at " + + maps.describe(blocker) + "; marked it.", COLOR_ORANGE); + } + stream.log("[Route] " + leg_label + ": stopped early after about " + std::to_string(walked_tiles) + + " tiles, at " + describe(maps, stopped) + "; planning again.", COLOR_ORANGE); + facing = leg.direction; + from = stopped; + replanned = true; + break; + } + }else{ + // The hold is timed for a player facing the way to go. + if (facing != leg.direction){ + route_turn(stream, context, leg.direction); + } + result = walk_for(stream, context, x, y, route_open_hold(leg.direction, leg.tiles), leg_label); + if (result.wild_battles > 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": a wild battle cut an open leg short; position unknown.", stream); + } + } + facing = leg.direction; + if (banner.empty()){ + context.wait_for_all_requests(); + if (VideoSnapshot screen = stream.video().snapshot()){ + watch_banner(*screen.frame); + } + } + // Entering another map shows its name; a missing or wrong name means + // the walk did not end where the plan says. + const std::string map_before = map_of(maps, from); + const std::string map_after = map_of(maps, planned.after); + if (!map_after.empty() && map_after != map_before && banner != normalize_name(map_after)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": expected to enter " + map_after + ", but the location banner read \"" + banner + "\".", stream); + } + if (!banner.empty()){ + stream.log("[Route] " + leg_label + ": location banner \"" + banner + "\"."); + } + // Where each leg ended, to compare with the plan after a run. + if (VideoSnapshot screen = stream.video().snapshot()){ + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/route/"; + Filesystem::create_directories(Filesystem::Path(folder)); + screen.frame->save(folder + now_to_filestring() + "-" + normalize_name(leg_label) + ".png"); + } + if (result.stop == WalkStop::TRANSITION){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": unexpected transition.", stream); + } + + // A trainer who sees us freezes the player and walks over; the + // dialogue may open after the walk has ended. + const std::vector> paths = [&]{ + std::vector> out; + for (const TilePos& pos : from){ + out.push_back(leg_path(grid, pos, leg.direction, + leg.ends_at_wall ? SIZE_MAX : leg.tiles + 1)); + } + return out; + }(); + std::vector watching; + for (size_t t = 0; t < trainers.size(); t++){ + const std::vector sight = sight_tiles(grid, trainers[t]); + for (const std::vector& path : paths){ + if (std::any_of(path.begin(), path.end(), [&](const TilePos& pos){ + return std::find(sight.begin(), sight.end(), pos) != sight.end(); + })){ + watching.push_back(t); + break; + } + } + } + bool talking = result.stop == WalkStop::DIALOG; + if (!talking){ + ShortDialogWatcher dialog(COLOR_YELLOW); + const Milliseconds wait = watching.empty() ? 500ms : 4000ms; + talking = wait_until(stream, context, wait, {{dialog}}) == 0; + } + if (!talking){ + from = planned.after; + continue; + } + + if (watching.empty()){ + // A trainer the map does not have. Fight, then plan again from + // anywhere the leg could have stopped. + std::vector anywhere = from; + for (const std::vector& path : paths){ + anywhere.insert(anywhere.end(), path.begin(), path.end()); + } + std::sort(anywhere.begin(), anywhere.end(), [](const TilePos& a, const TilePos& b){ + return a.y != b.y ? a.y < b.y : a.x < b.x; + }); + anywhere.erase(std::unique(anywhere.begin(), anywhere.end()), anywhere.end()); + route_status(stream, label + ": a trainer the map lacks stopped us; fighting."); + fight("unknown trainer"); + from = anywhere; + replanned = true; + break; + } + // Each possible start tile was stopped on the first tile of its path + // inside a watching trainer's sight. + const TrainerSight trainer = trainers[watching[0]]; + std::vector stopped; + for (const std::vector& path : paths){ + for (const TilePos& pos : path){ + bool seen = false; + for (size_t t : watching){ + const std::vector sight = sight_tiles(grid, trainers[t]); + seen |= std::find(sight.begin(), sight.end(), pos) != sight.end(); + } + if (seen){ + stopped.push_back(pos); + break; + } + } + } + if (stopped.empty()){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + leg_label + ": " + trainer.name + " talked, but no path enters their sight.", stream); + } + stream.log("[Route] " + label + ": " + trainer.name + " stopped us at " + describe(maps, stopped) + "."); + route_status(stream, "Fighting " + trainer.name + "."); + fight(trainer.name); + trainers.erase(trainers.begin() + watching[0]); + from = stopped; + replanned = true; + break; + } + if (!replanned){ + stream.log("[Route] " + label + ": arrived at " + describe(maps, from) + "."); + return from; + } + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": too many plans.", stream); +} + + + +void talk_to_npc( + VideoStream& stream, ProControllerContext& context, + const MapSet& maps, + std::vector& at, + const TilePos& npc, + const std::vector& sides, + std::vector& trainers, + const RouteTrainerFight& fight, + const std::string& label +){ + for (Direction side : sides){ + const TilePos stand = npc.next(side); + if (!maps.world().standable(stand)){ + continue; + } + at = walk_route(stream, context, maps, at, [&](const TilePos& pos){ return pos == stand; }, + trainers, fight, label); + // Face the NPC: the opposite of the side we stand on. + const Direction face = + side == Direction::NORTH ? Direction::SOUTH : + side == Direction::SOUTH ? Direction::NORTH : + side == Direction::EAST ? Direction::WEST : Direction::EAST; + route_turn(stream, context, face); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + context.wait_for_all_requests(); + ShortDialogWatcher dialog(COLOR_CYAN); + if (wait_until(stream, context, 2000ms, {{dialog}}) == 0){ + route_status(stream, label + ": talking from " + maps.describe(stand) + "."); + return; + } + route_status(stream, label + ": nobody answered from " + maps.describe(stand) + "; trying another side."); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": no side of " + maps.describe(npc) + " started a dialogue.", stream); +} + +void finish_dialogue( + VideoStream& stream, ProControllerContext& context, + size_t a_presses, const std::string& label +){ + for (size_t press = 0; press < a_presses; press++){ + pbf_press_button(context, BUTTON_A, 80ms, 1500ms); + } + ShortDialogDetector dialog; + OverworldDetector overworld; + size_t quiet = 0; + for (size_t press = 0; press < 30; press++){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (frame && !dialog.detect(frame) && overworld.detect(frame)){ + if (++quiet >= 2){ + return; + } + context.wait_for(500ms); + continue; + } + quiet = 0; + pbf_press_button(context, BUTTON_B, 80ms, 1200ms); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": the dialogue did not end.", stream); +} + +void enter_door_north(VideoStream& stream, ProControllerContext& context, const std::string& label){ + // Sideways steps to try after a blocked walk: east, then back west and + // one more. + const Direction steps[] = {Direction::EAST, Direction::WEST, Direction::WEST}; + for (size_t attempt = 0; attempt <= 3; attempt++){ + const WalkResult walk = walk_until(stream, context, 0, 1, 2500ms, label); + if (walk.stop == WalkStop::TRANSITION){ + context.wait_for(1500ms); + return; + } + if (attempt == 3){ + break; + } + route_status(stream, label + ": the door frame stopped us; one tile " + + (steps[attempt] == Direction::EAST ? "east." : "west.")); + route_turn(stream, context, steps[attempt]); + const double x = steps[attempt] == Direction::EAST ? 1 : -1; + pbf_move_left_joystick(context, {x, 0}, route_open_hold(steps[attempt], 1), 200ms); + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": could not go through the door.", stream); +} + + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h new file mode 100644 index 0000000000..3a0d4d6b8c --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -0,0 +1,106 @@ +/* BDSP AutoStory - Route Walk + * + * From: https://github.com/PokemonAutomation/ + * + * Walks a plan made on the story maps: legs to a wall are held until the + * player stops, open legs for their length in time. Tracks every tile the + * player may be on, and plans again after a trainer stops the walk. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_RouteWalk_H +#define PokemonAutomation_PokemonBDSP_AutoStory_RouteWalk_H + +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "Pokemon/Navigation/Pokemon_MapSet.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Walking time per tile, measured on the console from the post-catch save: +// a row is deeper than a column is wide under the tilted camera. +constexpr Milliseconds ROUTE_MS_PER_TILE_ACROSS = Milliseconds(160); // east or west +constexpr Milliseconds ROUTE_MS_PER_TILE_UP_DOWN = Milliseconds(240); // north or south +// From pressing the stick to the first step, when standing still. +constexpr Milliseconds ROUTE_START_DELAY = Milliseconds(250); + +// How long to hold the stick for an open leg of `tiles` tiles, for a player +// already facing that way: a step once started is always finished, so stop +// half a tile early. On the console a 1290ms hold west went 9 tiles and an +// 800ms hold south 4: the player starts at once and finishes the step. +Milliseconds route_open_hold(Pokemon::Direction direction, size_t tiles); + +// A tap this short turns a player who faces another way, without a step: on +// the console a 150ms tap south after walking north only turned the player. +constexpr Milliseconds ROUTE_TURN_TAP = Milliseconds(100); + +// Turns the player to face `direction` without moving. Only for a player who +// faces another way: one already facing it may take a step. +void route_turn(VideoStream& stream, ProControllerContext& context, Pokemon::Direction direction); + +// Shows what the route walk is doing in the log and the video overlay. +void route_status(VideoStream& stream, const std::string& text); + +// Called when a trainer's dialogue has opened, or when someone standing in +// the way was talked to. Fights the battle if one starts, and returns to the +// overworld either way. +using RouteTrainerFight = std::function; + +// Walks from any of the world tiles `from` of `maps` until every tile the +// player may be on is `goal`, and returns those tiles. Trainers in +// `trainers` who see the player are fought with `fight` and taken off the +// list. Fails with a screenshot on a transition, a dialogue no trainer +// explains, a wild battle during an open leg, or when no plan exists. +std::vector walk_route( + VideoStream& stream, ProControllerContext& context, + const Pokemon::MapSet& maps, + std::vector from, + const std::function& goal, + std::vector& trainers, + const RouteTrainerFight& fight, + const std::string& label +); + +// Walks next to the NPC on world tile `npc`, faces them and presses A, trying +// the sides in `sides` (the side of the NPC to stand on) in order until a +// dialogue opens. `at` is where the player may be, and is updated. Fails with +// a screenshot if no side starts a dialogue. A press from a diagonal tile, +// or from a tile a row off, talks to nobody, so each side is tried in turn. +void talk_to_npc( + VideoStream& stream, ProControllerContext& context, + const Pokemon::MapSet& maps, + std::vector& at, + const Pokemon::TilePos& npc, + const std::vector& sides, + std::vector& trainers, + const RouteTrainerFight& fight, + const std::string& label +); + +// Finishes a dialogue that has opened: `a_presses` presses of A, 1.5 s +// apart (A answers Yes and closes "obtained" messages), then B until the +// overworld is back with no dialogue. B never starts a new talk, while an A +// after the last line talks to the NPC again. +void finish_dialogue( + VideoStream& stream, ProControllerContext& context, + size_t a_presses, const std::string& label +); + +// For a player on the tile below a door: walks north through it. If a wall +// stops the walk (the door frame, a tile off the opening), steps a tile east, +// then west, and tries again. Fails with a screenshot if no step goes in. +void enter_door_north(VideoStream& stream, ProControllerContext& context, const std::string& label); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp new file mode 100644 index 0000000000..0703dcbece --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp @@ -0,0 +1,365 @@ +/* BDSP AutoStory - Walk + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTools/ImageDiff.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP_AutoStory_Segment_00_tools.h" +#include "PokemonBDSP_Autostory_Segment_01_tools.h" +#include "PokemonBDSP_AutoStory_Walk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; + + +const char* walk_stop_name(WalkStop stop){ + switch (stop){ + case WalkStop::ARRIVED: return "arrived"; + case WalkStop::DIALOG: return "dialog"; + case WalkStop::TRANSITION: return "transition"; + case WalkStop::BLOCKED: return "blocked"; + case WalkStop::LIMIT: return "limit"; + } + return "?"; +} + +double walk_screen_change(const ImageViewRGB32& before, const ImageViewRGB32& after){ + // Small copies: only the whole-view scroll matters, not detail. + return ImageMatch::pixel_RMSD(before.scale_to(192, 108), after.scale_to(192, 108)); +} + + +namespace{ + +VideoSnapshot snapshot_or_fail(VideoStream& stream, const std::string& label){ + VideoSnapshot frame = stream.video().snapshot(); + if (!frame){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No video frame.", stream); + } + return frame; +} + +// After a black screen: a wild battle if a battle screen shows up, else a transition. +bool black_screen_was_battle(VideoStream& stream, ProControllerContext& context){ + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + BlackScreenWatcher gone(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 100, 10, + BlackScreenWatcher::FinderType::GONE, 500ms); + const int ret = wait_until(stream, context, 10s, {{battle_menu}, {battle_dialog}, {gone}}); + if (ret == 2){ + // The battle intro also starts from black; give its first screen a moment. + BattleMenuWatcher late_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher late_dialog(COLOR_YELLOW); + return wait_until(stream, context, 2s, {{late_menu}, {late_dialog}}) >= 0; + } + return ret >= 0; +} + +// A grass encounter opens with a leaf animation, neither white nor black, and +// shows the battle's text box a few seconds later. After a change of the +// whole screen that walking cannot make, wait for the battle to show. +bool battle_follows(VideoStream& stream, ProControllerContext& context){ + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + return wait_until(stream, context, 5s, {{battle_menu}, {battle_dialog}}) >= 0; +} + +// A step in tall grass that was started before the stick was released still +// finishes, and its encounter starts after the walk has ended. Watch a little +// longer, and run from it. Returns whether there was one. +bool run_from_late_encounter(VideoStream& stream, ProControllerContext& context, const std::string& label){ + VideoSnapshot before = stream.video().snapshot(); + WhiteScreenOverWatcher encounter_flash(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); + BlackScreenWatcher black(COLOR_RED); + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + const int ret = wait_until(stream, context, 1200ms, {{encounter_flash}, {black}, {battle_menu}, {battle_dialog}}); + bool battle = ret == 0 || ret == 2 || ret == 3 || (ret == 1 && black_screen_was_battle(stream, context)); + if (ret < 0){ + VideoSnapshot after = stream.video().snapshot(); + battle = before && after && walk_screen_change(*before.frame, *after.frame) > WALK_SCENE_CHANGE && + battle_follows(stream, context); + } + if (!battle){ + return false; + } + stream.log("[Walk] " + label + ": wild battle after the walk; running."); + escape_wild_battle(stream, context, BattleType::STANDARD); + context.wait_for_all_requests(); + return true; +} + +// Samples the screen every 100ms for probe_walk() and stops it once the view +// has been still for 800ms. +class WalkProbeSampler : public VisualInferenceCallback{ +public: + WalkProbeSampler(WalkProbe& result, double moving_change, WallClock start) + : VisualInferenceCallback("WalkProbeSampler") + , m_result(result) + , m_moving_change(moving_change) + , m_start(start) + {} + virtual void make_overlays(VideoOverlaySet&) const override{} + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override{ + if (m_has_last && timestamp - m_last_time < 100ms){ + return false; + } + const Milliseconds at = std::chrono::duration_cast(timestamp - m_start); + ImageRGB32 small = frame.scale_to(192, 108); + if (m_has_last){ + const double change = ImageMatch::pixel_RMSD(m_last, small); + m_result.samples.push_back({at, change}); + if (change >= m_moving_change){ + if (m_result.first_move < 0ms){ + m_result.first_move = at; + } + m_result.last_move = at; + } + } + m_last = std::move(small); + m_last_time = timestamp; + m_has_last = true; + const Milliseconds still_since = m_result.last_move < 0ms ? 0ms : m_result.last_move; + return at - still_since >= 800ms; + } + +private: + WalkProbe& m_result; + double m_moving_change; + WallClock m_start; + ImageRGB32 m_last; + WallClock m_last_time; + bool m_has_last = false; +}; + +} + + +WalkResult walk_until( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label, + const std::function& arrived +){ + constexpr Milliseconds CHUNK = 400ms; + WalkResult result{WalkStop::LIMIT, 0ms, 0}; + size_t unchanged = 0; + + context.wait_for_all_requests(); + VideoSnapshot before = snapshot_or_fail(stream, label); + auto finish = [&](WalkStop stop){ + result.stop = stop; + if ((stop == WalkStop::LIMIT || stop == WalkStop::BLOCKED) && + run_from_late_encounter(stream, context, label) + ){ + result.wild_battles++; + } + stream.log("[Walk] " + label + ": " + walk_stop_name(stop) + " after " + + std::to_string(result.walked.count()) + "ms, " + + std::to_string(result.wild_battles) + " wild battle(s)."); + return result; + }; + + while (true){ + if (arrived && arrived(*before.frame)){ + return finish(WalkStop::ARRIVED); + } + if (result.walked >= limit){ + return finish(WalkStop::LIMIT); + } + const Milliseconds chunk = std::min(CHUNK, limit - result.walked); + + WhiteScreenOverWatcher encounter_flash(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); + BlackScreenWatcher black(COLOR_RED); + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + ShortDialogWatcher dialog(COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + const int ret = run_until( + stream, context, + [&](ProControllerContext& context){ + pbf_move_left_joystick(context, {x, y}, chunk, 0ms); + }, + {{encounter_flash}, {black}, {battle_menu}, {dialog}, {battle_dialog}} + ); + result.walked += chunk; + + bool battle = ret == 0 || ret == 2 || ret == 4; + if (ret == 1){ + battle = black_screen_was_battle(stream, context); + if (!battle){ + return finish(WalkStop::TRANSITION); + } + } + if (battle){ + result.wild_battles++; + stream.log("[Walk] " + label + ": wild battle; running."); + escape_wild_battle(stream, context, BattleType::STANDARD); + context.wait_for_all_requests(); + before = snapshot_or_fail(stream, label); + unchanged = 0; + continue; + } + if (ret == 3){ + return finish(WalkStop::DIALOG); + } + + context.wait_for_all_requests(); + VideoSnapshot after = snapshot_or_fail(stream, label); + const double change = walk_screen_change(*before.frame, *after.frame); + stream.log("[Walk] " + label + ": " + std::to_string(result.walked.count()) + + "ms, screen change " + std::to_string(change) + "."); + if (change > WALK_SCENE_CHANGE && battle_follows(stream, context)){ + result.wild_battles++; + stream.log("[Walk] " + label + ": wild battle (leaf opening); running."); + escape_wild_battle(stream, context, BattleType::STANDARD); + context.wait_for_all_requests(); + before = snapshot_or_fail(stream, label); + unchanged = 0; + continue; + } + // Two still chunks in a row, so one dropped frame does not stop us. A + // frame exactly the same is a stalled video or a game hitch, not a wall: + // standing at a wall still animates. + if (change == 0){ + before = std::move(after); + continue; + } + unchanged = change < WALK_BLOCKED_CHANGE ? unchanged + 1 : 0; + if (unchanged >= 2){ + return finish(WalkStop::BLOCKED); + } + before = std::move(after); + } +} + +WalkResult walk_for( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds duration, const std::string& label +){ + WalkResult result{WalkStop::LIMIT, duration, 0}; + context.wait_for_all_requests(); + WhiteScreenOverWatcher encounter_flash(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); + BlackScreenWatcher black(COLOR_RED); + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + ShortDialogWatcher dialog(COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + const int ret = run_until( + stream, context, + [&](ProControllerContext& context){ + pbf_move_left_joystick(context, {x, y}, duration, 0ms); + }, + {{encounter_flash}, {black}, {battle_menu}, {dialog}, {battle_dialog}} + ); + bool battle = ret == 0 || ret == 2 || ret == 4; + if (ret == 1){ + battle = black_screen_was_battle(stream, context); + if (!battle){ + result.stop = WalkStop::TRANSITION; + } + } + if (battle){ + result.wild_battles = 1; + stream.log("[Walk] " + label + ": wild battle; running."); + escape_wild_battle(stream, context, BattleType::STANDARD); + context.wait_for_all_requests(); + } + if (ret == 3){ + result.stop = WalkStop::DIALOG; + } + // An encounter after the hold leaves us where the walk ended, so it does + // not count as cutting the walk short. + if (ret < 0){ + run_from_late_encounter(stream, context, label); + } + stream.log("[Walk] " + label + ": " + walk_stop_name(result.stop) + " after a " + + std::to_string(duration.count()) + "ms hold, " + std::to_string(result.wild_battles) + " wild battle(s)."); + return result; +} + +WalkResult walk_to_talk( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label +){ + WalkResult total{WalkStop::LIMIT, 0ms, 0}; + while (total.walked < limit){ + const WalkResult step = walk_until(stream, context, x, y, + std::min(400ms, limit - total.walked), label); + total.walked += step.walked; + total.wild_battles += step.wild_battles; + if (step.stop == WalkStop::DIALOG || step.stop == WalkStop::TRANSITION){ + total.stop = step.stop; + return total; + } + ShortDialogWatcher dialog(COLOR_YELLOW); + const int ret = run_until( + stream, context, + [](ProControllerContext& context){ + pbf_press_button(context, BUTTON_A, 80ms, 720ms); + }, + {{dialog}} + ); + if (ret == 0){ + total.stop = WalkStop::DIALOG; + stream.log("[Walk] " + label + ": talking after " + std::to_string(total.walked.count()) + "ms."); + return total; + } + } + stream.log("[Walk] " + label + ": nobody to talk to within " + std::to_string(limit.count()) + "ms."); + return total; +} + +WalkProbe probe_walk( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, + double moving_change, const std::string& label +){ + WalkProbe result; + context.wait_for_all_requests(); + WalkProbeSampler sampler(result, moving_change, current_time()); + run_until( + stream, context, + [&](ProControllerContext& context){ + pbf_move_left_joystick(context, {x, y}, limit, 0ms); + }, + {{sampler}} + ); + context.wait_for_all_requests(); + stream.log("[Walk] " + label + ": probe moved from " + std::to_string(result.first_move.count()) + + "ms to " + std::to_string(result.last_move.count()) + "ms."); + return result; +} + +WalkResult walk_expecting( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label, + WalkStop expected, + const std::function& arrived +){ + const WalkResult result = walk_until(stream, context, x, y, limit, label, arrived); + if (result.stop != expected){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": expected " + walk_stop_name(expected) + ", got " + + walk_stop_name(result.stop) + ".", stream); + } + return result; +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h new file mode 100644 index 0000000000..77deca0893 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h @@ -0,0 +1,110 @@ +/* BDSP AutoStory - Walk + * + * From: https://github.com/PokemonAutomation/ + * + * Walking that reads the screen instead of trusting a duration: hold a + * direction and stop on whatever the screen says happened. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Walk_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Walk_H + +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +enum class WalkStop{ + ARRIVED, // `arrived` matched. + DIALOG, // A dialogue box opened: an NPC or a trainer stopped us. + TRANSITION, // The screen went black and came back without a battle: a door or a new area. + BLOCKED, // The screen stopped changing: we are walking into a wall. + LIMIT, // Walked `limit` without any of the above. +}; +const char* walk_stop_name(WalkStop stop); + +struct WalkResult{ + WalkStop stop; + Milliseconds walked; + size_t wild_battles; +}; + +// Holds the left stick at (x, y) until one of the WalkStop events. Wild battles on +// the way are run from, and walking resumes. Does not throw for any WalkStop; the +// caller decides which ones are expected. +WalkResult walk_until( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label, + const std::function& arrived = nullptr +); + +// Holds the left stick at (x, y) for `duration` in one go, for a walk of a +// set length: walk_until() checks the screen between short holds, and each +// break can cost a step. Stops early for a dialog or a transition. A wild +// battle is run from and ends the walk with `wild_battles` 1, at a place the +// caller cannot know. +WalkResult walk_for( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds duration, const std::string& label +); + +// walk_until() that requires `expected`. Fails with the screenshot otherwise. +WalkResult walk_expecting( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label, + WalkStop expected, + const std::function& arrived = nullptr +); + +// Walks toward someone to talk to: after each short stretch, presses A and stops +// once a dialogue opens. Use it where walking into the person does not read as +// BLOCKED, e.g. in tall grass, whose sway keeps the screen changing. +WalkResult walk_to_talk( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, const std::string& label +); + +// How much the screen changed between two frames, 0 for identical. Walking scrolls +// the whole view: 16 to 85 per 400ms on Route 202, against about 6 for running in +// place against trees. +double walk_screen_change(const ImageViewRGB32& before, const ImageViewRGB32& after); +constexpr double WALK_BLOCKED_CHANGE = 10.0; +// More than walking changes in 400ms: a new scene, such as a battle opening. +constexpr double WALK_SCENE_CHANGE = 120.0; + + +// One straight walk timed finely, to check a map against the game: holds the +// stick at (x, y) and samples the screen every 100ms until the view has been +// still for 800ms or `limit` runs out. Times count from the start of the hold. +// A sample moved if it changed by at least `moving_change`: still is 4 to 7, +// walking 16 to 85. Handles no battles or dialogs: use it on open ground, +// away from tall grass. +struct WalkProbe{ + Milliseconds first_move{-1}; // the first sample that moved; -1 if none did + Milliseconds last_move{-1}; // the last sample that moved; -1 if none did + struct Sample{ + Milliseconds at; + double change; // walk_screen_change() since the previous sample + }; + std::vector samples; +}; +WalkProbe probe_walk( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds limit, + double moving_change, const std::string& label +); + + +} +} +} +#endif From 511c1714f211b3db657499c76990f43656a51f8a Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 18:53:15 -0700 Subject: [PATCH 04/19] BDSP AutoStory: new-game intro on the screen machine Names typed on the Switch keyboard or picked from the preset list, each screen read before the next input. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../NintendoSwitch_SystemKeyboardDetector.cpp | 156 ++++++++++ .../NintendoSwitch_SystemKeyboardDetector.h | 67 ++++ .../Inference/PokemonBDSP_MenuDetector.cpp | 7 +- .../PokemonBDSP_NameListDetector.cpp | 67 ++++ .../Inference/PokemonBDSP_NameListDetector.h | 41 +++ .../Inference/PokemonBDSP_SelectionArrow.h | 5 + .../Utils/PokemonBDSP_AutoStory_NewGame.cpp | 286 ++++++++++++++++++ .../Utils/PokemonBDSP_AutoStory_NewGame.h | 71 +++++ 8 files changed, 697 insertions(+), 3 deletions(-) create mode 100644 SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.cpp create mode 100644 SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h diff --git a/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.cpp b/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.cpp new file mode 100644 index 0000000000..765baba05f --- /dev/null +++ b/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.cpp @@ -0,0 +1,156 @@ +/* System Keyboard Detector + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" +#include "CommonTools/Images/SolidColorTest.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch_SystemKeyboardDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ + +using namespace std::chrono_literals; + + +// Key grid of the QWERTY layout and where each key sits: rows "1".."z", columns "1".."-". +static const char* const KEY_ROWS[] = {"1234567890-", "qwertyuiop/", "asdfghjkl:'", "zxcvbnm,.?!"}; +static constexpr double KEY_ROW_Y[] = {0.500, 0.593, 0.681, 0.770}; +static constexpr double KEY_COL_X0 = 0.077; +static constexpr double KEY_COL_STEP = 0.075; + + +SystemKeyboardDetector::SystemKeyboardDetector(Color color) + : m_color(color) + , m_key_panel(0.15, 0.555, 0.65, 0.020) + , m_bottom_bar(0.10, 0.935, 0.35, 0.030) + , m_ok_button(0.875, 0.760, 0.045, 0.020) +{} +void SystemKeyboardDetector::make_overlays(VideoOverlaySet& items) const{ + items.add(m_color, m_key_panel); + items.add(m_color, m_bottom_bar); + items.add(m_color, m_ok_button); +} +bool SystemKeyboardDetector::detect(const ImageViewRGB32& screen){ + if (!is_white(extract_box_reference(screen, m_key_panel)) || + !is_white(extract_box_reference(screen, m_bottom_bar)) + ){ + return false; + } + // OK is blue, or flat grey while the field is empty (disabled). + const ImageStats ok = image_stats(extract_box_reference(screen, m_ok_button)); + return is_solid(ok, {0.172, 0.276, 0.552}) || is_grey(ok, 600, 690); +} + + +bool find_system_keyboard_cursor(const ImageViewRGB32& screen, int& row, int& col){ + // The cursor is a cyan border around one key. Neighbouring keys overlap that border, + // so take the centroid of all cyan pixels and map it to the nearest key. + const ImageFloatBox area(0.02, 0.44, 0.84, 0.38); + ImageViewRGB32 keys = extract_box_reference(screen, area); + double count = 0, sum_x = 0, sum_y = 0; + for (size_t y = 0; y < keys.height(); y++){ + for (size_t x = 0; x < keys.width(); x++){ + const uint32_t pixel = keys.pixel(x, y); + const int red = (pixel >> 16) & 0xff; + const int green = (pixel >> 8) & 0xff; + const int blue = pixel & 0xff; + if (red < 150 && green > 200 && blue > 190){ + count++; + sum_x += (double)x; + sum_y += (double)y; + } + } + } + // The border is about 0.15% of the screen; a few stray pixels are not a cursor. + if (count < 0.0005 * screen.width() * screen.height()){ + return false; + } + const double cx = area.x + sum_x / count / keys.width() * area.width; + const double cy = area.y + sum_y / count / keys.height() * area.height; + col = (int)std::lround((cx - KEY_COL_X0) / KEY_COL_STEP); + row = 0; + for (int r = 1; r < 4; r++){ + if (std::abs(KEY_ROW_Y[r] - cy) < std::abs(KEY_ROW_Y[row] - cy)){ + row = r; + } + } + return 0 <= col && col < 11; +} + +bool system_keyboard_field_has_text(const ImageViewRGB32& screen){ + // Left part of the field, after the caret's position on an empty field. + const ImageStats stats = image_stats(extract_box_reference(screen, ImageFloatBox(0.11, 0.235, 0.19, 0.030))); + return stats.stddev.sum() > 30; +} + + +void type_on_system_keyboard(VideoStream& stream, ProControllerContext& context, const std::string& text){ + for (char ch : text){ + const char key = (char)std::tolower((unsigned char)ch); + int target_row = -1; + int target_col = -1; + for (int r = 0; r < 4 && target_row < 0; r++){ + const char* p = std::strchr(KEY_ROWS[r], key); + if (p != nullptr && key != '\0'){ + target_row = r; + target_col = (int)(p - KEY_ROWS[r]); + } + } + if (target_row < 0){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + std::string("System keyboard: no key for '") + ch + "'.", + stream + ); + } + + // A button pressed while the keyboard slides in is ignored, so read the cursor + // again after each move until it is on the target. + bool arrived = false; + for (size_t attempt = 0; attempt < 10 && !arrived; attempt++){ + context.wait_for_all_requests(); + int row = -1; + int col = -1; + VideoSnapshot screen = stream.video().snapshot(); + if (!screen || !find_system_keyboard_cursor(screen, row, col)){ + pbf_wait(context, 300ms); + continue; + } + if (row == target_row && col == target_col){ + arrived = true; + break; + } + for (; row < target_row; row++) pbf_press_dpad(context, DPAD_DOWN, 100ms, 200ms); + for (; row > target_row; row--) pbf_press_dpad(context, DPAD_UP, 100ms, 200ms); + for (; col < target_col; col++) pbf_press_dpad(context, DPAD_RIGHT, 100ms, 200ms); + for (; col > target_col; col--) pbf_press_dpad(context, DPAD_LEFT, 100ms, 200ms); + pbf_wait(context, 300ms); + } + if (!arrived){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + std::string("System keyboard: the cursor did not reach '") + key + "'.", + stream + ); + } + if (std::isupper((unsigned char)ch)){ + pbf_press_button(context, BUTTON_LCLICK, 100ms, 250ms); + } + pbf_press_button(context, BUTTON_A, 80ms, 300ms); + } + context.wait_for_all_requests(); +} + + + +} +} diff --git a/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h b/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h new file mode 100644 index 0000000000..aa23e7ec81 --- /dev/null +++ b/SerialPrograms/Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h @@ -0,0 +1,67 @@ +/* System Keyboard Detector + * + * From: https://github.com/PokemonAutomation/ + * + * The Switch's own software keyboard, which games open for names (e.g. the + * player's name in BDSP): detect it, find the key under its cursor, and type + * a string by moving the cursor and checking where it landed. + * + * Measured on 1920x1080 captures of the QWERTY layout. + * + */ + +#ifndef PokemonAutomation_NintendoSwitch_SystemKeyboardDetector_H +#define PokemonAutomation_NintendoSwitch_SystemKeyboardDetector_H + +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "CommonTools/VisualDetector.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ + + +// White key panel and bottom bar, and the blue OK button. +class SystemKeyboardDetector : public StaticScreenDetector{ +public: + SystemKeyboardDetector(Color color = COLOR_CYAN); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + +private: + Color m_color; + ImageFloatBox m_key_panel; + ImageFloatBox m_bottom_bar; + ImageFloatBox m_ok_button; +}; +class SystemKeyboardWatcher : public DetectorToFinder{ +public: + SystemKeyboardWatcher(Color color = COLOR_CYAN) + : DetectorToFinder("SystemKeyboardWatcher", std::chrono::milliseconds(250), color) + {} +}; + + +// The key under the cursor, as row (0: digits .. 3: "zxcv") and column (0..10). +// The keyboard remembers the cursor between uses, so read it, don't assume it. +// Returns false if no cursor border is visible. +bool find_system_keyboard_cursor(const ImageViewRGB32& screen, int& row, int& col); + +// Whether the text field holds any text. Pressing B on an empty field closes the +// keyboard, so delete only while this is true. +bool system_keyboard_field_has_text(const ImageViewRGB32& screen); + +// Types `text` (letters, digits and the symbols on the QWERTY grid). For each character, +// reads the cursor, moves it with the D-pad and checks it arrived before pressing A; an +// uppercase letter is typed with Shift (left stick click). Throws, with a screenshot, if +// the cursor can't be read or doesn't arrive. +void type_on_system_keyboard(VideoStream& stream, ProControllerContext& context, const std::string& text); + + + +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp index 3df61e3b68..c6f5d497ba 100644 --- a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_MenuDetector.cpp @@ -40,8 +40,8 @@ MenuDetector::MenuDetector(Color color, bool skip_new_banner) , m_line4(menu_stripe(4, skip_new_banner)) , m_cross(0.20, 0.15, 0.60, 0.37) , m_panel_top(0.150, 0.100, 0.700, 0.015) - , m_panel_left(0.150, 0.125, 0.020, 0.460) - , m_panel_right(0.835, 0.125, 0.015, 0.460) + , m_panel_left(0.156, 0.125, 0.014, 0.460) + , m_panel_right(0.835, 0.125, 0.009, 0.460) , m_footer(0.050, 0.963, 0.600, 0.025) , m_confirm_button(0.736, 0.960, 0.022, 0.038) , m_confirm_button_with_swap(0.653, 0.960, 0.022, 0.038) @@ -95,7 +95,8 @@ static bool has_menu_button(const ImageViewRGB32& patch){ } bool MenuDetector::detect_menu_chrome(const ImageViewRGB32& screen) const{ - // The surrounding panel remains the same with six or eight unlocked icons. + // The surrounding panel remains the same with two, six or eight unlocked icons. + // Its edges are blue strips, so the side boxes stay inside the white. for (const ImageFloatBox& box : {m_panel_top, m_panel_left, m_panel_right}){ if (!is_white(image_stats(extract_box_reference(screen, box)))){ return false; diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.cpp b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.cpp new file mode 100644 index 0000000000..1ff7438165 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.cpp @@ -0,0 +1,67 @@ +/* Name List Detector + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" +#include "CommonTools/Images/SolidColorTest.h" +#include "PokemonBDSP_SelectionArrow.h" +#include "PokemonBDSP_NameListDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ +// Row centers, as a fraction of the screen height (1080p: 307, 370, 433, ...). +const double FIRST_ROW_Y = 0.284; +const double ROW_STEP_Y = 0.0587; +const int ROWS = 5; +} + + +NameListDetector::NameListDetector(Color color) + : m_color(color) + , m_arrows(0.137, 0.245, 0.030, 0.310) + , m_panel(0.140, 0.245, 0.140, 0.300) +{} +void NameListDetector::make_overlays(VideoOverlaySet& items) const{ + items.add(m_color, m_arrows); + items.add(m_color, m_panel); +} +bool NameListDetector::detect(const ImageViewRGB32& screen){ + return selected_row(screen) >= 0; +} +int NameListDetector::selected_row(const ImageViewRGB32& screen) const{ + // The list is a white panel with a little dark text. A thin margin strip is not + // enough: the border's antialiasing reaches into it on some frames. + const ImageViewRGB32 panel = extract_box_reference(screen, m_panel); + size_t white = 0; + for (size_t y = 0; y < panel.height(); y += 2){ + for (size_t x = 0; x < panel.width(); x += 2){ + const uint32_t p = panel.pixel(x, y); + white += ((p >> 16) & 0xff) > 220 && ((p >> 8) & 0xff) > 220 && (p & 0xff) > 220; + } + } + const size_t samples = ((panel.width() + 1) / 2) * ((panel.height() + 1) / 2); + if (samples == 0 || white < samples * 6 / 10){ + return -1; + } + ImageViewRGB32 area = extract_box_reference(screen, m_arrows); + std::vector arrows = find_selection_arrows(area); + if (arrows.size() != 1){ + return -1; + } + const double center_y = m_arrows.y + + m_arrows.height * (arrows[0].min_y + arrows[0].max_y) / 2.0 / (double)area.height(); + const int row = (int)std::lround((center_y - FIRST_ROW_Y) / ROW_STEP_Y); + return row >= 0 && row < ROWS ? row : -1; +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h new file mode 100644 index 0000000000..59fa1efbeb --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h @@ -0,0 +1,41 @@ +/* Name List Detector + * + * From: https://github.com/PokemonAutomation/ + * + * The list of preset names shown for the rival: "I'll tell you...", then four names. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_NameListDetector_H +#define PokemonAutomation_PokemonBDSP_NameListDetector_H + +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonTools/VisualDetector.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +class NameListDetector : public StaticScreenDetector{ +public: + NameListDetector(Color color = COLOR_MAGENTA); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool detect(const ImageViewRGB32& screen) override; + + // The row the arrow is on: 0 is "I'll tell you...", 1 the first preset name. + // -1 if the list is not showing. + int selected_row(const ImageViewRGB32& screen) const; + +private: + Color m_color; + ImageFloatBox m_arrows; + ImageFloatBox m_panel; +}; + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h index b8d7347339..4c87fcd2fb 100644 --- a/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h +++ b/SerialPrograms/Source/PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h @@ -8,6 +8,8 @@ #define PokemonAutomation_PokemonBDSP_SelectionArrow_H #include +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" #include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" #include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" @@ -16,6 +18,9 @@ namespace NintendoSwitch{ namespace PokemonBDSP{ +// The menu selection arrows in `image`, in its pixel coordinates. +std::vector find_selection_arrows(const ImageViewRGB32& image); + class SelectionArrowFinder : public VisualInferenceCallback{ public: SelectionArrowFinder( diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp new file mode 100644 index 0000000000..ac311dcba9 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp @@ -0,0 +1,286 @@ +/* BDSP AutoStory - New Game + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "CommonFramework/ImageTools/ImageDiff.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/ScreenMachine/ScreenMachine.h" +#include "CommonTools/ScreenMachine/StepTrace.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Inference/NintendoSwitch_HomeMenuDetector.h" +#include "NintendoSwitch/Inference/NintendoSwitch_StartGameUserSelectDetector.h" +#include "NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h" +#include "NintendoSwitch/Programs/NintendoSwitch_GameEntry.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h" +#include "PokemonBDSP_AutoStory_NewGame.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using namespace std::chrono_literals; + + +std::string classify_intro_screen(ConsoleHandle* console, const ImageViewRGB32& screen){ + // The Switch's own screens come first: BDSP's dialogue detector also fires on them. + if (console != nullptr && StartGameUserSelectDetector(*console).detect(screen)){ + return "user-select"; + } + if (console != nullptr && HomeMenuDetector(*console, COLOR_RED).detect(screen)){ + return "home"; + } + if (SystemKeyboardDetector().detect(screen)){ + return "keyboard"; + } + switch (NameListDetector().selected_row(screen)){ + case -1: + break; + case 0: + return "rival-list-tell"; + default: + return "rival-list-preset"; + } + if (MenuDetector().detect(screen)){ + return "menu"; + } + // Includes the appearance grid, whose prompt sits in a dialogue box. + if (ShortDialogDetector().detect(screen)){ + return "dialog"; + } + // Language, cutscenes, fades, the bedroom with no menu open. + return "other"; +} + +IntroStep decide_intro_step(const std::string& screen, const IntroProgress& progress){ + if (screen == "user-select") return IntroStep::SELECT_USER; + if (screen == "home") return IntroStep::FAIL_NOT_IN_GAME; + if (screen == "keyboard"){ + return progress.player_named ? IntroStep::CANCEL_RIVAL_KEYBOARD : IntroStep::ENTER_PLAYER_NAME; + } + if (screen == "rival-list-tell") return IntroStep::MOVE_TO_PRESET; + if (screen == "rival-list-preset") return IntroStep::PICK_PRESET; + if (screen == "menu") return IntroStep::CLOSE_MENU_AND_FINISH; + if (screen == "dialog") return IntroStep::ADVANCE; + // The bedroom comes only after the rival is named. Before that, every unknown + // screen (language, the appearance preview, cutscenes) takes A. + return progress.rival_named ? IntroStep::PROBE_FOR_MENU : IntroStep::ADVANCE; +} +bool advance_after_probe(bool screen_changed){ + return !screen_changed; +} + +size_t run_intro_screen_tests(Logger& logger, const std::string& folder){ + size_t passed = 0; + size_t failed = 0; + for (const Filesystem::Path& dir : Filesystem::list_directory(Filesystem::Path(folder))){ + if (!Filesystem::is_directory(dir)){ + continue; + } + const std::string expected = dir.filename().string(); + for (const Filesystem::Path& file : Filesystem::list_directory(dir)){ + if (file.extension().string() != ".png"){ + continue; + } + const ImageRGB32 image(file.string()); + // 1080p and 720p. At 960x540 the keyboard's and the name list's checks + // do not hold (blurred key text, an arrow below the object size limit). + for (size_t width : {size_t(1920), size_t(1280)}){ + const std::string actual = classify_intro_screen(nullptr, image.scale_to(width, width * 9 / 16)); + if (actual == expected){ + passed++; + continue; + } + failed++; + logger.log("[Intro tests] FAIL " + expected + "/" + file.filename().string() + + " at " + std::to_string(width) + "px: read as " + actual, COLOR_RED); + } + } + } + logger.log("[Intro tests] " + std::to_string(passed) + " passed, " + std::to_string(failed) + " failed.", + failed == 0 ? COLOR_BLUE : COLOR_RED); + return failed; +} + + +namespace{ + +// Waits until the keyboard is gone (or, with `gone` false, has appeared). +bool wait_for_keyboard(VideoStream& stream, ProControllerContext& context, bool gone, Milliseconds timeout){ + context.wait_for_all_requests(); + const WallClock deadline = current_time() + timeout; + while (current_time() < deadline){ + VideoSnapshot screen = stream.video().snapshot(); + if (screen && SystemKeyboardDetector().detect(screen) != gone){ + return true; + } + context.wait_for(100ms); + } + return false; +} + +void enter_player_name(VideoStream& stream, ProControllerContext& context, StepTrace& trace, const std::string& name){ + // Let the keyboard finish sliding in: inputs during that are dropped. + pbf_wait(context, 800ms); + context.wait_for_all_requests(); + // B deletes a character, and closes the keyboard on an empty field, so delete only + // while there is text (e.g. after a retry). + for (size_t c = 0; c < 14; c++){ + VideoSnapshot screen = stream.video().snapshot(); + if (!screen || !system_keyboard_field_has_text(screen)){ + break; + } + trace.action("Name field holds text; deleting a character."); + pbf_press_button(context, BUTTON_B, 60ms, 250ms); + context.wait_for_all_requests(); + } + trace.action("Typing the player's name: " + name); + type_on_system_keyboard(stream, context, name); + pbf_press_button(context, BUTTON_PLUS, 80ms, 0ms); + if (!wait_for_keyboard(stream, context, true, 5s)){ + trace.fail("The keyboard stayed open after PLUS (OK)."); + } + trace.action("Name confirmed."); +} + +} + + + +void play_new_game_intro( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const std::string& player_name +){ + VideoStream& stream = env.console; + StepTrace trace(stream, "Intro"); + + // Only close and reopen the game: the states below take it from the user select + // on. reset_game_from_home() waits for a loaded save's black screen to end, and a + // new game's first screen is a dialog on black, so it would sit there until its + // timeout. + trace.action("Restarting the game."); + go_home(env.console, context); + from_home_close_and_reopen_game(env.console, context, true); + + IntroProgress progress; + size_t x_without_menu = 0; + auto press = [&](Button button){ + pbf_press_button(context, button, 80ms, 0ms); + }; + + // Carries out what decide_intro_step() chose for `screen`. + auto act = [&](const char* screen){ + switch (decide_intro_step(screen, progress)){ + case IntroStep::SELECT_USER: + // The cursor starts on the user that launched the game. + press(BUTTON_A); + pbf_wait(context, 1000ms); + return; + case IntroStep::FAIL_NOT_IN_GAME: + trace.fail("The Switch HOME menu is showing: the game is not running in front."); + case IntroStep::ENTER_PLAYER_NAME: + enter_player_name(stream, context, trace, player_name); + progress.player_named = true; + return; + case IntroStep::CANCEL_RIVAL_KEYBOARD: + // Opened from "I'll tell you..."; inputs during the slide-in are dropped. + pbf_wait(context, 800ms); + press(BUTTON_X); + return; + case IntroStep::MOVE_TO_PRESET: + pbf_press_dpad(context, DPAD_DOWN, 100ms, 300ms); + return; + case IntroStep::PICK_PRESET: + press(BUTTON_A); + pbf_wait(context, 500ms); + progress.rival_named = true; + return; + case IntroStep::CLOSE_MENU_AND_FINISH:{ + trace.action("Main menu is open: the player is in control in the bedroom. Closing it."); + press(BUTTON_B); + pbf_wait(context, 1000ms); + context.wait_for_all_requests(); + VideoSnapshot screen_now = stream.video().snapshot(); + if (!screen_now || MenuDetector().detect(screen_now)){ + trace.fail("The main menu did not close."); + } + return; + } + case IntroStep::ADVANCE: + pbf_press_button(context, BUTTON_A, 60ms, 400ms); + return; + case IntroStep::PROBE_FOR_MENU:{ + context.wait_for_all_requests(); + VideoSnapshot before = stream.video().snapshot(); + press(BUTTON_X); + context.wait_for_all_requests(); + MenuWatcher menu; + if (wait_until(stream, context, 3s, {{menu}}) == 0){ + return; // The next read sees the menu and finishes. + } + VideoSnapshot after = stream.video().snapshot(); + const bool changed = !before || !after || ImageMatch::pixel_RMSD(before, after) > 10; + if (!advance_after_probe(changed)){ + trace.action("X changed the screen but no known menu opened; not pressing A."); + if (++x_without_menu >= 10){ + trace.fail("X keeps changing the screen without a known menu opening."); + } + return; + } + x_without_menu = 0; + pbf_press_button(context, BUTTON_A, 60ms, 400ms); + return; + } + } + }; + + auto screen_is = [&](const ImageViewRGB32& screen, const char* name){ + return classify_intro_screen(&env.console, screen) == name; + }; + // The machine's states: the screens classify_intro_screen() tells apart, with the + // keyboard split by whose name it asks for. Each state's step is act(). + const std::vector states{ + {"user-select", + [&](const ImageViewRGB32& s){ return screen_is(s, "user-select"); }, + [&]{ act("user-select"); }, {}, 3}, + {"home", + [&](const ImageViewRGB32& s){ return screen_is(s, "home"); }, + [&]{ act("home"); }, {}, 1}, + {"player-keyboard", + [&](const ImageViewRGB32& s){ return !progress.player_named && screen_is(s, "keyboard"); }, + [&]{ act("keyboard"); }, {"dialog", "other"}, 1}, + {"rival-keyboard", + [&](const ImageViewRGB32& s){ return progress.player_named && screen_is(s, "keyboard"); }, + [&]{ act("keyboard"); }, {"rival-list-tell", "dialog", "other"}, 3}, + {"rival-list-tell", + [&](const ImageViewRGB32& s){ return screen_is(s, "rival-list-tell"); }, + [&]{ act("rival-list-tell"); }, {"rival-list-preset", "other"}, 6}, + {"rival-list-preset", + [&](const ImageViewRGB32& s){ return screen_is(s, "rival-list-preset"); }, + [&]{ act("rival-list-preset"); }, {"dialog", "other"}, 3}, + {"menu", + [&](const ImageViewRGB32& s){ return screen_is(s, "menu"); }, + [&]{ act("menu"); }, {}, 2, true}, + {"dialog", + [&](const ImageViewRGB32& s){ return screen_is(s, "dialog"); }, + [&]{ act("dialog"); }, {}, 400}, + {"other", + [](const ImageViewRGB32&){ return true; }, + [&]{ act("other"); }, {}, 300, false, 60}, + }; + run_screen_machine(stream, context, trace, states, std::chrono::minutes(15)); + trace.action("Intro done."); +} + + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h new file mode 100644 index 0000000000..7ab9e4fb5c --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h @@ -0,0 +1,71 @@ +/* BDSP AutoStory - New Game + * + * From: https://github.com/PokemonAutomation/ + * + * Plays a new game's intro (language, Professor Rowan, appearance, names) up to + * the bedroom with the player in control, which is where the Tutorial starts. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_NewGame_H +#define PokemonAutomation_PokemonBDSP_AutoStory_NewGame_H + +#include +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "NintendoSwitch/NintendoSwitch_SingleSwitchProgram.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Which intro screen `screen` shows, as the intro's steps tell them apart: +// "user-select", "home", "keyboard", "rival-list-tell" (the rival's name list on +// "I'll tell you..."), "rival-list-preset" (on a preset name), "menu", "dialog" or +// "other". The Switch's own screens need `console`; without it they read as +// whatever else they look like. +std::string classify_intro_screen(ConsoleHandle* console, const ImageViewRGB32& screen); + +// What the intro does on a screen. Decided from the screen and how far the intro has +// got only, so it is tested without a console. +enum class IntroStep{ + SELECT_USER, // A on the Switch's user select + FAIL_NOT_IN_GAME, // HOME is showing + ENTER_PLAYER_NAME, // type the name on the first keyboard + CANCEL_RIVAL_KEYBOARD, // X: back to the rival's name list + MOVE_TO_PRESET, // Down: off "I'll tell you..." + PICK_PRESET, // A on a preset name + CLOSE_MENU_AND_FINISH, // B: the menu opened in the bedroom, the intro is over + ADVANCE, // A: dialogue, language, appearance, cutscenes + PROBE_FOR_MENU, // X, to see whether the player is in control +}; +struct IntroProgress{ + bool player_named = false; + bool rival_named = false; +}; +IntroStep decide_intro_step(const std::string& screen, const IntroProgress& progress); + +// After PROBE_FOR_MENU found no menu: whether A may follow. Only when X changed +// nothing, since A on something X opened would pick from it (the Bag). +bool advance_after_probe(bool screen_changed); + +// Checks classify_intro_screen() on //*.png at 1920 and +// 1280 px wide. Logs each mismatch; returns the number of them. +size_t run_intro_screen_tests(Logger& logger, const std::string& folder); + +// From the title screen or any intro screen of a game with no save. Restarts the +// game first, so it always starts from the title. Names the player `player_name` +// and picks the rival's default name (Barry) from the list. Each step reads the +// screen and waits for the expected next one; an unexpected screen fails with a +// screenshot and the recent screens. +void play_new_game_intro( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const std::string& player_name +); + + +} +} +} +#endif From d5f745214da252a72023b8aebd307ac5f475e074 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 18:53:15 -0700 Subject: [PATCH 05/19] BDSP AutoStory: segments 00 and 01, from a new game to Floaroma Town Our rewrite of segments 00 and 01 on PokemonAutomation/Arduino-Source#1717: the closed-loop intro, the planned walks and the battles to the Coal Badge and on to Floaroma. The checkpoint list holds 00 and 01 only; segments 02 to 09 from #1717 are kept and still build, but start where #1717's own 01 ends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Source/PokemonBDSP/PokemonBDSP_Tests.cpp | 2 + .../AutoStory/PokemonBDSP_AutoStory.cpp | 774 ++-- .../AutoStory/PokemonBDSP_AutoStory.h | 51 +- .../AutoStory/PokemonBDSP_AutoStoryTools.cpp | 187 +- .../AutoStory/PokemonBDSP_AutoStoryTools.h | 63 +- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 601 +++ .../AutoStory/PokemonBDSP_AutoStory_Tests.h | 26 + .../PokemonBDSP_AutoStory_Segment_00.cpp | 2523 ++++++----- .../PokemonBDSP_AutoStory_Segment_01.cpp | 2929 +++++-------- .../PokemonBDSP_AutoStory_Segment_01.h | 231 +- ...PokemonBDSP_AutoStory_Segment_00_tools.cpp | 3795 +++++++++-------- .../PokemonBDSP_AutoStory_Segment_00_tools.h | 280 +- SerialPrograms/cmake/SourceFiles.cmake | 34 + 13 files changed, 5687 insertions(+), 5809 deletions(-) create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h diff --git a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp index 569d479276..10dd5da86f 100644 --- a/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/PokemonBDSP_Tests.cpp @@ -18,6 +18,7 @@ #include "Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" #include "PokemonBDSP_Tests.h" #include "Inference/PokemonBDSP_MenuDetector.h" +#include "Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -288,6 +289,7 @@ bool run_bag_screenshot_tests(Logger& logger, const std::string& folder){ void add_tests(UnitTestDatabase& database){ database.add(); add_tests_MenuDetector(database); + add_tests_AutoStory(database); } } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp index f4b9f1676b..1e8d650440 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.cpp @@ -1,33 +1,25 @@ - - #include #include +#include "PokemonBDSP_AutoStory.h" +#include "Utils/PokemonBDSP_AutoStory_NewGame.h" + #include "CommonFramework/Globals.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" #include "CommonFramework/Notifications/ProgramNotifications.h" - #include "CommonTools/Async/InferenceRoutines.h" -#include "CommonTools/Options/StringSelectOption.h" #include "CommonTools/VisualDetectors/ImageMatchDetector.h" - #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "NintendoSwitch/Programs/NintendoSwitch_GameEntry.h" #include "StorySegments/PokemonBDSP_AutoStory_Segment_00.h" #include "StorySegments/PokemonBDSP_AutoStory_Segment_01.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_02.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_03.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_04.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_05.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_06.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_07.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_08.h" -#include "StorySegments/PokemonBDSP_AutoStory_Segment_09.h" -#include "PokemonBDSP_AutoStory.h" +#include "CommonTools/Options/StringSelectOption.h" -namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ using namespace std::chrono_literals; @@ -40,14 +32,6 @@ static std::vector> make_segment_list(){ std::vector> segment_list; segment_list.emplace_back(std::make_unique()); segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); - segment_list.emplace_back(std::make_unique()); return segment_list; } @@ -67,180 +51,11 @@ static std::vector> make_checkpoint_list() checkpoint_list.emplace_back(std::make_unique()); //Segment 01 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - //Segment 02 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - //Segment 03 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 04 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 05 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 06 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 07 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 08 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - // Segment 09 - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - - - //checkpoint_list.emplace_back(std::make_unique()); - /*checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique()); - checkpoint_list.emplace_back(std::make_unique());*/ + checkpoint_list.emplace_back(std::make_unique()); + checkpoint_list.emplace_back(std::make_unique()); + checkpoint_list.emplace_back(std::make_unique()); + checkpoint_list.emplace_back(std::make_unique()); + checkpoint_list.emplace_back(std::make_unique()); return checkpoint_list; } @@ -254,38 +69,20 @@ static const std::vector>& ALL_CHECKPOINTS // Segment database // --------------------------------------------------------------------------- -// Section databases retain global index slugs. Never execute across the -// tutorial/main-story boundary: the starter must be returned from HOME first. -static constexpr size_t TUTORIAL_CHECKPOINT_COUNT = 6; -static const StringSelectDatabase& section_database(bool checkpoints, bool tutorial){ - static const StringSelectDatabase tutorial_segments = [] { +static const StringSelectDatabase& section_database(bool checkpoints){ + static const StringSelectDatabase segments = []{ StringSelectDatabase db; - db.add_entry(StringSelectEntry("0", ALL_SEGMENTS()[0]->name())); - return db; - }(); - static const StringSelectDatabase main_segments = [] { - StringSelectDatabase db; - for (size_t i = 1; i < ALL_SEGMENTS().size(); i++){ + for (size_t i = 0; i < ALL_SEGMENTS().size(); i++) db.add_entry(StringSelectEntry(std::to_string(i), ALL_SEGMENTS()[i]->name())); - } return db; }(); - static const StringSelectDatabase tutorial_checkpoints = [] { + static const StringSelectDatabase checkpoint_db = []{ StringSelectDatabase db; - for (size_t i = 0; i < TUTORIAL_CHECKPOINT_COUNT; i++){ + for (size_t i = 0; i < ALL_CHECKPOINTS().size(); i++) db.add_entry(StringSelectEntry(std::to_string(i), ALL_CHECKPOINTS()[i]->name())); - } return db; }(); - static const StringSelectDatabase main_checkpoints = [] { - StringSelectDatabase db; - for (size_t i = TUTORIAL_CHECKPOINT_COUNT; i < ALL_CHECKPOINTS().size(); i++){ - db.add_entry(StringSelectEntry(std::to_string(i), ALL_CHECKPOINTS()[i]->name())); - } - return db; - }(); - return checkpoints ? (tutorial ? tutorial_checkpoints : main_checkpoints) - : (tutorial ? tutorial_segments : main_segments); + return checkpoints ? checkpoint_db : segments; } // --------------------------------------------------------------------------- @@ -293,20 +90,24 @@ static const StringSelectDatabase& section_database(bool checkpoints, bool tutor // --------------------------------------------------------------------------- BDSPAutoStory_Descriptor::BDSPAutoStory_Descriptor() - : SingleSwitchProgramDescriptor("PokemonBDSP:AutoStory", - "Pokemon BDSP", - "Auto Story", - "Programs/PokemonBDSP/AutoStory.html", - "Automatically completes BDSP story segments.", - PanelQualityStanding::THIRD_PARTY_UNVETTED, - ProgramControllerClass::StandardController_NoRestrictions, - FeedbackType::REQUIRED, - AllowCommandsWhenRunning::DISABLE_COMMANDS, - {"Tesseract"}){ -} - - -std::unique_ptr BDSPAutoStory_Descriptor::make_stats() const { + : SingleSwitchProgramDescriptor( + "PokemonBDSP:AutoStory", + "Pokemon BDSP", + "Auto Story", + "Programs/PokemonBDSP/AutoStory.html", + "Automatically completes BDSP story segments.", + PanelQualityStanding::OFFICIAL_BETA, + ProgramControllerClass::StandardController_NoRestrictions, + FeedbackType::REQUIRED, + AllowCommandsWhenRunning::DISABLE_COMMANDS, + {"Tesseract"} + ) +{} + + + +std::unique_ptr +BDSPAutoStory_Descriptor::make_stats() const{ return std::make_unique(); } @@ -318,16 +119,13 @@ std::unique_ptr BDSPAutoStory_Descriptor::make_stats() const { BDSPAutoStory::~BDSPAutoStory(){ OUTSIDE_HELP_POKEMON.remove_listener(*this); HELP_MODE.remove_listener(*this); - STORY_SECTION.remove_listener(*this); - START_SEGMENT_MAINSTORY.remove_listener(*this); - END_SEGMENT_MAINSTORY.remove_listener(*this); - START_CHECKPOINT_MAINSTORY.remove_listener(*this); - END_CHECKPOINT_MAINSTORY.remove_listener(*this); START_CHECKPOINT_TUTORIAL.remove_listener(*this); END_CHECKPOINT_TUTORIAL.remove_listener(*this); START_SEGMENT.remove_listener(*this); END_SEGMENT.remove_listener(*this); ENABLE_ADVANCED_MODE.remove_listener(*this); + + } @@ -339,40 +137,39 @@ BDSPAutoStory::BDSPAutoStory() : SingleSwitchProgramInstance() // DIAGNOSTIC ONLY: Remove before pushing to production. , DIAGNOSTIC_POST_CATCH_ONLY( - "Diagnostic only: Run 00.04 after catch through 00.05
" - "Start at the saved position immediately after catching the second Pokemon. " - "Applies only in advanced Tutorial mode when Start Checkpoint is 00.04. Runs its post-catch part, then 00.05. Other starting checkpoints run normally. " - "Remove before pushing to production.", - LockMode::LOCK_WHILE_RUNNING, - false) + "Diagnostic only: Run 00.04 after catch through 00.05
" + "Start at the saved position immediately after catching the second Pokemon. " + "Applies only in advanced Tutorial mode when Start Checkpoint is 00.04. Runs its post-catch part, then 00.05. Other starting checkpoints run normally. " + "Remove before pushing to production.", + LockMode::LOCK_WHILE_RUNNING, + false + ) // DIAGNOSTIC ONLY: Remove before pushing to production. , DIAGNOSTIC_FAKE_CHECKPOINT_SAVE( - "Diagnostic only: Fake checkpoint saves
" - "Use the fake save helper instead of normal checkpoint saving. " - "Use an existing in-game save at the selected starting checkpoint or segment. " - "Any failure reloads that save and reruns the entire selected range from its start. " - "Checkpoint saves are not verified in this mode. Remove before pushing to production.", - LockMode::LOCK_WHILE_RUNNING, - false) - , STORY_SECTION("Story Section:", - {{StorySection::TUTORIAL, "tutorial", "Tutorial"}, {StorySection::MAIN_STORY, "main-story", "Main Story"}}, - LockMode::LOCK_WHILE_RUNNING, - StorySection::TUTORIAL) - , MAINSTORY_NOTE("", - "Return your maxed starter from Pokemon HOME before starting Main Story. " - "Use the required moveset and the starting position shown below.") - , ENABLE_ADVANCED_MODE("Advanced mode:
" - "Use checkpoints instead of segments.", - LockMode::UNLOCK_WHILE_RUNNING, - false) - , START_CHECKPOINT_TUTORIAL("Start Checkpoint:", - section_database(true, true), - LockMode::LOCK_WHILE_RUNNING, - "0") - , END_CHECKPOINT_TUTORIAL("End Checkpoint:", - section_database(true, true), - LockMode::UNLOCK_WHILE_RUNNING, - "5") + "Diagnostic only: Fake checkpoint saves
" + "Use the fake save helper instead of normal checkpoint saving. " + "Use an existing in-game save at the selected starting checkpoint or segment. " + "Any failure reloads that save and reruns the entire selected range from its start. " + "Checkpoint saves are not verified in this mode. Remove before pushing to production.", + LockMode::LOCK_WHILE_RUNNING, false) + , ENABLE_ADVANCED_MODE( + "Advanced mode:
" + "Use checkpoints instead of segments.", + LockMode::UNLOCK_WHILE_RUNNING, + false + ) + , START_CHECKPOINT_TUTORIAL( + "Start Checkpoint:", + section_database(true), + LockMode::LOCK_WHILE_RUNNING, + "0" + ) + , END_CHECKPOINT_TUTORIAL( + "End Checkpoint:", + section_database(true), + LockMode::UNLOCK_WHILE_RUNNING, + "5" + ) , START_DESCRIPTION_SEGMENT("Start:
", "") , END_DESCRIPTION_SEGMENT("End:
", "") @@ -380,135 +177,155 @@ BDSPAutoStory::BDSPAutoStory() , START_DESCRIPTION_CHECKPOINT("Start:
", "") , END_DESCRIPTION_CHECKPOINT("End:
", "") - , START_CHECKPOINT_MAINSTORY("Start Checkpoint (Main):", - section_database(true, false), + , START_SEGMENT( + "Start Segment:", + section_database(false), LockMode::LOCK_WHILE_RUNNING, - "6") + "0" + ) - , END_CHECKPOINT_MAINSTORY("End Checkpoint (Main):", - section_database(true, false), + , END_SEGMENT( + "End Segment:", + section_database(false), LockMode::UNLOCK_WHILE_RUNNING, - std::to_string(ALL_CHECKPOINTS().size() - 1)) - - , START_SEGMENT("Start Segment:", section_database(false, true), LockMode::LOCK_WHILE_RUNNING, "0") - - , END_SEGMENT("End Segment:", section_database(false, true), LockMode::UNLOCK_WHILE_RUNNING, "0") - - , START_SEGMENT_MAINSTORY("Start Segment:", - section_database(false, false), - LockMode::LOCK_WHILE_RUNNING, - "1") - , END_SEGMENT_MAINSTORY("End Segment:", - section_database(false, false), - LockMode::LOCK_WHILE_RUNNING, - std::to_string(ALL_SEGMENTS().size() - 1)) - , STARTERCHOICE("Starter Pokemon:", - { - {StarterChoice::TURTWIG, "turtwig", "Turtwig"}, - {StarterChoice::CHIMCHAR, "chimchar", "Chimchar"}, - {StarterChoice::PIPLUP, "piplup", "Piplup"}, - }, - LockMode::LOCK_WHILE_RUNNING, - StarterChoice::CHIMCHAR) - - , STARTER_SHINY("Shiny Starter:", LockMode::LOCK_WHILE_RUNNING, false) - - , CATCH_LEGENDARY("Catch Box Legendary:", LockMode::LOCK_WHILE_RUNNING, false) - - , HELP_MODE("Help Mode:", - { - {HelpMode::NO_OUTSIDE_HELP, "no_outside_help", "No Outside Help"}, - {HelpMode::OUTSIDE_HELP, "outside_help", "Outside Help"}, - }, - LockMode::LOCK_WHILE_RUNNING, - HelpMode::NO_OUTSIDE_HELP) - - , OUTSIDE_HELP_POKEMON("Pokemon:", - { - {OutsideHelpPokemon::TORTERRA, "torterra", "Torterra"}, - }, - LockMode::LOCK_WHILE_RUNNING, - OutsideHelpPokemon::TORTERRA) - - , OUTSIDE_HELP_ATTACK_1("Attack 1:", - { - {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, - {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, - {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, - {TorterraMove::CRUNCH, "crunch", "Crunch"}, - {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, - {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, - {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, - {TorterraMove::FACADE, "facade", "Facade"}, - {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, - {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, - {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, - {TorterraMove::STRENGTH, "strength", "Strength"}, - {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, - {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, - }, - LockMode::LOCK_WHILE_RUNNING, - TorterraMove::EARTHQUAKE) - - , OUTSIDE_HELP_ATTACK_2("Attack 2:", - { - {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, - {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, - {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, - {TorterraMove::CRUNCH, "crunch", "Crunch"}, - {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, - {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, - {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, - {TorterraMove::FACADE, "facade", "Facade"}, - {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, - {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, - {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, - {TorterraMove::STRENGTH, "strength", "Strength"}, - {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, - {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, - }, - LockMode::LOCK_WHILE_RUNNING, - TorterraMove::ROCK_SLIDE) - - , OUTSIDE_HELP_ATTACK_3("Attack 3:", - { - {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, - {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, - {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, - {TorterraMove::CRUNCH, "crunch", "Crunch"}, - {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, - {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, - {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, - {TorterraMove::FACADE, "facade", "Facade"}, - {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, - {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, - {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, - {TorterraMove::STRENGTH, "strength", "Strength"}, - {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, - {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, - }, - LockMode::LOCK_WHILE_RUNNING, - TorterraMove::WOOD_HAMMER) - - , OUTSIDE_HELP_ATTACK_4("Attack 4:", - { - {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, - {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, - {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, - {TorterraMove::CRUNCH, "crunch", "Crunch"}, - {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, - {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, - {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, - {TorterraMove::FACADE, "facade", "Facade"}, - {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, - {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, - {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, - {TorterraMove::STRENGTH, "strength", "Strength"}, - {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, - {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, - }, - LockMode::LOCK_WHILE_RUNNING, - TorterraMove::CRUNCH) + "0" + ) + + , NEW_GAME_INTRO( + "Start a new game first:
" + "For a game with no save: restart it and play the intro (language, appearance, names) " + "until the player is in control in the bedroom. Runs before the selected range.", + LockMode::LOCK_WHILE_RUNNING, false) + , PLAYER_NAME(false, "Player name:
For a new game. Letters and digits.", LockMode::LOCK_WHILE_RUNNING, "Kosmos", "Kosmos") + , STARTERCHOICE( + "Starter Pokemon:", + { + {StarterChoice::TURTWIG, "turtwig", "Turtwig"}, + {StarterChoice::CHIMCHAR, "chimchar", "Chimchar"}, + {StarterChoice::PIPLUP, "piplup", "Piplup"}, + }, + LockMode::LOCK_WHILE_RUNNING, + StarterChoice::CHIMCHAR + ) + + , STARTER_SHINY( + "Shiny Starter:", + LockMode::LOCK_WHILE_RUNNING, + false + ) + + , CATCH_LEGENDARY( + "Catch Box Legendary:", + LockMode::LOCK_WHILE_RUNNING, + false + ) + + , HELP_MODE( + "Help Mode:", + { + {HelpMode::NO_OUTSIDE_HELP, "no_outside_help", "No Outside Help"}, + {HelpMode::OUTSIDE_HELP, "outside_help", "Outside Help"}, + }, + LockMode::LOCK_WHILE_RUNNING, + HelpMode::NO_OUTSIDE_HELP + ) + + , OUTSIDE_HELP_POKEMON( + "Pokemon:", + { + {OutsideHelpPokemon::TORTERRA, "torterra", "Torterra"}, + }, + LockMode::LOCK_WHILE_RUNNING, + OutsideHelpPokemon::TORTERRA + ) + + , OUTSIDE_HELP_ATTACK_1( + "Attack 1:", + { + {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, + {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, + {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, + {TorterraMove::CRUNCH, "crunch", "Crunch"}, + {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, + {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, + {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, + {TorterraMove::FACADE, "facade", "Facade"}, + {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, + {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, + {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, + {TorterraMove::STRENGTH, "strength", "Strength"}, + {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, + {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, + }, + LockMode::LOCK_WHILE_RUNNING, + TorterraMove::EARTHQUAKE + ) + + , OUTSIDE_HELP_ATTACK_2( + "Attack 2:", + { + {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, + {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, + {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, + {TorterraMove::CRUNCH, "crunch", "Crunch"}, + {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, + {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, + {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, + {TorterraMove::FACADE, "facade", "Facade"}, + {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, + {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, + {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, + {TorterraMove::STRENGTH, "strength", "Strength"}, + {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, + {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, + }, + LockMode::LOCK_WHILE_RUNNING, + TorterraMove::ROCK_SLIDE + ) + + , OUTSIDE_HELP_ATTACK_3( + "Attack 3:", + { + {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, + {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, + {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, + {TorterraMove::CRUNCH, "crunch", "Crunch"}, + {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, + {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, + {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, + {TorterraMove::FACADE, "facade", "Facade"}, + {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, + {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, + {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, + {TorterraMove::STRENGTH, "strength", "Strength"}, + {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, + {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, + }, + LockMode::LOCK_WHILE_RUNNING, + TorterraMove::WOOD_HAMMER + ) + + , OUTSIDE_HELP_ATTACK_4( + "Attack 4:", + { + {TorterraMove::EARTHQUAKE, "earthquake", "Earthquake"}, + {TorterraMove::WOOD_HAMMER, "wood_hammer", "Wood Hammer"}, + {TorterraMove::RAZOR_LEAF, "razor_leaf", "Razor Leaf"}, + {TorterraMove::CRUNCH, "crunch", "Crunch"}, + {TorterraMove::GIGA_DRAIN, "giga_drain", "Giga Drain"}, + {TorterraMove::IRON_TAIL, "iron_tail", "Iron Tail"}, + {TorterraMove::ROCK_TOMB, "rock_tomb", "Rock Tomb"}, + {TorterraMove::FACADE, "facade", "Facade"}, + {TorterraMove::STONE_EDGE, "stone_edge", "Stone Edge"}, + {TorterraMove::ROCK_SLIDE, "rock_slide", "Rock Slide"}, + {TorterraMove::BULLDOZE, "bulldoze", "Bulldoze"}, + {TorterraMove::STRENGTH, "strength", "Strength"}, + {TorterraMove::ROCK_SMASH, "rock_smash", "Rock Smash"}, + {TorterraMove::ROCK_CLIMB, "rock_climb", "Rock Climb"}, + }, + LockMode::LOCK_WHILE_RUNNING, + TorterraMove::CRUNCH + ) , NOTIFICATION_STATUS_UPDATE("Status Update", true, false, std::chrono::seconds(30)) , NOTIFICATIONS({ @@ -525,29 +342,25 @@ BDSPAutoStory::BDSPAutoStory() PA_ADD_OPTION(DIAGNOSTIC_POST_CATCH_ONLY); PA_ADD_OPTION(DIAGNOSTIC_FAKE_CHECKPOINT_SAVE); - PA_ADD_OPTION(STORY_SECTION); - PA_ADD_OPTION(MAINSTORY_NOTE); PA_ADD_OPTION(ENABLE_ADVANCED_MODE); // Segment UI PA_ADD_OPTION(START_SEGMENT); - PA_ADD_OPTION(START_SEGMENT_MAINSTORY); PA_ADD_OPTION(START_DESCRIPTION_SEGMENT); PA_ADD_OPTION(END_SEGMENT); - PA_ADD_OPTION(END_SEGMENT_MAINSTORY); PA_ADD_OPTION(END_DESCRIPTION_SEGMENT); // Checkpoint UI PA_ADD_OPTION(START_CHECKPOINT_TUTORIAL); - PA_ADD_OPTION(START_CHECKPOINT_MAINSTORY); PA_ADD_OPTION(START_DESCRIPTION_CHECKPOINT); PA_ADD_OPTION(END_CHECKPOINT_TUTORIAL); - PA_ADD_OPTION(END_CHECKPOINT_MAINSTORY); PA_ADD_OPTION(END_DESCRIPTION_CHECKPOINT); + PA_ADD_OPTION(NEW_GAME_INTRO); + PA_ADD_OPTION(PLAYER_NAME); PA_ADD_OPTION(STARTERCHOICE); PA_ADD_OPTION(STARTER_SHINY); PA_ADD_OPTION(CATCH_LEGENDARY); @@ -569,36 +382,27 @@ BDSPAutoStory::BDSPAutoStory() ENABLE_ADVANCED_MODE.add_listener(*this); START_SEGMENT.add_listener(*this); END_SEGMENT.add_listener(*this); - STORY_SECTION.add_listener(*this); - START_SEGMENT_MAINSTORY.add_listener(*this); - END_SEGMENT_MAINSTORY.add_listener(*this); - START_CHECKPOINT_MAINSTORY.add_listener(*this); - END_CHECKPOINT_MAINSTORY.add_listener(*this); on_config_value_changed(nullptr); } void BDSPAutoStory::update_ui_state(){ const bool advanced = ENABLE_ADVANCED_MODE; - const bool tutorial = STORY_SECTION == StorySection::TUTORIAL; auto visible = [](ConfigOption& option, bool show){ option.set_visibility(show ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); }; - visible(START_SEGMENT, !advanced && tutorial); - visible(END_SEGMENT, !advanced && tutorial); - visible(START_SEGMENT_MAINSTORY, !advanced && !tutorial); - visible(END_SEGMENT_MAINSTORY, !advanced && !tutorial); - visible(START_CHECKPOINT_TUTORIAL, advanced && tutorial); - visible(END_CHECKPOINT_TUTORIAL, advanced && tutorial); - visible(START_CHECKPOINT_MAINSTORY, advanced && !tutorial); - visible(END_CHECKPOINT_MAINSTORY, advanced && !tutorial); + visible(START_SEGMENT, !advanced); + visible(END_SEGMENT, !advanced); + visible(START_CHECKPOINT_TUTORIAL, advanced); + visible(END_CHECKPOINT_TUTORIAL, advanced); visible(START_DESCRIPTION_SEGMENT, !advanced); visible(END_DESCRIPTION_SEGMENT, !advanced); visible(START_DESCRIPTION_CHECKPOINT, advanced); visible(END_DESCRIPTION_CHECKPOINT, advanced); - visible(MAINSTORY_NOTE, !tutorial); bool show_help = ((HelpMode)HELP_MODE == HelpMode::OUTSIDE_HELP); - OUTSIDE_HELP_POKEMON.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); + OUTSIDE_HELP_POKEMON.set_visibility( + show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN + ); OUTSIDE_HELP_ATTACK_1.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); OUTSIDE_HELP_ATTACK_2.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); @@ -606,31 +410,38 @@ void BDSPAutoStory::update_ui_state(){ OUTSIDE_HELP_ATTACK_4.set_visibility(show_help ? ConfigOptionState::ENABLED : ConfigOptionState::HIDDEN); } void BDSPAutoStory::on_config_value_changed(void*){ - const bool tutorial = STORY_SECTION == StorySection::TUTORIAL; if (ENABLE_ADVANCED_MODE){ const auto& checkpoints = ALL_CHECKPOINTS(); - size_t start = std::stoul((tutorial ? START_CHECKPOINT_TUTORIAL : START_CHECKPOINT_MAINSTORY).slug()); - size_t end = std::stoul((tutorial ? END_CHECKPOINT_TUTORIAL : END_CHECKPOINT_MAINSTORY).slug()); + size_t start = std::stoul(START_CHECKPOINT_TUTORIAL.slug()); + size_t end = std::stoul(END_CHECKPOINT_TUTORIAL.slug()); if (start < checkpoints.size()){ - START_DESCRIPTION_CHECKPOINT.set_text("Start: " + checkpoints[start]->start_text()); + START_DESCRIPTION_CHECKPOINT.set_text( + "Start: " + checkpoints[start]->start_text() + ); } if (end < checkpoints.size()){ - END_DESCRIPTION_CHECKPOINT.set_text("End: " + checkpoints[end]->end_text()); + END_DESCRIPTION_CHECKPOINT.set_text( + "End: " + checkpoints[end]->end_text() + ); } }else{ const auto& segments = ALL_SEGMENTS(); - size_t start = std::stoul((tutorial ? START_SEGMENT : START_SEGMENT_MAINSTORY).slug()); - size_t end = std::stoul((tutorial ? END_SEGMENT : END_SEGMENT_MAINSTORY).slug()); + size_t start = std::stoul(START_SEGMENT.slug()); + size_t end = std::stoul(END_SEGMENT.slug()); if (start < segments.size()){ - START_DESCRIPTION_SEGMENT.set_text("Start: " + segments[start]->start_text()); + START_DESCRIPTION_SEGMENT.set_text( + "Start: " + segments[start]->start_text() + ); } if (end < segments.size()){ - END_DESCRIPTION_SEGMENT.set_text("End: " + segments[end]->end_text()); + END_DESCRIPTION_SEGMENT.set_text( + "End: " + segments[end]->end_text() + ); } } @@ -640,59 +451,86 @@ void BDSPAutoStory::on_config_value_changed(void*){ // Program logic // --------------------------------------------------------------------------- -void BDSPAutoStory::program(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ +void BDSPAutoStory::program( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context +){ AutoStoryStats& stats = env.current_stats(); - AutoStoryOptions options{Language::English, + AutoStoryOptions options{ + Language::English, STARTERCHOICE, NOTIFICATION_STATUS_UPDATE, (HelpMode)HELP_MODE, - OutsideHelpConfig{(OutsideHelpPokemon)OUTSIDE_HELP_POKEMON, + OutsideHelpConfig{ + (OutsideHelpPokemon)OUTSIDE_HELP_POKEMON, { (TorterraMove)OUTSIDE_HELP_ATTACK_1, (TorterraMove)OUTSIDE_HELP_ATTACK_2, (TorterraMove)OUTSIDE_HELP_ATTACK_3, (TorterraMove)OUTSIDE_HELP_ATTACK_4, - }}, - DIAGNOSTIC_FAKE_CHECKPOINT_SAVE ? CheckpointSaveMode::FAKE : CheckpointSaveMode::REAL}; + } + }, + DIAGNOSTIC_FAKE_CHECKPOINT_SAVE ? CheckpointSaveMode::FAKE : CheckpointSaveMode::REAL + }; const bool fake_saves = options.checkpoint_save_mode == CheckpointSaveMode::FAKE; - if (fake_saves){ - options.checkpoint_retry_scope = CheckpointRetryScope::DEFER_TO_SELECTED_RANGE; - } + if (fake_saves) options.checkpoint_retry_scope = CheckpointRetryScope::DEFER_TO_SELECTED_RANGE; + // Logs what a checkpoint is for before it runs, and that it finished after. + auto run_checkpoint_logged = [&](size_t index, const AutoStory_Checkpoint& checkpoint){ + stats.m_current = index; + env.update_stats(); + env.log("[AutoStory] Objective: " + checkpoint.name() + ". Ends: " + checkpoint.end_text(), COLOR_BLUE); + add_wrapped_log(env.console.overlay(), "Now: " + checkpoint.name(), COLOR_WHITE); + add_wrapped_log(env.console.overlay(), "Goal: " + checkpoint.end_text(), COLOR_GRAY); + checkpoint.run_checkpoint(env, context, options, stats); + stats.m_completed++; + stats.m_last_completed = index; + env.update_stats(); + env.log("[AutoStory] Completed: " + checkpoint.name() + ".", COLOR_DARKGREEN); + add_wrapped_log(env.console.overlay(), "Done: " + checkpoint.name(), COLOR_GREEN); + }; auto run_selected_range = [&](size_t attempt){ - if (attempt > 0){ - env.log("[Fake saves] Reloaded initial save; restarting the entire selected range from its start."); - } + if (attempt > 0) env.log("[Fake saves] Reloaded initial save; restarting the entire selected range from its start."); // DIAGNOSTIC ONLY: Remove this branch before pushing to production. - if (DIAGNOSTIC_POST_CATCH_ONLY && ENABLE_ADVANCED_MODE && STORY_SECTION == StorySection::TUTORIAL && + if (DIAGNOSTIC_POST_CATCH_ONLY && ENABLE_ADVANCED_MODE && START_CHECKPOINT_TUTORIAL.slug() == "4"){ env.log("Diagnostic only: Running checkpoint 00.04 after catch."); + stats.m_current = 4; + env.update_stats(); + env.log("[AutoStory] Objective: " + AutoStory_Checkpoint_0004().name() + " (after the catch). Ends: " + + AutoStory_Checkpoint_0004().end_text(), COLOR_BLUE); + add_wrapped_log(env.console.overlay(), "Now: " + AutoStory_Checkpoint_0004().name() + " (after the catch)", COLOR_WHITE); + add_wrapped_log(env.console.overlay(), "Goal: " + AutoStory_Checkpoint_0004().end_text(), COLOR_GRAY); diagnostic_checkpoint_0004_after_catch(env, context, options, stats); + stats.m_completed++; + stats.m_last_completed = 4; + env.update_stats(); + env.log("[AutoStory] Completed: " + AutoStory_Checkpoint_0004().name() + ".", COLOR_DARKGREEN); env.log("Diagnostic continuation: Running checkpoint 00.05."); - AutoStory_Checkpoint_0005().run_checkpoint(env, context, options, stats); + run_checkpoint_logged(5, AutoStory_Checkpoint_0005()); return; } - const bool tutorial = STORY_SECTION == StorySection::TUTORIAL; const auto& segments = ALL_SEGMENTS(); if (ENABLE_ADVANCED_MODE){ - size_t start = std::stoul((tutorial ? START_CHECKPOINT_TUTORIAL : START_CHECKPOINT_MAINSTORY).slug()); - size_t end = std::stoul((tutorial ? END_CHECKPOINT_TUTORIAL : END_CHECKPOINT_MAINSTORY).slug()); + size_t start = std::stoul(START_CHECKPOINT_TUTORIAL.slug()); + size_t end = std::stoul(END_CHECKPOINT_TUTORIAL.slug()); - env.log("Starting AutoStory from checkpoint " + std::to_string(start) + " to " + std::to_string(end)); + env.log("Starting AutoStory from checkpoint " + std::to_string(start) + + " to " + std::to_string(end)); const auto& checkpoints = ALL_CHECKPOINTS(); for (size_t i = start; i <= end && i < checkpoints.size(); i++){ env.log("Running Checkpoint: " + std::to_string(i)); - checkpoints[i]->run_checkpoint(env, context, options, stats); + run_checkpoint_logged(i, *checkpoints[i]); } }else{ - size_t start = std::stoul((tutorial ? START_SEGMENT : START_SEGMENT_MAINSTORY).slug()); - size_t end = std::stoul((tutorial ? END_SEGMENT : END_SEGMENT_MAINSTORY).slug()); + size_t start = std::stoul(START_SEGMENT.slug()); + size_t end = std::stoul(END_SEGMENT.slug()); for (size_t i = start; i <= end && i < segments.size(); i++){ env.log("Running Segment: " + std::to_string(i)); @@ -700,23 +538,35 @@ void BDSPAutoStory::program(SingleSwitchProgramEnvironment& env, ProControllerCo } } }; - if (fake_saves){ - checkpoint_reattempt_loop(env, - context, - options.notif_status_update, - stats, - std::move(run_selected_range), - true, - CheckpointSaveMode::FAKE, - CheckpointRetryScope::SELECTED_RANGE); - }else{ - run_selected_range(0); + try{ + if (NEW_GAME_INTRO){ + play_new_game_intro(env, context, PLAYER_NAME); + } + if (fake_saves){ + checkpoint_reattempt_loop(env, context, options.notif_status_update, stats, + std::move(run_selected_range), true, CheckpointSaveMode::FAKE, + CheckpointRetryScope::SELECTED_RANGE); + }else{ + run_selected_range(0); + } + }catch (OperationFailedException&){ + // Leave the Switch in a known state: on HOME with the game closed, so the next + // run (or the user) starts from there. The error already holds its screenshot. + env.log("AutoStory failed: going to HOME and closing the game.", COLOR_RED); + try{ + go_home(env.console, context); + close_game_from_home(env.console, context); + }catch (OperationFailedException& e){ + env.log("Could not close the game: " + e.message(), COLOR_RED); + } + throw; } send_program_finished_notification(env, NOTIFICATION_PROGRAM_FINISH); } + } } } \ No newline at end of file diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h index 541918b1c0..2ef3dc412d 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory.h @@ -2,29 +2,20 @@ #include #include - #include "Common/Cpp/Options/BooleanCheckBoxOption.h" #include "Common/Cpp/Options/EnumDropdownOption.h" #include "Common/Cpp/Options/StaticTextOption.h" - +#include "Common/Cpp/Options/StringOption.h" #include "CommonFramework/Notifications/EventNotificationsTable.h" - #include "CommonTools/Options/StringSelectOption.h" - #include "NintendoSwitch/NintendoSwitch_SingleSwitchProgram.h" - #include "PokemonBDSP_AutoStoryTools.h" -namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - -enum class StorySection { - TUTORIAL, - MAIN_STORY -}; +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ -class BDSPAutoStory_Descriptor : public SingleSwitchProgramDescriptor { +class BDSPAutoStory_Descriptor : public SingleSwitchProgramDescriptor{ public: BDSPAutoStory_Descriptor(); @@ -32,26 +23,26 @@ class BDSPAutoStory_Descriptor : public SingleSwitchProgramDescriptor { }; -class BDSPAutoStory : public SingleSwitchProgramInstance, public ConfigOption::Listener { +class BDSPAutoStory : public SingleSwitchProgramInstance, public ConfigOption::Listener{ public: using Descriptor = BDSPAutoStory_Descriptor; ~BDSPAutoStory(); BDSPAutoStory(); - virtual void program(SingleSwitchProgramEnvironment& env, ProControllerContext& context) override; + virtual void program( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context + ) override; private: virtual void on_config_value_changed(void* object) override; void update_ui_state(); - private: // DIAGNOSTIC ONLY: Remove before pushing to production. BooleanCheckBoxOption DIAGNOSTIC_POST_CATCH_ONLY; // DIAGNOSTIC ONLY: Remove before pushing to production. BooleanCheckBoxOption DIAGNOSTIC_FAKE_CHECKPOINT_SAVE; - EnumDropdownOption STORY_SECTION; - StaticTextOption MAINSTORY_NOTE; BooleanCheckBoxOption ENABLE_ADVANCED_MODE; StringSelectOption START_CHECKPOINT_TUTORIAL; @@ -62,22 +53,20 @@ class BDSPAutoStory : public SingleSwitchProgramInstance, public ConfigOption::L StaticTextOption START_DESCRIPTION_CHECKPOINT; StaticTextOption END_DESCRIPTION_CHECKPOINT; - StringSelectOption START_CHECKPOINT_MAINSTORY; - StringSelectOption END_CHECKPOINT_MAINSTORY; StringSelectOption START_SEGMENT; StringSelectOption END_SEGMENT; - StringSelectOption START_SEGMENT_MAINSTORY; - StringSelectOption END_SEGMENT_MAINSTORY; - EnumDropdownOption STARTERCHOICE; - BooleanCheckBoxOption STARTER_SHINY; - BooleanCheckBoxOption CATCH_LEGENDARY; - EnumDropdownOption HELP_MODE; + BooleanCheckBoxOption NEW_GAME_INTRO; + StringOption PLAYER_NAME; + EnumDropdownOption STARTERCHOICE; + BooleanCheckBoxOption STARTER_SHINY; + BooleanCheckBoxOption CATCH_LEGENDARY; + EnumDropdownOption HELP_MODE; EnumDropdownOption OUTSIDE_HELP_POKEMON; - EnumDropdownOption OUTSIDE_HELP_ATTACK_1; - EnumDropdownOption OUTSIDE_HELP_ATTACK_2; - EnumDropdownOption OUTSIDE_HELP_ATTACK_3; - EnumDropdownOption OUTSIDE_HELP_ATTACK_4; + EnumDropdownOption OUTSIDE_HELP_ATTACK_1; + EnumDropdownOption OUTSIDE_HELP_ATTACK_2; + EnumDropdownOption OUTSIDE_HELP_ATTACK_3; + EnumDropdownOption OUTSIDE_HELP_ATTACK_4; EventNotificationOption NOTIFICATION_STATUS_UPDATE; EventNotificationsOption NOTIFICATIONS; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp index 62de9bebf9..c0257e3e8f 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp @@ -1,6 +1,7 @@ - - - +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/PrettyPrint.h" /* BDSP AutoStory Tools * * From: https://github.com/PokemonAutomation/ @@ -107,7 +108,34 @@ bool fake_save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& c return true; } -bool save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& context){ +void add_wrapped_log(VideoOverlay& overlay, const std::string& text, Color color){ + // The log box is 35% of the video wide at a font 4% of its height: about + // 34 characters. + constexpr size_t WIDTH = 34; + std::string line; + size_t start = 0; + while (start < text.size()){ + size_t end = text.find(' ', start); + if (end == std::string::npos){ + end = text.size(); + } + const std::string word = text.substr(start, end - start); + if (!line.empty() && line.size() + 1 + word.size() > WIDTH){ + overlay.add_log(line, color); + line = " "; + } + line += (line.empty() || line == " " ? "" : " ") + word; + start = end + 1; + } + if (!line.empty()){ + overlay.add_log(line, color); + } +} + +bool save_game( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context +) { static constexpr size_t MAX_RETRIES = 3; for (size_t attempt = 0; attempt < MAX_RETRIES; attempt++){ @@ -120,9 +148,20 @@ bool save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& contex pbf_mash_button(context, BUTTON_B, 1000ms); } - // Reuse existing feedback-based save: opens menu with MenuWatcher - // + respects GameSettings::OVERWORLD_TO_MENU_DELAY0 internally. - save_game(env.console, context); + // The feedback-based save of save_game(VideoStream&, ...), split at the + // save prompt to keep a screenshot of it: it shows the play time and + // badges, a record of where each checkpoint save stands. + overworld_to_menu(env.console, context); + pbf_press_button(context, BUTTON_R, 80ms, 2000ms); + context.wait_for_all_requests(); + if (VideoSnapshot screen = env.console.video().snapshot()){ + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/saves/"; + Filesystem::create_directories(Filesystem::Path(folder)); + const std::string path = folder + now_to_filestring() + ".png"; + screen.frame->save(path); + env.console.log("Save prompt screenshot: " + path); + } + pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); // Return to overworld cleanly. pbf_mash_button(context, BUTTON_B, 1000ms); @@ -213,12 +252,17 @@ void checkpoint_reattempt_loop(SingleSwitchProgramEnvironment& env, action(i); return; // success - } catch (OperationFailedException& e){ - if (retry_scope == CheckpointRetryScope::DEFER_TO_SELECTED_RANGE){ - throw; + }catch (OperationFailedException& e){ + if (retry_scope == CheckpointRetryScope::DEFER_TO_SELECTED_RANGE) throw; + env.console.log("checkpoint_reattempt_loop: attempt " + std::to_string(i) + " failed: " + e.message(), COLOR_RED); + // Keep what the screen showed at the failure; the retry below resets the game. + if (VideoSnapshot screen = env.console.video().snapshot()){ + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/failures/"; + Filesystem::create_directories(Filesystem::Path(folder)); + const std::string path = folder + now_to_filestring() + "-attempt" + std::to_string(i) + ".png"; + screen.frame->save(path); + env.console.log("Failure screenshot: " + path); } - env.console.log("checkpoint_reattempt_loop: attempt " + std::to_string(i) + " failed: " + e.message(), - COLOR_RED); stats.m_reset++; env.update_stats(); @@ -936,27 +980,43 @@ static double confirmed_potion_hp(VideoStream& stream, ProControllerContext& con stream); } -void use_potion_first_pokemon(VideoStream& stream, ProControllerContext& context){ +bool bag_has_item(VideoStream& stream, ProControllerContext& context, const std::string& name, int target_pocket) { + const bool found = find_bag_item_by_name(stream, context, name, target_pocket, 5); + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + OverworldWatcher overworld; + if (wait_until(stream, context, std::chrono::seconds(10), {{overworld}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Bag: Did not return to the overworld after looking for " + name + ".", stream); + } + return found; +} + +void use_potion_first_pokemon(VideoStream& stream, ProControllerContext& context, double heal_below) { OverlayBoxScope hp_overlay(stream.overlay(), COLOR_GREEN, {0.137, 0.160, 0.202, 0.016}, ""); - BagItemTemplate potion; - potion.reference_path = "PokemonBDSP/Autostory/Bag_Potion.png"; - potion.label = "Potion"; - potion.reference_row = 1; bool healed = false; for (size_t dose = 0; dose < 3; dose++){ stream.log("[Potion] Finding Bag/Medicine/Potion; dose " + std::to_string(dose + 1) + "/3."); - if (!find_bag_item(stream, context, potion, 1, 5, 64, std::chrono::seconds(45))){ + if (!find_bag_item_by_name(stream, context, "potion", 1, 5)) { + if (dose == 0 && heal_below < 1.0) { + // Only a check: without Potions there is nothing to do here. + stream.log("[Potion] No Potion in the Bag; skipping the HP check.", COLOR_ORANGE); + healed = true; + break; + } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, "Potion: Could not verify Potion in the Medicine pocket.", stream); } const double before = confirmed_potion_hp(stream, context); stream.log("[Potion] Slot 1 HP bar before use=" + std::to_string(before * 100) + "%."); - if (before >= 0.98){ + if (before >= 0.98) { healed = true; break; } + if (dose == 0 && before >= heal_below) { + stream.log("[Potion] Slot 1 HP is at least " + std::to_string(heal_below * 100) + "%; no Potion needed."); healed = true; break; } - if (before <= 0.005){ + if (before <= 0.005) { OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, "Potion: Slot 1 appears fainted; a Potion cannot revive it.", stream); @@ -1801,10 +1861,89 @@ bool find_bag_item(VideoStream& stream, return false; } -bool use_escape_rope(VideoStream& stream, ProControllerContext& context, int num_icons){ - const BagItemTemplate rope{// "PokemonBDSP/AutoStory/Bag_EscapeRope.png", "escape-rope" - "PokemonBDSP/Autostory/Bag_EscapeRope.png", - "escape-rope"}; +bool find_bag_item_by_name( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket, + int num_icons +){ + open_menu(stream, context, MenuCursorPosition::BAG, num_icons); + pbf_wait(context, 1000ms); + return find_bag_item_in_open_bag(stream, context, name, target_pocket); +} + +bool find_bag_item_in_open_bag( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket +){ + context.wait_for_all_requests(); + if (!bag_navigate_to_pocket(stream, context, target_pocket)) return false; + + // Walk down the list reading the cursor row. The list does not wrap, so + // turn around once the cursor stops moving. + DpadPosition direction = DPAD_DOWN; + std::string previous; + size_t same = 0; + size_t confirmed = 0; + size_t unknown_cursor = 0; + for (size_t scan = 0; scan < 128; scan++){ + context.wait_for_all_requests(); + const VideoSnapshot frame = stream.video().snapshot(); + if (!frame || bag_list_is_empty(*frame.frame)){ + stream.log("[Bag search] " + name + ": pocket is empty.", COLOR_ORANGE); + return false; + } + const int cursor = bag_cursor_position(*frame.frame); + if (cursor == 0){ + if (++unknown_cursor >= 3){ + stream.log("[Bag search] " + name + ": cursor not found.", COLOR_ORANGE); + return false; + } + pbf_wait(context, 200ms); + continue; + } + unknown_cursor = 0; + const std::string text = OCR::tesseract_ocr_read(Language::English, + extract_box_reference(*frame.frame, bag_list_name_box(cursor))); + std::string normalized; + for (unsigned char ch : text){ + if (std::isalpha(ch)) normalized.push_back((char)std::tolower(ch)); + } + stream.log("[Bag search] Row " + std::to_string(cursor) + " reads \"" + normalized + "\"."); + if (normalized == name){ + if (++confirmed >= 2){ + stream.log("[Bag search] " + name + " confirmed in row " + std::to_string(cursor) + ".", COLOR_GREEN); + return true; + } + pbf_wait(context, 150ms); + continue; + } + confirmed = 0; + same = normalized == previous ? same + 1 : 0; + previous = normalized; + if (same >= 2){ + if (direction == DPAD_UP) break; + direction = DPAD_UP; + same = 0; + } + pbf_press_dpad(context, direction, 80ms, 300ms); + } + stream.log("[Bag search] " + name + " not found.", COLOR_ORANGE); + return false; +} + +bool use_escape_rope( + VideoStream& stream, + ProControllerContext& context, + int num_icons +){ + const BagItemTemplate rope{ + // "PokemonBDSP/AutoStory/Bag_EscapeRope.png", "escape-rope" + "PokemonBDSP/Autostory/Bag_EscapeRope.png", "escape-rope" + }; if (!find_bag_item(stream, context, rope, 5, num_icons)){ return false; } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h index 7d511bea85..8ef5f6f908 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h @@ -18,6 +18,7 @@ #include "CommonFramework/GlobalAutoPaths.h" #include "CommonFramework/ImageTools/ImageBoxes.h" #include "CommonFramework/Language.h" +#include "CommonFramework/VideoPipeline/VideoOverlay.h" #include "CommonFramework/Notifications/EventNotificationsTable.h" #include "CommonFramework/ProgramStats/StatsTracking.h" #include "CommonFramework/Tools/VideoStream.h" @@ -44,14 +45,24 @@ struct AutoStoryStats : public StatsTracker { AutoStoryStats() : m_checkpoint(m_stats["Checkpoint"]) , m_segment(m_stats["Segment"]) - , m_reset(m_stats["Reset"]){ + , m_reset(m_stats["Reset"]) + , m_current(m_stats["Current Checkpoint (00.0X = X)"]) + , m_completed(m_stats["Checkpoints Completed"]) + , m_last_completed(m_stats["Last Completed (00.0X = X)"]) + { m_display_order.emplace_back("Checkpoint"); m_display_order.emplace_back("Segment"); m_display_order.emplace_back("Reset"); + m_display_order.emplace_back("Current Checkpoint (00.0X = X)"); + m_display_order.emplace_back("Checkpoints Completed"); + m_display_order.emplace_back("Last Completed (00.0X = X)"); } std::atomic& m_checkpoint; std::atomic& m_segment; std::atomic& m_reset; + std::atomic& m_current; + std::atomic& m_completed; + std::atomic& m_last_completed; bool m_first_run = true; }; @@ -166,7 +177,14 @@ class AutoStory_Checkpoint { // Save the game from the overworld with retries. Returns true if the save was // verified via overworld detection, false if the blind fallback was used. -bool save_game(SingleSwitchProgramEnvironment& env, ProControllerContext& context); +// Adds `text` to the video overlay's log, split at spaces into lines short +// enough for the log box, which draws each line on one row and cuts it off. +void add_wrapped_log(VideoOverlay& overlay, const std::string& text, Color color); + +bool save_game( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context +); // Fake save game to be replaced with save_game when program runs smoothly void checkpoint_save(SingleSwitchProgramEnvironment& env, ProControllerContext& context); @@ -354,6 +372,28 @@ bool find_bag_item(VideoStream& stream, size_t max_scrolls_per_pocket = 512, std::chrono::milliseconds search_timeout = std::chrono::minutes(10)); +// Starts in the overworld, opens the Bag on `target_pocket`, and moves the cursor +// onto the item whose name reads `name` (letters only, lower case, e.g. "potion"). +// Reads the cursor row's name with OCR, so it does not depend on the capture +// card's colors or sharpness the way a template match does. +// Returns false if the pocket is empty or the name is not found. +bool find_bag_item_by_name( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket, + int num_icons = 8 +); + +// The same search in a Bag that is already open, such as the one a battle's +// Bag command opens. +bool find_bag_item_in_open_bag( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket +); + // Find Escape Rope, choose Use, and verify a transition back to the overworld. bool use_escape_rope(VideoStream& stream, ProControllerContext& context, int num_icons = 8); @@ -406,7 +446,24 @@ bool fly_to(VideoStream& stream, ProControllerContext& context, FlyPoint place); // num_icons: 5 (early-game layout) or 8 (late-game layout). void open_menu(VideoStream& stream, ProControllerContext& context, MenuCursorPosition target, int num_icons); -void use_potion_first_pokemon(VideoStream& stream, ProControllerContext& context); +// Open the Bag from the overworld, look for `name` in the pocket, and close +// it again. True if the item is there. +bool bag_has_item( + VideoStream& stream, + ProControllerContext& context, + const std::string& name, + int target_pocket +); + +// Read the lead's HP bar in the Bag and, if it is below `heal_below` (a +// fraction of full), use Potions until it is full. heal_below = 1 always +// heals; 0.5 only checks and heals when the lead is under half, and is +// skipped when the Bag has no Potion. +void use_potion_first_pokemon( + VideoStream& stream, + ProControllerContext& context, + double heal_below = 1.0 +); enum class GearLevel { Fast, diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp new file mode 100644 index 0000000000..378242a8a9 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -0,0 +1,601 @@ +/* BDSP AutoStory Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "Pokemon/Navigation/Pokemon_TileGrid.h" +#include "Data/PokemonBDSP_AutoStory_JubilifeCity.h" +#include "Data/PokemonBDSP_AutoStory_OreburghMine.h" +#include "Data/PokemonBDSP_AutoStory_RavagedPath.h" +#include "Data/PokemonBDSP_AutoStory_Route204.h" +#include "Data/PokemonBDSP_AutoStory_Route201.h" +#include "Data/PokemonBDSP_AutoStory_Route202.h" +#include "Data/PokemonBDSP_AutoStory_SandgemTown.h" +#include "Data/PokemonBDSP_AutoStory_StoryMaps.h" +#include "Utils/PokemonBDSP_AutoStory_NewGame.h" +#include "PokemonBDSP_AutoStory_Tests.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ + +using Pokemon::Direction; +using Pokemon::TileGrid; +using Pokemon::TileLeg; +using Pokemon::TilePos; +using Pokemon::TileType; + +// A small map with every tile type. +// Row 2 is a ledge with a gap at x = 4. The bottom row has a sign at (1,4), +// an NPC at (3,4) and a door at (5,4); (3,0) is off the map. +const TileGrid& small_grid(){ + static const TileGrid grid({ + R"(#..?..#)", + R"(#.."".#)", + R"(#vvv..#)", + R"(#.....#)", + R"(#S.N.D#)", + }); + return grid; +} + +class Test_TileGridParse : public UnitTest{ +public: + Test_TileGridParse() + : UnitTest("PokemonBDSP::AutoStory - tile grid parse") + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const TileGrid& grid = small_grid(); + if (grid.width() != 7 || grid.height() != 5){ + return "Small grid is " + std::to_string(grid.width()) + "x" + std::to_string(grid.height()) + "."; + } + struct Case{ + TilePos pos; + TileType type; + }; + const Case cases[] = { + {{0, 0}, TileType::BLOCKED}, + {{1, 0}, TileType::WALKABLE}, + {{3, 1}, TileType::TALL_GRASS}, + {{2, 2}, TileType::LEDGE}, + {{3, 4}, TileType::NPC}, + {{1, 4}, TileType::SIGN}, + {{5, 4}, TileType::DOOR}, + {{3, 0}, TileType::UNKNOWN}, + {{-1, 0}, TileType::UNKNOWN}, + {{7, 4}, TileType::UNKNOWN}, + }; + for (const Case& c : cases){ + if (grid.at(c.pos) != c.type){ + return "Tile " + Pokemon::to_string(c.pos) + " parsed as " + std::to_string((int)grid.at(c.pos)) + "."; + } + } + for (const std::vector& bad : { + std::vector{"...", ".."}, + std::vector{"..x"}, + }){ + try{ + TileGrid parsed(bad); + return "A malformed grid was accepted."; + }catch (InternalProgramError&){} + } + + if (grid.rows()[4] != "#S.N.D#"){ + return "Small grid row 4 prints as " + grid.rows()[4] + "."; + } + + struct Map{ + const char* name; + const TileGrid& grid; + int width; + int height; + }; + const Map maps[] = { + {MAP_ROUTE_201, Route201::grid(), 64, 20}, + {MAP_SANDGEM_TOWN, SandgemTown::grid(), 34, 23}, + {MAP_ROUTE_202, Route202::grid(), 36, 26}, + }; + for (const Map& map : maps){ + if (map.grid.width() != map.width || map.grid.height() != map.height){ + return std::string(map.name) + " is " + std::to_string(map.grid.width()) + "x" + std::to_string(map.grid.height()) + "."; + } + } + + struct Named{ + const char* map; + TilePos pos; + TileType type; + }; + const Named named[] = { + {MAP_ROUTE_201, Route201::POTION_GIVER, TileType::NPC}, + {MAP_ROUTE_201, Route201::POTION_GIVER.next(Direction::SOUTH), TileType::TALL_GRASS}, + {MAP_ROUTE_201, Route201::POTION_GIVER_TALK_FROM, TileType::TALL_GRASS}, + {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST, TileType::WALKABLE}, + {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END, TileType::TALL_GRASS}, + {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END, TileType::TALL_GRASS}, + {MAP_SANDGEM_TOWN, SandgemTown::LAB_DOOR, TileType::DOOR}, + {MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR, TileType::DOOR}, + {MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR, TileType::DOOR}, + {MAP_ROUTE_202, Route202::CATCH_SPOT, TileType::TALL_GRASS}, + {MAP_ROUTE_202, Route202::TRAINER_A, TileType::NPC}, + {MAP_ROUTE_202, Route202::TRAINER_B, TileType::NPC}, + {MAP_ROUTE_202, Route202::TRAINER_C, TileType::NPC}, + {MAP_ROUTE_202, Route202::TRAINER_D, TileType::NPC}, + {MAP_ROUTE_202, Route202::EXIT_JUBILIFE, TileType::WALKABLE}, + }; + const Pokemon::MapSet& world = story_maps(); + for (const Named& c : named){ + const TilePos pos = world.to_world(c.map, c.pos); + if (world.world().at(pos) != c.type){ + return std::string(c.map) + " tile " + Pokemon::to_string(c.pos) + " is type " + + std::to_string((int)world.world().at(pos)) + " in the joined maps."; + } + const std::optional local = world.to_local(pos); + if (!local || local->map != c.map || local->pos != c.pos){ + return std::string(c.map) + " tile " + Pokemon::to_string(c.pos) + " does not map back."; + } + } + if (Route201::POTION_GIVER_ROW_EAST.next(Direction::WEST) != Route201::POTION_GIVER_ROW_EAST_END){ + return "The Potion giver's row does not end next to its east tile."; + } + + // The maps join where their exits meet. + struct Join{ + const char* from_map; + TilePos from; + Direction direction; + const char* to_map; + TilePos to; + }; + const Join joins[] = { + {MAP_ROUTE_201, {63, 4}, Direction::EAST, MAP_SANDGEM_TOWN, {0, 7}}, + {MAP_ROUTE_201, {63, 6}, Direction::EAST, MAP_SANDGEM_TOWN, {0, 9}}, + {MAP_SANDGEM_TOWN, {24, 0}, Direction::NORTH, MAP_ROUTE_202, {25, 25}}, + {MAP_SANDGEM_TOWN, {29, 0}, Direction::NORTH, MAP_ROUTE_202, {30, 25}}, + }; + for (const Join& join : joins){ + const TilePos from = world.to_world(join.from_map, join.from); + const std::optional next = world.world().step(from, join.direction); + if (!next || *next != world.to_world(join.to_map, join.to)){ + return "No step from " + world.describe(from) + " to " + join.to_map + " " + Pokemon::to_string(join.to) + "."; + } + } + Pokemon::MapSet overlapping; + overlapping.add("a", small_grid(), {0, 0}); + overlapping.add("b", small_grid(), {6, 4}); // shares one blocked tile + try{ + overlapping.add("c", small_grid(), {5, 4}); + return "Maps that overlap on a door were accepted."; + }catch (InternalProgramError&){} + return true; + } +}; + +class Test_TileGridWallHit : public UnitTest{ +public: + Test_TileGridWallHit() + : UnitTest("PokemonBDSP::AutoStory - tile grid wall hit") + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + struct Case{ + const TileGrid& grid; + TilePos from; + Direction direction; + TilePos stop; + size_t tiles; + bool anchored; + }; + const TileGrid& small = small_grid(); + const TileGrid& route = Route202::grid(); + const Case cases[] = { + {small, {1, 1}, Direction::EAST, {5, 1}, 4, true}, // through grass to the east wall + {small, {1, 0}, Direction::SOUTH, {1, 3}, 3, true}, // hops the ledge, stops at the sign + {small, {2, 3}, Direction::NORTH, {2, 3}, 0, true}, // a ledge stops a north walk + {small, {3, 3}, Direction::SOUTH, {3, 3}, 0, true}, // the NPC is a wall + {small, {4, 4}, Direction::NORTH, {4, 0}, 4, false}, // through the gap, off the top edge + {small, {4, 3}, Direction::EAST, {5, 3}, 1, true}, + {small, {4, 4}, Direction::EAST, {4, 4}, 0, false}, // into the door + {small, {2, 0}, Direction::EAST, {2, 0}, 0, false}, // off the map + {route, Route202::CATCH_SPOT, Direction::WEST, {3, 22}, 16, true}, + {route, Route202::CATCH_SPOT, Direction::NORTH, {19, 20}, 2, true}, + {route, Route202::CATCH_SPOT, Direction::EAST, {30, 22}, 11, true}, + // Measured on the console from the post-catch save. + {route, {30, 22}, Direction::NORTH, {30, 19}, 3, true}, + {route, {30, 19}, Direction::WEST, {22, 19}, 8, true}, + {route, {22, 19}, Direction::SOUTH, {22, 24}, 5, true}, + {route, {7, 12}, Direction::NORTH, {7, 12}, 0, true}, // trainer_a holds the player + // After trainer_d, as measured in r75: the east wall is at column 31 + // on row 16, and column 30 is open up to the trees above row 4. + {route, {22, 16}, Direction::EAST, {30, 16}, 8, true}, + {route, {30, 16}, Direction::NORTH, {30, 4}, 12, true}, + // Through the gap east of the north ledge, over the south ledge + // and off the bottom edge to Sandgem Town. + {route, {29, 4}, Direction::SOUTH, {29, 25}, 21, false}, + {route, {12, 8}, Direction::SOUTH, {12, 22}, 14, true}, // hops both ledges + {route, Route202::EXIT_JUBILIFE, Direction::NORTH, Route202::EXIT_JUBILIFE, 0, false}, + }; + for (const Case& c : cases){ + const Pokemon::WallHit hit = Pokemon::walk_to_wall(c.grid, c.from, c.direction); + if (hit.stop != c.stop || hit.tiles != c.tiles || hit.anchored != c.anchored){ + return "Walking " + std::string(Pokemon::direction_name(c.direction)) + " from " + + Pokemon::to_string(c.from) + " stops at " + Pokemon::to_string(hit.stop) + " after " + + std::to_string(hit.tiles) + " tiles" + (hit.anchored ? "" : " (not anchored)") + + ", expected " + Pokemon::to_string(c.stop) + " after " + std::to_string(c.tiles) + "."; + } + } + return true; + } +}; + +// Plans from `from` to `goal` allowing open legs a tile short or long, and +// logs the legs with every map tile each may end on. +std::string check_robust_plan( + Logger& logger, const std::string& name, const Pokemon::MapSet& maps, + const std::vector& from, const std::function& goal +){ + const auto legs = Pokemon::plan_robust_route(maps.world(), from, goal); + if (!legs){ + return name + ": no robust route found."; + } + std::string text = name + " (robust): from"; + for (const TilePos& pos : from){ + text += " " + maps.describe(pos); + } + for (const Pokemon::PlannedLeg& leg : *legs){ + text += "\n " + Pokemon::to_string(leg.leg) + " to"; + for (const TilePos& pos : leg.after){ + text += " " + maps.describe(pos); + } + } + logger.log(text); + const std::vector& end = legs->empty() ? from : legs->back().after; + for (const TilePos& pos : end){ + if (!goal(pos)){ + return name + ": the robust plan may end on " + maps.describe(pos) + "."; + } + } + return ""; +} + +// Checks a plan from `from` to `to`: every leg can be walked, ledges are +// crossed only southward, and each leg's wall flag matches the grid. +// With `maps`, legs are printed with the map tile they end on, and with +// the trainers whose sight they cross. +std::string check_plan( + Logger& logger, const std::string& name, + const TileGrid& grid, const TilePos& from, const TilePos& to, + const Pokemon::MapSet* maps = nullptr, + const std::vector& trainers = {} +){ + const std::optional> legs = Pokemon::plan_tile_route(grid, from, to); + if (!legs){ + return name + ": no route found."; + } + auto describe = [&](const TilePos& pos){ + return maps ? maps->describe(pos) : Pokemon::to_string(pos); + }; + const std::vector> seen = Pokemon::legs_crossing_sight(grid, from, *legs, trainers); + std::string text = name + ": from " + describe(from); + TilePos end = from; + for (size_t i = 0; i < legs->size(); i++){ + const TileLeg& leg = (*legs)[i]; + end = Pokemon::trace_legs(grid, end, {leg}).back(); + text += "\n " + Pokemon::to_string(leg) + " to " + describe(end); + for (const std::string& trainer : seen[i]){ + text += ", crosses " + trainer + "'s sight"; + } + } + logger.log(text); + + std::vector tiles; + try{ + tiles = Pokemon::trace_legs(grid, from, *legs); + }catch (InternalProgramError& e){ + return name + ": " + e.message(); + } + if (tiles.back() != to){ + return name + ": the legs end at " + Pokemon::to_string(tiles.back()) + "."; + } + for (size_t i = 1; i < tiles.size(); i++){ + const TilePos& a = tiles[i - 1]; + const TilePos& b = tiles[i]; + if (!grid.standable(b)){ + return name + ": the route stands on " + Pokemon::to_string(b) + "."; + } + const int dx = b.x - a.x; + const int dy = b.y - a.y; + if (std::abs(dx) + std::abs(dy) == 1){ + continue; + } + if (dx != 0 || dy != 2 || grid.at(a.next(Direction::SOUTH)) != TileType::LEDGE){ + return name + ": jump from " + Pokemon::to_string(a) + " to " + Pokemon::to_string(b) + " is not a south ledge hop."; + } + } + TilePos pos = from; + for (const TileLeg& leg : *legs){ + if (leg.tiles == 0){ + return name + ": empty leg."; + } + pos = Pokemon::trace_legs(grid, pos, {leg}).back(); + if (leg.ends_at_wall != grid.at_wall(pos, leg.direction)){ + return name + ": leg " + Pokemon::to_string(leg) + " has the wrong wall flag."; + } + } + for (size_t i = 1; i < legs->size(); i++){ + if ((*legs)[i].direction == (*legs)[i - 1].direction){ + return name + ": two legs in a row go " + Pokemon::direction_name((*legs)[i].direction) + "."; + } + } + return ""; +} + +class Test_TileGridPlans : public UnitTest{ +public: + Test_TileGridPlans() + : UnitTest("PokemonBDSP::AutoStory - tile grid plans") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + const TileGrid& small = small_grid(); + + // South over the ledge is a straight hop; back north must use the gap. + std::string error = check_plan(logger, "Small south", small, {2, 0}, {2, 4}); + if (!error.empty()){ + return error; + } + error = check_plan(logger, "Small north", small, {2, 4}, {2, 0}); + if (!error.empty()){ + return error; + } + const auto south = Pokemon::plan_tile_route(small, {2, 0}, {2, 4}); + if (south->size() != 1 || south->front().direction != Direction::SOUTH){ + return "Small south should be one leg over the ledge."; + } + + // Walled in by ledges from below. + const TileGrid pit({ + R"(#.#)", + R"(#v#)", + R"(#.#)", + }); + if (Pokemon::plan_tile_route(pit, {1, 2}, {1, 0})){ + return "A route north over a ledge was found."; + } + + const Pokemon::MapSet& maps = story_maps(); + const std::vector trainers = story_trainer_sights(); + const TilePos catch_spot = maps.to_world(MAP_ROUTE_202, Route202::CATCH_SPOT); + const TilePos potion = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST); + const TilePos jubilife = maps.to_world(MAP_ROUTE_202, Route202::EXIT_JUBILIFE); + error = check_plan(logger, "Catch spot to Potion giver", maps.world(), catch_spot, potion, &maps, trainers); + if (!error.empty()){ + return error; + } + error = check_plan(logger, "Potion giver to Jubilife", maps.world(), potion, jubilife, &maps, trainers); + if (!error.empty()){ + return error; + } + const TilePos below_sign = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN.next(Direction::SOUTH)); + error = check_robust_plan(logger, "After training to the Pokemon Center", maps, story_after_training_area(), + [&](const TilePos& pos){ return pos == below_sign; }); + if (!error.empty()){ + return error; + } + const TilePos below_door = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR.next(Direction::SOUTH)); + error = check_robust_plan(logger, "Pokemon Center to Jubilife", maps, {below_door}, story_at_jubilife_exit); + if (!error.empty()){ + return error; + } + error = check_robust_plan(logger, "After training to Jubilife", maps, story_after_training_area(), story_at_jubilife_exit); + if (!error.empty()){ + return error; + } + error = check_robust_plan(logger, "Catch spot to Potion giver", maps, {catch_spot}, + [&](const TilePos& pos){ return pos == potion; }); + if (!error.empty()){ + return error; + } + // To where she is talked to, and back to the catch grass to save. + const TilePos talk_from = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_TALK_FROM); + error = check_robust_plan(logger, "Catch spot to the Potion giver", maps, {catch_spot}, + [&](const TilePos& pos){ return pos == talk_from; }); + if (!error.empty()){ + return error; + } + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::POTION_SAVE_SPOT); + error = check_robust_plan(logger, "Potion giver to the save spot", maps, {talk_from}, + [&](const TilePos& pos){ return pos == save_spot; }); + if (!error.empty()){ + return error; + } + const std::vector grass = story_after_training_area(); + error = check_robust_plan(logger, "Save spot to the catch grass", maps, {save_spot}, + [&](const TilePos& pos){ return std::find(grass.begin(), grass.end(), pos) != grass.end(); }); + if (!error.empty()){ + return error; + } + + // 00.05 in Jubilife: every leg the segment walks, from where the one + // before it may have left us. + { + using namespace JubilifeCity; + const Pokemon::MapSet& town = jubilife_maps(); + auto at = [&](const TilePos& pos){ return town.to_world(MAP_JUBILIFE_CITY, pos); }; + std::vector dawn; + for (const TilePos& pos : after_dawn()){ + dawn.push_back(at(pos)); + } + const TilePos school = at(TRAINERS_SCHOOL_DOOR.next(Direction::SOUTH)); + const TilePos center = at(POKEMON_CENTER_DOOR.next(Direction::SOUTH)); + const std::vector out_of_school{school.next(Direction::WEST), school, school.next(Direction::EAST)}; + const TilePos president = at(POKETCH_PRESIDENT).next(Direction::EAST); + const TilePos clown_mart = at(CLOWN_MART).next(Direction::SOUTH); + const TilePos clown_tv = at(CLOWN_TV_STATION).next(Direction::EAST); + const TilePos clown_company = at(CLOWN_POKETCH_COMPANY).next(Direction::EAST); + // A door's goal is its front row within a tile of its column; an + // NPC's is the one tile beside them. + const struct{ const char* name; std::vector from; TilePos to; bool door; } legs[] = { + {"Jubilife: after Dawn to the Trainers' School", dawn, school, true}, + {"Jubilife: school to the president", out_of_school, president, false}, + {"Jubilife: president to the clown by the Mart", {president}, clown_mart, false}, + {"Jubilife: Mart clown to the TV station clown", {clown_mart}, clown_tv, false}, + {"Jubilife: TV station clown to the Poketch Company clown", {clown_tv}, clown_company, false}, + {"Jubilife: Poketch Company clown to the president", {clown_company}, president, false}, + {"Jubilife: president to the Pokemon Center", {president}, center, true}, + }; + std::string failed; + // 01.03 in the Oreburgh Mine: the corridor's top to below Roark, and back. + { + const Pokemon::MapSet& mine = oreburgh_mine_maps(); + auto m = [&](const TilePos& pos){ return mine.to_world(MAP_OREBURGH_MINE, pos); }; + std::vector top; + for (const TilePos& pos : OreburghMine::corridor_top()){ + top.push_back(m(pos)); + } + const TilePos below_roark = m(OreburghMine::ROARK).next(Direction::SOUTH); + const TilePos roark = m(OreburghMine::ROARK); + std::string leg_error = check_robust_plan(logger, "Mine: corridor top to the stretch east of Roark", mine, top, + [&](const TilePos& pos){ return pos.y >= roark.y && pos.y <= roark.y + 1 && pos.x >= roark.x + 2 && pos.x <= roark.x + 7; }); + failed += leg_error.empty() ? "" : leg_error + " "; + const TilePos exit = m(OreburghMine::CORRIDOR_EXIT_START); + leg_error = check_robust_plan(logger, "Mine: below Roark to the corridor's corner", mine, {below_roark}, + [&](const TilePos& pos){ return pos == exit; }); + failed += leg_error.empty() ? "" : leg_error + " "; + } + // 01.05 in the Ravaged Path: from the south door to a rock across the + // corridor, and once it is smashed, to the tile above the way out. + { + const Pokemon::MapSet cave = ravaged_path_maps(); + for (const TilePos& rock : RavagedPath::rocks()){ + if (cave.world().at(rock) != Pokemon::TileType::BLOCKED){ + failed += "Ravaged Path: rock " + cave.describe(rock) + " is not blocked. "; + } + } + const std::vector& before = RavagedPath::before_rocks(); + std::string leg_error = check_robust_plan(logger, "Ravaged Path: south door to a rock", cave, {RavagedPath::ENTRANCE}, + [&](const TilePos& pos){ return std::find(before.begin(), before.end(), pos) != before.end(); }); + failed += leg_error.empty() ? "" : leg_error + " "; + for (const TilePos& from : before){ + const TilePos rock = from.next(Direction::EAST); + const Pokemon::MapSet open = ravaged_path_maps({rock}); + leg_error = check_robust_plan(logger, "Ravaged Path: after smashing " + open.describe(rock) + ", to the corridor's east end", open, {from}, + [&](const TilePos& pos){ return pos == RavagedPath::EAST_END; }); + failed += leg_error.empty() ? "" : leg_error + " "; + } + // What 01.05 plans: from either tile, with both rocks open. + std::vector both; + for (const TilePos& from : before){ + both.push_back(from.next(Direction::EAST)); + } + leg_error = check_robust_plan(logger, "Ravaged Path: from either rock's tile to the corridor's east end", ravaged_path_maps(both), before, + [&](const TilePos& pos){ return pos == RavagedPath::EAST_END; }); + failed += leg_error.empty() ? "" : leg_error + " "; + if (!(RavagedPath::EAST_END.next(Direction::WEST) == RavagedPath::ABOVE_EXIT) || + cave.world().at(RavagedPath::ABOVE_EXIT.next(Direction::SOUTH)) != Pokemon::TileType::DOOR + ){ + failed += "Ravaged Path: the way out is not a door a tile west of the corridor's east end. "; + } + } + // 01.05 on Route 204's north half: from the Ravaged Path's door to + // the path between the flower beds into Floaroma. + { + const Pokemon::MapSet& route = route204_north_maps(); + const std::string leg_error = check_robust_plan(logger, "Route 204: Ravaged Path to the Floaroma path", route, {Route204::CAVE_EXIT}, + Route204::in_floaroma_path); + failed += leg_error.empty() ? "" : leg_error + " "; + for (const Pokemon::TrainerSight& twin : Route204::north_trainer_sights()){ + if (route.world().at(twin.pos) != Pokemon::TileType::NPC){ + failed += "Route 204: " + twin.name + " is not on an NPC tile. "; + } + } + } + for (const auto& leg : legs){ + const TilePos to = leg.to; + const bool door = leg.door; + const std::string leg_error = check_robust_plan(logger, leg.name, town, leg.from, + [&](const TilePos& pos){ + return door ? pos.y == to.y && pos.x >= to.x - 1 && pos.x <= to.x + 1 : pos == to; + }); + failed += leg_error.empty() ? "" : leg_error + " "; + } + if (!failed.empty()){ + return failed; + } + } + return true; + } +}; + +class Test_IntroDecisions : public UnitTest{ +public: + Test_IntroDecisions() + : UnitTest("PokemonBDSP::AutoStory - new-game intro decisions") + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + struct Case{ + const char* screen; + IntroProgress progress; + IntroStep expected; + }; + const IntroProgress start{false, false}; + const IntroProgress named{true, false}; + const IntroProgress done{true, true}; + const Case cases[] = { + {"user-select", start, IntroStep::SELECT_USER}, + {"home", done, IntroStep::FAIL_NOT_IN_GAME}, + {"keyboard", start, IntroStep::ENTER_PLAYER_NAME}, + {"keyboard", named, IntroStep::CANCEL_RIVAL_KEYBOARD}, + {"rival-list-tell", named, IntroStep::MOVE_TO_PRESET}, + {"rival-list-preset", named, IntroStep::PICK_PRESET}, + {"menu", done, IntroStep::CLOSE_MENU_AND_FINISH}, + // The appearance grid has its prompt in a dialogue box. + {"dialog", start, IntroStep::ADVANCE}, + {"dialog", done, IntroStep::ADVANCE}, + // Language and the appearance preview: no X before the bedroom is possible. + {"other", start, IntroStep::ADVANCE}, + {"other", named, IntroStep::ADVANCE}, + {"other", done, IntroStep::PROBE_FOR_MENU}, + }; + for (const Case& c : cases){ + const IntroStep actual = decide_intro_step(c.screen, c.progress); + if (actual != c.expected){ + return std::string("Screen \"") + c.screen + "\" (player named " + + (c.progress.player_named ? "yes" : "no") + ", rival named " + + (c.progress.rival_named ? "yes" : "no") + "): step " + + std::to_string((int)actual) + ", expected " + std::to_string((int)c.expected) + "."; + } + } + if (advance_after_probe(true)){ + return "A after X changed the screen would pick from whatever X opened."; + } + if (!advance_after_probe(false)){ + return "A must advance a screen X left unchanged."; + } + return true; + } +}; + +} + + +void add_tests_AutoStory(UnitTestDatabase& database){ + database.add(); + database.add(); + database.add(); + database.add(); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h new file mode 100644 index 0000000000..18b8c8a666 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h @@ -0,0 +1,26 @@ +/* BDSP AutoStory Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Tests_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Tests_H + +#include "Common/Cpp/TestRunners/UnitTest.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + + +// AutoStory tests that need no console: the new-game intro's decisions. +void add_tests_AutoStory(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 6b68b60593..ca2faf0ebf 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -5,1389 +5,1270 @@ * */ +#include "CommonFramework/GlobalAutoPaths.h" +#include "PokemonBDSP_AutoStory_Segment_00.h" +#include "../Data/PokemonBDSP_AutoStory_JubilifeCity.h" +#include "../Data/PokemonBDSP_AutoStory_Route201.h" +#include "../Data/PokemonBDSP_AutoStory_Route202.h" +#include "../Data/PokemonBDSP_AutoStory_SandgemTown.h" +#include "../Data/PokemonBDSP_AutoStory_StoryMaps.h" +#include "../Utils/PokemonBDSP_AutoStory_PokemonCenter.h" +#include "../Utils/PokemonBDSP_AutoStory_RouteWalk.h" +#include "../Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" +#include "../Utils/PokemonBDSP_AutoStory_Walk.h" +#include "../Utils/PokemonBDSP_OverworldMatchers.h" + #include #include - #include - -#include "CommonFramework/Exceptions/OperationFailedException.h" -#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" -#include "CommonFramework/GlobalAutoPaths.h" #include "CommonFramework/ImageTypes/ImageRGB32.h" #include "CommonFramework/VideoPipeline/VideoFeed.h" #include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" - -#include "CommonTools/Async/InferenceRoutines.h" -#include "CommonTools/VisualDetectors/BlackScreenDetector.h" -#include "CommonTools/VisualDetectors/ImageMatchDetector.h" +#include "CommonFramework/Exceptions/OperationFailedException.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" -#include "NintendoSwitch/Programs/DateManip/NintendoSwitch_DateManip.h" -#include "Pokemon/Pokemon_Notification.h" #include "Pokemon/Pokemon_Strings.h" +#include "Pokemon/Pokemon_Notification.h" #include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" -#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" -#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h" -#include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" #include "PokemonBDSP/Programs/ShinyHunting/PokemonBDSP_StarterReset.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "CommonTools/VisualDetectors/ImageMatchDetector.h" -#include "PokemonBDSP_AutoStory_Segment_00.h" +#include "NintendoSwitch/Programs/DateManip/NintendoSwitch_DateManip.h" + +#include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" using namespace std::chrono_literals; +using PokemonAutomation::Pokemon::MapSet; +using PokemonAutomation::Pokemon::TilePos; +using PokemonAutomation::Pokemon::TrainerSight; namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { -// --------------------------------------------------------------------------- -// Starter selection -// --------------------------------------------------------------------------- - -// Watch before movement starts, release on the fade, and hold the -// following recorded wait until the destination is visible again. -static void recorded_move_until_transition(VideoStream& stream, - ProControllerContext& context, - double x, - double y, - Milliseconds duration, - const std::string& label){ - context.wait_for_all_requests(); - stream.log("[Tutorial transition] " + label + ": approaching fade."); - BlackScreenWatcher black; - int ret = run_until(stream, - context, - [x, y, duration](ProControllerContext& ctx){ - pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); - // Keep observing a delayed fade after the recorded hold. - pbf_wait(ctx, 5000ms); - }, - {{black}}); - if (ret != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Black transition not detected.", - stream); - } - BlackScreenWatcher visible(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, 100, 10, BlackScreenWatcher::FinderType::GONE, 500ms); - if (wait_until(stream, context, 15s, {{visible}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Transition did not finish.", - stream); - } - stream.log("[Tutorial transition] " + label + ": destination visible."); -} + namespace NintendoSwitch { + namespace PokemonBDSP { + // --------------------------------------------------------------------------- + // Starter selection + // --------------------------------------------------------------------------- + + // Watch before movement starts, release on the fade, and hold the + // following recorded wait until the destination is visible again. + static void recorded_move_until_transition( + VideoStream& stream, ProControllerContext& context, + double x, double y, Milliseconds duration, const std::string& label + ) { + context.wait_for_all_requests(); + stream.log("[Tutorial transition] " + label + ": approaching fade."); + BlackScreenWatcher black; + int ret = run_until(stream, context, + [x, y, duration](ProControllerContext& ctx) { + pbf_move_left_joystick(ctx, {x, y}, duration, 0ms); + // Keep observing a delayed fade after the recorded hold. + pbf_wait(ctx, 5000ms); + }, {{black}}); + if (ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Black transition not detected.", stream); + } + BlackScreenWatcher visible(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, + 100, 10, BlackScreenWatcher::FinderType::GONE, 500ms); + if (wait_until(stream, context, 15s, {{visible}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Transition did not finish.", stream); + } + stream.log("[Tutorial transition] " + label + ": destination visible."); + } -static void select_turtwig(VideoStream& stream, ProControllerContext& context){ - stream.log("[DEBUG] Turtwig Selection: Starting starter selection"); - - pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); - context.wait_for_all_requests(); - - ShortDialogWatcher watcher(COLOR_CYAN); - int ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher}); - if (ret != 0){ - ShortDialogWatcher watcher_red(COLOR_RED); - ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher_red}); - } - stream.log(ret == 0 ? "[DEBUG] Turtwig Selection: Confirmation dialogue detected" - : "[WARNING] Turtwig Selection: Dialogue detection failed, proceeding anyway"); - - pbf_wait(context, 400ms); - pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); - pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); - context.wait_for_all_requests(); - stream.log("[DEBUG] Turtwig Selection: Selection complete"); -} + static void select_turtwig(VideoStream& stream, ProControllerContext& context) { + stream.log("[DEBUG] Turtwig Selection: Starting starter selection"); -static void select_chimchar(VideoStream& stream, ProControllerContext& context){ - stream.log("[DEBUG] Chimchar Selection: Starting starter selection"); - - pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); - pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); - context.wait_for_all_requests(); - - ShortDialogWatcher watcher(COLOR_CYAN); - int ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher}); - if (ret != 0){ - ShortDialogWatcher watcher_red(COLOR_RED); - ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher_red}); - } - stream.log(ret == 0 ? "[DEBUG] Chimchar Selection: Confirmation dialogue detected" - : "[WARNING] Chimchar Selection: Dialogue detection failed, proceeding anyway"); - - pbf_wait(context, 400ms); - pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); - pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); - context.wait_for_all_requests(); - stream.log("[DEBUG] Chimchar Selection: Selection complete"); -} + pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); + context.wait_for_all_requests(); -static void select_piplup(VideoStream& stream, ProControllerContext& context){ - stream.log("[DEBUG] Piplup Selection: Starting starter selection"); - - pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); - pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); - pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); - context.wait_for_all_requests(); - - ShortDialogWatcher watcher(COLOR_CYAN); - int ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher}); - if (ret != 0){ - ShortDialogWatcher watcher_red(COLOR_RED); - ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - ctx.wait_for(std::chrono::milliseconds(5000)); - }, - {watcher_red}); - } - stream.log(ret == 0 ? "[DEBUG] Piplup Selection: Confirmation dialogue detected" - : "[WARNING] Piplup Selection: Dialogue detection failed, proceeding anyway"); - - pbf_wait(context, 400ms); - pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); - pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); - context.wait_for_all_requests(); - stream.log("[DEBUG] Piplup Selection: Selection complete"); -} + ShortDialogWatcher watcher(COLOR_CYAN); + int ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher } + ); + if (ret != 0) { + ShortDialogWatcher watcher_red(COLOR_RED); + ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher_red } + ); + } + stream.log(ret == 0 ? "[DEBUG] Turtwig Selection: Confirmation dialogue detected" + : "[WARNING] Turtwig Selection: Dialogue detection failed, proceeding anyway"); + + pbf_wait(context, 400ms); + pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); + pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); + context.wait_for_all_requests(); + stream.log("[DEBUG] Turtwig Selection: Selection complete"); + } -// Navigation functions for each part of the tutorial. These functions are meant to be used -// in order and rely on the state of the game being what it is at the end of the previous function. -// They may also be modified or removed without warning. - -// --------------------------------------------------------------------------- -// Segment 00-01 begins -// Navigates from player's room to Route 201 through Mom/Barry dialogues. -// --------------------------------------------------------------------------- - -static void -tutorial_part_1_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - DateTime t{2026, 1, 1, 0, 0, 0}; - set_time_fast(env, context, t); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 527ms, 0ms); - pbf_move_left_joystick(context, {1.000000, 1.000000}, 456ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 915ms, 0ms); - pbf_move_left_joystick(context, {1.000000, 1.000000}, 642ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 557ms, 0ms); - pbf_wait(context, 978ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1112ms, 0ms); - pbf_wait(context, 4967ms); - - wait_for_dialogue(stream, context, "Phase 1 (Mom)"); - - pbf_mash_button(context, BUTTON_A, 5000ms); - pbf_wait(context, 300ms); - - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1662ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -1.000000}, 49ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1215ms, 0ms); - pbf_wait(context, 1419ms); - - wait_for_dialogue(stream, context, "Phase 2 (Mom Warning)"); - - pbf_mash_button(context, BUTTON_A, 2500ms); - pbf_wait(context, 300ms); - context.wait_for_all_requests(); - - pbf_move_left_joystick(context, {0.000000, -1.000000}, 680ms, 0ms); - - pbf_wait(context, 4000ms); - - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1748ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 18ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2763ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 58ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1364ms, 0ms); - pbf_wait(context, 4000ms); - context.wait_for_all_requests(); - - wait_for_dialogue(stream, context, "Phase 3 (Barry 1st)"); - pbf_mash_button(context, BUTTON_A, 6000ms); - pbf_wait(context, 800ms); - wait_for_dialogue(stream, context, "Phase 3 (Barry 1st)"); - pbf_mash_button(context, BUTTON_A, 6000ms); - pbf_wait(context, 800ms); - - pbf_wait(context, 1306ms); - context.wait_for_all_requests(); - stream.log("[Barry house] Entering; waiting for entry fade."); - BlackScreenWatcher entry_black; - int entry_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_move_left_joystick(ctx, {0, 1}, 5000ms, 0ms); - }, - {{entry_black}}); - if (entry_ret != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Barry house: Entry fade not detected.", - stream); - } - // Release the joystick during loading; resume indoor movement only - // after the screen has stayed non-black for half a second. - BlackScreenWatcher entry_visible(COLOR_RED, - {0.1, 0.1, 0.8, 0.8}, - 100, - 10, - BlackScreenWatcher::FinderType::GONE, - 500ms); - if (wait_until(stream, context, 15s, {{entry_visible}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Barry house: Entry fade did not finish.", - stream); - } - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - stream.log("[Barry house] Entry settled; starting stair approach."); - // Align left after entry before the next upward stair approach. - pbf_move_left_joystick(context, {0.000000, 1.000000}, 400ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 0.000000}, 1600ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2722ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1600ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 501ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 652ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1817ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 975ms, 0ms); - pbf_wait(context, 1422ms); - - wait_for_dialogue(stream, context, "Phase 8 (Barry 2nd dialog)"); - pbf_mash_button(context, BUTTON_A, 10000ms); - - pbf_wait(context, 856ms); - - - // Recorded route from the top of Barry's stairs. Moves rounded to - // 100ms, waits to 50ms; transition waits gate both floor changes. - recorded_move_until_transition(stream, context, -1.0, 0.0, 900ms, "Barry house downstairs"); - pbf_wait(context, 750ms); - pbf_move_left_joystick(context, {-1.0, 0.0}, 2000ms, 0ms); - pbf_wait(context, 250ms); - pbf_move_left_joystick(context, {0.0, -1.0}, 2000ms, 0ms); - pbf_wait(context, 150ms); - pbf_move_left_joystick(context, {1.0, 0.0}, 1400ms, 0ms); - pbf_wait(context, 500ms); - recorded_move_until_transition(stream, context, 0.0, -1.0, 1200ms, "Barry house exit"); - // First-run video placed the player east of the northbound path. - // Shorten the rightward approach; tune further from the next recording. - stream.log("[Route 201] Aligning east for 2300ms before northbound approach."); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 2300ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 5861ms, 0ms); - pbf_wait(context, 134ms); - - wait_for_dialogue(stream, context, "Phase 9 (Barry Route 201)"); - - pbf_mash_button(context, BUTTON_B, 6000ms); - pbf_wait(context, 800ms); - - pbf_move_left_joystick(context, {0.000000, 1.000000}, 700ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 279ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 0.000000}, 10ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 145ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 0.000000}, 7703ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 140ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1700ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 39ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 0.000000}, 544ms, 1000ms); - - pbf_wait(context, 1000ms); - - // save_game(stream, context); // Disabled: checkpoint save mode controls saving. - context.wait_for_all_requests(); -} -// --------------------------------------------------------------------------- -// End of Segment 00 - 01 -// Standing in front lake varity ready to enter lake Verity for starter selection. -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Segment 00-02 begins -// Navigates into lake Verity to recieve starter -// --------------------------------------------------------------------------- -static void starter_nav(VideoStream& stream, ProControllerContext& context){ - // re-sync control - pbf_press_button(context, BUTTON_B, 20ms, 200ms); - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); - - // continue movement - pbf_move_left_joystick(context, {0.000000, 1.000000}, 300ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 300ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 10 (Barry before the starter selection)"); - - pbf_mash_button(context, BUTTON_A, 1000ms); - - pbf_wait(context, 500ms); - std::shared_ptr briefcase = - std::make_shared(RESOURCE_PATH() + "PokemonBDSP/StarterBriefcase.png"); - - // Mash B until we see the briefcase. - ImageMatchWatcher detector(briefcase, {0.5, 0.1, 0.5, 0.7}, 100, true); - - int ret = run_until(stream, - context, - [](ProControllerContext& context){ - pbf_mash_button(context, BUTTON_B, 120000ms); - }, - {{detector}}); - - if (ret == 0){ - stream.log("Detected briefcase!"); - }else{ - stream.log("Timed out waiting for briefcase."); - throw OperationFailedExceptionWithScreenshot(ErrorReportMode::SEND_ERROR_REPORT, - "Failed to detect briefcase in Segment 00.", - stream); - } + static void select_chimchar(VideoStream& stream, ProControllerContext& context) { + stream.log("[DEBUG] Chimchar Selection: Starting starter selection"); - // Wait for briefcase to fully open. - stream.log("Mashing B for briefcase to fully open."); - pbf_mash_button(context, BUTTON_B, 2000ms); -} -static void post_starly_clenaup(VideoStream& stream, ProControllerContext& context){ - pbf_mash_button(context, BUTTON_B, 60000ms); -} -// --------------------------------------------------------------------------- -// End of Segment 00 - 02 -// Standing in front lake varity after starter selection. -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Segment 00-03 begins -// Navigates from Exit of lake Verity to receipt of the Pokedex -// --------------------------------------------------------------------------- - -static void -tutorial_part_2_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); - - pbf_wait(context, 563ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1700ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -1.000000}, 510ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 50ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -1.000000}, 310ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 5500ms, 0ms); - pbf_wait(context, 3963ms); - context.wait_for_all_requests(); - - wait_for_dialogue(stream, context, "Phase 10 (Professor dialog to mom)"); - pbf_mash_button(context, BUTTON_B, 40000ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 820ms, 0ms); - pbf_wait(context, 4000ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 800ms, 0ms); - pbf_wait(context, 555ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 50ms, 0ms); - pbf_wait(context, 23ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 5020ms, 0ms); - pbf_wait(context, 169ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 745ms, 0ms); - DateTime t{2026, 1, 1, 0, 0, 50}; - set_time_fast(env, context, t); - walk_through_grass(stream, context, 1, 0, 20, "right through grass to path", BattleType::STARTER); - walk_through_grass(stream, context, 0, 1, 18, "up through grass 2", BattleType::STARTER); - walk_through_grass(stream, context, 1, 0, 10, "right through grass 2", BattleType::STARTER); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 730ms, 0ms); - pbf_wait(context, 941ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1950ms, 0ms); - wait_for_dialogue(stream, context, "Phase 11 (dawn conversation)"); - pbf_mash_button(context, BUTTON_B, 50000ms); - wait_for_dialogue(stream, context, "Phase 12 (Nickname reject)"); - pbf_mash_button(context, BUTTON_B, 40000ms); - wait_for_dialogue(stream, context, "Phase 13 (Pokedex accept)"); - pbf_mash_button(context, BUTTON_A, 40000ms); - - - // pokedex received -} + pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); + pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); + context.wait_for_all_requests(); + ShortDialogWatcher watcher(COLOR_CYAN); + int ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher } + ); + if (ret != 0) { + ShortDialogWatcher watcher_red(COLOR_RED); + ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher_red } + ); + } + stream.log(ret == 0 ? "[DEBUG] Chimchar Selection: Confirmation dialogue detected" + : "[WARNING] Chimchar Selection: Dialogue detection failed, proceeding anyway"); + + pbf_wait(context, 400ms); + pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); + pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); + context.wait_for_all_requests(); + stream.log("[DEBUG] Chimchar Selection: Selection complete"); + } -// --------------------------------------------------------------------------- -// End of Segment 00 - 03 -// Standing in front of the professor after receiving the pokedex. -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Begin Segment 00 - 04 -// Will heal at Sandgem pokeCenter to reliably catch a second pokemon for Home -// transfer. -// Collects poketech and stands in front of the PC to end the tutorial section -// and send the starter to pokemon HOME. -// --------------------------------------------------------------------------- - -static void -tutorial_part_3_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - // Arm exit detection before movement and finish the fade before - // starting the dialogue watcher, which cancels queued commands. - recorded_move_until_transition(stream, context, 0.0, -1.0, 2010ms, "Rowan lab exit"); - context.wait_for_all_requests(); - - wait_for_dialogue(stream, context, "Phase 14 (dawn tour)"); - - pbf_mash_button(context, BUTTON_B, 25000ms); - - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 5125ms, 0ms); - pbf_wait(context, 336ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1700ms, 0ms); - pbf_wait(context, 213ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2325ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, 1.000000}, 950ms, 0ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2360ms, 0ms); - pbf_wait(context, 253ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 4610ms, 0ms); - pbf_wait(context, 954ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 220ms, 0ms); - pbf_wait(context, 587ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1375ms, 0ms); - pbf_wait(context, 464ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 775ms, 0ms); - pbf_wait(context, 1655ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 640ms, 0ms); - pbf_wait(context, 3128ms); - pbf_press_dpad(context, DPAD_UP, 180ms, 0ms); - pbf_wait(context, 472ms); - pbf_press_dpad(context, DPAD_UP, 130ms, 0ms); - pbf_wait(context, 1621ms); - pbf_press_dpad(context, DPAD_RIGHT, 130ms, 0ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 15 (Mom conversation to accept parcel)"); - - pbf_mash_button(context, BUTTON_B, 40000ms); - - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - pbf_press_dpad(context, DPAD_LEFT, 150ms, 0ms); - pbf_wait(context, 1143ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 875ms, 0ms); - pbf_wait(context, 1169ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - pbf_wait(context, 946ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - pbf_wait(context, 9092ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - pbf_wait(context, 740ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - pbf_wait(context, 2998ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 240ms, 0ms); - pbf_wait(context, 893ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 5300ms, 0ms); - pbf_wait(context, 282ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 260ms, 0ms); - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - - DateTime t{2026, 1, 1, 0, 0, 50}; - set_time_fast(env, context, t); - walk_through_grass(stream, context, 1, 0, 20, "right through grass to path", BattleType::STARTER); - walk_through_grass(stream, context, 0, 1, 18, "up through grass 2", BattleType::STARTER); - walk_through_grass(stream, context, 1, 0, 10, "right through grass 2", BattleType::STARTER); - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - - pbf_move_left_joystick(context, {0.000000, -1.000000}, 560ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 5000ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1000ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1855ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 430ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 850ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1120ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 16 (catch tutorial)"); - - pbf_mash_button(context, BUTTON_B, 70000ms); - context.wait_for_all_requests(); - pbf_wait(context, 2000ms); - catch_1_pokemon(stream, context); - pbf_wait(context, 1500ms); - context.wait_for_all_requests(); -} + static void select_piplup(VideoStream& stream, ProControllerContext& context) { + stream.log("[DEBUG] Piplup Selection: Starting starter selection"); -// Align to a route reference before continuing recorded navigation. -// Matcher offsets and thresholds are fractions of screen width/height. -static void align_to_tutorial_landmarks(VideoStream& stream, - ProControllerContext& context, - OverworldMatchResult (*matcher)(const ImageRGB32&), - const std::string& label){ - context.wait_for_all_requests(); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - size_t confirmations = 0, coarse_confirmations = 0; - double previous_dx = 0, previous_dy = 0; - for (size_t attempt = 0; attempt < 60; attempt++){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": No video frame.", - stream); - } - const auto result = matcher(*frame.frame); - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); - } - stream.log("[" + label + "] " + landmark.name + " score=" + std::to_string(landmark.score) + - " margin=" + std::to_string(landmark.margin) + " accepted=" + std::to_string(landmark.accepted) + - " screen-relative offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); - } - stream.log("[" + label + "] located=" + std::to_string(result.located) + - " mode=" + (result.coarse ? "vertical acquisition" : "fine alignment") + - " reference screen-relative offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")"); - if (result.coarse && result.located){ - if (coarse_confirmations && std::abs(result.dx - previous_dx) <= 0.00625 && - std::abs(result.dy - previous_dy) <= 1.0 / 90){ - coarse_confirmations++; - }else{ - coarse_confirmations = 1; - } - previous_dx = result.dx; - previous_dy = result.dy; - }else{ - coarse_confirmations = 0; - } - if (!result.located){ - confirmations = 0; - pbf_wait(context, 250ms); - }else if (result.coarse && coarse_confirmations < 3){ - confirmations = 0; - stream.log("[" + label + "] Waiting for consistent vertical acquisition."); - pbf_wait(context, 250ms); - }else if (result.at_target){ - if (++confirmations >= 3){ - stream.log("[" + label + "] Reference position confirmed."); - return; + pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); + pbf_press_dpad(context, DPAD_RIGHT, 160ms, 840ms); + pbf_press_button(context, BUTTON_ZL, 160ms, 240ms); + context.wait_for_all_requests(); + + ShortDialogWatcher watcher(COLOR_CYAN); + int ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher } + ); + if (ret != 0) { + ShortDialogWatcher watcher_red(COLOR_RED); + ret = run_until( + stream, context, + [](ProControllerContext& ctx) { ctx.wait_for(std::chrono::milliseconds(5000)); }, + { watcher_red } + ); + } + stream.log(ret == 0 ? "[DEBUG] Piplup Selection: Confirmation dialogue detected" + : "[WARNING] Piplup Selection: Dialogue detection failed, proceeding anyway"); + + pbf_wait(context, 400ms); + pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); + pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); + context.wait_for_all_requests(); + stream.log("[DEBUG] Piplup Selection: Selection complete"); } + + // Navigation functions for each part of the tutorial. These functions are meant to be used + // in order and rely on the state of the game being what it is at the end of the previous function. + // They may also be modified or removed without warning. + + // --------------------------------------------------------------------------- + // Segment 00-01 begins + // Navigates from player's room to Route 201 through Mom/Barry dialogues. + // --------------------------------------------------------------------------- + + static void tutorial_part_1_navigation(SingleSwitchProgramEnvironment& env, + VideoStream& stream, ProControllerContext& context) { + DateTime t{ 2026, 1, 1, 0, 0, 0 }; + set_time_fast(env, context, t); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 527ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, 1.000000 }, 456ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 915ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, 1.000000 }, 642ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 557ms, 0ms); + pbf_wait(context, 978ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1112ms, 0ms); + pbf_wait(context, 4967ms); + + wait_for_dialogue(stream, context, "Phase 1 (Mom)"); + + pbf_mash_button(context, BUTTON_A, 5000ms); pbf_wait(context, 300ms); - }else{ - confirmations = 0; - const bool horizontal = !result.coarse && std::abs(result.dx) >= std::abs(result.dy) * 9.0 / 16; - const double error = horizontal ? result.dx : result.dy * 9.0 / 16; - const auto duration = std::abs(error) > 1.0 / 24 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; - stream.log("[" + label + "] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + " axis " + - (horizontal ? (result.dx > 0 ? "right" : "left") : (result.dy < 0 ? "up" : "down")) + " for " + - std::to_string(duration.count()) + "ms."); - // Reuse confirmed-Run encounter handling if correction enters grass. - walk_through_grass(stream, - context, - horizontal ? (result.dx > 0 ? 1 : -1) : 0, - horizontal ? 0 : (result.dy < 0 ? 1 : -1), - 1, - label, - BattleType::STANDARD, - duration); - } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Reference position not confirmed; route movement stopped.", - stream); -} -static void align_to_sandgem_junction(VideoStream& stream, ProControllerContext& context){ - align_to_tutorial_landmarks(stream, context, match_sandgem_junction, "Sandgem junction"); -} + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1662ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -1.000000 }, 49ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1215ms, 0ms); + pbf_wait(context, 1419ms); -// Resumes from the save made after catching the second Pokemon. -static void tutorial_part_3_after_catch_navigation(SingleSwitchProgramEnvironment& env, - VideoStream& stream, - ProControllerContext& context){ - pbf_wait(context, 1500ms); - //-------------------------------------------------------- - // To get potions - // ------------------------------------------------------- - walk_through_grass(stream, - context, - 1, - 0, - 10, - "Route 202 grass exit", - BattleType::STANDARD, - 200ms, - match_route202_grass_exit); - // Recording: the exit reference was reached after two 200ms steps. - // Eight further steps (1600ms) reached the eastern tree boundary. - // Continue outside grass to preserve the next down movement's start. - stream.log("[Tutorial] Route 202 grass exit confirmed; continuing right to the tree boundary."); - pbf_move_left_joystick(context, {1.0, 0.0}, 1600ms, 0ms); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1421ms, 0ms); - pbf_wait(context, 52ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1013ms, 0ms); - pbf_wait(context, 142ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1112ms, 0ms); - pbf_wait(context, 45ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 3206ms, 0ms); - pbf_wait(context, 912ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 856ms, 0ms); - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - walk_through_grass(stream, context, -1, 0, 18, "left through grass 3"); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 17 (accept potions)"); - - pbf_mash_button(context, BUTTON_B, 10000ms); - context.wait_for_all_requests(); - // Catching the second Pokemon can damage the starter. Verify healing - // once Potions are available, before continuing toward Tristan. - stream.log("[Tutorial] Checking starter HP after the first Potion pickup."); - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - // Complete one training encounter before Tristan. The helper waits - // through XP/automatic move learning and heals after the fight. - battle_1_pokemon(stream, context); - - // Stop on the verified Route 201 reference instead of overshooting - // with ten fixed steps. The limit also allows small final corrections. - walk_through_grass(stream, - context, - 1, - 0, - 20, - "Route 201 Potion exit", - BattleType::STANDARD, - 200ms, - match_route201_potion_exit); - context.wait_for_all_requests(); - pbf_wait(context, 1500ms); - - // --------------------------------------------------------- - // Navigate back to catch tutorial grass - // --------------------------------------------------------- - pbf_move_left_joystick(context, {0.000000, -1.000000}, 560ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 5000ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1000ms, 0ms); - pbf_wait(context, 1500ms); - align_to_sandgem_junction(stream, context); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1855ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 430ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 850ms, 0ms); - pbf_wait(context, 1500ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1120ms, 0ms); - - // Continue on to jubilife - walk_through_grass(stream, context, -1, 0, 18, "left through grass 3"); - walk_through_grass(stream, context, 0, 1, 18, "up through grass 3"); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1000ms, 0ms); - wait_for_dialogue(stream, context, "Phase 18 (tristan encounter dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); - fight_tristan(stream, context); - - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1366ms, 0ms); - pbf_wait(context, 467ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1018ms, 0ms); - pbf_wait(context, 311ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 353ms, 0ms); - pbf_wait(context, 660ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 580ms, 0ms); - pbf_wait(context, 1672ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1344ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 19 (natalie encounter dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); - - fight_Natalie(stream, context); - context.wait_for_all_requests(); - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1301ms, 0ms); - walk_through_grass(stream, context, 0, 1, 15, "up through grass"); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 392ms, 0ms); - pbf_wait(context, 1261ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 815ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 20 (logan encounter dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); - fight_Natalie(stream, context); - context.wait_for_all_requests(); - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - walk_through_grass(stream, context, -1, 0, 8, "left through grass"); - - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2469ms, 0ms); - wait_for_dialogue(stream, context, "Phase 21 (dawn Jubilife dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); -} -// --------------------------------------------------------------------------- -// End of Segment 00 - 04 -// Standing in Jubilife after speaking to dawn. -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Begin Segment 00 - 05 -// Standing at the entrance to jubilife after speaking to dawn, will navigate to the poketech challenge. -// Collects poketech and stands in front of the PC to end the tutorial section -// and send the starter to pokemon HOME. -// --------------------------------------------------------------------------- - -// Stop the approach as soon as buildings can guide it, instead of -// overshooting south and then blindly walking back north. -static void approach_poketch_landmarks(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - Milliseconds walked = 0ms; - while (walked < 1900ms){ - auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poketch approach: No video frame.", - stream); + wait_for_dialogue(stream, context, "Phase 2 (Mom Warning)"); + + pbf_mash_button(context, BUTTON_A, 2500ms); + pbf_wait(context, 300ms); + context.wait_for_all_requests(); + + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 680ms, 0ms); + + pbf_wait(context, 4000ms); + + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1748ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 18ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 2763ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 58ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1364ms, 0ms); + pbf_wait(context, 4000ms); + context.wait_for_all_requests(); + + wait_for_dialogue(stream, context, "Phase 3 (Barry 1st)"); + pbf_mash_button(context, BUTTON_A, 6000ms); + pbf_wait(context, 800ms); + wait_for_dialogue(stream, context, "Phase 3 (Barry 1st)"); + pbf_mash_button(context, BUTTON_A, 6000ms); + pbf_wait(context, 800ms); + + pbf_wait(context, 1306ms); + context.wait_for_all_requests(); + stream.log("[Barry house] Entering; waiting for entry fade."); + BlackScreenWatcher entry_black; + int entry_ret = run_until(stream, context, + [](ProControllerContext& ctx) { + pbf_move_left_joystick(ctx, {0, 1}, 5000ms, 0ms); + }, {{entry_black}}); + if (entry_ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Barry house: Entry fade not detected.", stream); + } + // Release the joystick during loading; resume indoor movement only + // after the screen has stayed non-black for half a second. + BlackScreenWatcher entry_visible(COLOR_RED, {0.1, 0.1, 0.8, 0.8}, + 100, 10, BlackScreenWatcher::FinderType::GONE, 500ms); + if (wait_until(stream, context, 15s, {{entry_visible}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Barry house: Entry fade did not finish.", stream); + } + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + stream.log("[Barry house] Entry settled; starting stair approach."); + // Align left after entry before the next upward stair approach. + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 400ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 0.000000 }, 1600ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 2722ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1600ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 501ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 652ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 1817ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 975ms, 0ms); + pbf_wait(context, 1422ms); + + wait_for_dialogue(stream, context, "Phase 8 (Barry 2nd dialog)"); + pbf_mash_button(context, BUTTON_A, 10000ms); + + pbf_wait(context, 856ms); + + + // Recorded route from the top of Barry's stairs. Moves rounded to + // 100ms, waits to 50ms; transition waits gate both floor changes. + recorded_move_until_transition(stream, context, -1.0, 0.0, 900ms, + "Barry house downstairs"); + pbf_wait(context, 750ms); + pbf_move_left_joystick(context, {-1.0, 0.0}, 2000ms, 0ms); + pbf_wait(context, 250ms); + pbf_move_left_joystick(context, {0.0, -1.0}, 2000ms, 0ms); + pbf_wait(context, 150ms); + pbf_move_left_joystick(context, {1.0, 0.0}, 1400ms, 0ms); + pbf_wait(context, 500ms); + recorded_move_until_transition(stream, context, 0.0, -1.0, 1200ms, + "Barry house exit"); + // First-run video placed the player east of the northbound path. + // Shorten the rightward approach; tune further from the next recording. + stream.log("[Route 201] Aligning east for 2300ms before northbound approach."); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 2300ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 5861ms, 0ms); + pbf_wait(context, 134ms); + + wait_for_dialogue(stream, context, "Phase 9 (Barry Route 201)"); + + pbf_mash_button(context, BUTTON_B, 6000ms); + pbf_wait(context, 800ms); + + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 700ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 279ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 0.000000 }, 10ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 145ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 0.000000 }, 7703ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 140ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 1700ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 39ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 0.000000 }, 544ms, 1000ms); + + pbf_wait(context, 1000ms); + + // save_game(stream, context); // Disabled: checkpoint save mode controls saving. + context.wait_for_all_requests(); } - const auto result = match_poketch_landmarks(*frame.frame); - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); + // --------------------------------------------------------------------------- + // End of Segment 00 - 01 + // Standing in front lake varity ready to enter lake Verity for starter selection. + // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // Segment 00-02 begins + // Navigates into lake Verity to recieve starter + // --------------------------------------------------------------------------- + static void starter_nav(VideoStream& stream, ProControllerContext& context) { + // re-sync control + pbf_press_button(context, BUTTON_B, 20ms, 200ms); + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + + // continue movement + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 300ms, 0ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 300ms, 0ms); + + wait_for_dialogue(stream, context, "Phase 10 (Barry before the starter selection)"); + + pbf_mash_button(context, BUTTON_A, 1000ms); + + pbf_wait(context, 500ms); + std::shared_ptr briefcase = + std::make_shared( + RESOURCE_PATH() + "PokemonBDSP/StarterBriefcase.png" + ); + + // Mash B until we see the briefcase. + ImageMatchWatcher detector(briefcase, { 0.5, 0.1, 0.5, 0.7 }, 100, true); + + int ret = run_until( + stream, context, + [](ProControllerContext& context) { + pbf_mash_button(context, BUTTON_B, 120000ms); + }, + { {detector} } + ); + + if (ret == 0) { + stream.log("Detected briefcase!"); } - stream.log("[Poketch approach] " + landmark.name + " score=" + std::to_string(landmark.score) + - " margin=" + std::to_string(landmark.margin)); + else { + stream.log("Timed out waiting for briefcase."); + throw OperationFailedExceptionWithScreenshot( + ErrorReportMode::SEND_ERROR_REPORT, + "Failed to detect briefcase in Segment 00.", + stream + ); + } + + // Wait for briefcase to fully open. + stream.log("Mashing B for briefcase to fully open."); + pbf_mash_button(context, BUTTON_B, 2000ms); } - if (result.located){ - stream.log("[Poketch approach] Landmarks acquired after " + std::to_string(walked.count()) + - "ms; ending downward approach."); - return; + static void post_starly_clenaup(VideoStream& stream, ProControllerContext& context) { + pbf_mash_button(context, BUTTON_B, 60000ms); + } + // --------------------------------------------------------------------------- + // End of Segment 00 - 02 + // Standing in front lake varity after starter selection. + // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // Segment 00-03 begins + // Navigates from Exit of lake Verity to receipt of the Pokedex + // --------------------------------------------------------------------------- + + static void tutorial_part_2_navigation(SingleSwitchProgramEnvironment& env, + VideoStream& stream, ProControllerContext& context) { + pbf_wait(context, 2000ms); + context.wait_for_all_requests(); + + pbf_wait(context, 563ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1700ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -1.000000 }, 510ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 50ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -1.000000 }, 310ms, 0ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 5500ms, 0ms); + pbf_wait(context, 3963ms); + context.wait_for_all_requests(); + + wait_for_dialogue(stream, context, "Phase 10 (Professor dialog to mom)"); + pbf_mash_button(context, BUTTON_B, 40000ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 820ms, 0ms); + pbf_wait(context, 4000ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 800ms, 0ms); + pbf_wait(context, 555ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 50ms, 0ms); + pbf_wait(context, 23ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 5020ms, 0ms); + pbf_wait(context, 169ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 745ms, 0ms); + DateTime t{ 2026, 1, 1, 0, 0, 50 }; + set_time_fast(env, context, t); + walk_through_grass(stream, context, 1, 0, 20, "right through grass to path", BattleType::STARTER); + walk_through_grass(stream, context, 0, 1, 18, "up through grass 2", BattleType::STARTER); + walk_through_grass(stream, context, 1, 0, 10, "right through grass 2", BattleType::STARTER); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 730ms, 0ms); + pbf_wait(context, 941ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 1950ms, 0ms); + wait_for_dialogue(stream, context, "Phase 11 (dawn conversation)"); + pbf_mash_button(context, BUTTON_B, 50000ms); + wait_for_dialogue(stream, context, "Phase 12 (Nickname reject)"); + pbf_mash_button(context, BUTTON_B, 40000ms); + wait_for_dialogue(stream, context, "Phase 13 (Pokedex accept)"); + pbf_mash_button(context, BUTTON_A, 40000ms); + + + // pokedex received } - const Milliseconds step = std::min(200ms, 1900ms - walked); - pbf_move_left_joystick(context, {0, -1}, step, 0ms); - walked += step; - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - } - stream.log("[Poketch approach] Reached 1900ms downward limit; handing off to locator."); -} -static void align_and_talk_to_poketch_man(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - size_t confirmed = 0, coarse_confirmed = 0; - double previous_dx = 0, previous_dy = 0; - size_t acquisition_misses = 0, vertical_probe = 0; - // Two small excursions around the approach position. This is - // visibility recovery, not permission to interact without a match. - const int vertical_probes[] = {-80, 160, -240, 320, -160, -120, 240, -360, 480, -240}; - for (size_t attempt = 0; attempt < 80; attempt++){ - auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poketch interaction: No video frame.", - stream); + + // --------------------------------------------------------------------------- + // End of Segment 00 - 03 + // Standing in front of the professor after receiving the pokedex. + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // Begin Segment 00 - 04 + // Will heal at Sandgem pokeCenter to reliably catch a second pokemon for Home + // transfer. + // Collects poketech and stands in front of the PC to end the tutorial section + // and send the starter to pokemon HOME. + // --------------------------------------------------------------------------- + + static void tutorial_part_3_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { + // Arm exit detection before movement and finish the fade before + // starting the dialogue watcher, which cancels queued commands. + recorded_move_until_transition(stream, context, 0.0, -1.0, 2010ms, + "Rowan lab exit"); + context.wait_for_all_requests(); + + wait_for_dialogue(stream, context, "Phase 14 (dawn tour)"); + + pbf_mash_button(context, BUTTON_B, 25000ms); + + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 5125ms, 0ms); + pbf_wait(context, 336ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1700ms, 0ms); + pbf_wait(context, 213ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 2325ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, 1.000000 }, 950ms, 0ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 2360ms, 0ms); + pbf_wait(context, 253ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 4610ms, 0ms); + pbf_wait(context, 954ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 220ms, 0ms); + pbf_wait(context, 587ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1375ms, 0ms); + pbf_wait(context, 464ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 775ms, 0ms); + pbf_wait(context, 1655ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 640ms, 0ms); + pbf_wait(context, 3128ms); + pbf_press_dpad(context, DPAD_UP, 180ms, 0ms); + pbf_wait(context, 472ms); + pbf_press_dpad(context, DPAD_UP, 130ms, 0ms); + pbf_wait(context, 1621ms); + pbf_press_dpad(context, DPAD_RIGHT, 130ms, 0ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + + wait_for_dialogue(stream, context, "Phase 15 (Mom conversation to accept parcel)"); + + pbf_mash_button(context, BUTTON_B, 40000ms); + + context.wait_for_all_requests(); + pbf_wait(context, 1500ms); + pbf_press_dpad(context, DPAD_LEFT, 150ms, 0ms); + pbf_wait(context, 1143ms); + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 875ms, 0ms); + pbf_wait(context, 1169ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + pbf_wait(context, 946ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + pbf_wait(context, 9092ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + pbf_wait(context, 740ms); + pbf_press_button(context, BUTTON_A, 180ms, 0ms); + pbf_wait(context, 2998ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 240ms, 0ms); + pbf_wait(context, 893ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 5300ms, 0ms); + pbf_wait(context, 282ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 260ms, 0ms); + context.wait_for_all_requests(); + pbf_wait(context, 1500ms); + + DateTime t{ 2026, 1, 1, 0, 0, 50 }; + set_time_fast(env, context, t); + walk_through_grass(stream, context, 1, 0, 20, "right through grass to path", BattleType::STARTER); + walk_through_grass(stream, context, 0, 1, 18, "up through grass 2", BattleType::STARTER); + walk_through_grass(stream, context, 1, 0, 10, "right through grass 2", BattleType::STARTER); + context.wait_for_all_requests(); + pbf_wait(context, 1500ms); + + pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 560ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 5000ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1000ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 1855ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 430ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 850ms, 0ms); + pbf_wait(context, 1500ms); + pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 1120ms, 0ms); + + wait_for_dialogue(stream, context, "Phase 16 (catch tutorial)"); + + pbf_mash_button(context, BUTTON_B, 70000ms); + context.wait_for_all_requests(); + pbf_wait(context, 2000ms); + catch_1_pokemon(stream, context); + pbf_wait(context, 1500ms); + context.wait_for_all_requests(); } - const OverworldMatchResult result = match_poketch_landmarks(*frame.frame); - stream.log(result.coarse ? "[Poketch landmarks] Coarse acquisition." : "[Poketch landmarks] Fine alignment."); - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); + + // Align to a route reference before continuing recorded navigation. + // Matcher offsets and thresholds are fractions of screen width/height. + static void align_to_tutorial_landmarks( + VideoStream& stream, ProControllerContext& context, + OverworldMatchResult (*matcher)(const ImageRGB32&), const std::string& label + ) { + context.wait_for_all_requests(); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + size_t confirmations = 0, coarse_confirmations = 0; + double previous_dx = 0, previous_dy = 0; + for (size_t attempt = 0; attempt < 60; attempt++) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No video frame.", stream); + const auto result = matcher(*frame.frame); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[" + label + "] " + landmark.name + + " score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin) + + " accepted=" + std::to_string(landmark.accepted) + + " screen-relative offset=(" + std::to_string(landmark.dx) + + "," + std::to_string(landmark.dy) + ")"); + } + stream.log("[" + label + "] located=" + std::to_string(result.located) + + " mode=" + (result.coarse ? "vertical acquisition" : "fine alignment") + + " reference screen-relative offset=(" + std::to_string(result.dx) + + "," + std::to_string(result.dy) + ")"); + if (result.coarse && result.located) { + if (coarse_confirmations && std::abs(result.dx-previous_dx)<=0.00625 && + std::abs(result.dy-previous_dy)<=1.0/90) coarse_confirmations++; + else coarse_confirmations=1; + previous_dx=result.dx; + previous_dy=result.dy; + } else coarse_confirmations=0; + if (!result.located) { + confirmations = 0; + pbf_wait(context, 250ms); + } else if (result.coarse && coarse_confirmations<3) { + confirmations = 0; + stream.log("[" + label + "] Waiting for consistent vertical acquisition."); + pbf_wait(context, 250ms); + } else if (result.at_target) { + if (++confirmations >= 3) { + stream.log("[" + label + "] Reference position confirmed."); + return; + } + pbf_wait(context, 300ms); + } else { + confirmations = 0; + const bool horizontal = !result.coarse && + std::abs(result.dx) >= std::abs(result.dy)*9.0/16; + const double error = horizontal ? result.dx : result.dy*9.0/16; + const auto duration = std::abs(error) > 1.0/24 ? 80ms : + std::abs(error) > 0.015625 ? 40ms : 16ms; + stream.log("[" + label + "] Correcting " + + std::string(horizontal ? "horizontal" : "vertical") + " axis " + + (horizontal ? (result.dx>0 ? "right" : "left") : (result.dy<0 ? "up" : "down")) + + " for " + std::to_string(duration.count()) + "ms."); + // Reuse confirmed-Run encounter handling if correction enters grass. + walk_through_grass(stream, context, + horizontal ? (result.dx > 0 ? 1 : -1) : 0, + horizontal ? 0 : (result.dy < 0 ? 1 : -1), + 1, label, BattleType::STANDARD, duration); + } + context.wait_for_all_requests(); } - stream.log("[Poketch landmarks] " + landmark.name + " score=" + std::to_string(landmark.score) + - " margin=" + std::to_string(landmark.margin) + " accepted=" + std::to_string(landmark.accepted) + - " screen-relative offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); - } - if (result.located){ - acquisition_misses = 0; + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Reference position not confirmed; route movement stopped.", stream); } - if (result.coarse && result.located){ - if (coarse_confirmed && std::abs(result.dx - previous_dx) <= 0.00625 && - std::abs(result.dy - previous_dy) <= 1.0 / 90){ - coarse_confirmed++; - }else{ - coarse_confirmed = 1; - } - previous_dx = result.dx; - previous_dy = result.dy; - }else{ - coarse_confirmed = 0; + + static void align_to_sandgem_junction(VideoStream& stream, ProControllerContext& context) { + align_to_tutorial_landmarks(stream, context, match_sandgem_junction, "Sandgem junction"); } - if (!result.located){ - confirmed = 0; - if (++acquisition_misses >= 4 && vertical_probe < sizeof(vertical_probes) / sizeof(vertical_probes[0])){ - int probe = vertical_probes[vertical_probe++]; - acquisition_misses = 0; - stream.log("[Poketch landmarks] Bounded vertical visibility probe " + std::to_string(vertical_probe) + - "/10: " + (probe < 0 ? "down " : "up ") + std::to_string(std::abs(probe)) + "ms."); - pbf_move_left_joystick(context, {0, probe < 0 ? -1.0 : 1.0}, Milliseconds(std::abs(probe)), 0ms); - pbf_wait(context, 700ms); - }else{ - stream.log("[Poketch landmarks] No trustworthy acquisition; waiting for another frame."); - pbf_wait(context, 250ms); + + // Resumes from the save made after catching the second Pokemon. + static void tutorial_part_3_after_catch_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { + // A retry reloads the save at whatever time the clock has reached. + // Pin it again so the landmark references see the same lighting. + set_time_fast(env, context, DateTime{ 2026, 1, 1, 0, 0, 50 }); + + // What this part of 00.04 does, logged up front and as each step starts. + // The Potion on Route 201 is optional, and the starter outclasses the + // Route 202 trainers, so we go straight north. + static const char* const STEPS[] = { + "Get 10 Potions from the Poke Mart lady on Route 201, walk back to Route 202 and save (skipped if the Bag has Potions)", + "Train: fight 3 wild Pokemon in the catch grass, with the best move", + "Heal to full with Potions from the Bag", + "Walk north up Route 202 to the Jubilife exit, fighting trainers who see us; after each, use a Potion if HP is under half", + "Enter Jubilife and get through Dawn's scene", + }; + constexpr size_t STEP_COUNT = sizeof(STEPS) / sizeof(STEPS[0]); + { + std::string text = "[00.04] After the catch, the steps are:"; + for (size_t i = 0; i < STEP_COUNT; i++) { + text += "\n " + std::to_string(i + 1) + ". " + STEPS[i]; + } + stream.log(text, COLOR_BLUE); } - }else if (result.coarse && coarse_confirmed < 3){ - confirmed = 0; - stream.log("[Poketch landmarks] Waiting for consistent coarse offset before movement."); - pbf_wait(context, 250ms); - }else if (result.at_target){ - if (++confirmed >= 3){ - stream.log("[Poketch landmarks] Above-NPC reference confirmed; turning down."); - pbf_press_dpad(context, DPAD_DOWN, 40ms, 400ms); + auto step = [&](size_t number) { + std::string text = "[00.04 step " + std::to_string(number) + "/" + std::to_string(STEP_COUNT) + "] " + STEPS[number - 1]; + text += number < STEP_COUNT ? std::string(" -- next: ") + STEPS[number] : std::string(" -- last step"); + stream.log(text, COLOR_BLUE); + add_wrapped_log(stream.overlay(), "00.04 step " + std::to_string(number) + "/" + std::to_string(STEP_COUNT) + ": " + STEPS[number - 1], COLOR_WHITE); + if (number < STEP_COUNT) { + add_wrapped_log(stream.overlay(), "Next: " + std::string(STEPS[number]), COLOR_GRAY); + } + }; + + const MapSet& maps = story_maps(); + std::vector trainers = story_trainer_sights(); + // Fight a trainer whose dialogue has opened, with whatever starter we + // have. Someone who only talks leaves no battle to fight. + auto fight = [&](const std::string& name) { + pbf_mash_button(context, BUTTON_B, 5000ms); + context.wait_for_all_requests(); + BattleMenuWatcher battle_menu(BattleType::TRAINER, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + if (wait_until(stream, context, 8s, {{battle_menu}, {battle_dialog}}) < 0) { + stream.log("[00.04] " + name + ": no battle followed the talk."); + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + return; + } + fight_trainer(stream, context, name); context.wait_for_all_requests(); - pbf_press_button(context, BUTTON_A, 80ms, 500ms); + // The next trainer may be a few tiles on: check HP now. + use_potion_first_pokemon(stream, context, 0.5); + }; + + pbf_wait(context, 1500ms); + step(1); + // Potions let us heal where we stand, instead of walking back to the + // Pokemon Center. A save made after getting them starts at the save + // spot with Potions in the Bag; the post-catch save starts at the + // catch spot without. + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::POTION_SAVE_SPOT); + if (bag_has_item(stream, context, "potion", 1)) { + stream.log("[00.04] The Bag has Potions; not visiting the Poke Mart lady."); + } else { + const TilePos catch_spot = maps.to_world(MAP_ROUTE_202, Route202::CATCH_SPOT); + const TilePos talk_from = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_TALK_FROM); + walk_route(stream, context, maps, {catch_spot}, + [&](const TilePos& pos) { return pos == talk_from; }, trainers, fight, "To the Poke Mart lady"); + // Her lines take a while to print: one A per line. + for (size_t line = 0; line < 9; line++) { + pbf_press_button(context, BUTTON_A, 80ms, 1500ms); + } context.wait_for_all_requests(); - wait_for_dialogue(stream, context, "Phase 27 (poketech challenge completed dialog)", 10s); - return; + if (!bag_has_item(stream, context, "potion", 1)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart lady: no Potion in the Bag after talking to her.", stream); + } + walk_route(stream, context, maps, {talk_from}, + [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); + stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); + save_game(env, context); } - pbf_wait(context, 300ms); - }else{ - confirmed = 0; - const double dx = result.dx, dy = result.dy; - stream.log("[Poketch landmarks] Reference screen-relative offset=(" + std::to_string(result.dx) + "," + - std::to_string(result.dy) + ")."); - const bool horizontal = - std::abs(dx) > 7.0 / 960 && (std::abs(dy) <= 7.0 / 540 || std::abs(dx) >= std::abs(dy) * 9.0 / 16); - double error = horizontal ? dx : dy * 9.0 / 16; - auto duration = std::abs(error) > 0.03125 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; - if (std::abs(dx) <= 7.0 / 960 && std::abs(dy) <= 7.0 / 540){ - stream.log("[Poketch landmarks] Coarse position near target; waiting for fine confirmation."); - pbf_wait(context, 300ms); + walk_route(stream, context, maps, {save_spot}, + [&](const TilePos& pos) { + const std::vector grass = story_after_training_area(); + return std::find(grass.begin(), grass.end(), pos) != grass.end(); + }, trainers, fight, "To the catch grass"); + + step(2); + // The starter starts around level 5 with only a Normal move; the + // Route 202 trainers need a level or two more. + for (size_t battle = 0; battle < 3; battle++) { + route_status(stream, "Training battle " + std::to_string(battle + 1) + "/3"); + battle_1_pokemon(stream, context); + } + + step(3); + use_potion_first_pokemon(stream, context); + + step(4); + walk_route(stream, context, maps, story_after_training_area(), + story_at_jubilife_exit, trainers, fight, "To Jubilife"); + + step(5); + // Jubilife's south edge is a wall with stairs in it, west of where the + // route may leave us (r78 stood at its east end). Entering starts a + // scene: Dawn walks over while we stand still. So walk north; if the + // wall stops us and no dialogue follows, the stairs are further west. + bool met_dawn = false; + for (size_t attempt = 0; attempt < 6 && !met_dawn; attempt++) { + const WalkResult walk = walk_until(stream, context, 0, 1, 4000ms, "to Dawn in Jubilife"); + if (walk.stop == WalkStop::TRANSITION) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Jubilife: left the area instead of meeting Dawn.", stream); + } + ShortDialogWatcher dialog(COLOR_CYAN); + if (walk.stop == WalkStop::DIALOG || wait_until(stream, context, 6s, {{dialog}}) == 0) { + met_dawn = true; + break; + } + stream.log("[00.04] Jubilife: the wall stopped us; looking for the stairs one tile west."); + route_turn(stream, context, Pokemon::Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, route_open_hold(Pokemon::Direction::WEST, 1), 200ms); context.wait_for_all_requests(); - continue; } - stream.log("[Poketch landmarks] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + - " axis for " + std::to_string(duration.count()) + "ms."); - pbf_move_left_joystick(context, - {horizontal ? (dx > 0 ? 1.0 : -1.0) : 0.0, horizontal ? 0.0 : (dy < 0 ? 1.0 : -1.0)}, - duration, - 0ms); - pbf_wait(context, 700ms); - } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poketch interaction: Building landmarks did not confirm the reference position; A not pressed.", - stream); -} + if (!met_dawn) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Jubilife: no stairs found and no Dawn after six tiles west.", stream); + } + wait_for_dialogue(stream, context, "Phase 21 (dawn Jubilife dialog)"); -void align_and_enter_jubilife_center(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - size_t confirmed = 0; - for (size_t attempt = 0; attempt < 60; attempt++){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Jubilife Center alignment: No video frame.", - stream); + pbf_mash_button(context, BUTTON_B, 5000ms); } - const auto result = match_jubilife_center_entrance(*frame.frame); - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); + // --------------------------------------------------------------------------- + // End of Segment 00 - 04 + // Standing in Jubilife after speaking to dawn. + // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // Begin Segment 00 - 05 + // Standing at the entrance to jubilife after speaking to dawn, will navigate to the poketech challenge. + // Collects poketech and stands in front of the PC to end the tutorial section + // and send the starter to pokemon HOME. + // --------------------------------------------------------------------------- + + // Stop the approach as soon as buildings can guide it, instead of + // overshooting south and then blindly walking back north. + static void approach_poketch_landmarks(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); + Milliseconds walked = 0ms; + while (walked < 1900ms) { + auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poketch approach: No video frame.", stream); + const auto result = match_poketch_landmarks(*frame.frame); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[Poketch approach] " + landmark.name + " score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin)); + } + if (result.located) { + stream.log("[Poketch approach] Landmarks acquired after " + std::to_string(walked.count()) + "ms; ending downward approach."); + return; + } + const Milliseconds step = std::min(200ms, 1900ms - walked); + pbf_move_left_joystick(context, {0, -1}, step, 0ms); + walked += step; + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + } + stream.log("[Poketch approach] Reached 1900ms downward limit; handing off to locator."); } - stream.log("[Jubilife Center alignment] score=" + std::to_string(landmark.score) + - " margin=" + std::to_string(landmark.margin) + " screen-relative offset=(" + - std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); - } - if (!result.located){ - confirmed = 0; - pbf_wait(context, 250ms); - }else if (result.at_target){ - if (++confirmed >= 3){ - stream.log("[Jubilife Center alignment] Door position confirmed; entering."); - recorded_move_until_transition(stream, context, 0, 1, 2000ms, "Jubilife Pokemon Center entry"); + + static void align_and_talk_to_poketch_man(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); pbf_wait(context, 700ms); context.wait_for_all_requests(); - return; + size_t confirmed = 0, coarse_confirmed = 0; + double previous_dx = 0, previous_dy = 0; + size_t acquisition_misses = 0, vertical_probe = 0; + // Two small excursions around the approach position. This is + // visibility recovery, not permission to interact without a match. + const int vertical_probes[] = {-80, 160, -240, 320, -160, -120, 240, -360, 480, -240}; + for (size_t attempt = 0; attempt < 80; attempt++) { + auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poketch interaction: No video frame.", stream); + const OverworldMatchResult result = match_poketch_landmarks(*frame.frame); + stream.log(result.coarse ? "[Poketch landmarks] Coarse acquisition." : "[Poketch landmarks] Fine alignment."); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[Poketch landmarks] " + landmark.name + " score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin) + " accepted=" + std::to_string(landmark.accepted) + + " screen-relative offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); + } + if (result.located) acquisition_misses = 0; + if (result.coarse && result.located) { + if (coarse_confirmed && std::abs(result.dx - previous_dx) <= 0.00625 && std::abs(result.dy - previous_dy) <= 1.0/90) + coarse_confirmed++; + else coarse_confirmed = 1; + previous_dx = result.dx; previous_dy = result.dy; + } else coarse_confirmed = 0; + if (!result.located) { + confirmed = 0; + if (++acquisition_misses >= 4 && vertical_probe < sizeof(vertical_probes) / sizeof(vertical_probes[0])) { + int probe = vertical_probes[vertical_probe++]; + acquisition_misses = 0; + stream.log("[Poketch landmarks] Bounded vertical visibility probe " + std::to_string(vertical_probe) + + "/10: " + (probe < 0 ? "down " : "up ") + std::to_string(std::abs(probe)) + "ms."); + pbf_move_left_joystick(context, {0, probe < 0 ? -1.0 : 1.0}, Milliseconds(std::abs(probe)), 0ms); + pbf_wait(context, 700ms); + } else { + stream.log("[Poketch landmarks] No trustworthy acquisition; waiting for another frame."); + pbf_wait(context, 250ms); + } + } else if (result.coarse && coarse_confirmed < 3) { + confirmed = 0; + stream.log("[Poketch landmarks] Waiting for consistent coarse offset before movement."); + pbf_wait(context, 250ms); + } else if (result.at_target) { + if (++confirmed >= 3) { + stream.log("[Poketch landmarks] Above-NPC reference confirmed; turning down."); + pbf_press_dpad(context, DPAD_DOWN, 40ms, 400ms); + context.wait_for_all_requests(); + pbf_press_button(context, BUTTON_A, 80ms, 500ms); + context.wait_for_all_requests(); + wait_for_dialogue(stream, context, "Phase 27 (poketech challenge completed dialog)", 10s); + return; + } + pbf_wait(context, 300ms); + } else { + confirmed = 0; + const double dx = result.dx, dy = result.dy; + stream.log("[Poketch landmarks] Reference screen-relative offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")."); + const bool horizontal = std::abs(dx) > 7.0/960 && + (std::abs(dy) <= 7.0/540 || std::abs(dx) >= std::abs(dy)*9.0/16); + double error = horizontal ? dx : dy*9.0/16; + auto duration = std::abs(error) > 0.03125 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; + if (std::abs(dx) <= 7.0/960 && std::abs(dy) <= 7.0/540) { + stream.log("[Poketch landmarks] Coarse position near target; waiting for fine confirmation."); + pbf_wait(context, 300ms); + context.wait_for_all_requests(); + continue; + } + stream.log("[Poketch landmarks] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + + " axis for " + std::to_string(duration.count()) + "ms."); + pbf_move_left_joystick(context, + {horizontal ? (dx > 0 ? 1.0 : -1.0) : 0.0, + horizontal ? 0.0 : (dy < 0 ? 1.0 : -1.0)}, duration, 0ms); + pbf_wait(context, 700ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poketch interaction: Building landmarks did not confirm the reference position; A not pressed.", stream); } - pbf_wait(context, 300ms); - }else{ - confirmed = 0; - bool horizontal = std::abs(result.dx) >= std::abs(result.dy) * 9.0 / 16; - double error = horizontal ? result.dx : result.dy * 9.0 / 16; - const auto duration = std::abs(error) > 1.0 / 24 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; - pbf_move_left_joystick(context, - {horizontal ? (result.dx > 0 ? 1.0 : -1.0) : 0.0, horizontal ? 0.0 : (result.dy < 0 ? 1.0 : -1.0)}, - duration, - 0ms); - pbf_wait(context, 700ms); - } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Jubilife Center alignment: Door reference not confirmed; entry not attempted.", - stream); -} - -static void -tutorial_part_4_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2580ms, 0ms); - pbf_wait(context, 504ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 830ms, 0ms); - pbf_wait(context, 617ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 800ms, 0ms); - pbf_wait(context, 509ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2000ms, 0ms); - pbf_wait(context, 598ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 200ms, 0ms); - pbf_wait(context, 581ms); - pbf_press_button(context, BUTTON_A, 150ms, 0ms); - pbf_wait(context, 795ms); - - wait_for_dialogue(stream, context, "Phase 22 (barry town map dialog)"); - - pbf_mash_button(context, BUTTON_B, 15000ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1521ms, 0ms); - pbf_wait(context, 602ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1100ms, 0ms); - pbf_wait(context, 864ms); - pbf_press_dpad(context, DPAD_RIGHT, 138ms, 0ms); - pbf_wait(context, 2398ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1018ms, 0ms); - pbf_wait(context, 3811ms); - - wait_for_dialogue(stream, context, "Phase 23 (poketech challenge dialog)"); - - pbf_mash_button(context, BUTTON_B, 5000ms); - context.wait_for_all_requests(); - - pbf_move_left_joystick(context, {1.000000, -0.000000}, 192ms, 0ms); - pbf_wait(context, 820ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 980ms, 0ms); - pbf_wait(context, 543ms); - pbf_press_dpad(context, DPAD_UP, 203ms, 0ms); - pbf_wait(context, 593ms); - pbf_press_dpad(context, DPAD_UP, 115ms, 0ms); - pbf_wait(context, 566ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1567ms, 0ms); - pbf_wait(context, 538ms); - pbf_press_dpad(context, DPAD_LEFT, 157ms, 0ms); - pbf_wait(context, 735ms); - pbf_press_dpad(context, DPAD_LEFT, 164ms, 0ms); - pbf_wait(context, 564ms); - pbf_press_dpad(context, DPAD_LEFT, 250ms, 0ms); - pbf_wait(context, 513ms); - pbf_press_dpad(context, DPAD_LEFT, 250ms, 0ms); - pbf_wait(context, 513ms); - pbf_press_dpad(context, DPAD_DOWN, 163ms, 0ms); - pbf_wait(context, 609ms); - pbf_press_dpad(context, DPAD_DOWN, 193ms, 0ms); - pbf_wait(context, 584ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 151ms, 0ms); - context.wait_for_all_requests(); - - //phase 24 Clown 1 dialog - clown_dialog_helper(stream, context, DPAD_RIGHT); - context.wait_for_all_requests(); - - pbf_press_dpad(context, DPAD_UP, 187ms, 0ms); - pbf_wait(context, 106ms); - pbf_press_dpad(context, DPAD_UP, 32ms, 0ms); - pbf_wait(context, 671ms); - pbf_press_dpad(context, DPAD_UP, 262ms, 0ms); - pbf_wait(context, 684ms); - pbf_press_dpad(context, DPAD_UP, 372ms, 0ms); - pbf_wait(context, 392ms); - pbf_press_dpad(context, DPAD_UP, 419ms, 0ms); - pbf_wait(context, 658ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 2013ms, 0ms); - pbf_wait(context, 369ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1448ms, 0ms); - pbf_wait(context, 2435ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1077ms, 0ms); - pbf_wait(context, 1084ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 1280ms, 0ms); - pbf_wait(context, 1895ms); - pbf_press_dpad(context, DPAD_LEFT, 88ms, 0ms); - - context.wait_for_all_requests(); - - //phase 25 Clown 2 dialog - - clown_dialog_helper(stream, context, DPAD_UP); - - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 511ms, 0ms); - pbf_wait(context, 1318ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 140ms, 0ms); - pbf_wait(context, 1502ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2093ms, 0ms); - pbf_wait(context, 2327ms); - pbf_press_dpad(context, DPAD_LEFT, 151ms, 0ms); - pbf_wait(context, 549ms); - pbf_press_dpad(context, DPAD_LEFT, 118ms, 0ms); - pbf_wait(context, 999ms); - pbf_press_dpad(context, DPAD_UP, 206ms, 0ms); - pbf_wait(context, 474ms); - pbf_press_dpad(context, DPAD_UP, 249ms, 0ms); - pbf_wait(context, 333ms); - pbf_press_dpad(context, DPAD_UP, 212ms, 0ms); - pbf_wait(context, 427ms); - pbf_press_dpad(context, DPAD_UP, 210ms, 0ms); - context.wait_for_all_requests(); - //phase 26 Clown 3 dialog - - clown_dialog_helper(stream, context, DPAD_LEFT); - - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1098ms, 0ms); - pbf_wait(context, 2779ms); - pbf_press_dpad(context, DPAD_LEFT, 177ms, 0ms); - pbf_wait(context, 488ms); - pbf_press_dpad(context, DPAD_LEFT, 200ms, 0ms); - pbf_wait(context, 649ms); - pbf_press_dpad(context, DPAD_LEFT, 160ms, 0ms); - pbf_wait(context, 4285ms); - pbf_press_dpad(context, DPAD_DOWN, 307ms, 0ms); - pbf_wait(context, 639ms); - pbf_press_dpad(context, DPAD_DOWN, 463ms, 0ms); - pbf_wait(context, 2521ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 3600ms, 0ms); - pbf_wait(context, 610ms); - approach_poketch_landmarks(stream, context); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 192ms, 0ms); - pbf_wait(context, 3238ms); - align_and_talk_to_poketch_man(stream, context); - - pbf_mash_button(context, BUTTON_B, 15000ms); - context.wait_for_all_requests(); - - // poketech acquired - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1194ms, 0ms); - pbf_wait(context, 1984ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 832ms, 0ms); - pbf_wait(context, 1522ms); - pbf_press_dpad(context, DPAD_RIGHT, 144ms, 0ms); - pbf_wait(context, 1767ms); - align_and_enter_jubilife_center(stream, context); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1099ms, 0ms); - pbf_wait(context, 1222ms); - pbf_press_dpad(context, DPAD_RIGHT, 228ms, 0ms); - pbf_wait(context, 625ms); - pbf_press_dpad(context, DPAD_RIGHT, 238ms, 0ms); - pbf_wait(context, 537ms); - pbf_press_dpad(context, DPAD_RIGHT, 210ms, 0ms); - pbf_wait(context, 566ms); - pbf_press_dpad(context, DPAD_RIGHT, 179ms, 0ms); - pbf_wait(context, 692ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 642ms, 0ms); -} -// --------------------------------------------------------------------------- -// Segment 00-05 ends -// Standing in front of the Jubilife City Pokemon Center PC. -// Player will need to deposit pokemon in the pc and transfer to Pokemon Home -// and level to 100 before starting the next segment. -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Segment / Checkpoint classes -// --------------------------------------------------------------------------- - -std::string AutoStory_Segment_00::name() const { - return "00: Intro to Poketech"; -} -std::string AutoStory_Segment_00::start_text() const { - return "Start: Game launched, Standing in the player's room after tv stops playing"; -} -std::string AutoStory_Segment_00::end_text() const { - return "End: Starter Pokemon chosen, received, and Pokedex acquired, standing in front of the PC."; -} - -void AutoStory_Segment_00::run_segment(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - stats.m_segment++; - env.update_stats(); - context.wait_for_all_requests(); - env.console.log("Start Segment " + name(), COLOR_ORANGE); - - AutoStory_Checkpoint_0000().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0001().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0002().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0003().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0004().run_checkpoint(env, context, options, stats); - AutoStory_Checkpoint_0005().run_checkpoint(env, context, options, stats); - - context.wait_for_all_requests(); - env.console.log("End Segment " + name(), COLOR_GREEN); -} - -std::string AutoStory_Checkpoint_0000::name() const { - return "00.00 - Verify Settings"; -} -std::string AutoStory_Checkpoint_0000::start_text() const { - return "Standing in the initial bedroom before beginning the tutorial."; -} -std::string AutoStory_Checkpoint_0000::end_text() const { - return "Required game settings verified and saved; ready for 00.01."; -} -void AutoStory_Checkpoint_0000::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - // Before any route movement or checkpoint save: fail visibly if - // options cannot be read, rather than navigate with unknown settings. - verify_tutorial_settings(env.console, context); - context.wait_for_all_requests(); -} -static void checkpoint_0001(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-00: Intro and starter selection - tutorial_part_1_navigation(env, env.console, context); - }, - true); -} -static void checkpoint_0002(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-01: Starter acquisition - starter_nav(env.console, context); - // Segment 00-00 starter selection - switch (options.starter_choice){ - case StarterChoice::TURTWIG: - select_turtwig(env.console, context); - break; - case StarterChoice::CHIMCHAR: - select_chimchar(env.console, context); - break; - case StarterChoice::PIPLUP: - select_piplup(env.console, context); - break; + void align_and_enter_jubilife_center(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + size_t confirmed = 0; + for (size_t attempt = 0; attempt < 60; attempt++) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Jubilife Center alignment: No video frame.", stream); + const auto result = match_jubilife_center_entrance(*frame.frame); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[Jubilife Center alignment] score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin) + " screen-relative offset=(" + + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); + } + if (!result.located) { + confirmed = 0; + pbf_wait(context, 250ms); + } else if (result.at_target) { + if (++confirmed >= 3) { + stream.log("[Jubilife Center alignment] Door position confirmed; entering."); + recorded_move_until_transition(stream, context, 0, 1, 2000ms, "Jubilife Pokemon Center entry"); + pbf_wait(context, 700ms); + context.wait_for_all_requests(); + return; + } + pbf_wait(context, 300ms); + } else { + confirmed = 0; + bool horizontal = std::abs(result.dx) >= std::abs(result.dy)*9.0/16; + double error = horizontal ? result.dx : result.dy*9.0/16; + const auto duration = std::abs(error) > 1.0/24 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms; + pbf_move_left_joystick(context, + {horizontal ? (result.dx > 0 ? 1.0 : -1.0) : 0.0, + horizontal ? 0.0 : (result.dy < 0 ? 1.0 : -1.0)}, duration, 0ms); + pbf_wait(context, 700ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Jubilife Center alignment: Door reference not confirmed; entry not attempted.", stream); } - fight_starly(env.console, context); - post_starly_clenaup(env.console, context); - }, - true); -} -static void checkpoint_0003(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-002: Pokedex acquisition - tutorial_part_2_navigation(env, env.console, context); - }, - true); -} -static void checkpoint_0004(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-003: Poketech acquisition - tutorial_part_3_navigation(env, env.console, context); - }, - true); - - // Save and advance progress at the catch boundary. Retries of this - // section must start from the caught-Pokemon save, not the old route. - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - tutorial_part_3_after_catch_navigation(env, env.console, context); - }, - true); -} -// DIAGNOSTIC ONLY: Remove before pushing to production. -void diagnostic_checkpoint_0004_after_catch(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - tutorial_part_3_after_catch_navigation(env, env.console, context); - }, - true); -} - -static void checkpoint_0005(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - // Segment 00-004: Poketech acquisition - tutorial_part_4_navigation(env, env.console, context); - }, - true); -} - -// ======================== -// Checkpoint 0001 Wrapper -// ======================== -std::string AutoStory_Checkpoint_0001::name() const { - return "00.01 - After TV"; -} -std::string AutoStory_Checkpoint_0001::start_text() const { - return "Game launched, Standing in the player's room after tv stops playing"; -} -std::string AutoStory_Checkpoint_0001::end_text() const { - return "Standing outside Lake Verity before picking starter."; -} - -void AutoStory_Checkpoint_0001::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0001(env, context, options, stats); -} - -// ======================== -// Checkpoint 0002 Wrapper -// ======================== -std::string AutoStory_Checkpoint_0002::name() const { - return "00.02 - Outside Verity before starter"; -} -std::string AutoStory_Checkpoint_0002::start_text() const { - return AutoStory_Checkpoint_0001().end_text(); -} -std::string AutoStory_Checkpoint_0002::end_text() const { - return "Standing outside Lake Verity after picking starter."; -} + static void tutorial_part_4_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { + // What 00.05 does, stepped through on the console before writing it. + // Every walk is planned on Jubilife's tile grid; every talk stands + // beside the person, faces them and presses A once. + static const char* const STEPS[] = { + "Trainers' School: talk to Barry, who gives the Town Map", + "Talk to the Poketch Company president on the main road", + "Get the three coupons from the clowns by the Poke Mart, the TV station and the Poketch Company", + "Show the coupons to the president for the Poketch", + "Walk into the Pokemon Center and stand at the PC", + }; + constexpr size_t STEP_COUNT = sizeof(STEPS) / sizeof(STEPS[0]); + { + std::string text = "[00.05] In Jubilife, the steps are:"; + for (size_t i = 0; i < STEP_COUNT; i++) { + text += "\n " + std::to_string(i + 1) + ". " + STEPS[i]; + } + stream.log(text, COLOR_BLUE); + } + auto step = [&](size_t number) { + std::string text = "[00.05 step " + std::to_string(number) + "/" + std::to_string(STEP_COUNT) + "] " + STEPS[number - 1]; + text += number < STEP_COUNT ? std::string(" -- next: ") + STEPS[number] : std::string(" -- last step"); + stream.log(text, COLOR_BLUE); + add_wrapped_log(stream.overlay(), "00.05 step " + std::to_string(number) + "/" + std::to_string(STEP_COUNT) + ": " + STEPS[number - 1], COLOR_WHITE); + if (number < STEP_COUNT) { + add_wrapped_log(stream.overlay(), "Next: " + std::string(STEPS[number]), COLOR_GRAY); + } + }; + + const MapSet& maps = jubilife_maps(); + auto world = [&](const TilePos& pos) { return maps.to_world(MAP_JUBILIFE_CITY, pos); }; + // Jubilife has no trainers; someone in the way only talks. + std::vector trainers; + auto in_the_way = [&](const std::string&) { finish_dialogue(stream, context, 0, "Someone in the way"); }; + std::vector at; + for (const TilePos& pos : JubilifeCity::after_dawn()) { + at.push_back(world(pos)); + } -void AutoStory_Checkpoint_0002::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0002(env, context, options, stats); -} + step(1); + const TilePos school = world(JubilifeCity::TRAINERS_SCHOOL_DOOR.next(Pokemon::Direction::SOUTH)); + // Doors sit in open plaza, which no wall pins down: end anywhere + // within a tile of the door's column, and let the door search + // step sideways. + auto by_door = [](const TilePos& front) { + return [front](const TilePos& pos) { return pos.y == front.y && pos.x >= front.x - 1 && pos.x <= front.x + 1; }; + }; + at = walk_route(stream, context, maps, at, by_door(school), + trainers, in_the_way, "To the Trainers' School"); + enter_door_north(stream, context, "Trainers' School door"); + // Barry stands at the blackboard, a tile west of the door's column: + // step west, walk north until he stops us, talk. + route_turn(stream, context, Pokemon::Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, route_open_hold(Pokemon::Direction::WEST, 1), 200ms); + walk_until(stream, context, 0, 1, 3000ms, "to Barry"); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + context.wait_for_all_requests(); + { + ShortDialogWatcher dialog(COLOR_CYAN); + if (wait_until(stream, context, 2000ms, {{dialog}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Trainers' School: Barry did not answer.", stream); + } + } + // His scene ends with "You obtained a Town Map!", which A closes. + finish_dialogue(stream, context, 16, "Barry"); + // Out: south to the front wall, a tile east (the door's column), out. + walk_until(stream, context, 0, -1, 4000ms, "to the school's front wall"); + route_turn(stream, context, Pokemon::Direction::EAST); + pbf_move_left_joystick(context, {1, 0}, route_open_hold(Pokemon::Direction::EAST, 1), 200ms); + if (walk_until(stream, context, 0, -1, 2500ms, "out of the Trainers' School").stop != WalkStop::TRANSITION) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Trainers' School: did not leave by the door.", stream); + } + context.wait_for(1500ms); + at = {school.next(Pokemon::Direction::WEST), school, school.next(Pokemon::Direction::EAST)}; + + step(2); + // The president talks on with A after each last line; B moves his + // lines on and never starts a new talk. + const TilePos president = world(JubilifeCity::POKETCH_PRESIDENT); + talk_to_npc(stream, context, maps, at, president, + {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH}, trainers, in_the_way, "Poketch president"); + finish_dialogue(stream, context, 0, "Poketch president"); + + step(3); + // Each clown asks a question whose default answer, Yes, is right, + // and hands over a coupon with an "obtained" message A closes. + talk_to_npc(stream, context, maps, at, world(JubilifeCity::CLOWN_MART), + {Pokemon::Direction::SOUTH, Pokemon::Direction::WEST, Pokemon::Direction::EAST}, + trainers, in_the_way, "Clown by the Poke Mart"); + finish_dialogue(stream, context, 8, "Clown by the Poke Mart"); + talk_to_npc(stream, context, maps, at, world(JubilifeCity::CLOWN_TV_STATION), + {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH, Pokemon::Direction::WEST}, + trainers, in_the_way, "Clown by the TV station"); + finish_dialogue(stream, context, 8, "Clown by the TV station"); + talk_to_npc(stream, context, maps, at, world(JubilifeCity::CLOWN_POKETCH_COMPANY), + {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH, Pokemon::Direction::WEST}, + trainers, in_the_way, "Clown by the Poketch Company"); + finish_dialogue(stream, context, 8, "Clown by the Poketch Company"); + + step(4); + talk_to_npc(stream, context, maps, at, president, + {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH}, trainers, in_the_way, "Poketch president"); + finish_dialogue(stream, context, 0, "Poketch president"); + + step(5); + const TilePos center = world(JubilifeCity::POKEMON_CENTER_DOOR.next(Pokemon::Direction::SOUTH)); + at = walk_route(stream, context, maps, at, by_door(center), + trainers, in_the_way, "To the Pokemon Center"); + enter_door_north(stream, context, "Pokemon Center door"); + // Inside: north to the counter, three tiles east, north to the PC + // at the back wall right of the counter. + walk_until(stream, context, 0, 1, 2500ms, "to the counter"); + route_turn(stream, context, Pokemon::Direction::EAST); + pbf_move_left_joystick(context, {1, 0}, route_open_hold(Pokemon::Direction::EAST, 3), 200ms); + walk_until(stream, context, 0, 1, 1500ms, "to the PC"); + context.wait_for_all_requests(); + } + // --------------------------------------------------------------------------- + // Segment 00-05 ends + // Standing in front of the Jubilife City Pokemon Center PC. + // Player will need to deposit pokemon in the pc and transfer to Pokemon Home + // and level to 100 before starting the next segment. + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // Segment / Checkpoint classes + // --------------------------------------------------------------------------- + + std::string AutoStory_Segment_00::name() const { return "00: Intro to Poketech"; } + std::string AutoStory_Segment_00::start_text() const { return "Start: Game launched, Standing in the player's room after tv stops playing"; } + std::string AutoStory_Segment_00::end_text() const { return "End: Starter Pokemon chosen, received, and Pokedex acquired, standing in front of the PC."; } + + void AutoStory_Segment_00::run_segment( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + stats.m_segment++; + env.update_stats(); + context.wait_for_all_requests(); + env.console.log("Start Segment " + name(), COLOR_ORANGE); + + AutoStory_Checkpoint_0000().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0001().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0002().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0003().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0004().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0005().run_checkpoint(env, context, options, stats); + + context.wait_for_all_requests(); + env.console.log("End Segment " + name(), COLOR_GREEN); + } + std::string AutoStory_Checkpoint_0000::name() const { return "00.00 - Verify Settings"; } + std::string AutoStory_Checkpoint_0000::start_text() const { return "Standing in the initial bedroom before beginning the tutorial."; } + std::string AutoStory_Checkpoint_0000::end_text() const { return "Required game settings verified and saved; ready for 00.01."; } + void AutoStory_Checkpoint_0000::run_checkpoint( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + AutoStoryOptions options, AutoStoryStats& stats + ) const { + // Before any route movement or checkpoint save: fail visibly if + // options cannot be read, rather than navigate with unknown settings. + verify_tutorial_settings(env.console, context); + context.wait_for_all_requests(); + } -// ======================== -// Checkpoint 0003 Wrapper -// ======================== + static void checkpoint_0001( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-00: Intro and starter selection + tutorial_part_1_navigation(env, env.console, context); + }, + true + ); + } + static void checkpoint_0002( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-01: Starter acquisition + starter_nav(env.console, context); + // Segment 00-00 starter selection + switch (options.starter_choice) { + case StarterChoice::TURTWIG: select_turtwig(env.console, context); break; + case StarterChoice::CHIMCHAR: select_chimchar(env.console, context); break; + case StarterChoice::PIPLUP: select_piplup(env.console, context); break; + } + fight_starly(env.console, context); + post_starly_clenaup(env.console, context); + }, + true + ); + } + static void checkpoint_0003( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-002: Pokedex acquisition + tutorial_part_2_navigation(env, env.console, context); + }, + true + ); + } + static void checkpoint_0004( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-003: Poketech acquisition + tutorial_part_3_navigation(env, env.console, context); + }, + true + ); + + // Save and advance progress at the catch boundary. Retries of this + // section must start from the caught-Pokemon save, not the old route. + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + tutorial_part_3_after_catch_navigation(env, env.console, context); + }, + true + ); + } + // DIAGNOSTIC ONLY: Remove before pushing to production. + void diagnostic_checkpoint_0004_after_catch( + SingleSwitchProgramEnvironment& env, ProControllerContext& context, + AutoStoryOptions options, AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + tutorial_part_3_after_catch_navigation(env, env.console, context); + }, + true + ); + } -std::string AutoStory_Checkpoint_0003::name() const { - return "00.03 - Outside Verity after starter"; -} -std::string AutoStory_Checkpoint_0003::start_text() const { - return AutoStory_Checkpoint_0002().end_text(); -} -std::string AutoStory_Checkpoint_0003::end_text() const { - return "Standing in front of The professor after recieving the Pokedex"; -} + static void checkpoint_0005( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) { + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/) { + // Segment 00-004: Poketech acquisition + tutorial_part_4_navigation(env, env.console, context); + }, + true + ); + } -void AutoStory_Checkpoint_0003::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0003(env, context, options, stats); -} -// ======================== -// Checkpoint 0004 Wrapper -// ======================== + // ======================== + // Checkpoint 0001 Wrapper + // ======================== + std::string AutoStory_Checkpoint_0001::name() const { return "00.01 - After TV"; } + std::string AutoStory_Checkpoint_0001::start_text() const { return "Game launched, Standing in the player's room after tv stops playing"; } + std::string AutoStory_Checkpoint_0001::end_text() const { return "Standing outside Lake Verity before picking starter."; } + + void AutoStory_Checkpoint_0001::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0001(env, context, options, stats); + } -std::string AutoStory_Checkpoint_0004::name() const { - return "00.04 - After Pokedex"; -} -std::string AutoStory_Checkpoint_0004::start_text() const { - return AutoStory_Checkpoint_0003().end_text(); -} -std::string AutoStory_Checkpoint_0004::end_text() const { - return "Standing in in jubilife after speaking to dawn. Next: give town map to barry"; -} + // ======================== + // Checkpoint 0002 Wrapper + // ======================== + + std::string AutoStory_Checkpoint_0002::name() const { return "00.02 - Outside Verity before starter"; } + std::string AutoStory_Checkpoint_0002::start_text() const { return AutoStory_Checkpoint_0001().end_text(); } + std::string AutoStory_Checkpoint_0002::end_text() const { return "Standing outside Lake Verity after picking starter."; } + + void AutoStory_Checkpoint_0002::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0002(env, context, options, stats); + } -void AutoStory_Checkpoint_0004::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0004(env, context, options, stats); -} -// ======================== -// Checkpoint 0005 Wrapper -// ======================== -std::string AutoStory_Checkpoint_0005::name() const { - return "00.05 - Jubilife After Dawn"; -} -std::string AutoStory_Checkpoint_0005::start_text() const { - return AutoStory_Checkpoint_0004().end_text(); -} -std::string AutoStory_Checkpoint_0005::end_text() const { - return "Standing in front of PC. Recieved the Poketech. Ready to deposit starter"; -} + // ======================== + // Checkpoint 0003 Wrapper + // ======================== -void AutoStory_Checkpoint_0005::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0005(env, context, options, stats); -} + std::string AutoStory_Checkpoint_0003::name() const { return "00.03 - Outside Verity after starter"; } + std::string AutoStory_Checkpoint_0003::start_text() const { return AutoStory_Checkpoint_0002().end_text(); } + std::string AutoStory_Checkpoint_0003::end_text() const { return "Standing in front of The professor after recieving the Pokedex"; } -} -} + void AutoStory_Checkpoint_0003::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0003(env, context, options, stats); + } + // ======================== + // Checkpoint 0004 Wrapper + // ======================== + + std::string AutoStory_Checkpoint_0004::name() const { return "00.04 - After Pokedex"; } + std::string AutoStory_Checkpoint_0004::start_text() const { return AutoStory_Checkpoint_0003().end_text(); } + std::string AutoStory_Checkpoint_0004::end_text() const { return "Standing in in jubilife after speaking to dawn. Next: give town map to barry"; } + + void AutoStory_Checkpoint_0004::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0004(env, context, options, stats); + } + // ======================== + // Checkpoint 0005 Wrapper + // ======================== + + std::string AutoStory_Checkpoint_0005::name() const { return "00.05 - Jubilife After Dawn"; } + std::string AutoStory_Checkpoint_0005::start_text() const { return AutoStory_Checkpoint_0004().end_text(); } + std::string AutoStory_Checkpoint_0005::end_text() const { return "Standing in front of PC. Recieved the Poketech. Ready to deposit starter"; } + + void AutoStory_Checkpoint_0005::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const { + checkpoint_0005(env, context, options, stats); + } + + } + } } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp index 03d1aa16e2..a4db9550d9 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp @@ -1,1829 +1,1100 @@ -/* BDSP AutoStory - Segment 01 - * From after choosing the starter Pokemon until after acquiring the first badge. - * From: https://github.com/PokemonAutomation/ - */ - -#include -#include -#include - -#include "CommonFramework/Exceptions/OperationFailedException.h" -#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" -#include "CommonFramework/GlobalAutoPaths.h" -#include "CommonFramework/ImageTools/ImageDiff.h" -#include "CommonFramework/VideoPipeline/VideoFeed.h" - -#include "CommonTools/Async/InferenceRoutines.h" -#include "CommonTools/VisualDetectors/BlackScreenDetector.h" -#include "CommonTools/VisualDetectors/ImageMatchDetector.h" - -#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" - -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_EndBattleDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" -#include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" -#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" -#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" -#include "PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_Autostory_Segment_01_tools.h" - -#include "PokemonBDSP_AutoStory_Segment_01.h" - -using namespace std::chrono_literals; - -namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - -static const std::string& mine_navigation_resource_path(){ - static const std::string path = RESOURCE_PATH() + "PokemonBDSP/Autostory/Oreburg_mine_nav/"; - return path; -} - -enum class MoveDirection { - UP, - DOWN, - LEFT, - RIGHT -}; - -// Left joystick vector for a direction (matches the game's joystick convention used in this file). -static std::pair joystick_for(MoveDirection direction){ - switch (direction){ - case MoveDirection::UP: - return {0.0, 1.0}; - case MoveDirection::DOWN: - return {0.0, -1.0}; - case MoveDirection::LEFT: - return {-1.0, 0.0}; - case MoveDirection::RIGHT: - return {1.0, 0.0}; - } - return {0.0, 0.0}; -} - -// Runs from a wild battle that is already in progress (menu visible or about to be). -// Throws OperationFailedException if it cannot escape within `timeout`. - -// The main game menu is independent evidence that battle has ended. - - -// Watches for a short dialogue box over several attempts. Returns true as soon as one is seen. -// Returns false (without throwing) only if every attempt times out, so callers can skip -// an optional interaction. Multiple attempts guard against a single missed detection. -static bool try_detect_dialogue(VideoStream& stream, - ProControllerContext& context, - const std::string& label, - size_t attempts = 3, - std::chrono::milliseconds window = 5000ms){ - for (size_t attempt = 0; attempt < attempts; attempt++){ - ShortDialogWatcher watcher_cyan(COLOR_CYAN); - ShortDialogWatcher watcher_red(COLOR_RED); - if (wait_until(stream, context, window, {{watcher_cyan}, {watcher_red}}) >= 0){ - stream.log(label + " dialogue detected."); - return true; - } - stream.log(label + " dialogue not detected (attempt " + std::to_string(attempt + 1) + "/" + - std::to_string(attempts) + ").", - COLOR_ORANGE); - } - return false; -} - -struct DoorwayWalkOptions { - BattleType battle_type = BattleType::STANDARD; - // Active movement per step. Time spent in battles or waiting is never counted. - std::chrono::milliseconds step = 200ms; - // Extra walking allowed beyond `travel_time` before giving up. - std::chrono::milliseconds grace = 1600ms; - // How long to watch for a black screen after each step. - std::chrono::milliseconds black_screen_window = 1500ms; - // After a black screen, how long to wait for a battle menu before deciding it was a doorway. - std::chrono::milliseconds battle_menu_window = 8000ms; - size_t max_battles = 10; - // Also treat a white screen as the doorway transition (e.g. leaving the Oreburgh Gate). - bool detect_white_screen = false; -}; - -// Reusable: walk in `direction` for a cumulative `travel_time` of active movement until -// a black screen NOT followed by a battle menu (a doorway/area transition) is seen. -// Wild battles (black screen followed by the battle menu, e.g. in caves or grass) are -// escaped and do not consume the time budget. -// Throws OperationFailedException if the doorway is not found within -// travel_time + options.grace, if more than options.max_battles occur, or if a battle -// cannot be escaped. -// Returns the total active walking time used. -static std::chrono::milliseconds walk_until_doorway(VideoStream& stream, - ProControllerContext& context, - MoveDirection direction, - std::chrono::milliseconds travel_time, - const DoorwayWalkOptions& options = DoorwayWalkOptions()){ - enum class NavState { - WALKING, - BLACK_SCREEN, - IN_BATTLE, - DONE - }; - - const auto [x, y] = joystick_for(direction); - const std::chrono::milliseconds max_travel = travel_time + options.grace; - - std::chrono::milliseconds walked = 0ms; - size_t battles = 0; - NavState state = NavState::WALKING; - - while (state != NavState::DONE){ - switch (state){ - case NavState::WALKING: { - if (walked >= max_travel){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "walk_until_doorway: Doorway not found within time budget.", - stream); - } - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {x, y}, options.step, 0ms); - context.wait_for_all_requests(); - walked += options.step; - - BlackScreenWatcher black_screen; - WhiteScreenWatcher white_screen; - if (options.detect_white_screen){ - int ret = wait_until(stream, context, options.black_screen_window, {{black_screen}, {white_screen}}); - if (ret == 0){ - state = NavState::BLACK_SCREEN; - }else if (ret == 1){ - stream.log("[AutoStory] White screen detected.", COLOR_ORANGE); - state = NavState::DONE; - } - }else if (wait_until(stream, context, options.black_screen_window, {{black_screen}}) == 0){ - state = NavState::BLACK_SCREEN; - } - break; - } - case NavState::BLACK_SCREEN: { - stream.log("[AutoStory] Black screen detected. Checking for battle menu.", COLOR_ORANGE); - BattleMenuWatcher battle_menu(options.battle_type, COLOR_YELLOW); - if (wait_until(stream, context, options.battle_menu_window, {{battle_menu}}) == 0){ - state = NavState::IN_BATTLE; - }else{ - state = NavState::DONE; - } - break; - } - case NavState::IN_BATTLE: { - if (++battles > options.max_battles){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "walk_until_doorway: Too many battles while walking.", - stream); - } - stream.log("[AutoStory] Wild battle. Escaping. Walked " + std::to_string(walked.count()) + "ms of " + - std::to_string(travel_time.count()) + "ms.", - COLOR_ORANGE); - escape_wild_battle(stream, context, options.battle_type); - state = NavState::WALKING; - break; - } - case NavState::DONE: - break; - } - } - stream.log("[AutoStory] Doorway transition detected after " + std::to_string(walked.count()) + "ms of walking.", - COLOR_GREEN); - return walked; -} - - -// ---------------------------------------------------------------- -// Segment 1 Part 1: Jubilife City to Oreburgh Gate -// Navigates the player from Jubilife City to the Oreburgh Gate, including the -// first Barry fight. -// --------------------------------------------------------------------------- -void jubilife_to_oreburgh_gate(SingleSwitchProgramEnvironment& env, - VideoStream& stream, - ProControllerContext& context){ - // Wait for game to fully accept input after save/overworld return - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); - - pbf_move_left_joystick(context, {0.000000, -1.000000}, 1465ms, 0ms); - pbf_wait(context, 127ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 644ms, 0ms); - pbf_wait(context, 698ms); - leave_pokemon_center_before_barry_1(stream, context); - pbf_wait(context, 2340ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 742ms, 0ms); - pbf_wait(context, 348ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 2694ms, 0ms); - pbf_wait(context, 482ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 3810ms, 0ms); - - wait_for_dialogue(stream, context, "Phase 1 (Barry 1 fight dialog)"); - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); - pbf_mash_button(context, BUTTON_B, 5000ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 2761ms, 0ms); - pbf_wait(context, 1647ms); - walk_through_grass(stream, context, 1, 0, 5, "right through grass"); - wait_for_dialogue(stream, context, "Dallas fight dialog)"); - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); //fighting Dallas - context.wait_for_all_requests(); - walk_through_grass(stream, context, 1, 0, 5, "right through grass"); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 600ms, 0ms); - walk_through_grass(stream, context, 1, 0, 10, "right through grass"); - //there is a small chance that the player dodges sSebastian, so this - //function addresses that edge case - if (try_detect_dialogue(stream, context, "Sebastian fight")){ - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); //fighting Sebastian - context.wait_for_all_requests(); - }else{ - stream.log("Sebastian dialogue never appeared. Skipping fight.", COLOR_ORANGE); - } - pbf_move_left_joystick(context, {1.000000, -0.000000}, 2000ms, 0ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 8000ms, 0ms); - pbf_wait(context, 200ms); - context.wait_for_all_requests(); - enter_cave_with_down_right_search(stream, context, "jubilife_to_oreburgh_gate", 10000ms); -} -void oreburgh_gate_nav(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - pbf_wait(context, 2000ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 879ms, 0ms); - wait_for_dialogue(stream, context, "HM Rock Smash Dialog"); - pbf_mash_button(context, BUTTON_B, 15000ms); - walk_through_grass(stream, context, 1, 0, 10, "right through gate"); - //Similar to sebastian, an edge case exists where we run into too many - //wild battles and don't reach curtis or diana. This detect dialog function solves that. - while (!try_detect_dialogue(stream, context, "Curtis fight", 1, 3000ms)){ - walk_through_grass(stream, context, 1, 0, 1, "right through gate"); - } - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); //fighting Curtis - walk_through_grass(stream, context, 1, 0, 10, "right through gate"); - while (!try_detect_dialogue(stream, context, "Diana fight", 1, 3000ms)){ - walk_through_grass(stream, context, 1, 0, 1, "right through gate"); - } - pbf_mash_button(context, BUTTON_B, 5000ms); - Barry_1_fight(env, stream, context); //fighting Diana - // Walk right until the gate exit transition (white screen, or black screen without a battle menu). - // Wild battles are escaped without using up the budget. Throws on failure so the - // checkpoint is retried. - DoorwayWalkOptions gate_exit_options; - gate_exit_options.detect_white_screen = true; - walk_until_doorway(stream, context, MoveDirection::RIGHT, 4000ms, gate_exit_options); - context.wait_for_all_requests(); -} -// --------------------------------------------------------------------------- -// Segment 1 Part 2: Oreburgh Gate to Oreburgh Mine -// Assumes that the player is standing outside the Oreburgh Gate on the Jubilife City side. -// Navigates to the mine. -// ---------------------------------------------------------------------------- -void oreburgh_gate_to_mine(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1056ms, 0ms); - while (!try_detect_dialogue(stream, context, "Phase 2 (oreburgh tour)", 1, 3000ms)){ - pbf_move_left_joystick(context, {1.000000, -0.000000}, 1056ms, 0ms); - } - pbf_mash_button(context, BUTTON_B, 12000ms); - pbf_move_left_joystick(context, {0.000000, -1.000000}, 319ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -1.000000}, 332ms, 0ms); - pbf_move_left_joystick(context, {1.000000, -0.000000}, 3124ms, 0ms); - pbf_wait(context, 23ms); - bool black_screen_found = false; - for (size_t attempt = 0; attempt < 3 && !black_screen_found; attempt++){ - BlackScreenWatcher black_screen; - int ret = run_until(stream, - context, - [](ProControllerContext& context){ - pbf_move_left_joystick(context, {0.000000, -1.000000}, 4740ms, 0ms); - }, - {black_screen}); - if (ret == 0){ - stream.log("Black screen detected."); - black_screen_found = true; - } - } - context.wait_for_all_requests(); - - if (!black_screen_found){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "oreburgh_gate_to_mine: No black screen after 3 attempts.", - stream); - } - - // Let the mine entrance transition fully finish so the next step's black screen - // watcher can't pick up the tail of this one. - BlackScreenOverWatcher black_over; - wait_until(stream, context, 6000ms, {{black_over}}); - context.wait_for_all_requests(); - pbf_wait(context, 2000ms); - context.wait_for_all_requests(); -} - -// Diagnostic only: identifies the entrance start without moving the player. - - -struct MineCenterOffset { - bool valid; - double offset; - double score; -}; - -// Finds the sideways shift of stationary scenery relative to the center picture. -// Positive offset: scenery shifted right, so the player is left of center. -static MineCenterOffset measure_mine_center_offset(const ImageViewRGB32& reference, const ImageViewRGB32& live){ - if (!reference || !live){ - return {false, 0.0, 1000.0}; - } - - // Use a fixed working size so the search works at different capture resolutions. - const ImageRGB32 reference_scaled = reference.scale_to(960, 540); - const ImageRGB32 live_scaled = live.scale_to(960, 540); - - // Entrance beam and both posts. Avoid the player and shadow. - const std::array boxes = {ImageFloatBox(0.35, 0.285, 0.30, 0.07), - ImageFloatBox(0.355, 0.37, 0.035, 0.23), - ImageFloatBox(0.615, 0.37, 0.035, 0.23)}; - - const int search_radius = (int)std::lround(0.10 * live_scaled.width()); - std::vector scores(2 * search_radius + 1); - std::vector worst_regions(2 * search_radius + 1); - - int best_shift = 0; - double best_score = 1000.0; - - // Search up to 10% of the screen width in either direction. - for (int shift = -search_radius; shift <= search_radius; shift++){ - double total = 0.0; - double worst = 0.0; - - for (const ImageFloatBox& box : boxes){ - const ImageViewRGB32 reference_crop = extract_box_reference(reference_scaled, box); - - // Translate the same crop horizontally in the live image. - const ImageViewRGB32 live_crop = extract_box_reference(live_scaled, box, shift, 0); - - const double score = ImageMatch::pixel_RMSD(reference_crop, live_crop); - - total += score; - if (score > worst){ - worst = score; - } - } - - const double average = total / boxes.size(); - const size_t index = static_cast(shift + search_radius); - - scores[index] = average; - worst_regions[index] = worst; - - if (average < best_score){ - best_score = average; - best_shift = shift; - } - } - - // Reject another similarly good match well away from the chosen position. - double alternative_score = 1000.0; - - for (int shift = -search_radius; shift <= search_radius; shift++){ - if (std::abs(shift - best_shift) <= std::lround(0.0125 * live_scaled.width())){ - continue; - } - - const double score = scores[static_cast(shift + search_radius)]; - if (score < alternative_score){ - alternative_score = score; - } - } - - const size_t best_index = static_cast(best_shift + search_radius); - - const bool valid = best_score <= 20.0 && worst_regions[best_index] <= 30.0 && - alternative_score - best_score >= 3.0 && std::abs(best_shift) < search_radius; - - return {valid, static_cast(best_shift) / live_scaled.width(), best_score}; -} -// Test only: align with the entrance center, then stop. -static bool oreburgh_mine_alignment_test(VideoStream& stream, ProControllerContext& context){ - const ImageRGB32 reference(mine_navigation_resource_path() + "mine_start_center.png"); - - constexpr size_t MAX_MOVEMENTS = 12; - constexpr double CENTER_TOLERANCE = 0.008; - - size_t movements = 0; - size_t center_streak = 0; - - context.wait_for_all_requests(); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - - while (true){ - const VideoSnapshot frame = stream.video().snapshot(); - - const MineCenterOffset measurement = measure_mine_center_offset(reference, frame); - - stream.log("[Mine align] Offset=" + std::to_string(measurement.offset) + - ", score=" + std::to_string(measurement.score) + (measurement.valid ? ", trusted" : ", unsure")); - - if (!measurement.valid){ - stream.log("[Mine align] Entrance position unsure. Stopping.", COLOR_ORANGE); - return false; - } - - if (std::abs(measurement.offset) <= CENTER_TOLERANCE){ - center_streak++; - - if (center_streak >= 3){ - stream.log("[Mine align] Center reached. Test complete.", COLOR_GREEN); - return true; - } - - pbf_wait(context, 250ms); - context.wait_for_all_requests(); - continue; - } - - center_streak = 0; - - // Check the latest position before enforcing the movement limit. - if (movements >= MAX_MOVEMENTS){ - stream.log("[Mine align] Movement limit reached. Stopping.", COLOR_ORANGE); - return false; - } - - const double x = measurement.offset > 0.0 ? 1.0 : -1.0; - - // Smaller movements when close to the center. - const auto duration = std::abs(measurement.offset) < 0.025 ? 40ms : 80ms; - - stream.log(x > 0.0 ? "[Mine align] Moving a little right." : "[Mine align] Moving a little left."); - - BlackScreenWatcher black; - BattleMenuWatcher menu(BattleType::STANDARD, COLOR_YELLOW); - BattleDialogWatcher dialog(COLOR_YELLOW); - - const int result = run_until(stream, - context, - [x, duration](ProControllerContext& ctx){ - pbf_move_left_joystick(ctx, {x, 0.0}, duration, 0ms); - pbf_wait(ctx, 500ms); - ctx.wait_for_all_requests(); - }, - {black, menu, dialog}); - - if (result >= 0){ - stream.log("[Mine align] Battle or transition detected. " - "Stopping the alignment test.", - COLOR_ORANGE); - return false; - } - - movements++; - } -} -// Confirm a battle before using the existing escape helper. -static bool mine_escape_and_settle(VideoStream& stream, ProControllerContext& context){ - BattleMenuWatcher menu(BattleType::STANDARD, COLOR_YELLOW); - - if (wait_until(stream, context, 12s, {{menu}}) != 0){ - stream.log("[Mine encounter] Battle menu not confirmed. Stopping.", COLOR_ORANGE); - return false; - } - - stream.log("[Mine encounter] Battle confirmed. Escaping."); - escape_wild_battle(stream, context); - context.wait_for_all_requests(); - - // Check the return waits instead of ignoring their results. - BlackScreenWatcher visible(COLOR_YELLOW, - ImageFloatBox(0.1, 0.1, 0.8, 0.8), - 100, - 10, - BlackScreenWatcher::FinderType::GONE, - 500ms); - - if (wait_until(stream, context, 8s, {{visible}}) != 0){ - stream.log("[Mine encounter] Screen did not return. Stopping.", COLOR_ORANGE); - return false; - } - - - pbf_wait(context, 800ms); - context.wait_for_all_requests(); - - stream.log("[Mine encounter] Escaped. Returning to the rock-walking loop.", COLOR_GREEN); - return true; -} - -// Temporary test: wiggle along the corridor until a battle is detected. -static bool mine_force_encounter_test(VideoStream& stream, ProControllerContext& context){ - stream.log("[Mine encounter] Starting forced encounter wiggle."); - - const auto deadline = std::chrono::steady_clock::now() + 60s; - - size_t pulses = 0; - - while (std::chrono::steady_clock::now() < deadline){ - // Alternate down and up. Do not move sideways. - const double y = pulses % 2 == 0 ? -1.0 : 1.0; - - BlackScreenWatcher black; - BattleMenuWatcher menu(BattleType::STANDARD, COLOR_YELLOW); - BattleDialogWatcher dialog(COLOR_YELLOW); - - const int result = run_until(stream, - context, - [y](ProControllerContext& ctx){ - pbf_move_left_joystick(ctx, {0.0, y}, 120ms, 0ms); - pbf_wait(ctx, 400ms); - ctx.wait_for_all_requests(); - }, - {black, menu, dialog}); - - if (result >= 0){ - stream.log("[Mine encounter] Wiggle interrupted. Checking for battle."); - return mine_escape_and_settle(stream, context); - } - - pulses++; - } - - stream.log("[Mine encounter] No encounter within 60 seconds. " - "Test incomplete; stopping.", - COLOR_ORANGE); - return false; -} - -// Watch throughout an action, including its movement and picture checks. -// After an encounter, restart the action using fresh watcher state. -template -static bool mine_run_with_encounters(VideoStream& stream, ProControllerContext& context, Action action){ - for (size_t encounters = 0;;){ - BlackScreenWatcher black; - BattleMenuWatcher menu(BattleType::STANDARD, COLOR_YELLOW); - BattleDialogWatcher dialog(COLOR_YELLOW); - bool completed = false; - - const int result = run_until(stream, - context, - [&](ProControllerContext& ctx){ - completed = action(ctx); - }, - {black, menu, dialog}); - - if (result < 0){ - return completed; - } - - if (++encounters > 10){ - stream.log("[Mine encounter] Encounter limit reached.", COLOR_ORANGE); - return false; - } - - if (!mine_escape_and_settle(stream, context)){ - return false; - } - - stream.log("[Mine encounter] Escaped; resuming interrupted action."); - } -} - -static bool mine_timed_walk(VideoStream& stream, - ProControllerContext& context, - double x, - double y, - std::chrono::milliseconds duration){ - auto remaining = duration; - - return mine_run_with_encounters(stream, context, [&](ProControllerContext& ctx){ - while (remaining > 0ms){ - const auto pulse = remaining < 100ms ? remaining : 100ms; - pbf_move_left_joystick(ctx, {x, y}, pulse, 0ms); - pbf_wait(ctx, 700ms); - ctx.wait_for_all_requests(); - - // An interrupted pulse is retried after escaping. - // This is a timing budget, not exact distance tracking. - remaining -= pulse; - } - return true; - }); -} - - -struct MinePictureScore { - double average = 1000.0; - double worst = 1000.0; - double horizontal = 0; - double vertical = 0; -}; - -// Supports four, five, or six regions. Existing four-region callers keep -// their original search ranges through the default arguments. -template -static MinePictureScore mine_score_picture(const ImageRGB32& reference, - const ImageRGB32& live, - const std::array& boxes, - double horizontal_range = 1.0 / 60, - double vertical_range = 2.0 / 135){ - MinePictureScore best; - const int radius_x = (int)std::lround(horizontal_range * live.width()); - const int radius_y = (int)std::lround(vertical_range * live.height()); - for (int y = -radius_y; y <= radius_y; y++){ - for (int x = -radius_x; x <= radius_x; x++){ - double total = 0.0; - double worst = 0.0; - for (const ImageFloatBox& box : boxes){ - const ImageViewRGB32 ref = extract_box_reference(reference, box); - const ImageViewRGB32 patch = extract_box_reference(live, box, x, y); - const double score = ImageMatch::pixel_RMSD(ref, patch); - total += score; - if (score > worst){ - worst = score; - } - } - const double average = total / boxes.size(); - if (average < best.average){ - best.average = average; - best.worst = worst; - best.horizontal = (double)x / live.width(); - best.vertical = (double)y / live.height(); - } - } - } - return best; -} - - -static bool mine_check_picture(VideoStream& stream, - ProControllerContext& context, - const char* filename, - const std::array& boxes, - const std::string& label, - size_t max_attempts = 8, - double max_average = 20.0, - double max_patch = 30.0){ - const ImageRGB32 reference(mine_navigation_resource_path() + filename); - const ImageRGB32 reference_scaled = reference.scale_to(960, 540); - - const bool confirmed = mine_run_with_encounters(stream, context, [&](ProControllerContext& ctx){ - // The watcher stays active throughout settling and sampling. - // There is no separate 500ms watcher wait before each sample. - ctx.wait_for_all_requests(); - pbf_wait(ctx, 700ms); - ctx.wait_for_all_requests(); - - // Restart confirmation after a battle; do not reuse old matches. - size_t matching_frames = 0; - - for (size_t attempt = 0; attempt < max_attempts; attempt++){ - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - stream.log(label + ": no video frame.", COLOR_ORANGE); - return false; - } - - const ImageRGB32 live = frame->scale_to(960, 540); - const MinePictureScore score = mine_score_picture(reference_scaled, live, boxes); - - stream.log( - label + ": average=" + std::to_string(score.average) + ", worst patch=" + std::to_string(score.worst)); - - if (score.average <= max_average && score.worst <= max_patch){ - if (++matching_frames >= 3){ - return true; - } - }else{ - matching_frames = 0; - } - - pbf_wait(ctx, 250ms); - ctx.wait_for_all_requests(); - } - - return false; - }); - - stream.log(label + (confirmed ? ": confirmed." : ": not confirmed."), confirmed ? COLOR_GREEN : COLOR_ORANGE); - return confirmed; -} - -struct MineWalkOptions { - double x = 0.0; - double y = 0.0; - std::chrono::milliseconds pulse = 40ms; - std::chrono::milliseconds settle = 500ms; - std::chrono::milliseconds initial_settle = 0ms; - size_t max_movements = 30; - double horizontal_range = 0.0125; - double vertical_range = 1.0 / 90; - double max_average = 20.0; - double max_patch = 30.0; -}; - -template -static bool mine_walk_to_endpoints(VideoStream& stream, - ProControllerContext& context, - const std::array& files, - const std::array, Endpoints>& boxes, - const MineWalkOptions& options, - const std::string& label, - bool force_encounter = false){ - std::array, Endpoints> references; - for (size_t index = 0; index < Endpoints; index++){ - const ImageRGB32 image(mine_navigation_resource_path() + files[index]); - references[index] = std::make_unique(image.scale_to(960, 540)); - } - - size_t movements = 0; - bool forced_completed = false; - - while (true){ - bool request_forced_test = false; - const bool reached = mine_run_with_encounters(stream, context, [&](ProControllerContext& ctx){ - if (options.initial_settle > 0ms){ - pbf_wait(ctx, options.initial_settle); - ctx.wait_for_all_requests(); - } - // Reset the match history whenever an encounter interrupts us. - size_t matching_frames = 0; - int previous_endpoint = -1; - - while (true){ - ctx.wait_for_all_requests(); - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - stream.log(label + ": no video frame.", COLOR_ORANGE); - return false; - } - - const ImageRGB32 live = frame->scale_to(960, 540); - int accepted = -1; - double accepted_average = 1000.0; - - for (size_t endpoint = 0; endpoint < Endpoints; endpoint++){ - const MinePictureScore score = mine_score_picture(*references[endpoint], - live, - boxes[endpoint], - options.horizontal_range, - options.vertical_range); - stream.log(label + " Endpoint " + std::to_string(endpoint + 1) + - ": average=" + std::to_string(score.average) + ", worst patch=" + std::to_string(score.worst) + - ", screen-relative offset=(" + std::to_string(score.horizontal) + "," + - std::to_string(score.vertical) + ")"); - - if (score.average <= options.max_average && score.worst <= options.max_patch && - score.average < accepted_average){ - accepted = static_cast(endpoint); - accepted_average = score.average; - } - } - - if (accepted >= 0){ - matching_frames = accepted == previous_endpoint ? matching_frames + 1 : 1; - previous_endpoint = accepted; - if (matching_frames >= 3){ - stream.log(label + " Endpoint " + std::to_string(accepted + 1) + " confirmed.", COLOR_GREEN); - return true; - } - pbf_wait(ctx, 250ms); - ctx.wait_for_all_requests(); - continue; - } - - matching_frames = 0; - previous_endpoint = -1; - - // Exit the guard before invoking the diagnostic wiggle: - // it has its own encounter watcher and must not be nested. - if (force_encounter && !forced_completed && movements >= 4){ - request_forced_test = true; - return false; - } - - if (movements >= options.max_movements){ - stream.log(label + ": movement limit reached.", COLOR_ORANGE); - return false; - } - - // Count issued commands, including interrupted commands. - movements++; - pbf_move_left_joystick(ctx, {options.x, options.y}, options.pulse, 0ms); - pbf_wait(ctx, options.settle); - ctx.wait_for_all_requests(); - } - }); - - if (reached){ - return true; - } - if (!request_forced_test){ - return false; - } - if (!mine_force_encounter_test(stream, context)){ - return false; - } - forced_completed = true; - stream.log(label + ": forced encounter completed; resuming."); - } -} - -static bool -oreburgh_mine_walk_to_first_rock(VideoStream& stream, ProControllerContext& context, bool force_encounter = false){ - const std::array files = {"mine_path_01.png"}; - const std::array, 1> boxes = {{{{ImageFloatBox(0.482, 0.585, 0.055, 0.055), - ImageFloatBox(0.335, 0.670, 0.050, 0.055), - ImageFloatBox(0.412, 0.670, 0.050, 0.055), - ImageFloatBox(0.570, 0.752, 0.045, 0.055), - ImageFloatBox(0.645, 0.752, 0.045, 0.055), - ImageFloatBox(0.570, 0.290, 0.080, 0.120)}}}}; - - MineWalkOptions options; - options.x = 0.0; - options.y = -1.0; - options.pulse = 80ms; - options.max_movements = 40; - options.horizontal_range = 0.025; - return mine_walk_to_endpoints(stream, context, files, boxes, options, "[Mine path 01]", force_encounter); -} - -static bool oreburgh_mine_move_right(VideoStream& stream, ProControllerContext& context){ - const std::array files = {"mine_path_01_1.png", "mine_path_01_2.png"}; - const std::array, 2> boxes = {{{{ImageFloatBox(0.400, 0.585, 0.055, 0.055), - ImageFloatBox(0.245, 0.670, 0.050, 0.055), - ImageFloatBox(0.322, 0.670, 0.050, 0.055), - ImageFloatBox(0.480, 0.752, 0.045, 0.055), - ImageFloatBox(0.555, 0.752, 0.045, 0.055), - ImageFloatBox(0.485, 0.290, 0.080, 0.090)}}, - {{ImageFloatBox(0.325, 0.585, 0.055, 0.055), - ImageFloatBox(0.170, 0.670, 0.050, 0.055), - ImageFloatBox(0.244, 0.670, 0.050, 0.055), - ImageFloatBox(0.395, 0.752, 0.045, 0.055), - ImageFloatBox(0.470, 0.752, 0.045, 0.055), - ImageFloatBox(0.415, 0.290, 0.080, 0.090)}}}}; - - MineWalkOptions options; - options.x = 1.0; - options.y = 0.0; - options.pulse = 40ms; - options.max_movements = 20; - options.horizontal_range = 0.0125; - return mine_walk_to_endpoints(stream, context, files, boxes, options, "[Mine right]"); -} - -static bool oreburgh_mine_down_past_central_rock(VideoStream& stream, ProControllerContext& context){ - const std::array files = {"mine_path_02_1.png", "mine_path_02_2.png"}; - const std::array, 2> boxes = {{{{ImageFloatBox(0.345, 0.420, 0.040, 0.045), - ImageFloatBox(0.195, 0.500, 0.040, 0.045), - ImageFloatBox(0.270, 0.500, 0.040, 0.045), - ImageFloatBox(0.410, 0.590, 0.040, 0.045), - ImageFloatBox(0.480, 0.590, 0.040, 0.045), - ImageFloatBox(0.445, 0.180, 0.060, 0.090)}}, - {{ImageFloatBox(0.420, 0.420, 0.040, 0.045), - ImageFloatBox(0.270, 0.500, 0.040, 0.045), - ImageFloatBox(0.345, 0.500, 0.040, 0.045), - ImageFloatBox(0.485, 0.590, 0.040, 0.045), - ImageFloatBox(0.555, 0.590, 0.040, 0.045), - ImageFloatBox(0.520, 0.180, 0.060, 0.090)}}}}; - - MineWalkOptions options; - options.x = 0.0; - options.y = -1.0; - options.pulse = 40ms; - options.max_movements = 30; - // options.horizontal_range = 0.0125; - // Path 02 can be shifted horizontally after the first right endpoint. - // The screenshot regression matches at about -2.08% of screen width. - options.horizontal_range = 0.025; - return mine_walk_to_endpoints(stream, context, files, boxes, options, "[Mine down 02]"); -} - -static bool oreburgh_mine_move_left_to_gap(VideoStream& stream, ProControllerContext& context){ - const std::array files = {"mine_path_03.png"}; - const std::array, 1> boxes = {{{{ImageFloatBox(0.340, 0.495, 0.040, 0.045), - ImageFloatBox(0.410, 0.495, 0.040, 0.045), - ImageFloatBox(0.555, 0.580, 0.040, 0.045), - ImageFloatBox(0.625, 0.580, 0.040, 0.045), - ImageFloatBox(0.580, 0.180, 0.060, 0.090)}}}}; - - MineWalkOptions options; - options.x = -1.0; - options.y = 0.0; - options.pulse = 40ms; - options.max_movements = 30; - options.horizontal_range = 0.0125; - return mine_walk_to_endpoints(stream, context, files, boxes, options, "[Mine left 03]"); -} - - -// Confirm we are far enough down before beginning the leftward section. - - -// Each picture stage owns its movement and matching settings. -struct MinePictureStep { - const char* file; - const char* label; - std::array boxes; - double x; - double y; - std::chrono::milliseconds pulse = 80ms; - size_t max_movements = 30; - double max_average = 20.0; - double max_patch = 30.0; -}; - -static bool mine_run_picture_step(VideoStream& stream, ProControllerContext& context, const MinePictureStep& step){ - MineWalkOptions options; - options.x = step.x; - options.y = step.y; - options.pulse = step.pulse; - options.max_movements = step.max_movements; - options.max_average = step.max_average; - options.max_patch = step.max_patch; - options.horizontal_range = 1.0 / 60; - options.vertical_range = 2.0 / 135; - options.initial_settle = 700ms; - options.settle = 700ms; - const std::array files = {step.file}; - const std::array, 1> boxes = {{step.boxes}}; - return mine_walk_to_endpoints(stream, context, files, boxes, options, step.label); -} - -static bool oreburgh_mine_confirm_05(VideoStream& stream, ProControllerContext& context){ - const MinePictureStep step = {"mine_path_05.png", - "[Mine endpoint 05]", - {ImageFloatBox(0.240, 0.240, 0.055, 0.080), - ImageFloatBox(0.390, 0.220, 0.050, 0.090), - ImageFloatBox(0.360, 0.090, 0.030, 0.030), - ImageFloatBox(0.550, 0.140, 0.040, 0.040)}, - 0.0, - -1.0, - 60ms, - 10, - 20.0, - 30.0}; - return mine_run_picture_step(stream, context, step); -} - -// Timed movement through paths 6 and 7, with picture checks. -static bool oreburgh_mine_timed_to_07(VideoStream& stream, ProControllerContext& context){ - stream.log("[Mine 04-07] Walking down for 1500ms."); - - if (!mine_timed_walk(stream, context, 0.0, -1.0, 1500ms)){ - return false; - } - - if (!oreburgh_mine_confirm_05(stream, context)){ - return false; - } - - stream.log("[Mine 04-07] Walking left for 3000ms."); - - if (!mine_timed_walk(stream, context, -1.0, 0.0, 3000ms)){ - return false; - } - - // Path 6: crate, nearby small rock, large rock, upper-right small rock. - const std::array path6_boxes = {ImageFloatBox(0.350, 0.250, 0.085, 0.075), - ImageFloatBox(0.405, 0.510, 0.035, 0.040), - ImageFloatBox(0.520, 0.300, 0.050, 0.080), - ImageFloatBox(0.620, 0.350, 0.030, 0.040)}; - - if (!mine_check_picture(stream, context, "mine_path_06.png", path6_boxes, "[Mine midpoint 06]")){ - return false; - } - - stream.log("[Mine 04-07] Path 6 confirmed; walking down for 600ms."); - - if (!mine_timed_walk(stream, context, 0.0, -1.0, 600ms)){ - return false; - } - - // Path 7: crate and three visible rock surfaces. - const std::array path7_boxes = {ImageFloatBox(0.365, 0.025, 0.070, 0.055), - ImageFloatBox(0.420, 0.270, 0.035, 0.040), - ImageFloatBox(0.560, 0.415, 0.025, 0.040), - ImageFloatBox(0.530, 0.650, 0.060, 0.090)}; - - return mine_check_picture(stream, context, "mine_path_07.png", path7_boxes, "[Mine endpoint 07]"); -} - -// Move left from path 7 to the position shown in path 8. -static bool oreburgh_mine_left_to_08(VideoStream& stream, ProControllerContext& context){ - const MinePictureStep step = {"mine_path_08.png", - "[Mine endpoint 08]", - {ImageFloatBox(0.495, 0.035, 0.070, 0.055), - ImageFloatBox(0.550, 0.270, 0.035, 0.040), - ImageFloatBox(0.695, 0.420, 0.030, 0.040), - ImageFloatBox(0.555, 0.585, 0.035, 0.040)}, - -1.0, - 0.0, - 40ms, - 20, - 20.0, - 30.0}; - return mine_run_picture_step(stream, context, step); -} - -// Walk down along the left wall to path 9. - -static bool oreburgh_mine_down_to_09(VideoStream& stream, ProControllerContext& context){ - const MinePictureStep step = {"mine_path_09.png", - "[Mine endpoint 09]", - {ImageFloatBox(0.550, 0.115, 0.030, 0.040), - ImageFloatBox(0.550, 0.390, 0.035, 0.040), - ImageFloatBox(0.620, 0.390, 0.025, 0.035), - ImageFloatBox(0.500, 0.720, 0.085, 0.080)}, - 0.0, - -1.0, - 40ms, - 25, - 25.0, - 35.0}; - return mine_run_picture_step(stream, context, step); -} - - -// Require dialogue to appear first. -// Then finish only after it has been absent for one continuous second. -static bool mine_finish_item_dialogue(VideoStream& stream, ProControllerContext& context){ - ShortDialogDetector dialogue(COLOR_YELLOW); - - bool saw_dialogue = false; - auto absent_since = std::chrono::steady_clock::now(); - - const auto start = std::chrono::steady_clock::now(); - const auto deadline = start + 30s; - - stream.log("[Mine item] Starting interaction."); - - while (std::chrono::steady_clock::now() < deadline){ - context.wait_for_all_requests(); - const VideoSnapshot frame = stream.video().snapshot(); - - if (!frame){ - stream.log("[Mine item] No video frame. Stopping.", COLOR_ORANGE); - return false; - } - - const auto now = std::chrono::steady_clock::now(); - const bool visible = dialogue.detect(frame); - - if (visible){ - if (!saw_dialogue){ - stream.log("[Mine item] Dialogue detected."); - } - - saw_dialogue = true; - absent_since = now; - - // Short mash bursts allow frequent checks. - pbf_mash_button(context, BUTTON_A, 100ms); - }else if (!saw_dialogue){ - if (now - start >= 10s){ - stream.log("[Mine item] No dialogue appeared. Stopping.", COLOR_ORANGE); - return false; - } - - // Try to start the interaction. - pbf_press_button(context, BUTTON_A, 60ms, 100ms); - }else if (now - absent_since >= 1s){ - stream.log("[Mine item] Dialogue absent for one second. Finished.", COLOR_GREEN); - return true; - } - - pbf_wait(context, 100ms); - context.wait_for_all_requests(); - } - - stream.log("[Mine item] Dialogue timeout. Stopping.", COLOR_ORANGE); - return false; -} - - -static bool oreburgh_mine_09_to_14(VideoStream& stream, ProControllerContext& context){ - const std::array path10_boxes = { - ImageFloatBox(0.405, 0.720, 0.085, 0.080), // Crate - ImageFloatBox(0.480, 0.120, 0.030, 0.040), // Upper rock - ImageFloatBox(0.575, 0.420, 0.045, 0.080), // Large right rock - ImageFloatBox(0.550, 0.720, 0.030, 0.040) // Lower small rock - }; - - const MinePictureStep path10_boxes_step = - {"mine_path_10.png", "[Mine endpoint 10]", path10_boxes, 1.0, 0.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path10_boxes_step)){ - return false; - } - - const std::array path11_boxes = {ImageFloatBox(0.405, 0.560, 0.070, 0.080), - ImageFloatBox(0.475, 0.270, 0.030, 0.040), - ImageFloatBox(0.575, 0.290, 0.045, 0.080), - ImageFloatBox(0.550, 0.575, 0.030, 0.040)}; - - const MinePictureStep path11_boxes_step = - {"mine_path_11.png", "[Mine endpoint 11]", path11_boxes, 0.0, -1.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path11_boxes_step)){ - return false; - } - - const std::array path12_boxes = {ImageFloatBox(0.260, 0.560, 0.080, 0.080), - ImageFloatBox(0.355, 0.270, 0.025, 0.035), - ImageFloatBox(0.435, 0.290, 0.040, 0.070), - ImageFloatBox(0.415, 0.585, 0.025, 0.035)}; - - const MinePictureStep path12_boxes_step = - {"mine_path_12.png", "[Mine endpoint 12]", path12_boxes, 1.0, 0.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path12_boxes_step)){ - return false; - } - - const std::array path13_boxes = { - ImageFloatBox(0.290, 0.230, 0.070, 0.060), // Upper crate - ImageFloatBox(0.285, 0.390, 0.070, 0.060), // Lower crate - ImageFloatBox(0.425, 0.265, 0.025, 0.035), // Upper small rock - ImageFloatBox(0.405, 0.580, 0.030, 0.040) // Bottom-left small rock - }; - - const MinePictureStep path13_boxes_step = - {"mine_path_13.png", "[Mine endpoint 13]", path13_boxes, 0.0, -1.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path13_boxes_step)){ - return false; - } - - // Turn left, then verify position before starting the interaction. - pbf_move_left_joystick(context, {-1.0, 0.0}, 40ms, 0ms); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - - if (!mine_check_picture(stream, context, "mine_path_14.png", path13_boxes, "[Mine position 14]", 3)){ - return false; - } - stream.log("[Mine item] Position confirmed. Pressing A."); - - pbf_press_button(context, BUTTON_A, 80ms, 200ms); - context.wait_for_all_requests(); - - return mine_finish_item_dialogue(stream, context); -} - -// Compare a reference patch with a shifted live patch using the same -// brightness adjustment as the native ImageMatchDetector. -static double mine_offset_patch_score(const ImageRGB32& reference, - const ImageRGB32& live, - const ImageFloatBox& box, - double horizontal, - double vertical){ - const ImageViewRGB32 reference_crop = extract_box_reference(reference, box); - ImageFloatBox shifted = box; - shifted.x += horizontal; - shifted.y += vertical; - const ImageViewRGB32 live_crop = extract_box_reference(live, shifted); - ImageRGB32 adjusted = live_crop.scale_to(reference_crop.width(), reference_crop.height()); - - const auto reference_average = ImageMatch::pixel_average(reference_crop, reference_crop); - const auto live_average = ImageMatch::pixel_average(adjusted, reference_crop); - auto scale = reference_average / live_average; - if (std::isnan(scale.r)){ - scale.r = 1.0; - } - if (std::isnan(scale.g)){ - scale.g = 1.0; - } - if (std::isnan(scale.b)){ - scale.b = 1.0; - } - scale.bound(0.8, 1.2); - ImageMatch::scale_brightness(adjusted, scale); - - return ImageMatch::pixel_RMSD(reference_crop, adjusted); -} - -struct MineRoarkOffsetScore { - double left = 1000.0; - double right = 1000.0; - double helmet = 1000.0; - double horizontal = 0; - double vertical = 0; - double helmet_horizontal = 0; - double helmet_vertical = 0; -}; - -static MineRoarkOffsetScore mine_score_roark_offsets(const ImageRGB32& reference, const ImageRGB32& live){ - const ImageFloatBox left_box(0.415, 0.400, 0.050, 0.095); - const ImageFloatBox right_box(0.555, 0.400, 0.055, 0.095); - const ImageFloatBox helmet_box(0.485, 0.335, 0.035, 0.045); - - MineRoarkOffsetScore best; - double best_worst_rock = 1000.0; - double best_total = 2000.0; - - // Both rocks must use the SAME offset. They cannot match independently. - // Search ranges are screen fractions; enumerate pixels only for scoring. - const int radius_x = (int)std::lround(live.width() / 60.0); - const int radius_y = (int)std::lround(live.height() * 2.0 / 135); - for (int y = -radius_y; y <= radius_y; y++){ - for (int x = -radius_x; x <= radius_x; x++){ - const double dx = (double)x / live.width(), dy = (double)y / live.height(); - const double left = mine_offset_patch_score(reference, live, left_box, dx, dy); - const double right = mine_offset_patch_score(reference, live, right_box, dx, dy); - const double worst = left > right ? left : right; - const double total = left + right; - - if (worst < best_worst_rock || (worst == best_worst_rock && total < best_total)){ - best_worst_rock = worst; - best_total = total; - best.left = left; - best.right = right; - best.horizontal = (double)x / live.width(); - best.vertical = (double)y / live.height(); - } - } - } - - // Roark's helmet may move slightly relative to the stationary rocks. - // Keep this extra search small so another red object cannot match far away. - const int helmet_radius_x = (int)std::lround(live.width() * 0.00625); - const int helmet_radius_y = (int)std::lround(live.height() / 90.0); - for (int extra_y = -helmet_radius_y; extra_y <= helmet_radius_y; extra_y++){ - for (int extra_x = -helmet_radius_x; extra_x <= helmet_radius_x; extra_x++){ - const double score = mine_offset_patch_score(reference, - live, - helmet_box, - best.horizontal + (double)extra_x / live.width(), - best.vertical + (double)extra_y / live.height()); - if (score < best.helmet){ - best.helmet = score; - best.helmet_horizontal = (double)extra_x / live.width(); - best.helmet_vertical = (double)extra_y / live.height(); - } - } - } - - return best; -} - -static bool oreburgh_mine_align_to_18(VideoStream& stream, ProControllerContext& context){ - const ImageRGB32 reference(mine_navigation_resource_path() + "Mine_path_18.png"); - const ImageRGB32 reference_scaled = reference.scale_to(960, 540); - const double max_rmsd = 65.0; - size_t movements = 0; - - return mine_run_with_encounters(stream, context, [&](ProControllerContext& ctx){ - // Fresh confirmation after any encounter. - size_t matching_frames = 0; - pbf_wait(ctx, 500ms); - ctx.wait_for_all_requests(); - - while (true){ - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - stream.log("[Mine endpoint 18] No video frame.", COLOR_ORANGE); - return false; - } - - const ImageRGB32 live = frame->scale_to(960, 540); - const MineRoarkOffsetScore score = mine_score_roark_offsets(reference_scaled, live); - - stream.log("[Mine endpoint 18] Left rock=" + std::to_string(score.left) + - ", Right rock=" + std::to_string(score.right) + ", Helmet=" + std::to_string(score.helmet) + - ", shared screen-relative offset=(" + std::to_string(score.horizontal) + "," + - std::to_string(score.vertical) + "), helmet screen-relative extra=(" + - std::to_string(score.helmet_horizontal) + "," + std::to_string(score.helmet_vertical) + ")"); - - if (score.left <= max_rmsd && score.right <= max_rmsd && score.helmet <= max_rmsd){ - if (++matching_frames >= 3){ - stream.log("[Mine endpoint 18] Rocks and helmet confirmed.", COLOR_GREEN); - return true; - } - pbf_wait(ctx, 250ms); - ctx.wait_for_all_requests(); - continue; - } - - matching_frames = 0; - if (movements >= 80){ - stream.log("[Mine endpoint 18] Movement limit reached.", COLOR_ORANGE); - return false; - } - - const bool approaching = score.left <= 120.0 && score.right <= 120.0 && score.helmet <= 150.0; - const bool near = score.left <= 100.0 && score.right <= 100.0 && score.helmet <= 100.0; - const auto pulse = near ? 20ms : approaching ? 40ms : 60ms; - - // Use the outer encounter watcher; do not nest mine_timed_walk. - // Count issued pulses, including any interrupted by a battle. - movements++; - pbf_move_left_joystick(ctx, {1.0, 0.0}, pulse, 0ms); - pbf_wait(ctx, 700ms); - ctx.wait_for_all_requests(); - } - }); -} - -static bool oreburgh_mine_14_to_19(VideoStream& stream, ProControllerContext& context){ - const std::array path15_boxes = { - ImageFloatBox(0.140, 0.220, 0.080, 0.070), // Upper crate - ImageFloatBox(0.125, 0.370, 0.080, 0.070), // Lower crate - ImageFloatBox(0.285, 0.250, 0.035, 0.045), // Small upper rock - ImageFloatBox(0.325, 0.560, 0.035, 0.045) // Lower rock - }; - - const MinePictureStep path15_boxes_step = - {"mine_path_15_actual.png", "[Mine endpoint 15]", path15_boxes, 1.0, 0.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path15_boxes_step)){ - return false; - } - - // Move below the worker before continuing right. - const std::array path16_boxes = { - ImageFloatBox(0.145, 0.160, 0.080, 0.070), // Upper crate - ImageFloatBox(0.135, 0.305, 0.080, 0.070), // Lower crate - ImageFloatBox(0.290, 0.195, 0.035, 0.045), // Small upper rock - ImageFloatBox(0.330, 0.495, 0.035, 0.045) // Lower rock - }; - - const MinePictureStep path16_boxes_step = - {"mine_path_16.png", "[Mine endpoint 16]", path16_boxes, 0.0, -1.0, 80ms, 30, 20.0, 30.0}; - if (!mine_run_picture_step(stream, context, path16_boxes_step)){ - return false; - } - - // Skip 17 and find the actual stopping point at 18. - if (!oreburgh_mine_align_to_18(stream, context)){ - return false; - } - - // Position 19 is the upward turn toward Roark. - pbf_move_left_joystick(context, {0.0, 1.0}, 40ms, 0ms); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - - stream.log("[Mine endpoint 19] Position 18 confirmed; north turn sent.", COLOR_GREEN); - return true; -} - -bool oreburgh_mine_route(VideoStream& stream, ProControllerContext& context, const MineRouteTestOptions& options){ - stream.log("[Mine resources] " + mine_navigation_resource_path()); - struct Step { - const char* name; - bool (*run)(VideoStream&, ProControllerContext&); - }; - - const std::array steps = {{{"Align entrance", oreburgh_mine_alignment_test}, - {"Walk to first rock", - [](VideoStream& video, ProControllerContext& controller){ - return oreburgh_mine_walk_to_first_rock(video, controller, false); - }}, - {"Move right of first rock", oreburgh_mine_move_right}, - {"Move below first rock", oreburgh_mine_down_past_central_rock}, - {"Move left into center gap", oreburgh_mine_move_left_to_gap}, - {"Reach position 07", oreburgh_mine_timed_to_07}, - {"Reach position 08", oreburgh_mine_left_to_08}, - {"Reach position 09", oreburgh_mine_down_to_09}, - {"Reach 14 and collect item", oreburgh_mine_09_to_14}, - {"Reach 18 and turn north for 19", oreburgh_mine_14_to_19}}}; - - const size_t first = static_cast(options.first); - const size_t last = static_cast(options.last); - - if (first > last || last >= steps.size()){ - stream.log("[Mine route] Invalid stage range.", COLOR_ORANGE); - return false; - } - - for (size_t index = first; index <= last; index++){ - const Step& step = steps[index]; - - stream.log(std::string("[Mine route] Starting: ") + step.name); - - bool succeeded = false; - - try { - succeeded = step.run(stream, context); - } catch (OperationFailedException& error){ - stream.log(std::string("[Mine route] Failed during ") + step.name + ": " + error.message(), COLOR_ORANGE); - - if (options.reset_on_failure){ - throw; - } - - stream.log("[Mine route] Test stopped without requesting a reset.", COLOR_ORANGE); - return false; - } - - if (!succeeded){ - stream.log(std::string("[Mine route] Stage failed: ") + step.name, COLOR_ORANGE); - - if (options.reset_on_failure){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - std::string("Mine route failed during: ") + step.name, - stream); - } - - return false; - } - - stream.log(std::string("[Mine route] Completed: ") + step.name, COLOR_GREEN); - } - - stream.log("[Mine route] Selected stages completed.", COLOR_GREEN); - return true; -} - -// Start Roark's conversation once, then advance it with B. -// Require dialogue to be seen before accepting its disappearance. - -static bool mine_talk_to_roark(VideoStream& stream, ProControllerContext& context){ - stream.log("[Mine Roark] Pressing A to start conversation."); - pbf_press_button(context, BUTTON_A, 80ms, 100ms); - context.wait_for_all_requests(); - - // The opening cutscene precedes the first dialogue box. - // Do not mash B or measure dialogue absence during that cutscene. - wait_for_dialogue(stream, context, "[Mine Roark] First box", 30s); - - ShortDialogDetector dialogue(COLOR_YELLOW); - const auto deadline = std::chrono::steady_clock::now() + 60s; - bool timing_absence = false; - auto absent_since = std::chrono::steady_clock::now(); - while (std::chrono::steady_clock::now() < deadline){ - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - stream.log("[Mine Roark] No video frame.", COLOR_ORANGE); - return false; - } - const auto now = std::chrono::steady_clock::now(); - if (dialogue.detect(frame)){ - timing_absence = false; - }else{ - if (!timing_absence){ - timing_absence = true; - absent_since = now; - } - if (now - absent_since >= 4s){ - stream.log("[Mine Roark] Dialogue absent for 4 seconds. Finished.", COLOR_GREEN); - return true; - } - } - pbf_mash_button(context, BUTTON_B, 100ms); - context.wait_for_all_requests(); - } - stream.log("[Mine Roark] Conversation timed out.", COLOR_ORANGE); - return false; -} - -// Retain the developer test entry point; both callers share the same route. -bool oreburgh_mine_first_leg_test(VideoStream& stream, - ProControllerContext& context, - const MineRouteTestOptions& options){ - return oreburgh_mine_route(stream, context, options); -} - -// Shared final step for the live route and the entrance-start test. -static bool mine_talk_and_escape(VideoStream& stream, ProControllerContext& context){ - if (!mine_talk_to_roark(stream, context)){ - return false; - } - stream.log("[Mine exit] Finding Escape Rope in the Bag."); - if (!use_escape_rope(stream, context, 8)){ - return false; - } - stream.log("[Mine exit] Roark conversation and Escape Rope exit succeeded.", COLOR_GREEN); - return true; -} - -bool oreburgh_mine_route_and_escape_test(VideoStream& stream, ProControllerContext& context){ - MineRouteTestOptions route; - route.reset_on_failure = false; - if (!oreburgh_mine_first_leg_test(stream, context, route)){ - return false; - } - try { - if (!mine_talk_and_escape(stream, context)){ - stream.log("[Mine exit test] Failed. Stopping without resetting.", COLOR_ORANGE); - return false; - } - } catch (OperationFailedException& error){ - stream.log("[Mine exit test] " + error.message(), COLOR_ORANGE); - stream.log("[Mine exit test] Stopped without requesting a reset.", COLOR_ORANGE); - return false; - } - return true; -} - -void oreburgh_mine_nav(VideoStream& stream, ProControllerContext& context){ - walk_through_grass(stream, context, 0, -1, 1, "down into mine"); - context.wait_for_all_requests(); - walk_until_doorway(stream, context, MoveDirection::DOWN, 4350ms); - context.wait_for_all_requests(); - pbf_wait(context, 1824ms); - - MineRouteTestOptions route; - route.reset_on_failure = true; - - if (!oreburgh_mine_route(stream, context, route)){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Mine route did not complete.", - stream); - } - - - if (!mine_talk_and_escape(stream, context)){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Mine route: Roark conversation or Escape Rope exit failed.", - stream); - } -} -// --------------------------------------------------------------------------- -// Segment 1 Part 3: Oreburgh Mine to Gym -// Assumes that escape rope was picked up and used to exit the mine. -// Follows the validated blind route into the gym. -// ---------------------------------------------------------------------------- -void oreburgh_mine_to_gym(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - - stream.log("[Mine to gym] Following recorded route from the mine exit."); - pbf_wait(context, 700ms); - context.wait_for_all_requests(); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 3995ms, 0ms); - pbf_wait(context, 672ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 681ms, 0ms); - pbf_wait(context, 468ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1081ms, 0ms); - pbf_wait(context, 420ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 2445ms, 0ms); - pbf_wait(context, 1056ms); - pbf_move_left_joystick(context, {-1.000000, -0.000000}, 95ms, 0ms); - pbf_wait(context, 4787ms); - pbf_move_left_joystick(context, {0.000000, 1.000000}, 1309ms, 0ms); - - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 4450ms, 0ms); - //pbf_wait(context, 3635ms); - //pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 640ms, 0ms); - //pbf_wait(context, 3795ms); - context.wait_for_all_requests(); - - //stream.log("[Mine to gym] Staying west of the hiker; moving north along the left fence."); - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 2000ms, 0ms); - //pbf_wait(context, 602ms); - //pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 2364ms, 0ms); - //pbf_wait(context, 5069ms); - //pbf_move_left_joystick(context, { 0.000000, -1.000000 }, 1079ms, 0ms); - //pbf_wait(context, 605ms); - //pbf_move_left_joystick(context, { -1.000000, -0.000000 }, 728ms, 0ms); - //pbf_wait(context, 1081ms); - //pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 155ms, 0ms); - //pbf_wait(context, 2642ms); - //pbf_move_left_joystick(context, { 1.000000, -0.000000 }, 64ms, 0ms); - //pbf_wait(context, 5340ms); - //context.wait_for_all_requests(); - - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 541ms, 0ms); - //pbf_wait(context, 140ms); - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 47ms, 0ms); - //context.wait_for_all_requests(); - //stream.log("[Mine to gym] Recorded movement finished; waiting for gym entry to settle."); - // walk_until_doorway(stream, context, MoveDirection::UP, 5s); - // Finish entering the doorway with a bounded blind input. - //pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 1000ms, 0ms); - //context.wait_for_all_requests(); - //pbf_wait(context, 1500ms); - //context.wait_for_all_requests(); - stream.log("[Mine to gym] Blind mine-to-gym sequence completed.", COLOR_GREEN); -} - -// --------------------------------------------------------------------------- -// Segment 1 Part 4: Gym navigation -// Navigates the player through the first Gym in Oreburgh City and completes the Gym battle. -// --------------------------------------------------------------------------- - -void gym_Puzzle_01(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context){ - - //Upon entering the Gym. - // The entry camera sequence lasts about 7.5 seconds in the reference - // video. Allow another 500ms before sending puzzle movement inputs. - context.wait_for_all_requests(); - env.log("[Gym puzzle] Waiting for the entry cutscene to finish."); - pbf_wait(context, 8000ms); - context.wait_for_all_requests(); - - struct Move { - DpadPosition dir; - int count; - }; - const Move sequence[] = { - {DPAD_UP, 3}, - {DPAD_LEFT, 4}, - {DPAD_UP, 5}, - {DPAD_RIGHT, 8}, - {DPAD_UP, 11}, - {DPAD_LEFT, 5}, - {DPAD_UP, 4}, - }; - - for (const auto& move : sequence){ - for (int i = 0; i < move.count; i++){ - pbf_press_dpad(context, move.dir, 80ms, 300ms); - } - } - - //Button presses to initiate the Gym battle. - pbf_press_button(context, BUTTON_A, 180ms, 0ms); - wait_for_dialogue(stream, context, "Phase 3 (Roark fight dialog)"); - pbf_mash_button(context, BUTTON_B, 5000ms); - //Barry 1 battle logic can be used here since the moves don't matter this early. - Barry_1_fight(env, stream, context); - - context.wait_for_all_requests(); - wait_for_dialogue(stream, context, "Phase 4 (Roark post battle dialog)"); - ShortDialogDetector post_battle_dialogue(COLOR_YELLOW); - const auto dialogue_deadline = std::chrono::steady_clock::now() + 180s; - auto dialogue_absent_since = std::chrono::steady_clock::now(); - bool dialogue_finished = false; - stream.log("[Gym post battle] Clearing dialogue until absent for 4 seconds."); - while (std::chrono::steady_clock::now() < dialogue_deadline){ - const VideoSnapshot frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Gym post battle: No video frame while clearing dialogue.", - stream); - } - const auto now = std::chrono::steady_clock::now(); - if (post_battle_dialogue.detect(frame)){ - dialogue_absent_since = now; - }else if (now - dialogue_absent_since >= 4s){ - dialogue_finished = true; - break; - } - pbf_mash_button(context, BUTTON_B, 100ms); - context.wait_for_all_requests(); - } - if (!dialogue_finished){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Gym post battle: Dialogue did not finish within 180 seconds.", - stream); - } - stream.log("[Gym post battle] Dialogue absent for 4 seconds. Walking down."); - - for (int i = 0; i < 21; i++){ - pbf_press_dpad(context, DPAD_DOWN, 80ms, 300ms); - } - - // Exit the building. - pbf_wait(context, 1000ms); -} - - -std::string AutoStory_Segment_01::name() const { - return "01: Jubilife City to First Badge"; -} -std::string AutoStory_Segment_01::start_text() const { - return "Start: Received Starter back from Pokemon Home. Max leveled pokemon should have the moveset in the wiki."; -} -std::string AutoStory_Segment_01::end_text() const { - return "End: First Gym Badge acquired (Oreburgh City)."; -} - -void AutoStory_Segment_01::run_segment(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - stats.m_segment++; - env.update_stats(); - context.wait_for_all_requests(); - env.console.log("Start Segment " + name(), COLOR_ORANGE); - - AutoStory_checkpoint_0101().run_checkpoint(env, context, options, stats); - AutoStory_checkpoint_0102().run_checkpoint(env, context, options, stats); - AutoStory_checkpoint_0103().run_checkpoint(env, context, options, stats); - context.wait_for_all_requests(); - env.console.log("End Segment " + name(), COLOR_GREEN); -} - - -std::string AutoStory_checkpoint_0101::name() const { - return "01.01 - Maxed Starter from Home"; -} -std::string AutoStory_checkpoint_0101::start_text() const { - return "Received Starter back from Pokemon Home. Max leveled pokemon should have the moveset in the wiki."; -} -std::string AutoStory_checkpoint_0101::end_text() const { - return "Standing outside the Oreburgh Gate on the Jubilife City side."; -} -// ======================== -// Checkpoint 0101 Wrapper -// ======================== -void AutoStory_checkpoint_0101::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0101(env, context, options, stats); -} - - -void checkpoint_0101(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - jubilife_to_oreburgh_gate(env, env.console, context); - oreburgh_gate_nav(env, env.console, context); - }, - true); -} - -// ======================== -// Checkpoint 0102 Wrapper -// ======================== - -std::string AutoStory_checkpoint_0102::name() const { - return "01.02 - Oreburgh Mine"; -} -std::string AutoStory_checkpoint_0102::start_text() const { - return AutoStory_checkpoint_0101().end_text(); -} -std::string AutoStory_checkpoint_0102::end_text() const { - return "Inside Oreburgh Gym, ready to navigate to Roark."; -} - -void AutoStory_checkpoint_0102::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0102(env, context, options, stats); -} - - -void checkpoint_0102(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - oreburgh_gate_to_mine(env, env.console, context); - oreburgh_mine_nav(env.console, context); - // Standalone test entry points are not part of the checkpoint route. - // oreburgh_mine_first_leg_test(env.console, context); - // oreburgh_mine_route_and_escape_test(env.console, context); - oreburgh_mine_to_gym(env, env.console, context); - }, - - true); -} - -// ======================== -// Checkpoint 0103 Wrapper -// ======================== - -std::string AutoStory_checkpoint_0103::name() const { - return "01.03 - Oreburgh Gym"; -} -std::string AutoStory_checkpoint_0103::start_text() const { - return AutoStory_checkpoint_0102().end_text(); -} -std::string AutoStory_checkpoint_0103::end_text() const { - return "Standing at Roark after defeating him and acquiring the first badge."; -} - -void AutoStory_checkpoint_0103::run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const { - checkpoint_0103(env, context, options, stats); -} - - -void checkpoint_0103(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats){ - checkpoint_reattempt_loop( - env, - context, - options, - stats, - [&](size_t /*attempt*/){ - //#. Oreburgh Gym navigation and battle - gym_Puzzle_01(env, env.console, context); - }, - - true); -} - - -} -} -} +/* BDSP AutoStory - Segment 01 + * From the Jubilife Pokemon Center to Oreburgh City. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "PokemonBDSP_AutoStory_Segment_01.h" +#include "../Data/PokemonBDSP_AutoStory_OreburghMine.h" +#include "../Data/PokemonBDSP_AutoStory_RavagedPath.h" +#include "../Data/PokemonBDSP_AutoStory_Route204.h" +#include "../Data/PokemonBDSP_AutoStory_StoryMaps.h" +#include "../Detect/PokemonBDSP_AutoStory_OverworldDetector.h" +#include "../Utils/PokemonBDSP_AutoStory_PokemonCenter.h" +#include "../Utils/PokemonBDSP_AutoStory_RouteWalk.h" +#include "../Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" +#include "../Utils/PokemonBDSP_AutoStory_Walk.h" +#include "Common/Cpp/PrettyPrint.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "CommonFramework/Globals.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" + +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/VisualDetectors/BlackScreenDetector.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" + +#include +#include + +using namespace std::chrono_literals; + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + +using Pokemon::Direction; + + +namespace{ + +[[noreturn]] void fail(VideoStream& stream, const std::string& message){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, message, stream); +} + +// One tile south, for a player facing another way. +void step_south(VideoStream& stream, ProControllerContext& context){ + route_turn(stream, context, Direction::SOUTH); + pbf_move_left_joystick(context, {0, -1}, route_open_hold(Direction::SOUTH, 1), 200ms); + context.wait_for_all_requests(); +} + +// True if a dialogue opens within `timeout`: someone who stopped us as the +// walk ended reads as BLOCKED, and their box comes up a moment later. +bool dialogue_opens(VideoStream& stream, ProControllerContext& context, Milliseconds timeout){ + context.wait_for_all_requests(); + ShortDialogWatcher dialog(COLOR_CYAN); + return wait_until(stream, context, timeout, {{dialog}}) == 0; +} + +// For a dialogue that has opened: B through it. A trainer's ends in a +// battle, which is fought with the best move (Potions when HP runs low), +// followed by a Potion if HP is under half. Anyone else's ends back in the +// overworld. Returns whether there was a battle. Between a trainer's last +// line and the battle the overworld shows with no dialogue for a moment, +// so "only talked" needs 4 s of it, and never comes when `battle_expected`. +// `doubles` for tag and twin battles. A lost battle ends in the last +// Pokemon Center; see in_pokemon_center(). +bool in_pokemon_center(VideoStream& stream); +bool talk_or_fight(VideoStream& stream, ProControllerContext& context, const std::string& label, bool battle_expected = false, bool doubles = false){ + ShortDialogDetector dialog; + OverworldDetector overworld; + BattleMenuDetector battle_menu(BattleType::TRAINER); + BlackScreenDetector black; + auto quiet_since = std::chrono::steady_clock::now(); + for (size_t check = 0; check < 120; check++){ + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + if (!frame){ + fail(stream, label + ": No video frame."); + } + if (battle_menu.detect(*frame.frame) || black.detect(*frame.frame)){ + route_status(stream, "Fighting " + label + "."); + fight_story_battle(stream, context, label, doubles); + if (!in_pokemon_center(stream)){ + use_potion_first_pokemon(stream, context, 0.5); + } + return true; + } + const bool talking = dialog.detect(*frame.frame); + if (!talking && overworld.detect(*frame.frame)){ + if (!battle_expected && std::chrono::steady_clock::now() - quiet_since >= 4s){ + route_status(stream, label + " only talked."); + return false; + } + pbf_wait(context, 300ms); + continue; + } + quiet_since = std::chrono::steady_clock::now(); + if (talking){ + pbf_press_button(context, BUTTON_B, 80ms, 1100ms); + }else{ + pbf_wait(context, 300ms); + } + } + fail(stream, label + ": neither a battle nor the overworld after the dialogue."); +} + +// Logs a checkpoint's steps up front, then each step as it starts, with the +// one after it, in the log and the overlay. +class StepLog{ +public: + StepLog(VideoStream& stream, std::string checkpoint, std::string intro, std::vector steps) + : m_stream(stream), m_checkpoint(std::move(checkpoint)), m_steps(std::move(steps)) + { + std::string text = "[" + m_checkpoint + "] " + intro + ", the steps are:"; + for (size_t i = 0; i < m_steps.size(); i++){ + text += "\n " + std::to_string(i + 1) + ". " + m_steps[i]; + } + m_stream.log(text, COLOR_BLUE); + } + void operator()(size_t number){ + const std::string count = std::to_string(number) + "/" + std::to_string(m_steps.size()); + const bool last = number >= m_steps.size(); + m_stream.log("[" + m_checkpoint + " step " + count + "] " + m_steps[number - 1] + + (last ? std::string(" -- last step") : " -- next: " + m_steps[number]), COLOR_BLUE); + add_wrapped_log(m_stream.overlay(), m_checkpoint + " step " + count + ": " + m_steps[number - 1], COLOR_WHITE); + if (!last){ + add_wrapped_log(m_stream.overlay(), "Next: " + m_steps[number], COLOR_GRAY); + } + } + +private: + VideoStream& m_stream; + std::string m_checkpoint; + std::vector m_steps; +}; + +void jubilife_to_barry(VideoStream& stream, ProControllerContext& context){ + // What 01.01 does, stepped through on the console before writing it. + StepLog step(stream, "01.01", "From the Jubilife Pokemon Center to Barry", { + "Heal at the nurse, then walk out of the Pokemon Center", + "Walk to Jubilife's east exit and battle Barry at the Route 203 entrance", + }); + + step(1); + // The save stands us at the PC, right of the counter, facing it; west + // is the counter's end. Two tiles south clears it, then three west is + // the floor emblem's column, with the nurse north and the door south. + route_turn(stream, context, Direction::SOUTH); + pbf_move_left_joystick(context, {0, -1}, route_open_hold(Direction::SOUTH, 2), 200ms); + route_turn(stream, context, Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, route_open_hold(Direction::WEST, 3), 200ms); + if (walk_to_talk(stream, context, 0, 1, 3000ms, "to the nurse").stop != WalkStop::DIALOG){ + fail(stream, "Jubilife Pokemon Center: the nurse did not answer."); + } + // Two lines, then Yes to "rest your Pokemon?" (B would answer No), and + // one more A for "I'll take your Pokemon"; B for the rest. + finish_dialogue(stream, context, 4, "Nurse"); + // Healing shows no text box for a few seconds, which reads as the end; + // her "Thank you for waiting" lines follow it. + if (dialogue_opens(stream, context, 6000ms)){ + finish_dialogue(stream, context, 0, "Nurse, after healing"); + } + // South through the door until a girl standing below it stops us. The + // door's fade may or may not read as a transition. + WalkResult out{WalkStop::LIMIT, 0ms, 0}; + for (size_t walk = 0; walk < 3 && out.stop != WalkStop::BLOCKED; walk++){ + out = walk_until(stream, context, 0, -1, 8000ms, "out of the Pokemon Center"); + if (out.stop == WalkStop::DIALOG || out.stop == WalkStop::ARRIVED){ + break; + } + } + if (out.stop != WalkStop::BLOCKED){ + fail(stream, std::string("Jubilife Pokemon Center: walking out ended with ") + walk_stop_name(out.stop) + ", not at the girls below the door."); + } + + step(2); + // Jubilife's plaza has too few walls the grid gets right to plan on (a + // leg to a wall the map shows walked on 19 tiles). These are the holds + // that worked on the console: west onto the main road, north to the + // street east to Route 203, and back a couple of tiles. + route_turn(stream, context, Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, 720ms, 300ms); + route_turn(stream, context, Direction::NORTH); + pbf_move_left_joystick(context, {0, 1}, 3960ms, 300ms); + route_turn(stream, context, Direction::SOUTH); + pbf_move_left_joystick(context, {0, -1}, 600ms, 300ms); + context.wait_for_all_requests(); + // East along the street. The opening to the route is a few rows high; + // Barry stops us a little way in. A wall means we are a row off it: try + // rows to the south, then to the north. + static const int ROW_CHANGES[] = {0, 1, 1, -3, -1}; + bool barry = false; + for (int change : ROW_CHANGES){ + if (barry){ + break; + } + for (int i = 0; i < std::abs(change); i++){ + const Direction dir = change > 0 ? Direction::SOUTH : Direction::NORTH; + route_turn(stream, context, dir); + pbf_move_left_joystick(context, {0, change > 0 ? -1.0 : 1.0}, route_open_hold(dir, 1), 200ms); + } + context.wait_for_all_requests(); + WalkResult walk{WalkStop::LIMIT, 0ms, 0}; + for (size_t stretch = 0; stretch < 3 && walk.stop == WalkStop::LIMIT; stretch++){ + walk = walk_until(stream, context, 1, 0, 9000ms, "east to Route 203"); + } + barry = walk.stop == WalkStop::DIALOG; + } + if (!barry){ + fail(stream, "Route 203: Barry did not stop us at the entrance."); + } + if (!talk_or_fight(stream, context, "Barry", true)){ + fail(stream, "Route 203: Barry talked but did not battle."); + } +} + +void route203_to_oreburgh(VideoStream& stream, ProControllerContext& context){ + StepLog step(stream, "01.02", "From Barry on Route 203 to Oreburgh City", { + "Walk east along Route 203 into the Oreburgh Gate, fighting the trainers who see us", + "Listen to the Hiker, who gives HM Rock Smash", + "Walk east through the Gate past Curtis and Diana, out to Oreburgh City", + "Listen to the man who meets us at the city's entrance", + }); + + step(1); + // Route 203 only goes east, over three stretches joined by stairs, and + // the Gate's opening is in the cliff at its north-east end. Who stops us + // differs from run to run (a trainer's sight depends on our row), so + // there is no fixed list: walk east, talk or fight whoever stops us, and + // at a wall find a row from which east goes on. + size_t fights = 0; + bool inside = false; + // On Route 203 only trainers start a talk; one with no battle is the + // Hiker, who meets us inside the Gate (whose fade may not read as a + // transition). + bool met_hiker = false; + auto stopped_by = [&](){ + if (talk_or_fight(stream, context, "Route 203 trainer " + std::to_string(fights + 1))){ + fights++; + }else{ + route_status(stream, "No battle: that was the Hiker, inside the Oreburgh Gate."); + met_hiker = true; + inside = true; + } + }; + for (size_t leg = 0; leg < 40 && !inside; leg++){ + const WalkResult walk = walk_until(stream, context, 1, 0, 10000ms, "east along Route 203"); + if (walk.stop == WalkStop::TRANSITION){ + inside = true; + break; + } + if (walk.stop == WalkStop::LIMIT){ + continue; + } + if (walk.stop == WalkStop::DIALOG || dialogue_opens(stream, context, 1500ms)){ + stopped_by(); + continue; + } + // A wall. Find the row from which east goes on: north to the top wall + // first (a step north there does not move us, so rows are counted + // from it), then a row at a time south. At the route's end this is the + // corner by the second sign, with the Gate's opening two rows down. + walk_until(stream, context, 0, 1, 6000ms, "north to the top wall"); + bool moved_on = false; + for (size_t row = 1; row <= 10 && !moved_on; row++){ + step_south(stream, context); + const WalkResult next = walk_until(stream, context, 1, 0, 10000ms, "east along Route 203, row " + std::to_string(row) + " below the top"); + if (next.stop == WalkStop::TRANSITION){ + inside = true; + } + if (next.stop == WalkStop::DIALOG || (next.stop == WalkStop::BLOCKED && dialogue_opens(stream, context, 1500ms))){ + stopped_by(); + } + // Two still chunks are the least a wall takes; more is progress. + moved_on = inside || next.stop != WalkStop::BLOCKED || next.walked > 1200ms; + } + if (!moved_on){ + fail(stream, "Route 203: walls east on every row down to ten below the top wall."); + } + } + if (!inside){ + fail(stream, "Route 203: did not reach the Oreburgh Gate."); + } + route_status(stream, "Route 203: " + std::to_string(fights) + " trainer battle(s) on the way."); + context.wait_for(1500ms); + + step(2); + // The Hiker steps up to us as we walk east. + if (!met_hiker){ + const WalkResult to_hiker = walk_until(stream, context, 1, 0, 4000ms, "to the Hiker"); + if (to_hiker.stop != WalkStop::DIALOG && !dialogue_opens(stream, context, 2000ms)){ + fail(stream, "Oreburgh Gate: the Hiker did not stop us."); + } + finish_dialogue(stream, context, 0, "Hiker"); + } + // Then a "Hidden Moves" tutorial card, closed with B. It is no dialogue + // box, and it can come up a moment after his last line. + pbf_wait(context, 1500ms); + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + context.wait_for_all_requests(); + + step(3); + // East: Camper Curtis and Picnicker Diana see us; past them the exit's + // white fade, and the man in Oreburgh stops us right outside. + fights = 0; + bool city = false; + size_t stuck = 0; + for (size_t leg = 0; leg < 12 && !city; leg++){ + const WalkResult walk = walk_until(stream, context, 1, 0, 10000ms, "east through the Oreburgh Gate"); + if (walk.stop == WalkStop::LIMIT || walk.stop == WalkStop::TRANSITION){ + continue; + } + if (walk.stop != WalkStop::DIALOG && !dialogue_opens(stream, context, 2000ms)){ + // A card or menu left up holds the screen still: B once, then retry. + if (++stuck > 1){ + fail(stream, "Oreburgh Gate: a wall stopped us walking east."); + } + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + continue; + } + stuck = 0; + if (fights >= 2){ + city = true; + break; + } + fights += talk_or_fight(stream, context, "Oreburgh Gate trainer " + std::to_string(fights + 1)) ? 1 : 0; + } + if (!city){ + fail(stream, "Oreburgh Gate: did not reach Oreburgh City."); + } + + step(4); + // He walks us to the Pokemon Center between his lines, and Barry joins + // there: more lines after each walk. + finish_dialogue(stream, context, 0, "Man at Oreburgh's entrance"); + for (size_t scene = 0; scene < 6 && dialogue_opens(stream, context, 6000ms); scene++){ + finish_dialogue(stream, context, 0, "Oreburgh scene"); + } + context.wait_for_all_requests(); +} + +// Holds the stick toward `dir` for `tiles` tiles, turning first. +void hold_tiles(VideoStream& stream, ProControllerContext& context, Direction dir, size_t tiles){ + route_turn(stream, context, dir); + const double x = dir == Direction::EAST ? 1 : dir == Direction::WEST ? -1 : 0; + const double y = dir == Direction::NORTH ? 1 : dir == Direction::SOUTH ? -1 : 0; + pbf_move_left_joystick(context, {x, y}, route_open_hold(dir, tiles), 200ms); + context.wait_for_all_requests(); +} + +WalkResult walk_dir(VideoStream& stream, ProControllerContext& context, Direction dir, Milliseconds limit, const std::string& label){ + const double x = dir == Direction::EAST ? 1 : dir == Direction::WEST ? -1 : 0; + const double y = dir == Direction::NORTH ? 1 : dir == Direction::SOUTH ? -1 : 0; + return walk_until(stream, context, x, y, limit, label); +} + +void oreburgh_to_roark(VideoStream& stream, ProControllerContext& context){ + // What 01.03 does, stepped through on the console before writing it. + StepLog step(stream, "01.03", "From the Oreburgh Pokemon Center to Roark in the mine", { + "Walk past Barry at the Center's door to the stone ledge, then east to the fence below the Poke Mart", + "South to the right-hand conveyor pillar, west to the left-hand pillars, then south into the mine", + "Through the mine's entrance hall to the rocky corridor", + "Walk the planned route down the corridor and round the coal block, and talk to Roark", + "Walk back to the corridor's top and out of the mine", + }); + + step(1); + // The save stands us in front of the Center, two tiles west of Barry at + // its door. These are the holds that worked on the console. + hold_tiles(stream, context, Direction::EAST, 3); + walk_dir(stream, context, Direction::NORTH, 3000ms, "north to the Center's front"); + hold_tiles(stream, context, Direction::EAST, 2); + walk_dir(stream, context, Direction::NORTH, 2500ms, "north beside Barry"); + // Roark answers in the mine only after Barry has told us he is there + // (2026-10-29: he ignored A in r101 and r104 and by hand, and answered at + // once after a talk with Barry). Barry is just east of us. + route_turn(stream, context, Direction::EAST); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + if (!dialogue_opens(stream, context, 3000ms)){ + fail(stream, "Oreburgh: Barry did not talk at the gym door."); + } + finish_dialogue(stream, context, 0, "Barry at the gym door"); + if (walk_dir(stream, context, Direction::SOUTH, 8000ms, "south to the stone ledge").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking south did not stop at the stone ledge."); + } + // A townsperson wandering across the row can stop us a tile or two short + // (r95), so walk east again until a walk stops without moving. + bool at_fence = false; + for (size_t attempt = 0; attempt < 5 && !at_fence; attempt++){ + const WalkResult east = walk_dir(stream, context, Direction::EAST, 10000ms, "east to the fence below the Poke Mart"); + if (east.stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking east did not stop at the fence below the Poke Mart."); + } + at_fence = attempt > 0 && east.walked <= 800ms; + if (!at_fence){ + context.wait_for(1500ms); + } + } + if (!at_fence){ + fail(stream, "Oreburgh: something kept stopping the walk east to the fence below the Poke Mart."); + } + + step(2); + // The mine's stairs are straight south of here, two columns west of + // the fence (Bulbapedia's Oreburgh map: column 46, between the conveyor + // pillars at 42-44 and 48-50). One walk south goes down the yard's + // stairs, under the conveyors and into the mine; on the console it ran + // on through the entrance hall into the rocky corridor. A worker walks + // about on the sand path: wait for him, then try the columns beside. + // Timed holds of a tile or two move one tile or two unpredictably, so + // the column comes from walls. Down the path along its east fence ends + // against the right-hand conveyor pillar in the yard (column 48); from + // there west stops against the left-hand pillars, on column 45, which + // runs clear south into the mine. The belts keep the screen moving, so + // these stops read as the walk's time limit, not as a wall. + // + // The worker by the fence wanders onto the path and stops the walk south + // as a wall would (r95, r96): wait for him to move and walk on. + WalkResult down{WalkStop::BLOCKED, 0ms, 0}; + for (size_t attempt = 0; attempt < 5; attempt++){ + down = walk_dir(stream, context, Direction::SOUTH, 12000ms, "south to the right-hand pillar"); + if (down.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, "Someone on the way to the mine"); + }else if (down.stop != WalkStop::BLOCKED){ + break; + } + context.wait_for(2000ms); + } + bool in_mine = down.stop == WalkStop::TRANSITION; // already in the mine's column + if (!in_mine && down.stop != WalkStop::LIMIT){ + fail(stream, "Oreburgh: something kept stopping the walk south to the conveyor pillars."); + } + // West stops against the left-hand pillars, on the column of the sign + // above the mine's stairs (on the console, r96): the stairs are open a + // tile either side of it, so step back one tile east and walk south. + auto step_tiles = [&](Direction side, size_t tiles){ + route_turn(stream, context, side); + for (size_t tile = 0; tile < tiles; tile++){ + pbf_move_left_joystick(context, {side == Direction::EAST ? 1.0 : -1.0, 0}, 120ms, 300ms); + } + context.wait_for_all_requests(); + }; + if (!in_mine){ + walk_dir(stream, context, Direction::WEST, 2500ms, "west to the left-hand pillars"); + step_tiles(Direction::EAST, 1); + in_mine = walk_dir(stream, context, Direction::SOUTH, 15000ms, "south into the mine").stop == WalkStop::TRANSITION; + } + if (!in_mine){ + step_tiles(Direction::WEST, 2); // the sign's other side + in_mine = walk_dir(stream, context, Direction::SOUTH, 5000ms, "south down the mine's stairs").stop == WalkStop::TRANSITION; + } + if (!in_mine){ + fail(stream, "Oreburgh: no column south of the Center led into the mine."); + } + context.wait_for(1500ms); + + step(3); + // If the fade into the mine was missed, we are already in the corridor + // and this walk stops at its boulder; otherwise it crosses the hall. + const WalkResult hall = walk_dir(stream, context, Direction::SOUTH, 10000ms, "through the mine's entrance hall"); + if (hall.stop == WalkStop::TRANSITION){ + context.wait_for(1500ms); + } + + step(4); + // Planned on the coal room's tile grid (Data/OreburghMine): the + // corridor's rocks zigzag a row at a time, the room's second row passes + // under the worker on the first, and the east side leads down to the + // bottom row below Roark. + const Pokemon::MapSet& maps = oreburgh_mine_maps(); + auto world = [&](const Pokemon::TilePos& pos){ return maps.to_world(MAP_OREBURGH_MINE, pos); }; + std::vector no_trainers; + auto in_the_way = [&](const std::string& who){ talk_or_fight(stream, context, who); }; + std::vector at; + for (const Pokemon::TilePos& pos : OreburghMine::corridor_top()){ + at.push_back(world(pos)); + } + // Roark stands with a rock on each side, so the rock east of him is the + // anchor: plan to his row's open stretch east of the rock, walk north to + // the wall above it and west until the rock stops us, two tiles east of + // him (on the console, r97). Then south to the bottom wall and two tiles + // west puts us below him. + const Pokemon::TilePos roark = world(OreburghMine::ROARK); + at = walk_route(stream, context, maps, at, + [&](const Pokemon::TilePos& pos){ return pos.y >= roark.y && pos.y <= roark.y + 1 && pos.x >= roark.x + 2 && pos.x <= roark.x + 7; }, + no_trainers, in_the_way, "To the stretch east of Roark"); + walk_dir(stream, context, Direction::NORTH, 2000ms, "north to the wall on Roark's row"); + // A wild battle on the way eats into the walk's time (r100): walk again. + WalkStop west = WalkStop::LIMIT; + for (size_t attempt = 0; attempt < 3 && west == WalkStop::LIMIT; attempt++){ + west = walk_dir(stream, context, Direction::WEST, 6000ms, "west to the rock beside Roark").stop; + } + if (west != WalkStop::BLOCKED){ + fail(stream, "Oreburgh Mine: walking west along Roark's row did not stop at his rock."); + } + if (walk_dir(stream, context, Direction::SOUTH, 2000ms, "south to the bottom wall").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh Mine: walking south from Roark's rock did not stop at the bottom wall."); + } + route_turn(stream, context, Direction::WEST); + pbf_move_left_joystick(context, {-1, 0}, 120ms, 300ms); + pbf_move_left_joystick(context, {-1, 0}, 120ms, 300ms); + route_turn(stream, context, Direction::NORTH); + context.wait_for_all_requests(); + // Movement is by whole tiles: a sideways nudge only steps onto his rock's + // tile (r105, r106 read the rock's line as his). Turn to him and press A + // once; the camera zooms in and his first line, "OK, stand back and watch + // this.", can take some seconds (r98). + bool talked = false; + for (size_t attempt = 0; attempt < 3 && !talked; attempt++){ + route_turn(stream, context, Direction::EAST); + route_turn(stream, context, Direction::NORTH); + context.wait_for(500ms); + if (VideoSnapshot screen = stream.video().snapshot()){ + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/route/"; + Filesystem::create_directories(Filesystem::Path(folder)); + screen.frame->save(folder + now_to_filestring() + "-below-roark-" + std::to_string(attempt) + ".png"); + } + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + talked = dialogue_opens(stream, context, 8000ms); + } + if (!talked){ + fail(stream, "Oreburgh Mine: no talk opened below Roark."); + } + finish_dialogue(stream, context, 0, "Roark"); + // His scene ends with him smashing his rock and leaving: his tile is + // free now. Step onto it and back, or fail if he is still there. + if (walk_dir(stream, context, Direction::NORTH, 2000ms, "onto Roark's tile").walked <= 800ms){ + fail(stream, "Oreburgh Mine: Roark is still there after the talk."); + } + walk_dir(stream, context, Direction::SOUTH, 2000ms, "back to the bottom wall"); + at = {roark.next(Direction::SOUTH)}; + + step(5); + // Back up the corridor, then north out through the hall. A route leg + // north to the top's wall walks on through it into the hall (r99), and + // no wall stops us a row short of it, so the route ends on the corner + // at (14,11), where that leg started, and the walk does the rest. + const Pokemon::TilePos exit = world(OreburghMine::CORRIDOR_EXIT_START); + at = walk_route(stream, context, maps, at, + [&](const Pokemon::TilePos& pos){ return pos == exit; }, + no_trainers, in_the_way, "Back to the corridor's top"); + // Up into the entrance hall (a transition, as on the way in), then on + // north out of the mine: in r103 that walk left the mine without a + // transition we saw and ended against the sign above the stairs, which + // the moving belts make read as the time limit. + if (walk_dir(stream, context, Direction::NORTH, 10000ms, "into the entrance hall").stop != WalkStop::TRANSITION){ + fail(stream, "Oreburgh Mine: walking north from the corridor did not reach the entrance hall."); + } + context.wait_for(1500ms); + if (walk_dir(stream, context, Direction::NORTH, 10000ms, "out of the mine").stop == WalkStop::DIALOG){ + fail(stream, "Oreburgh Mine: someone stopped us on the way out of the mine."); + } + context.wait_for(1500ms); +} + + +// A rough check that we stand in a Pokemon Center: its floor fills the lower +// half of the screen with warm yellow, where the gym is grey and routes are +// green. After a battle it tells a loss (a whiteout ends at the counter) +// from a win. +bool in_pokemon_center(VideoStream& stream){ + const VideoSnapshot frame = stream.video().snapshot(); + if (!frame){ + return false; + } + const ImageRGB32 small = frame.frame->scale_to(192, 108); + size_t warm = 0, total = 0; + for (size_t y = 54; y < 108; y++){ + for (size_t x = 0; x < 192; x++){ + const uint32_t pixel = small.pixel(x, y); + const int r = (pixel >> 16) & 0xff, g = (pixel >> 8) & 0xff, b = pixel & 0xff; + total++; + if (r > 200 && g > 150 && b < 150 && r - b > 70){ + warm++; + } + } + } + const bool inside = warm * 10 > total * 3; + stream.log("[Center check] warm floor " + std::to_string(warm * 100 / total) + " %: " + + (inside ? "in a Pokemon Center." : "not in a Pokemon Center.")); + return inside; +} + +// For a player inside a Pokemon Center after a whiteout, at the counter: +// out through the door. The door is straight south of the nurse in most +// Centers; in Oreburgh's it was two tiles east of where we stood (by hand). +void walk_out_of_center(VideoStream& stream, ProControllerContext& context, const std::string& label){ + finish_dialogue(stream, context, 0, label + ": the nurse"); + const int shifts[] = {0, 2, -4}; + for (int shift : shifts){ + if (shift != 0){ + hold_tiles(stream, context, shift > 0 ? Direction::EAST : Direction::WEST, (size_t)std::abs(shift)); + } + if (walk_dir(stream, context, Direction::SOUTH, 8000ms, label + ": out of the Center").stop == WalkStop::TRANSITION){ + context.wait_for(2000ms); + return; + } + } + fail(stream, label + ": could not find the Pokemon Center's door."); +} + +// Oreburgh, from the street in front of the Pokemon Center (its door or its +// front wall) into the gym, as walked by hand on 2026-10-29: west to the +// cliff, south to the posts, a row up, then 17 tiles east is the gym door's +// column. The hold is checked by the door itself: walk north, and if the +// gym's wall stops us, try the columns either side. +void oreburgh_street_to_gym(VideoStream& stream, ProControllerContext& context){ + if (walk_dir(stream, context, Direction::WEST, 15000ms, "west to the cliff").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking west from the Center did not stop at the cliff."); + } + if (walk_dir(stream, context, Direction::SOUTH, 3000ms, "south to the posts").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking south did not stop at the posts."); + } + hold_tiles(stream, context, Direction::NORTH, 1); + hold_tiles(stream, context, Direction::EAST, 17); + // Cumulative steps: here, a tile east, a tile west, two east, two west. + const int steps[] = {0, 1, -2, 3, -4}; + for (int step : steps){ + if (step != 0){ + hold_tiles(stream, context, step > 0 ? Direction::EAST : Direction::WEST, (size_t)std::abs(step)); + } + const WalkResult door = walk_dir(stream, context, Direction::NORTH, 4000ms, "north into the gym"); + // The door's fade can read as the time limit rather than a black + // screen (by hand: "limit after 4000ms", and we were inside). + if (door.stop == WalkStop::TRANSITION || door.stop == WalkStop::LIMIT){ + context.wait_for(1000ms); + return; + } + if (door.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, "Someone outside the gym"); + } + } + fail(stream, "Oreburgh: no column near the gym's door went in."); +} + +// Inside the Oreburgh Gym: straight up the middle, fighting the trainers +// who stop us, to Roark on the top tier. Returns whether Roark was beaten; +// false after a whiteout, which leaves us in the Pokemon Center. +// Walking straight up meets both gym trainers (one at the entrance, one on +// the second tier); their experience helps an under-levelled team, and +// after a whiteout they stay beaten. Butters' route goes round them. +bool oreburgh_gym_battles(VideoStream& stream, ProControllerContext& context){ + // The entry camera pans for about 7.5 s (Butters' reference video). + context.wait_for(8000ms); + if (dialogue_opens(stream, context, 3000ms)){ + if (talk_or_fight(stream, context, "Gym trainer at the entrance") && in_pokemon_center(stream)){ + return false; + } + } + size_t stuck = 0; + for (size_t leg = 0; leg < 10; leg++){ + const WalkResult up = walk_dir(stream, context, Direction::NORTH, 8000ms, "north up the gym"); + if (up.stop == WalkStop::DIALOG){ + if (talk_or_fight(stream, context, "Gym trainer") && in_pokemon_center(stream)){ + return false; + } + continue; + } + if (up.stop != WalkStop::BLOCKED){ + continue; + } + // Roark stands at the top facing us; a wall or a stair edge says + // nothing to A. Only Roark starts a battle from our A here. + route_turn(stream, context, Direction::NORTH); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + if (dialogue_opens(stream, context, 3000ms)){ + if (talk_or_fight(stream, context, "Roark")){ + if (in_pokemon_center(stream)){ + return false; + } + finish_dialogue(stream, context, 0, "Roark after the battle"); + return true; + } + continue; + } + if (up.walked <= 800ms && ++stuck >= 3){ + fail(stream, "Oreburgh Gym: stuck on the way up, and nobody answers A."); + } + } + fail(stream, "Oreburgh Gym: did not reach Roark."); +} + +void oreburgh_gym(VideoStream& stream, ProControllerContext& context){ + StepLog step(stream, "01.04", "From the Oreburgh Mine to the Coal Badge", { + "North to the Pokemon Center's front", + "West to the cliff, south to the posts, east along the street into the gym", + "Up the gym, fighting its trainers, and beat Roark (after a loss: from the Center, again)", + "Out of the gym", + }); + step(1); + // 01.03 leaves us below the mine's sign; a tile east is the column + // straight up to the Center (by hand: blocked at its front after 6.4 s). + hold_tiles(stream, context, Direction::EAST, 1); + if (walk_dir(stream, context, Direction::NORTH, 12000ms, "north to the Center's front").stop != WalkStop::BLOCKED){ + fail(stream, "Oreburgh: walking north from the mine did not stop at the Pokemon Center."); + } + + // Losing costs some money and keeps the experience, so each try comes + // back stronger: Roark won twice by hand against Piplup Lv 10 and 14 and + // lost the third time. + bool won = false; + for (size_t attempt = 0; attempt < 5 && !won; attempt++){ + step(2); + oreburgh_street_to_gym(stream, context); + step(3); + won = oreburgh_gym_battles(stream, context); + if (!won){ + stream.log("[01.04] Lost in the gym (try " + std::to_string(attempt + 1) + "); back from the Center.", COLOR_ORANGE); + walk_out_of_center(stream, context, "Oreburgh"); + } + } + if (!won){ + fail(stream, "Oreburgh Gym: lost to Roark five times."); + } + + step(4); + // Back down the middle and out of the door. + for (size_t leg = 0; leg < 4; leg++){ + const WalkResult down = walk_dir(stream, context, Direction::SOUTH, 10000ms, "south out of the gym"); + if (down.stop == WalkStop::TRANSITION){ + context.wait_for(2000ms); + return; + } + if (down.stop == WalkStop::DIALOG){ + finish_dialogue(stream, context, 0, "Someone in the gym"); + } + } + fail(stream, "Oreburgh Gym: did not leave by its door."); +} + +// West until a transition, by the user's rule for a wall in the way: walk +// north to the top wall, which pins the row, then try the rows south of it +// one at a time. Trainers who stop us are fought. Returns at the first +// transition (a building, a cave, a gate). +void west_until_transition(VideoStream& stream, ProControllerContext& context, const std::string& label, size_t max_legs){ + for (size_t leg = 0; leg < max_legs; leg++){ + const WalkResult west = walk_dir(stream, context, Direction::WEST, 20000ms, label); + if (west.stop == WalkStop::TRANSITION){ + context.wait_for(2000ms); + return; + } + if (west.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, label + ": someone in the way"); + continue; + } + if (west.stop != WalkStop::BLOCKED){ + continue; + } + walk_dir(stream, context, Direction::NORTH, 4000ms, label + ": north to the top wall"); + bool moved = false; + for (size_t row = 0; row < 10 && !moved; row++){ + hold_tiles(stream, context, Direction::SOUTH, 1); + const WalkResult probe = walk_dir(stream, context, Direction::WEST, 1500ms, label + ": west on the next row"); + moved = probe.stop != WalkStop::BLOCKED || probe.walked > 1200ms; + if (probe.stop == WalkStop::TRANSITION){ + context.wait_for(2000ms); + return; + } + if (probe.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, label + ": someone in the way"); + } + } + if (!moved){ + fail(stream, label + ": no row below the top wall leads west."); + } + } + fail(stream, label + ": no transition after " + std::to_string(max_legs) + " legs."); +} + +void oreburgh_to_floaroma(VideoStream& stream, ProControllerContext& context){ + StepLog step(stream, "01.05", "From the Oreburgh Gym to Floaroma Town", { + "Up to the platform by Oreburgh Gate and into it (Barry bumps into us there)", + "Through the Gate and west along Route 203 and Jubilife to the Route 218 gate", + "Back east to Jubilife's north road and up it (Rowan, Dawn and two Galactic grunts: a tag battle)", + "North on Route 204 into the Ravaged Path", + "Through the Ravaged Path, smashing its rocks", + "Route 204 north on its grid, past the twins, into Floaroma", + "Heal at the Floaroma Pokemon Center", + }); + + step(1); + // From the gym door, as walked by hand on 2026-10-29. + hold_tiles(stream, context, Direction::SOUTH, 1); + hold_tiles(stream, context, Direction::NORTH, 1); + walk_dir(stream, context, Direction::WEST, 20000ms, "west to the stairs' cliff"); + walk_dir(stream, context, Direction::NORTH, 10000ms, "north along the cliff"); + hold_tiles(stream, context, Direction::EAST, 2); + walk_dir(stream, context, Direction::NORTH, 5000ms, "north up the stairs"); + walk_dir(stream, context, Direction::WEST, 6000ms, "west along the platform"); + hold_tiles(stream, context, Direction::SOUTH, 1); + // Barry runs into us on the platform ("*thud*") the first time; his lines + // end with no battle. + west_until_transition(stream, context, "Into Oreburgh Gate", 8); + + step(2); + // The Gate's cave, Route 203 (trainers stop us) and Jubilife's road all + // run west; the first door at the end is the Route 218 gate on + // Jubilife's west edge (by hand: twice). + west_until_transition(stream, context, "West to the Route 218 gate", 16); + + step(3); + // Back out east into Jubilife: its north road is columns 43 to 46 of the + // Jubilife grid, about 40 tiles east of the gate. Rowan's scene starts + // on the way up it; a wall in a few tiles means we are off its column. + if (walk_dir(stream, context, Direction::EAST, 6000ms, "back out of the gate").stop != WalkStop::TRANSITION){ + fail(stream, "Jubilife: did not leave the Route 218 gate eastwards."); + } + context.wait_for(2000ms); + hold_tiles(stream, context, Direction::EAST, 40); + const int steps[] = {0, 1, -2, 3, -4, 5, -6}; + bool scene = false; + for (int s_step : steps){ + if (s_step != 0){ + hold_tiles(stream, context, s_step > 0 ? Direction::EAST : Direction::WEST, (size_t)std::abs(s_step)); + } + const WalkResult up = walk_dir(stream, context, Direction::NORTH, 15000ms, "north up Jubilife's north road"); + if (up.stop == WalkStop::DIALOG){ + scene = true; + break; + } + walk_dir(stream, context, Direction::SOUTH, 15000ms, "back down to the street"); + } + if (!scene){ + fail(stream, "Jubilife: no column led up the north road to Rowan."); + } + if (!talk_or_fight(stream, context, "Galactic grunts with Dawn", true, true)){ + fail(stream, "Jubilife: Rowan's scene ended without the tag battle."); + } + if (in_pokemon_center(stream)){ + fail(stream, "Jubilife: lost the tag battle with Dawn."); + } + finish_dialogue(stream, context, 0, "Rowan and Dawn after the battle"); + + step(4); + // Route 204's south half: a trainer just past the sign, then the cave in + // the cliff a column west of the path. + for (size_t leg = 0; leg < 6; leg++){ + const WalkResult up = walk_dir(stream, context, Direction::NORTH, 15000ms, "north on Route 204"); + if (up.stop == WalkStop::TRANSITION){ + break; + } + if (up.stop == WalkStop::DIALOG){ + talk_or_fight(stream, context, "Route 204 trainer"); + continue; + } + if (up.stop == WalkStop::BLOCKED){ + hold_tiles(stream, context, Direction::WEST, 1); + } + if (leg == 5){ + fail(stream, "Route 204: did not reach the Ravaged Path."); + } + } + context.wait_for(2000ms); + + step(5); + // On the traced grid (Data/PokemonBDSP_AutoStory_RavagedPath): from the + // south door up the stairs to the tile west of one of the two rocks + // across the lower corridor, smash it, then east and south to the + // corridor's east end; the way out is a tile west of it, south. + { + std::vector no_trainers; + auto in_the_way = [&](const std::string& who){ talk_or_fight(stream, context, who); }; + std::vector at{RavagedPath::ENTRANCE}; + const Pokemon::MapSet rocks_in = ravaged_path_maps(); + at = walk_route(stream, context, rocks_in, at, + [&](const Pokemon::TilePos& pos){ + const auto& before = RavagedPath::before_rocks(); + return std::find(before.begin(), before.end(), pos) != before.end(); + }, + no_trainers, in_the_way, "To a rock across the corridor"); + route_turn(stream, context, Direction::EAST); + pbf_press_button(context, BUTTON_A, 80ms, 200ms); + if (!dialogue_opens(stream, context, 2000ms)){ + fail(stream, "Ravaged Path: no Rock Smash offer facing the rock east of us."); + } + // "Would you like to use Rock Smash?": A for Yes, the animation, then + // B for anything left (by hand, 2026-10-29). + pbf_press_button(context, BUTTON_A, 80ms, 1500ms); + pbf_press_button(context, BUTTON_A, 80ms, 4000ms); + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + context.wait_for_all_requests(); + // We may be on either row; the rock east of the other stays, but no + // leg from our row crosses it. + std::vector smashed; + for (const Pokemon::TilePos& pos : RavagedPath::before_rocks()){ + smashed.push_back(pos.next(Direction::EAST)); + } + const Pokemon::MapSet rocks_out = ravaged_path_maps(smashed); + at = walk_route(stream, context, rocks_out, at, + [&](const Pokemon::TilePos& pos){ return pos == RavagedPath::EAST_END; }, + no_trainers, in_the_way, "To the corridor's east end"); + hold_tiles(stream, context, Direction::WEST, 1); + const WalkResult out = walk_dir(stream, context, Direction::SOUTH, 4000ms, "south out of the Ravaged Path"); + if (out.stop != WalkStop::TRANSITION && out.stop != WalkStop::LIMIT){ + fail(stream, "Ravaged Path: the walk south from a tile west of the corridor's end did not leave the cave."); + } + context.wait_for(2000ms); + } + + step(6); + // On the traced grid (Data/PokemonBDSP_AutoStory_Route204): from the + // cave's door west and up the stairs, round the pocket the ledge closes, + // east and north through the tall grass, and west below the flower beds, + // where the twins see us; then north between the beds into Floaroma. + { + std::vector twins = Route204::north_trainer_sights(); + auto fight = [&](const std::string& who){ + const bool doubles = who.find("Twins") != std::string::npos || who.find("someone") != std::string::npos; + talk_or_fight(stream, context, who, false, doubles); + }; + std::vector at{Route204::CAVE_EXIT}; + at = walk_route(stream, context, route204_north_maps(), at, Route204::in_floaroma_path, + twins, fight, "To the path into Floaroma"); + // Floaroma's arrival did not read as a black screen by hand ("limit + // after 20000ms", and we stood in the town), so either will do. + const WalkResult in = walk_dir(stream, context, Direction::NORTH, 20000ms, "north into Floaroma"); + if (in.stop == WalkStop::BLOCKED || in.stop == WalkStop::DIALOG){ + fail(stream, "Route 204: the walk north into Floaroma was stopped."); + } + context.wait_for(2000ms); + } + + step(7); + // We arrive in front of a house; the Center is a few tiles south-east. + walk_dir(stream, context, Direction::SOUTH, 1500ms, "south off the house's path"); + hold_tiles(stream, context, Direction::EAST, 4); + heal_at_pokemon_center(stream, context, "Floaroma Pokemon Center"); +} + +void checkpoint_0101( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + jubilife_to_barry(env.console, context); + }, + true + ); +} + +void checkpoint_0103( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + oreburgh_to_roark(env.console, context); + }, + true + ); +} + +void checkpoint_0102( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + route203_to_oreburgh(env.console, context); + }, + true + ); +} + +void checkpoint_0104( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + oreburgh_gym(env.console, context); + }, + true + ); +} + +void checkpoint_0105( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +){ + checkpoint_reattempt_loop(env, context, options, stats, + [&](size_t /*attempt*/){ + oreburgh_to_floaroma(env.console, context); + }, + true + ); +} +} + + +std::string AutoStory_Segment_01::name() const { return "01: Jubilife to Floaroma"; } +std::string AutoStory_Segment_01::start_text() const { return "Start: Standing in front of the Jubilife City Pokemon Center PC."; } +std::string AutoStory_Segment_01::end_text() const { return "End: In the Floaroma Town Pokemon Center, with the Coal Badge."; } + +void AutoStory_Segment_01::run_segment( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + stats.m_segment++; + env.update_stats(); + env.console.log("Start Segment " + name(), COLOR_ORANGE); + AutoStory_Checkpoint_0101().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0102().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0103().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0104().run_checkpoint(env, context, options, stats); + AutoStory_Checkpoint_0105().run_checkpoint(env, context, options, stats); + env.console.log("End Segment " + name(), COLOR_GREEN); +} + +std::string AutoStory_Checkpoint_0101::name() const { return "01.01 - Jubilife to Barry"; } +std::string AutoStory_Checkpoint_0101::start_text() const { return "Standing in front of the Jubilife City Pokemon Center PC."; } +std::string AutoStory_Checkpoint_0101::end_text() const { return "On Route 203 by its sign, after beating Barry."; } + +void AutoStory_Checkpoint_0101::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0101(env, context, options, stats); +} + +std::string AutoStory_Checkpoint_0102::name() const { return "01.02 - Route 203 to Oreburgh"; } +std::string AutoStory_Checkpoint_0102::start_text() const { return AutoStory_Checkpoint_0101().end_text(); } +std::string AutoStory_Checkpoint_0102::end_text() const { return "In front of the Oreburgh Pokemon Center, after the man's tour and Barry."; } + +void AutoStory_Checkpoint_0102::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0102(env, context, options, stats); +} + + + +std::string AutoStory_Checkpoint_0103::name() const { return "01.03 - Oreburgh Mine to Roark"; } +std::string AutoStory_Checkpoint_0103::start_text() const { return AutoStory_Checkpoint_0102().end_text(); } +std::string AutoStory_Checkpoint_0103::end_text() const { return "Outside the Oreburgh Mine, after talking to Roark."; } + +void AutoStory_Checkpoint_0103::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0103(env, context, options, stats); +} + + + +std::string AutoStory_Checkpoint_0104::name() const { return "01.04 - Oreburgh Gym"; } +std::string AutoStory_Checkpoint_0104::start_text() const { return AutoStory_Checkpoint_0103().end_text(); } +std::string AutoStory_Checkpoint_0104::end_text() const { return "Outside the Oreburgh Gym's door, with the Coal Badge."; } + +void AutoStory_Checkpoint_0104::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0104(env, context, options, stats); +} + +std::string AutoStory_Checkpoint_0105::name() const { return "01.05 - Oreburgh to Floaroma"; } +std::string AutoStory_Checkpoint_0105::start_text() const { return AutoStory_Checkpoint_0104().end_text(); } +std::string AutoStory_Checkpoint_0105::end_text() const { return "In the Floaroma Town Pokemon Center, healed."; } + +void AutoStory_Checkpoint_0105::run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats +) const{ + checkpoint_0105(env, context, options, stats); +} + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h index a9cd8d6a76..c237106102 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.h @@ -1,131 +1,100 @@ -/* BDSP AutoStory - Segment 01 - * From after choosing the starter Pokemon until after acquiring the first badge. - * - * From: https://github.com/PokemonAutomation/ - * - */ - -#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment01_H -#define PokemonAutomation_PokemonBDSP_AutoStory_Segment01_H - -#include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" - -namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - - -// Route ownership: Oreburgh navigation and isolated route tests belong to Segment 01. -enum class MineRouteStage { - ALIGN_ENTRANCE, - FIRST_ROCK, - RIGHT_OF_FIRST_ROCK, - BELOW_FIRST_ROCK, - CENTER_GAP, - POSITION_07, - POSITION_08, - POSITION_09, - ITEM_AT_14, - ROARK_AT_19 -}; - -struct MineRouteTestOptions { - MineRouteStage first = MineRouteStage::ALIGN_ENTRANCE; - MineRouteStage last = MineRouteStage::ROARK_AT_19; - - // False: stop the test on failure. - // True: let the checkpoint retry handler reset the game. - bool reset_on_failure = false; -}; - -void jubilife_to_oreburgh_gate(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -void oreburgh_gate_nav(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -void oreburgh_gate_to_mine(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -void oreburgh_mine_nav(VideoStream& stream, ProControllerContext& context); - -void oreburgh_mine_to_gym(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -void gym_Puzzle_01(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context); - -// Starts inside the mine at an entrance position; stops outside after -// talking to Roark and using Escape Rope. Does not reset on failure. -bool oreburgh_mine_route_and_escape_test(VideoStream& stream, ProControllerContext& context); - -// Shared stage runner for production navigation and isolated route tests. -bool oreburgh_mine_route(VideoStream& stream, ProControllerContext& context, const MineRouteTestOptions& options); - -bool oreburgh_mine_first_leg_test(VideoStream& stream, - ProControllerContext& context, - const MineRouteTestOptions& options = MineRouteTestOptions()); - -class AutoStory_Segment_01 : public AutoStory_Segment { -public: - virtual std::string name() const override; - virtual std::string start_text() const override; - virtual std::string end_text() const override; - virtual void run_segment(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const override; -}; - -class AutoStory_checkpoint_0101 : public AutoStory_Checkpoint { -public: - virtual std::string name() const override; - virtual std::string start_text() const override; - virtual std::string end_text() const override; - virtual void run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const override; -}; - -// start: starter Pokemon taken out of PC at level 100 and ready to be used in battle -// end: first Gym Badge acquired (Oreburgh City) -void checkpoint_0101(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats); -class AutoStory_checkpoint_0102 : public AutoStory_Checkpoint { -public: - virtual std::string name() const override; - virtual std::string start_text() const override; - virtual std::string end_text() const override; - virtual void run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const override; -}; - -// start: starter Pokemon taken out of PC at level 100 and ready to be used in battle -// end: first Gym Badge acquired (Oreburgh City) -void checkpoint_0102(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats); - -class AutoStory_checkpoint_0103 : public AutoStory_Checkpoint { -public: - virtual std::string name() const override; - virtual std::string start_text() const override; - virtual std::string end_text() const override; - virtual void run_checkpoint(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats) const override; -}; - -// start: starter Pokemon taken out of PC at level 100 and ready to be used in battle -// end: first Gym Badge acquired (Oreburgh City) -void checkpoint_0103(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - AutoStoryOptions options, - AutoStoryStats& stats); - -} -} -} -#endif +/* BDSP AutoStory - Segment 01 + * From the Jubilife Pokemon Center to Oreburgh City. + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Segment01_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Segment01_H + +#include "../PokemonBDSP_AutoStoryTools.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +class AutoStory_Segment_01 : public AutoStory_Segment{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_segment( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0101 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0102 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0103 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0104 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + +class AutoStory_Checkpoint_0105 : public AutoStory_Checkpoint{ +public: + virtual std::string name() const override; + virtual std::string start_text() const override; + virtual std::string end_text() const override; + virtual void run_checkpoint( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + AutoStoryOptions options, + AutoStoryStats& stats + ) const override; +}; + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp index c7843b41cb..554347d66b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.cpp @@ -6,15 +6,25 @@ * */ + +#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" +#include "CommonFramework/GlobalAutoPaths.h" #include -#include -#include #include +#include +#include +#include + +#include "PokemonBDSP_AutoStory_RouteWalk.h" +#include "PokemonBDSP_AutoStory_Segment_00_tools.h" +#include "PokemonBDSP_Autostory_Segment_01_tools.h" #include "CommonFramework/Exceptions/OperationFailedException.h" #include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" -#include "CommonFramework/GlobalAutoPaths.h" +#include "Common/Cpp/PrettyPrint.h" +#include "Common/Cpp/Filesystem/Filesystem.h" #include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonTools/OCR/OCR_RawTesseractOCR.h" #include "CommonFramework/Tools/ErrorDumper.h" #include "CommonFramework/VideoPipeline/VideoFeed.h" #include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" @@ -25,2017 +35,2044 @@ #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "NintendoSwitch/NintendoSwitch_Settings.h" + #include "NintendoSwitch/Programs/DateManip/NintendoSwitch_DateManip.h" #include "NintendoSwitch/Programs/DateSpam/NintendoSwitch_HomeToDateTime.h" #include "NintendoSwitch/Programs/NintendoSwitch_GameEntry.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_EndBattleDetector.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_ExperienceGainDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" #include "PokemonBDSP/Inference/PokemonBDSP_MenuDetector.h" -#include "PokemonBDSP/Inference/PokemonBDSP_ReceivePokemonDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" #include "PokemonBDSP/Inference/PokemonBDSP_SelectionArrow.h" -#include "PokemonBDSP/Inference/PokemonBDSP_TransitionWatchers.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_EndBattleDetector.h" +#include "PokemonBDSP/Inference/PokemonBDSP_ReceivePokemonDetector.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_ExperienceGainDetector.h" + #include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" + +#include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" #include "PokemonBDSP/Programs/PokemonBDSP_BasicCatcher.h" #include "PokemonBDSP/Programs/PokemonBDSP_GameEntry.h" -#include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" - -#include "PokemonBDSP_Autostory_Segment_01_tools.h" - -#include "PokemonBDSP_AutoStory_Segment_00_tools.h" using namespace std::chrono_literals; namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - -// --------------------------------------------------------------------------- -// BattleDialogWatcher -// --------------------------------------------------------------------------- - -BattleDialogWatcher::BattleDialogWatcher(Color color) - : DetectorToFinder("BattleDialogWatcher", std::chrono::milliseconds(250), color){ -} - -// --------------------------------------------------------------------------- -// Poketech Clown dialog helper -// --------------------------------------------------------------------------- - -int detect_clown_choice_arrow(VideoOverlay& overlay, VideoSnapshot snapshot){ - SelectionArrowFinder top_arrow(overlay, {0.62, 0.37, 0.08, 0.10}, COLOR_RED); - - SelectionArrowFinder bottom_arrow(overlay, {0.62, 0.48, 0.08, 0.10}, COLOR_RED); - - if (top_arrow.detect(snapshot)){ - return 0; - } - - if (bottom_arrow.detect(snapshot)){ - return 1; - } - - return -1; -} + namespace NintendoSwitch { + namespace PokemonBDSP { + + // --------------------------------------------------------------------------- + // BattleDialogWatcher + // --------------------------------------------------------------------------- + + BattleDialogWatcher::BattleDialogWatcher(Color color) + : DetectorToFinder( + "BattleDialogWatcher", + std::chrono::milliseconds(250), + color + ) + { + } -void clown_dialog_helper(VideoStream& stream, ProControllerContext& context, DpadPosition direction){ - stream.log("[AutoStory] Starting clown dialog helper...", COLOR_BLUE); + // --------------------------------------------------------------------------- + // Poketech Clown dialog helper + // --------------------------------------------------------------------------- + + int detect_clown_choice_arrow( + VideoOverlay& overlay, + VideoSnapshot snapshot + ) { + SelectionArrowFinder top_arrow( + overlay, + { 0.62, 0.37, 0.08, 0.10 }, + COLOR_RED + ); + + SelectionArrowFinder bottom_arrow( + overlay, + { 0.62, 0.48, 0.08, 0.10 }, + COLOR_RED + ); + + if (top_arrow.detect(snapshot)) { + return 0; + } - const auto overall_deadline = std::chrono::steady_clock::now() + 30s; + if (bottom_arrow.detect(snapshot)) { + return 1; + } - // ------------------------------------------------- - // Phase 1: Move + interact until dialog is detected. - // ------------------------------------------------- - ShortDialogWatcher dialog(COLOR_YELLOW); - bool dialog_started = false; + return -1; + } - while (!dialog_started){ + void clown_dialog_helper( + VideoStream& stream, + ProControllerContext& context, + DpadPosition direction + ) { + stream.log( + "[AutoStory] Starting clown dialog helper...", + COLOR_BLUE + ); + + const auto overall_deadline = + std::chrono::steady_clock::now() + 30s; + + // ------------------------------------------------- + // Phase 1: Move + interact until dialog is detected. + // ------------------------------------------------- + ShortDialogWatcher dialog(COLOR_YELLOW); + bool dialog_started = false; + + while (!dialog_started) { + + if (std::chrono::steady_clock::now() > overall_deadline) { + stream.log( + "[AutoStory] Clown helper timed out finding dialog.", + COLOR_RED + ); + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "[AutoStory] Clown helper timed out finding dialog.", stream); + } - if (std::chrono::steady_clock::now() > overall_deadline){ - stream.log("[AutoStory] Clown helper timed out finding dialog.", COLOR_RED); - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "[AutoStory] Clown helper timed out finding dialog.", - stream); - } + context.wait_for_all_requests(); + pbf_press_dpad(context, direction, 80ms, 200ms); + pbf_press_button(context, BUTTON_A, 80ms, 500ms); - context.wait_for_all_requests(); - pbf_press_dpad(context, direction, 80ms, 200ms); - pbf_press_button(context, BUTTON_A, 80ms, 500ms); + if (wait_until(stream, context, 1500ms, { {dialog} }) == 0) { + dialog_started = true; + stream.log("[AutoStory] Dialog detected.", COLOR_GREEN); + } + } - if (wait_until(stream, context, 1500ms, {{dialog}}) == 0){ - dialog_started = true; - stream.log("[AutoStory] Dialog detected.", COLOR_GREEN); - } - } + // ------------------------------------------------- + // Phase 2: A mash for 2 seconds. + // ------------------------------------------------- + stream.log("[AutoStory] Mashing A to advance dialog...", COLOR_BLUE); + pbf_mash_button(context, BUTTON_A, 4000ms); + context.wait_for_all_requests(); - // ------------------------------------------------- - // Phase 2: A mash for 2 seconds. - // ------------------------------------------------- - stream.log("[AutoStory] Mashing A to advance dialog...", COLOR_BLUE); - pbf_mash_button(context, BUTTON_A, 4000ms); - context.wait_for_all_requests(); + // ------------------------------------------------- + // Phase 3: Clear remaining dialog. + // ------------------------------------------------- + pbf_mash_button(context, BUTTON_B, 4000ms); - // ------------------------------------------------- - // Phase 3: Clear remaining dialog. - // ------------------------------------------------- - pbf_mash_button(context, BUTTON_B, 4000ms); + context.wait_for_all_requests(); - context.wait_for_all_requests(); + stream.log( + "[AutoStory] Clown dialog helper complete.", + COLOR_GREEN + ); + } + // --------------------------------------------------------------------------- + // Time helper + // --------------------------------------------------------------------------- + + // Options chrome and position detectors are independent of setting values. + class TutorialOptionsDetector { + public: + bool detect(const ImageViewRGB32& screen) const { + ImageViewRGB32 panel = extract_box_reference(screen, ImageFloatBox{0.04, 0.065, 0.93, 0.70}); + size_t white = 0; + for (size_t y = 0; y < panel.height(); y += 4) + for (size_t x = 0; x < panel.width(); x += 4) { + uint32_t p = panel.pixel(x, y); + if (((p >> 16) & 255) > 225 && ((p >> 8) & 255) > 225 && (p & 255) > 225) white++; + } + double samples = (double)(((panel.width() + 3) / 4) * ((panel.height() + 3) / 4)); + // Distinguish Options from the icon menu and bag using its + // blue heading and full-width white settings panel. + ImageViewRGB32 heading = extract_box_reference(screen, ImageFloatBox{0.01, 0.005, 0.20, 0.025}); + size_t blue = 0, total = 0; + for (size_t y = 0; y < heading.height(); y++) + for (size_t x = 0; x < heading.width(); x++) { + uint32_t p = heading.pixel(x, y); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + blue += b > r + 20 && g > r + 10; + total++; + } + return samples > 0 && white / samples > 0.70 && total > 0 && blue > total / 4; + } + }; - stream.log("[AutoStory] Clown dialog helper complete.", COLOR_GREEN); -} -// --------------------------------------------------------------------------- -// Time helper -// --------------------------------------------------------------------------- - -// Options chrome and position detectors are independent of setting values. -class TutorialOptionsDetector { -public: - bool detect(const ImageViewRGB32& screen) const { - ImageViewRGB32 panel = extract_box_reference(screen, ImageFloatBox{0.04, 0.065, 0.93, 0.70}); - size_t white = 0; - for (size_t y = 0; y < panel.height(); y += 4){ - for (size_t x = 0; x < panel.width(); x += 4){ - uint32_t p = panel.pixel(x, y); - if (((p >> 16) & 255) > 225 && ((p >> 8) & 255) > 225 && (p & 255) > 225){ - white++; + // Options fades in after A on its icon: poll instead of judging one frame. + static bool wait_for_options_screen(VideoStream& stream, ProControllerContext& context, + const TutorialOptionsDetector& options_screen, Milliseconds timeout) { + context.wait_for_all_requests(); + const WallClock deadline = current_time() + timeout; + while (current_time() < deadline) { + auto frame = stream.video().snapshot(); + if (frame && options_screen.detect(frame)) return true; + context.wait_for(100ms); } + return false; } - } - double samples = (double)(((panel.width() + 3) / 4) * ((panel.height() + 3) / 4)); - // Distinguish Options from the icon menu and bag using its - // blue heading and full-width white settings panel. - ImageViewRGB32 heading = extract_box_reference(screen, ImageFloatBox{0.01, 0.005, 0.20, 0.025}); - size_t blue = 0, total = 0; - for (size_t y = 0; y < heading.height(); y++){ - for (size_t x = 0; x < heading.width(); x++){ - uint32_t p = heading.pixel(x, y); - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - blue += b > r + 20 && g > r + 10; - total++; - } - } - return samples > 0 && white / samples > 0.70 && total > 0 && blue > total / 4; - } -}; - -class TutorialOptionPositionDetector { -public: - // Locate the two long red horizontal lines surrounding the - // selected row. Scrolling changes their position automatically. - double selected_row(const ImageViewRGB32& screen) const { - int first = -1, last = -1; - for (size_t y = (size_t)(screen.height() * 0.08); y < screen.height() * 0.75; y++){ - size_t red = 0, total = 0; - for (size_t x = (size_t)(screen.width() * 0.08); x < screen.width() * 0.90; x += 2){ - uint32_t p = screen.pixel(x, y); - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - red += r > 150 && r > g + 40 && r > b + 40; - total++; - } - if (total && red > total * 0.70){ - if (first < 0){ - first = (int)y; + + class TutorialOptionPositionDetector { + public: + // Locate the two long red horizontal lines surrounding the + // selected row. Scrolling changes their position automatically. + double selected_row(const ImageViewRGB32& screen) const { + int first = -1, last = -1; + for (size_t y = (size_t)(screen.height() * 0.08); y < screen.height() * 0.75; y++) { + size_t red = 0, total = 0; + for (size_t x = (size_t)(screen.width() * 0.08); x < screen.width() * 0.90; x += 2) { + uint32_t p = screen.pixel(x, y); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + red += r > 150 && r > g + 40 && r > b + 40; + total++; + } + if (total && red > total * 0.70) { + if (first < 0) first = (int)y; + last = (int)y; + } + } + if (first < 0 || last - first < screen.height() * 0.04 || last - first > screen.height() * 0.12) return -1; + return (first + last) / (2.0 * screen.height()); } - last = (int)y; - } - } - if (first < 0 || last - first < screen.height() * 0.04 || last - first > screen.height() * 0.12){ - return -1; - } - return (first + last) / (2.0 * screen.height()); - } - // Locate selected red value text separately from the row border. - bool selected_value(const ImageViewRGB32& screen, double y) const { - ImageViewRGB32 value = extract_box_reference(screen, ImageFloatBox{0.33, y - 0.025, 0.54, 0.05}); - size_t red = 0; - for (size_t py = 0; py < value.height(); py++){ - for (size_t px = 0; px < value.width(); px++){ - uint32_t p = value.pixel(px, py); - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - red += r > g + 20 && r > b + 20; - } - } - return red > value.width() * value.height() * 0.0005; - } -}; - - -// Compare binary visual shapes with small alignment tolerance. -// No text recognition is needed for fixed English option labels. -static double -option_image_score(const ImageViewRGB32& screen, const ImageFloatBox& box, const ImageRGB32& reference, bool red_only){ - ImageRGB32 actual = extract_box_reference(screen, box).scale_to(384, 32); - ImageRGB32 expected = reference.scale_to(384, 32); - auto ink = [red_only](uint32_t p){ - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - return red_only ? r > g + 20 && r > b + 20 : r + g + b < 500; - }; - double best = 0; - for (int dy = -2; dy <= 2; dy++){ - for (int dx = -2; dx <= 2; dx++){ - size_t overlap = 0, count = 0; - for (int y = 2; y < 30; y++){ - for (int x = 2; x < 382; x++){ - bool a = ink(actual.pixel(x + dx, y + dy)); - bool b = ink(expected.pixel(x, y)); - overlap += a && b; - count += a + b; + // Locate selected red value text separately from the row border. + bool selected_value(const ImageViewRGB32& screen, double y) const { + ImageViewRGB32 value = extract_box_reference(screen, ImageFloatBox{0.33, y - 0.025, 0.54, 0.05}); + size_t red = 0; + for (size_t py = 0; py < value.height(); py++) + for (size_t px = 0; px < value.width(); px++) { + uint32_t p = value.pixel(px, py); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + red += r > g + 20 && r > b + 20; + } + return red > value.width() * value.height() * 0.0005; } + }; + + + // Compare binary visual shapes with small alignment tolerance. + // No text recognition is needed for fixed English option labels. + static double option_image_score(const ImageViewRGB32& screen, + const ImageFloatBox& box, const ImageRGB32& reference, bool red_only) { + ImageRGB32 actual = extract_box_reference(screen, box).scale_to(384, 32); + ImageRGB32 expected = reference.scale_to(384, 32); + auto ink = [red_only](uint32_t p){ + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + return red_only ? r > g + 20 && r > b + 20 : r + g + b < 500; + }; + double best = 0; + for (int dy = -2; dy <= 2; dy++) for (int dx = -2; dx <= 2; dx++) { + size_t overlap = 0, count = 0; + for (int y = 2; y < 30; y++) for (int x = 2; x < 382; x++) { + bool a = ink(actual.pixel(x + dx, y + dy)); + bool b = ink(expected.pixel(x, y)); + overlap += a && b; + count += a + b; + } + if (count) best = std::max(best, 2.0 * overlap / count); + } + return best; } - if (count){ - best = std::max(best, 2.0 * overlap / count); - } - } - } - return best; -} -static bool autosave_off_highlight(const ImageViewRGB32& screen, double y){ - auto red_count = [&](const ImageFloatBox& box){ - ImageViewRGB32 image = extract_box_reference(screen, box); - size_t red = 0; - for (size_t py = 0; py < image.height(); py++){ - for (size_t px = 0; px < image.width(); px++){ - uint32_t p = image.pixel(px, py); - int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; - red += r > g + 30 && r > b + 30; + static bool autosave_off_highlight(const ImageViewRGB32& screen, double y) { + auto red_count = [&](const ImageFloatBox& box){ + ImageViewRGB32 image = extract_box_reference(screen, box); + size_t red = 0; + for (size_t py = 0; py < image.height(); py++) + for (size_t px = 0; px < image.width(); px++) { + uint32_t p = image.pixel(px, py); + int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + red += r > g + 30 && r > b + 30; + } + return red; + }; + size_t off = red_count({0.63, y - 0.022, 0.055, 0.044}); + size_t on = red_count({0.34, y - 0.022, 0.055, 0.044}); + // Require a clear Off highlight and reject any competing On highlight. + return off >= 10 && on * 10 < off; } - } - return red; - }; - size_t off = red_count({0.63, y - 0.022, 0.055, 0.044}); - size_t on = red_count({0.34, y - 0.022, 0.055, 0.044}); - // Require a clear Off highlight and reject any competing On highlight. - return off >= 10 && on * 10 < off; -} -static void verify_tutorial_settings_once(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - MenuDetector menu; - VideoOverlaySet detector_overlays(stream.overlay()); - menu.make_overlays(detector_overlays); - detector_overlays.add(COLOR_CYAN, {0.04, 0.065, 0.93, 0.70}, ""); - detector_overlays.add(COLOR_BLUE, {0.01, 0.005, 0.20, 0.025}, ""); - detector_overlays.add(COLOR_RED, {0.08, 0.08, 0.82, 0.67}, ""); - auto initial = stream.video().snapshot(); - if (!initial || !menu.detect(initial)){ - pbf_press_button(context, BUTTON_X, 80ms, 1000ms); - context.wait_for_all_requests(); - } - MenuWatcher menu_open; - if (wait_until(stream, context, 5s, {{menu_open}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Main menu not detected.", - stream); - } - stream.log("[Tutorial settings] Main menu detected; settling for 500ms before Options navigation."); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - TutorialOptionsDetector options_screen; - TutorialOptionPositionDetector position; - // Only two icons are available at this initial checkpoint. - // Verify the main menu before navigating to the neighboring icon. - bool options_open = false; - for (size_t attempt = 0; attempt < 2; attempt++){ - pbf_press_dpad(context, attempt == 0 ? DPAD_RIGHT : DPAD_LEFT, 80ms, 350ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - context.wait_for_all_requests(); - auto frame = stream.video().snapshot(); - if (frame && options_screen.detect(frame)){ - options_open = true; - break; - } - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - context.wait_for_all_requests(); - MenuWatcher back_to_menu; - if (wait_until(stream, context, 5s, {{back_to_menu}}) != 0){ - break; - } - } - if (!options_open){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Options screen not detected.", - stream); - } - stream.log("[Tutorial settings] Options screen detected."); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - struct Setting { - const char* label; - const char* value; - }; - const Setting settings[] = { - {"textspeed", "fast"}, - {"changewindow", "type1"}, - {"battleeffects", "off"}, - {"battlestyle", "set"}, - {"sendtoboxes", "automatic"}, - {"givenicknames", "dontgive"}, - {"gyroscope", "use"}, - {"autosave", "off"}, - {"shownicknames", "show"}, - {"backgroundmusic", "8"}, - {"soundeffects", "8"}, - }; - for (size_t row = 0; row < sizeof(settings) / sizeof(settings[0]); row++){ - // Detect the selected row afresh after every navigation/correction. - double y = -1; - const std::string base = RESOURCE_PATH() + "PokemonBDSP/Autostory/Options/" + settings[row].label; - const ImageRGB32 label_reference(base + "_label.png"); - const ImageRGB32 value_reference(base + "_value.png"); - bool slider_reset = false; - bool correct = false; - for (size_t attempt = 0; attempt < 30; attempt++){ - context.wait_for_all_requests(); - auto frame = stream.video().snapshot(); - if (frame && options_screen.detect(frame)){ - y = position.selected_row(frame); - }else{ - y = -1; - } - const bool value_visible = y >= 0 && position.selected_value(frame, y); - const double label_score = - y >= 0 ? option_image_score(frame, {0.08, y - 0.025, 0.24, 0.05}, label_reference, false) : 0; - stream.log("[Tutorial settings] Expected row=" + std::string(settings[row].label) + - " row_y=" + std::to_string(y) + " highlighted_value=" + std::to_string(value_visible) + - " label_match=" + std::to_string(label_score)); - if (y < 0 || !value_visible || label_score < 0.80){ - // A menu can be detected while its row/text is still - // fading in. Re-read without directional inputs first. - if (attempt < 4){ - pbf_wait(context, 200ms); + static void verify_tutorial_settings_once(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); + MenuDetector menu; + VideoOverlaySet detector_overlays(stream.overlay()); + menu.make_overlays(detector_overlays); + detector_overlays.add(COLOR_CYAN, {0.04, 0.065, 0.93, 0.70}, ""); + detector_overlays.add(COLOR_BLUE, {0.01, 0.005, 0.20, 0.025}, ""); + detector_overlays.add(COLOR_RED, {0.08, 0.08, 0.82, 0.67}, ""); + auto initial = stream.video().snapshot(); + if (!initial || !menu.detect(initial)) { + pbf_press_button(context, BUTTON_X, 80ms, 1000ms); context.wait_for_all_requests(); - continue; } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Unable to confirm expected row " + std::string(settings[row].label), - stream); - } - // These are regular video-overlay boxes, so the existing - // overlay display control can show/hide every active ROI. - OverlayBoxScope row_overlay(stream.overlay(), COLOR_RED, {0.07, y - 0.04, 0.84, 0.08}, ""); - OverlayBoxScope label_overlay(stream.overlay(), COLOR_YELLOW, {0.08, y - 0.025, 0.24, 0.05}, ""); - OverlayBoxScope value_overlay(stream.overlay(), COLOR_GREEN, {0.33, y - 0.025, 0.54, 0.05}, ""); - VideoOverlaySet autosave_rois(stream.overlay()); - if (row == 7){ - autosave_rois.add(COLOR_GREEN, {0.63, y - 0.022, 0.055, 0.044}); - autosave_rois.add(COLOR_RED, {0.34, y - 0.022, 0.055, 0.044}); - } - double score; - if (row >= 9){ - // Compare the numeric value rather than the long slider: - // neighboring slider levels otherwise look too similar. - ImageViewRGB32 number = value_reference.sub_image(value_reference.width() * 82 / 100, - 0, - value_reference.width() - value_reference.width() * 82 / 100, - value_reference.height()); - ImageRGB32 number_reference = number.scale_to(96, 32); - score = option_image_score(frame, {0.7728, y - 0.025, 0.0972, 0.05}, number_reference, true); - }else{ - score = option_image_score(frame, {0.33, y - 0.025, 0.54, 0.05}, value_reference, true); - } - stream.log( - "[Tutorial settings] " + std::string(settings[row].label) + " target match=" + std::to_string(score)); - // The reference recording uses inactive pale-red text; - // the focused live row has a thicker dark-red glyph mask. - // Verified Fast scores 0.734 while Slow/Normal score 0. - if (score >= 0.65 && (row != 7 || autosave_off_highlight(frame, y))){ - if (row == 7){ - for (size_t confirmation = 0; confirmation < 3; confirmation++){ - pbf_wait(context, 200ms); + MenuWatcher menu_open; + if (wait_until(stream, context, 5s, {{menu_open}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Settings checkpoint: Main menu not detected.", stream); + } + stream.log("[Tutorial settings] Main menu detected; settling for 500ms before Options navigation."); + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + TutorialOptionsDetector options_screen; + TutorialOptionPositionDetector position; + // Only two icons are available at this initial checkpoint. + // Verify the main menu before navigating to the neighboring icon. + bool options_open = false; + for (size_t attempt = 0; attempt < 2; attempt++) { + pbf_press_dpad(context, attempt == 0 ? DPAD_RIGHT : DPAD_LEFT, 80ms, 350ms); + pbf_press_button(context, BUTTON_A, 80ms, 0ms); + if (wait_for_options_screen(stream, context, options_screen, 5s)) { + options_open = true; + break; + } + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + context.wait_for_all_requests(); + MenuWatcher back_to_menu; + if (wait_until(stream, context, 5s, {{back_to_menu}}) != 0) break; + } + if (!options_open) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Settings checkpoint: Options screen not detected.", stream); + } + stream.log("[Tutorial settings] Options screen detected."); + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + struct Setting { const char* label; const char* value; }; + const Setting settings[] = { + {"textspeed", "fast"}, {"changewindow", "type1"}, + {"battleeffects", "off"}, {"battlestyle", "set"}, + {"sendtoboxes", "automatic"}, {"givenicknames", "dontgive"}, + {"gyroscope", "use"}, {"autosave", "off"}, + {"shownicknames", "show"}, {"backgroundmusic", "8"}, + {"soundeffects", "8"}, + }; + for (size_t row = 0; row < sizeof(settings) / sizeof(settings[0]); row++) { + // Detect the selected row afresh after every navigation/correction. + double y = -1; + const std::string base = RESOURCE_PATH() + "PokemonBDSP/Autostory/Options/" + settings[row].label; + const ImageRGB32 label_reference(base + "_label.png"); + const ImageRGB32 value_reference(base + "_value.png"); + bool slider_reset = false; + bool correct = false; + for (size_t attempt = 0; attempt < 30; attempt++) { context.wait_for_all_requests(); - auto check = stream.video().snapshot(); - double check_y = check ? position.selected_row(check) : -1; - if (!check || !options_screen.detect(check) || check_y < 0 || - option_image_score(check, {0.08, check_y - 0.025, 0.24, 0.05}, label_reference, false) < - 0.80 || - !autosave_off_highlight(check, check_y)){ + auto frame = stream.video().snapshot(); + if (frame && options_screen.detect(frame)) y = position.selected_row(frame); + else y = -1; + const bool value_visible = y >= 0 && position.selected_value(frame, y); + const double label_score = y >= 0 ? option_image_score(frame, + {0.08, y - 0.025, 0.24, 0.05}, label_reference, false) : 0; + stream.log("[Tutorial settings] Expected row=" + std::string(settings[row].label) + + " row_y=" + std::to_string(y) + " highlighted_value=" + std::to_string(value_visible) + + " label_match=" + std::to_string(label_score)); + if (y < 0 || !value_visible || label_score < 0.80) { + // A menu can be detected while its row/text is still + // fading in. Re-read without directional inputs first. + if (attempt < 4) { + pbf_wait(context, 200ms); + context.wait_for_all_requests(); + continue; + } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Autosave Off could not be confirmed. Stopping before movement.", - stream); + "Settings checkpoint: Unable to confirm expected row " + std::string(settings[row].label), stream); + } + // These are regular video-overlay boxes, so the existing + // overlay display control can show/hide every active ROI. + OverlayBoxScope row_overlay(stream.overlay(), COLOR_RED, + {0.07, y - 0.04, 0.84, 0.08}, ""); + OverlayBoxScope label_overlay(stream.overlay(), COLOR_YELLOW, + {0.08, y - 0.025, 0.24, 0.05}, ""); + OverlayBoxScope value_overlay(stream.overlay(), COLOR_GREEN, + {0.33, y - 0.025, 0.54, 0.05}, ""); + VideoOverlaySet autosave_rois(stream.overlay()); + if (row == 7) { + autosave_rois.add(COLOR_GREEN, {0.63, y - 0.022, 0.055, 0.044}); + autosave_rois.add(COLOR_RED, {0.34, y - 0.022, 0.055, 0.044}); + } + double score; + if (row >= 9) { + // Compare the numeric value rather than the long slider: + // neighboring slider levels otherwise look too similar. + ImageViewRGB32 number = value_reference.sub_image( + value_reference.width() * 82 / 100, 0, + value_reference.width() - value_reference.width() * 82 / 100, + value_reference.height()); + ImageRGB32 number_reference = number.scale_to(96, 32); + score = option_image_score(frame, + {0.7728, y - 0.025, 0.0972, 0.05}, number_reference, true); + } else { + score = option_image_score(frame, + {0.33, y - 0.025, 0.54, 0.05}, value_reference, true); } + stream.log("[Tutorial settings] " + std::string(settings[row].label) + + " target match=" + std::to_string(score)); + // The reference recording uses inactive pale-red text; + // the focused live row has a thicker dark-red glyph mask. + // Verified Fast scores 0.734 while Slow/Normal score 0. + if (score >= 0.65 && (row != 7 || autosave_off_highlight(frame, y))) { + if (row == 7) { + for (size_t confirmation = 0; confirmation < 3; confirmation++) { + pbf_wait(context, 200ms); + context.wait_for_all_requests(); + auto check = stream.video().snapshot(); + double check_y = check ? position.selected_row(check) : -1; + if (!check || !options_screen.detect(check) || check_y < 0 || + option_image_score(check, {0.08, check_y - 0.025, 0.24, 0.05}, label_reference, false) < 0.80 || + !autosave_off_highlight(check, check_y)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Autosave Off could not be confirmed. Stopping before movement.", stream); + } + } + } + stream.log("[Tutorial settings] Verified " + std::string(settings[row].label) + "=" + settings[row].value); + correct = true; + break; + } + stream.log("[Tutorial settings] Correcting " + std::string(settings[row].label)); + if (row >= 9 && !slider_reset) { + // Sliders clamp at zero. Establish a known endpoint, + // then increment; verify the target image afterward. + for (size_t step = 0; step < 12; step++) + pbf_press_dpad(context, DPAD_LEFT, 80ms, 100ms); + slider_reset = true; + } else { + pbf_press_dpad(context, DPAD_RIGHT, 80ms, 350ms); + } + context.wait_for_all_requests(); + } + if (!correct) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Settings checkpoint: Could not apply " + std::string(settings[row].label), stream); } + if (row + 1 < sizeof(settings) / sizeof(settings[0])) + pbf_press_dpad(context, DPAD_DOWN, 80ms, 350ms); } - stream.log( - "[Tutorial settings] Verified " + std::string(settings[row].label) + "=" + settings[row].value); - correct = true; - break; - } - stream.log("[Tutorial settings] Correcting " + std::string(settings[row].label)); - if (row >= 9 && !slider_reset){ - // Sliders clamp at zero. Establish a known endpoint, - // then increment; verify the target image afterward. - for (size_t step = 0; step < 12; step++){ - pbf_press_dpad(context, DPAD_LEFT, 80ms, 100ms); + // Confirm settings before exiting: A A A A B B. + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1200ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + context.wait_for_all_requests(); + OverworldWatcher overworld; + if (wait_until(stream, context, 10s, {{overworld}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Settings checkpoint: Did not return to the overworld after saving options.", stream); } - slider_reset = true; - }else{ - pbf_press_dpad(context, DPAD_RIGHT, 80ms, 350ms); - } - context.wait_for_all_requests(); - } - if (!correct){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Could not apply " + std::string(settings[row].label), - stream); - } - if (row + 1 < sizeof(settings) / sizeof(settings[0])){ - pbf_press_dpad(context, DPAD_DOWN, 80ms, 350ms); - } - } - // Confirm settings before exiting: A A A A B B. - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1200ms); - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - context.wait_for_all_requests(); - OverworldWatcher overworld; - if (wait_until(stream, context, 10s, {{overworld}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Settings checkpoint: Did not return to the overworld after saving options.", - stream); - } - // Reopen the saved Options screen and independently verify Autosave. - // No route movement is permitted until this persisted-value check passes. - pbf_press_button(context, BUTTON_X, 80ms, 1000ms); - context.wait_for_all_requests(); - MenuWatcher verify_menu; - if (wait_until(stream, context, 5s, {{verify_menu}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Autosave verification: Main menu not detected.", - stream); - } - bool reopened = false; - for (size_t attempt = 0; attempt < 2; attempt++){ - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - context.wait_for_all_requests(); - auto check = stream.video().snapshot(); - if (check && options_screen.detect(check)){ - reopened = true; - break; - } - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - context.wait_for_all_requests(); - MenuWatcher return_menu; - if (wait_until(stream, context, 5s, {{return_menu}}) != 0){ - break; - } - pbf_press_dpad(context, DPAD_RIGHT, 80ms, 350ms); - } - if (!reopened){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Autosave verification: Could not reopen saved Options.", - stream); - } - // Options starts on Text Speed; Autosave is seven rows below it. - for (size_t step = 0; step < 7; step++){ - pbf_press_dpad(context, DPAD_DOWN, 80ms, 350ms); - } - context.wait_for_all_requests(); - const ImageRGB32 autosave_label(RESOURCE_PATH() + "PokemonBDSP/Autostory/Options/autosave_label.png"); - for (size_t confirmation = 0; confirmation < 3; confirmation++){ - auto check = stream.video().snapshot(); - double check_y = check ? position.selected_row(check) : -1; - if (!check || !options_screen.detect(check) || check_y < 0 || - option_image_score(check, {0.08, check_y - 0.025, 0.24, 0.05}, autosave_label, false) < 0.80 || - !autosave_off_highlight(check, check_y)){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Saved Autosave setting is not verified Off. Stopping before movement.", - stream); - } - pbf_wait(context, 200ms); - context.wait_for_all_requests(); - } - stream.log("[Tutorial settings] Autosave = Off: confirmed after reopening saved Options (3 frames)."); - // Confirm settings before exiting: A A A A B B. - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 1200ms); - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - pbf_press_button(context, BUTTON_B, 80ms, 1000ms); - context.wait_for_all_requests(); - OverworldWatcher verified_exit; - if (wait_until(stream, context, 10s, {{verified_exit}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Autosave verification: Could not return to the overworld.", - stream); - } - // Report inferred values explicitly: these are visual matches, - // allowing the user to compare our conclusions with the screen. - stream.log("[Tutorial settings] Completed. Values believed applied (visual matches before saving):"); - stream.log( - "[Tutorial settings] Text Speed = Fast; Change Window = Type 1; Battle Effects = Off; Battle Style = Set."); - stream.log("[Tutorial settings] Send to Boxes = Automatic; Give Nicknames = Don't give; Gyroscope = Use."); - stream.log("[Tutorial settings] Autosave = Off; Show Nicknames = Show; Background Music = 8; Sound Effects = 8."); -} - - -void verify_tutorial_settings(VideoStream& stream, ProControllerContext& context){ - // Settings-only recovery: initial attempt plus three retries. - // Never permit route movement unless the complete check succeeds. - constexpr size_t MAX_RETRIES = 3; - for (size_t attempt = 0; attempt <= MAX_RETRIES; attempt++){ - stream.log("[Tutorial settings] Attempt " + std::to_string(attempt + 1) + "/4."); - try { - verify_tutorial_settings_once(stream, context); - return; - } catch (OperationFailedException& error){ - stream.log("[Tutorial settings] Attempt failed: " + error.message(), COLOR_ORANGE); - if (attempt == MAX_RETRIES){ - stream.log("[Tutorial settings] All three retries exhausted; stopping before movement.", COLOR_RED); - throw; - } - // Back out of Options, save prompts, bag, or menu without - // moving the player or resetting/loading the game. - stream.log("[Tutorial settings] Returning to overworld before retry."); - context.wait_for_all_requests(); - TutorialOptionsDetector options_screen; - const auto failed_frame = stream.video().snapshot(); - if (failed_frame && options_screen.detect(failed_frame)){ - // Finish the known Options save/exit sequence before - // observing state; never cancel it on an Options frame - // misclassified as overworld by the generic detector. - for (size_t press = 0; press < 4; press++){ - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + // Reopen the saved Options screen and independently verify Autosave. + // No route movement is permitted until this persisted-value check passes. + pbf_press_button(context, BUTTON_X, 80ms, 1000ms); + context.wait_for_all_requests(); + MenuWatcher verify_menu; + if (wait_until(stream, context, 5s, {{verify_menu}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Autosave verification: Main menu not detected.", stream); } - } - pbf_mash_button(context, BUTTON_B, 3000ms); - context.wait_for_all_requests(); - OverworldDetector overworld; - MenuDetector menu; - BlackScreenDetector black; - bool recovered = false; - size_t confirmations = 0; - for (size_t check = 0; check < 15; check++){ - const auto frame = stream.video().snapshot(); - const bool clear = frame && !options_screen.detect(frame) && !menu.detect(frame) && - !black.detect(frame) && bag_pocket_position(frame) == 0 && overworld.detect(frame); - if (clear){ - if (++confirmations >= 3){ - recovered = true; - break; + bool reopened = false; + for (size_t attempt = 0; attempt < 2; attempt++) { + pbf_press_button(context, BUTTON_A, 80ms, 0ms); + if (wait_for_options_screen(stream, context, options_screen, 5s)) { reopened = true; break; } + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + context.wait_for_all_requests(); + MenuWatcher return_menu; + if (wait_until(stream, context, 5s, {{return_menu}}) != 0) break; + pbf_press_dpad(context, DPAD_RIGHT, 80ms, 350ms); + } + if (!reopened) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Autosave verification: Could not reopen saved Options.", stream); + } + // Options starts on Text Speed; Autosave is seven rows below it. + for (size_t step = 0; step < 7; step++) + pbf_press_dpad(context, DPAD_DOWN, 80ms, 350ms); + context.wait_for_all_requests(); + const ImageRGB32 autosave_label(RESOURCE_PATH() + "PokemonBDSP/Autostory/Options/autosave_label.png"); + for (size_t confirmation = 0; confirmation < 3; confirmation++) { + auto check = stream.video().snapshot(); + double check_y = check ? position.selected_row(check) : -1; + if (!check || !options_screen.detect(check) || check_y < 0 || + option_image_score(check, {0.08, check_y - 0.025, 0.24, 0.05}, autosave_label, false) < 0.80 || + !autosave_off_highlight(check, check_y)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Saved Autosave setting is not verified Off. Stopping before movement.", stream); } pbf_wait(context, 200ms); - }else{ - confirmations = 0; - pbf_mash_button(context, BUTTON_B, 500ms); + context.wait_for_all_requests(); } + stream.log("[Tutorial settings] Autosave = Off: confirmed after reopening saved Options (3 frames)."); + // Confirm settings before exiting: A A A A B B. + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + pbf_press_button(context, BUTTON_A, 80ms, 1200ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); + pbf_press_button(context, BUTTON_B, 80ms, 1000ms); context.wait_for_all_requests(); + OverworldWatcher verified_exit; + if (wait_until(stream, context, 10s, {{verified_exit}}) != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Autosave verification: Could not return to the overworld.", stream); + } + // Report inferred values explicitly: these are visual matches, + // allowing the user to compare our conclusions with the screen. + stream.log("[Tutorial settings] Completed. Values believed applied (visual matches before saving):"); + stream.log("[Tutorial settings] Text Speed = Fast; Change Window = Type 1; Battle Effects = Off; Battle Style = Set."); + stream.log("[Tutorial settings] Send to Boxes = Automatic; Give Nicknames = Don't give; Gyroscope = Use."); + stream.log("[Tutorial settings] Autosave = Off; Show Nicknames = Show; Background Music = 8; Sound Effects = 8."); } - stream.log(recovered ? "[Tutorial settings] Recovery confirmed outside Options/menu; retrying." - : "[Tutorial settings] Recovery not confirmed; next attempt will recheck the screen."); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - } - } -} - -void set_time_fast(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - const DateTime& target, - bool preserve_date){ - env.log("[AutoStory] Fast date set starting...", COLOR_ORANGE); - - // 1) Home - NintendoSwitch::go_home(env.console, context); - - // 2) Navigate to Date/Time settings quickly. - NintendoSwitch::home_to_date_time(env.console, context, true); - - // 3) Enter date editor. - pbf_press_button(context, BUTTON_A, 80ms, 240ms); - // 4) Wait for date screen and set exact time. - NintendoSwitch::DateChangeWatcher date_reader(env.console); - int ret = wait_until(env.console, context, 10s, {date_reader}); - if (ret < 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Failed to enter Date Change screen.", - env.console); - } - { - VideoOverlaySet overlays(env.console.overlay()); - date_reader.make_overlays(overlays); - DateTime effective_target = target; - if (preserve_date){ - context.wait_for_all_requests(); - const auto snapshot = env.console.video().snapshot(); - if (!snapshot){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Clock-only setup: No date-editor frame.", - env.console); - } - const DateTime current = date_reader.read_date(env.console, snapshot.frame).second; - if (current.year < 2000 || current.year > 2060 || current.month < 1 || current.month > 12 || - current.day < 1 || current.day > 31){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Clock-only setup: Could not reliably read the current console date.", - env.console); + void verify_tutorial_settings(VideoStream& stream, ProControllerContext& context) { + // Settings-only recovery: initial attempt plus three retries. + // Never permit route movement unless the complete check succeeds. + constexpr size_t MAX_RETRIES = 3; + for (size_t attempt = 0; attempt <= MAX_RETRIES; attempt++) { + stream.log("[Tutorial settings] Attempt " + std::to_string(attempt + 1) + "/4."); + try { + verify_tutorial_settings_once(stream, context); + return; + } catch (OperationFailedException& error) { + stream.log("[Tutorial settings] Attempt failed: " + error.message(), COLOR_ORANGE); + if (attempt == MAX_RETRIES) { + stream.log("[Tutorial settings] All three retries exhausted; stopping before movement.", COLOR_RED); + throw; + } + // Back out of Options, save prompts, bag, or menu without + // moving the player or resetting/loading the game. + stream.log("[Tutorial settings] Returning to overworld before retry."); + context.wait_for_all_requests(); + TutorialOptionsDetector options_screen; + const auto failed_frame = stream.video().snapshot(); + if (failed_frame && options_screen.detect(failed_frame)) { + // Finish the known Options save/exit sequence before + // observing state; never cancel it on an Options frame + // misclassified as overworld by the generic detector. + for (size_t press = 0; press < 4; press++) + pbf_press_button(context, BUTTON_A, 80ms, 1000ms); + } + pbf_mash_button(context, BUTTON_B, 3000ms); + context.wait_for_all_requests(); + OverworldDetector overworld; + MenuDetector menu; + BlackScreenDetector black; + bool recovered = false; + size_t confirmations = 0; + for (size_t check = 0; check < 15; check++) { + const auto frame = stream.video().snapshot(); + const bool clear = frame && !options_screen.detect(frame) && + !menu.detect(frame) && !black.detect(frame) && bag_pocket_position(frame) == 0 && overworld.detect(frame); + if (clear) { + if (++confirmations >= 3) { recovered = true; break; } + pbf_wait(context, 200ms); + } else { + confirmations = 0; + pbf_mash_button(context, BUTTON_B, 500ms); + } + context.wait_for_all_requests(); + } + stream.log(recovered ? "[Tutorial settings] Recovery confirmed outside Options/menu; retrying." + : "[Tutorial settings] Recovery not confirmed; next attempt will recheck the screen."); + pbf_wait(context, 500ms); + context.wait_for_all_requests(); + } + } } - effective_target.year = current.year; - effective_target.month = current.month; - effective_target.day = current.day; - env.log("[AutoStory] Clock-only target=" + std::to_string((int)target.hour) + ":" + - std::to_string((int)target.minute) + "; preserving date=" + std::to_string(current.year) + "-" + - std::to_string((int)current.month) + "-" + std::to_string((int)current.day) + "."); - } - date_reader.set_date(env.program_info(), env.console, context, effective_target); - } - // 5) Commit date. - pbf_press_button(context, BUTTON_A, 160ms, 240ms); + void set_time_fast( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const DateTime& target, + bool preserve_date + ) { + env.log("[AutoStory] Fast date set starting...", COLOR_ORANGE); + + // 1) Home + NintendoSwitch::go_home(env.console, context); + + // 2) Navigate to Date/Time settings quickly. + NintendoSwitch::home_to_date_time(env.console, context, true); + + // 3) Enter date editor. + pbf_press_button(context, BUTTON_A, 80ms, 240ms); + + // 4) Wait for date screen and set exact time. + NintendoSwitch::DateChangeWatcher date_reader(env.console); + int ret = wait_until( + env.console, context, 10s, + { date_reader } + ); + if (ret < 0) { + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "Failed to enter Date Change screen.", + env.console + ); + } - // 6) Back to game. - pbf_press_button(context, BUTTON_HOME, 160ms, ConsoleSettings::instance().SETTINGS_TO_HOME_DELAY0); - NintendoSwitch::resume_game_from_home(env.console, context, false); + { + VideoOverlaySet overlays(env.console.overlay()); + date_reader.make_overlays(overlays); + DateTime effective_target = target; + if (preserve_date) { + context.wait_for_all_requests(); + const auto snapshot = env.console.video().snapshot(); + if (!snapshot) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Clock-only setup: No date-editor frame.", env.console); + const DateTime current = date_reader.read_date(env.console, snapshot.frame).second; + if (current.year < 2000 || current.year > 2060 || current.month < 1 || current.month > 12 || + current.day < 1 || current.day > 31) + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Clock-only setup: Could not reliably read the current console date.", env.console); + effective_target.year = current.year; + effective_target.month = current.month; + effective_target.day = current.day; + env.log("[AutoStory] Clock-only target=" + std::to_string((int)target.hour) + ":" + + std::to_string((int)target.minute) + "; preserving date=" + std::to_string(current.year) + "-" + + std::to_string((int)current.month) + "-" + std::to_string((int)current.day) + "."); + } + date_reader.set_date(env.program_info(), env.console, context, effective_target); + } - context.wait_for_all_requests(); - context.wait_for(500ms); -} + // 5) Commit date. + pbf_press_button(context, BUTTON_A, 160ms, 240ms); + // 6) Back to game. + pbf_press_button( + context, BUTTON_HOME, 160ms, + ConsoleSettings::instance().SETTINGS_TO_HOME_DELAY0 + ); + NintendoSwitch::resume_game_from_home(env.console, context, false); -// --------------------------------------------------------------------------- -// Natalie battle -// --------------------------------------------------------------------------- -static void select_battle_move_slot(VideoStream& stream, - ProControllerContext& context, - BattleType battle_type, - size_t target, - const std::string& label, - bool confirm_default_target = false, - size_t current_move = 1){ - BattleMenuDetector commands(battle_type); - VideoOverlaySet overlays(stream.overlay()); - commands.make_overlays(overlays); - overlays.add(COLOR_GREEN, {0.90, 0.48, 0.075, 0.505}); - // Reenter Fight from an already-open list without assuming - // that this resets the remembered move cursor. - if (confirm_default_target){ - context.wait_for_all_requests(); - const auto frame = stream.video().snapshot(); - if (frame && detect_battle_move_menu(frame).move_count){ - pbf_press_button(context, BUTTON_B, 80ms, 500ms); - } - } - bool fight = false; - size_t confirmed = 0; - for (size_t check = 0; check < 30; check++){ - context.wait_for_all_requests(); - auto frame = stream.video().snapshot(); - BattleMenuOption selected = frame ? commands.selected_option(frame) : BattleMenuOption::UNKNOWN; - stream.log("[" + label + "] Command cursor=" + std::to_string((int)selected + 1) + "; target Battle=1."); - if (selected == BattleMenuOption::BATTLE){ - if (++confirmed >= 3){ - fight = true; - break; - } - pbf_wait(context, 100ms); - }else{ - confirmed = 0; - if (selected == BattleMenuOption::UNKNOWN){ - pbf_wait(context, 150ms); - }else{ - pbf_press_dpad(context, DPAD_UP, 80ms, 250ms); - } - } - } - if (!fight){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Could not confirm Battle command; A not pressed.", - stream); - } - pbf_press_button(context, BUTTON_A, 80ms, confirm_default_target ? 1000ms : 500ms); - if (confirm_default_target){ - // Reuse the farming move/target confirmation sequence, but - // navigate relative to this battle's retained move cursor. - // Reopening Fight does not reset it to move 1. - size_t available = 0, stable = 0; - for (size_t check = 0; check < 20 && stable < 3; check++){ - context.wait_for_all_requests(); - const auto frame = stream.video().snapshot(); - const auto moves = frame ? detect_battle_move_menu(frame) : BattleMoveMenuState{}; - if (moves.move_count && moves.move_count == available){ - stable++; - }else{ - available = moves.move_count; - stable = moves.move_count ? 1 : 0; - } - if (stable < 3){ - pbf_wait(context, 100ms); - } - } - if (stable < 3 || available < target || available < current_move){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Requested move position is not available.", - stream); - } - stream.log("[" + label + "] Retained move cursor " + std::to_string(current_move) + " -> " + - std::to_string(target) + "."); - for (size_t slot = current_move; slot < target; slot++){ - pbf_press_dpad(context, DPAD_DOWN, 80ms, 400ms); - } - for (size_t slot = current_move; slot > target; slot--){ - pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); - } - pbf_press_button(context, BUTTON_A, 80ms, 1000ms); - pbf_press_button(context, BUTTON_A, 80ms, 3000ms); - return; - } - confirmed = 0; - size_t previous_move_count = 0, stable_move_count = 0; - for (size_t check = 0; check < 30; check++){ - context.wait_for_all_requests(); - auto frame = stream.video().snapshot(); - const BattleMoveMenuState moves = frame ? detect_battle_move_menu(frame) : BattleMoveMenuState{}; - const int slot = (int)moves.selected_slot; - VideoOverlaySet move_overlays(stream.overlay()); - for (size_t i = 0; i < moves.move_count; i++){ - move_overlays.add(moves.selected_slot == i + 1 ? COLOR_GREEN : COLOR_YELLOW, moves.buttons[i]); - } - stream.log("[" + label + "] Available moves=" + std::to_string(moves.move_count) + - "; selected slot=" + std::to_string(slot) + "/4; target=" + std::to_string(target) + "."); - if (moves.move_count == previous_move_count && moves.move_count > 0){ - stable_move_count++; - }else{ - previous_move_count = moves.move_count; - stable_move_count = 1; - confirmed = 0; - } - if (moves.move_count > 0 && moves.move_count < target && stable_move_count >= 3){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Move slot " + std::to_string(target) + " is absent; no move confirmed.", - stream); - } - if (slot == (int)target && stable_move_count >= 3){ - if (++confirmed >= 3){ - stream.log("[" + label + "] Move " + std::to_string(target) + " confirmed; using it."); - pbf_press_button(context, BUTTON_A, 80ms, 3000ms); - return; - } - pbf_wait(context, 100ms); - }else{ - confirmed = 0; - if (slot == 0 || slot == (int)target){ - pbf_wait(context, 150ms); - }else{ - pbf_press_dpad(context, slot < (int)target ? DPAD_DOWN : DPAD_UP, 80ms, 250ms); + context.wait_for_all_requests(); + context.wait_for(500ms); } - } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Could not confirm move " + std::to_string(target) + "; A not pressed.", - stream); -} - -static void select_natalie_move_three(VideoStream& stream, ProControllerContext& context){ - select_battle_move_slot(stream, context, BattleType::TRAINER, 3, "Natalie"); -} -void fight_Natalie(VideoStream& stream, ProControllerContext& context){ - stream.log("[AutoStory] Starting Natalie battle...", COLOR_BLUE); + + // --------------------------------------------------------------------------- + // Natalie battle + // --------------------------------------------------------------------------- + // Early moves the starters and their foes have, as type, power and + // most PP. A status move has power 0. + struct KnownMove { const char* name; const char* type; int power; int pp; }; + static const KnownMove KNOWN_MOVES[] = { + {"pound", "normal", 40, 35}, {"tackle", "normal", 40, 35}, {"scratch", "normal", 40, 35}, + {"quickattack", "normal", 40, 30}, {"headbutt", "normal", 70, 15}, + {"watergun", "water", 40, 25}, {"bubble", "water", 40, 30}, {"bubblebeam", "water", 65, 20}, + {"ember", "fire", 40, 25}, {"flamewheel", "fire", 60, 25}, {"machpunch", "fighting", 40, 30}, + {"absorb", "grass", 20, 25}, {"razorleaf", "grass", 55, 25}, {"bite", "dark", 60, 25}, + {"peck", "flying", 35, 35}, {"wingattack", "flying", 60, 35}, {"metalclaw", "steel", 50, 35}, + {"growl", "normal", 0, 40}, {"leer", "normal", 0, 30}, {"tailwhip", "normal", 0, 30}, + {"withdraw", "water", 0, 40}, {"taunt", "dark", 0, 20}, + }; + + // A move button's PP left, "8/25" at its right end; -1 if it cannot be + // read. The text is dark, or yellow to red as PP runs low, so text is + // whatever differs from the button's own color. `max_pp` helps when + // the OCR reads the slash as a digit. + static int read_move_pp(const ImageViewRGB32& screen, const ImageFloatBox& button, int max_pp) { + const ImageViewRGB32 box = extract_box_reference(screen, ImageFloatBox{ + button.x + button.width * 0.75, button.y + button.height * 0.10, + button.width * 0.21, button.height * 0.80}); + if (box.width() == 0 || box.height() == 0) return -1; + std::vector pixels; + for (size_t y = 0; y < box.height(); y++) for (size_t x = 0; x < box.width(); x++) pixels.push_back(box.pixel(x, y)); + auto brightness = [](uint32_t p) { return ((p >> 16) & 255) + ((p >> 8) & 255) + (p & 255); }; + std::vector sorted = pixels; + std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end(), + [&](uint32_t a, uint32_t b) { return brightness(a) < brightness(b); }); + const uint32_t background = sorted[sorted.size() / 2]; + const size_t pad = 8; + ImageRGB32 text(box.width() + 2 * pad, box.height() + 2 * pad); + text.fill(0xffffffff); + for (size_t y = 0; y < box.height(); y++) { + for (size_t x = 0; x < box.width(); x++) { + const uint32_t p = box.pixel(x, y); + int distance = 0; + for (int shift : {16, 8, 0}) distance += std::abs((int)((p >> shift) & 255) - (int)((background >> shift) & 255)); + if (distance > 120) text.pixel(x + pad, y + pad) = 0xff000000; + } + } + const std::string read = OCR::tesseract_ocr_read(Language::English, text); + const size_t slash = read.find('/'); + std::string digits; + for (size_t i = 0; i < read.size() && i < slash; i++) if (std::isdigit((unsigned char)read[i])) digits.push_back(read[i]); + if (slash == std::string::npos && max_pp > 0) { + // "29135" for 29/35: drop the most PP and the misread slash. + const std::string max = std::to_string(max_pp); + if (digits.size() > max.size() + 1 && digits.compare(digits.size() - max.size(), max.size(), max) == 0) { + digits.resize(digits.size() - max.size() - 1); + } else { + return -1; + } + } + if (digits.empty() || digits.size() > 2) return -1; + return std::stoi(digits); + } - // Allow battle transition to finish. - pbf_wait(context, 5000ms); + // The best move on the move menu: the most powerful one we know, and + // on a tie one that is not Normal (an early starter's own type). + // Reads the names with OCR; 1 if none is known. + static size_t pick_best_move(VideoStream& stream, const ImageViewRGB32& screen, + const BattleMoveMenuState& moves, const std::string& label) { + // Keep the menu as it looked, to check the reads against. + { + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/moves/"; + Filesystem::create_directories(Filesystem::Path(folder)); + screen.save(folder + now_to_filestring() + "-" + label + ".png"); + } + size_t best = 1; + int best_score = -1; + bool any_attack = false; + size_t last_not_status = 1; + for (size_t i = 0; i < moves.move_count; i++) { + // The name is dark text on a light button (grey, or light blue under the + // cursor). Take the box right of the type icon and left of the PP, tall + // enough for a status move's name, which sits lower: it has no "Effective" + // line under it. + const ImageFloatBox& button = moves.buttons[i]; + const ImageFloatBox name_box{button.x + button.width * 0.17, button.y + button.height * 0.08, + button.width * 0.53, button.height * 0.80}; + const ImageViewRGB32 box = extract_box_reference(screen, name_box); + // Text is darker than halfway between the background and the darkest pixel; + // a fixed cut thins the strokes on the blue button. + std::vector gray(box.width() * box.height()); + for (size_t y = 0; y < box.height(); y++) { + for (size_t x = 0; x < box.width(); x++) { + const uint32_t pixel = box.pixel(x, y); + gray[y * box.width() + x] = (int)(((pixel >> 16) & 0xff) + ((pixel >> 8) & 0xff) + (pixel & 0xff)) / 3; + } + } + std::vector sorted = gray; + std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end()); + const int background = sorted[sorted.size() / 2]; + const int darkest = *std::min_element(gray.begin(), gray.end()); + const int threshold = (background + darkest) / 2; + // The name is the tallest band of text rows; "Effective" is smaller. + size_t band_top = 0, band_bottom = 0; + for (size_t y = 0; y < box.height();) { + auto has_text = [&](size_t row) { + for (size_t x = 0; x < box.width(); x++) if (gray[row * box.width() + x] < threshold) return true; + return false; + }; + if (!has_text(y)) { y++; continue; } + size_t end = y; + while (end < box.height() && has_text(end)) end++; + if (end - y > band_bottom - band_top) { band_top = y; band_bottom = end; } + y = end; + } + // Black text on white, with a margin, for the OCR. + const size_t pad = 6; + const size_t top = band_top >= 4 ? band_top - 4 : 0; + const size_t bottom = std::min(box.height(), band_bottom + 4); + ImageRGB32 text(box.width() + 2 * pad, bottom - top + 2 * pad); + text.fill(0xffffffff); + for (size_t y = top; y < bottom; y++) { + for (size_t x = 0; x < box.width(); x++) { + if (gray[y * box.width() + x] < threshold) text.pixel(x + pad, y - top + pad) = 0xff000000; + } + } + std::string name; + for (unsigned char ch : OCR::tesseract_ocr_read(Language::English, text)) { + if (std::isalpha(ch)) name.push_back((char)std::tolower(ch)); + } + // A slot read well once keeps its name when a later read fails. + static std::array last_read; + const KnownMove* known = nullptr; + for (int pass = 0; pass < 2 && !known; pass++) { + if (pass == 1) name = last_read[i]; + for (const KnownMove& move : KNOWN_MOVES) { + // The longest name that appears wins: "bubblebeam" over "bubble". + if (name.find(move.name) != std::string::npos && + (!known || std::string(move.name).size() > std::string(known->name).size())) { + known = &move; + } + } + } + if (known) last_read[i] = known->name; + // A move with no PP left cannot be chosen; the game says so and + // keeps the menu up. + const int pp = read_move_pp(screen, button, known ? known->pp : 0); + stream.log("[" + label + "] Move " + std::to_string(i + 1) + " PP left: " + (pp < 0 ? std::string("unread") : std::to_string(pp)) + "."); + if (pp == 0) { + continue; + } + // An unreadable move counts as a plain attack, ahead of known + // status moves. + const int score = known ? known->power * 2 + (std::string(known->type) != "normal" ? 1 : 0) : 40 * 2 - 1; + stream.log("[" + label + "] Move " + std::to_string(i + 1) + " reads \"" + name + "\"" + + (known ? std::string(" = ") + known->name + " (" + known->type + ", " + std::to_string(known->power) + ")" : " (unknown)") + "."); + if (score > best_score) { best_score = score; best = i + 1; } + if (known && known->power > 0) any_attack = true; + if (!known || known->power > 0) last_not_status = i + 1; + } + // No attack read: the newest move is the starter's own-type attack + // (Water Gun, Ember, Absorb), unless it is a status move. + if (!any_attack) best = last_not_status; + stream.log("[" + label + "] Best move: " + std::to_string(best) + "."); + return best; + } - bool battle_menu_seen = false; + static void select_battle_move_slot(VideoStream& stream, ProControllerContext& context, + BattleType battle_type, size_t target, const std::string& label, + bool confirm_default_target = false, size_t current_move = 1) { + BattleMenuDetector commands(battle_type); + VideoOverlaySet overlays(stream.overlay()); + commands.make_overlays(overlays); + overlays.add(COLOR_GREEN, {0.90, 0.48, 0.075, 0.505}); + // Reenter Fight from an already-open list without assuming + // that this resets the remembered move cursor. + if (confirm_default_target) { + context.wait_for_all_requests(); + const auto frame=stream.video().snapshot(); + if (frame && detect_battle_move_menu(frame).move_count) { + pbf_press_button(context,BUTTON_B,80ms,500ms); + } + } + bool fight = false; + size_t confirmed = 0; + for (size_t check = 0; check < 30; check++) { + context.wait_for_all_requests(); + auto frame = stream.video().snapshot(); + BattleMenuOption selected = frame ? commands.selected_option(frame) : BattleMenuOption::UNKNOWN; + stream.log("[" + label + "] Command cursor=" + std::to_string((int)selected + 1) + "; target Battle=1."); + if (selected == BattleMenuOption::BATTLE) { + if (++confirmed >= 3) { fight = true; break; } + pbf_wait(context, 100ms); + } else { + confirmed = 0; + if (selected == BattleMenuOption::UNKNOWN) pbf_wait(context, 150ms); + else pbf_press_dpad(context, DPAD_UP, 80ms, 250ms); + } + } + if (!fight) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Could not confirm Battle command; A not pressed.", stream); + pbf_press_button(context,BUTTON_A,80ms,confirm_default_target ? 1000ms : 500ms); + if (confirm_default_target) { + // Reuse the farming move/target confirmation sequence, but + // navigate relative to this battle's retained move cursor. + // Reopening Fight does not reset it to move 1. + size_t available=0,stable=0; + for (size_t check=0;check<20 && stable<3;check++) { + context.wait_for_all_requests(); + const auto frame=stream.video().snapshot(); + const auto moves=frame ? detect_battle_move_menu(frame) : BattleMoveMenuState{}; + if (moves.move_count && moves.move_count==available) stable++; + else {available=moves.move_count;stable=moves.move_count ? 1:0;} + if (stable<3) pbf_wait(context,100ms); + } + if (stable<3 || available "+std::to_string(target)+"."); + for (size_t slot=current_move;slottarget;slot--) pbf_press_dpad(context,DPAD_UP,80ms,400ms); + pbf_press_button(context,BUTTON_A,80ms,1000ms); + pbf_press_button(context,BUTTON_A,80ms,3000ms); + return; + } + confirmed = 0; + size_t previous_move_count = 0, stable_move_count = 0; + for (size_t check = 0; check < 30; check++) { + context.wait_for_all_requests(); + auto frame = stream.video().snapshot(); + const BattleMoveMenuState moves = frame ? detect_battle_move_menu(frame) : BattleMoveMenuState{}; + const int slot = (int)moves.selected_slot; + VideoOverlaySet move_overlays(stream.overlay()); + for (size_t i = 0; i < moves.move_count; i++) + move_overlays.add(moves.selected_slot == i + 1 ? COLOR_GREEN : COLOR_YELLOW, moves.buttons[i]); + stream.log("[" + label + "] Available moves=" + std::to_string(moves.move_count) + + "; selected slot=" + std::to_string(slot) + "/4; target=" + std::to_string(target) + "."); + if (moves.move_count == previous_move_count && moves.move_count > 0) stable_move_count++; + else { previous_move_count = moves.move_count; stable_move_count = 1; confirmed = 0; } + // Target 0: pick the best move once the menu has settled. + if (target == 0 && stable_move_count >= 3 && frame) { + target = pick_best_move(stream, *frame.frame, moves, label); + } + if (moves.move_count > 0 && moves.move_count < target && stable_move_count >= 3) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Move slot " + std::to_string(target) + " is absent; no move confirmed.", stream); + } + if (slot == (int)target && stable_move_count >= 3) { + if (++confirmed >= 3) { + stream.log("[" + label + "] Move " + std::to_string(target) + " confirmed; using it."); + pbf_press_button(context, BUTTON_A, 80ms, 3000ms); + return; + } + pbf_wait(context, 100ms); + } else { + confirmed = 0; + if (slot == 0 || slot == (int)target) pbf_wait(context, 150ms); + else pbf_press_dpad(context, slot < (int)target ? DPAD_DOWN : DPAD_UP, 80ms, 250ms); + } + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Could not confirm move " + std::to_string(target) + "; A not pressed.", stream); + } - // State machine loop. - while (true){ + // --------------------------------------------------------------------------- + // Starly battle + // --------------------------------------------------------------------------- - context.wait_for_all_requests(); + void fight_starly(VideoStream& stream, ProControllerContext& context) { + stream.log("[AutoStory] Starting Starly battle...", COLOR_BLUE); - BattleMenuWatcher battle_menu(BattleType::TRAINER); - EndBattleWatcher end_battle; + // Declare OUTSIDE the loop so accumulated detector state + // (trigger counts, black-screen transitions) is preserved across frames. + // STARTER type: player has no Pokeballs, so ball-count areas won't + // be white. STARTER skips that check and only verifies status bars + // and the Fight button color. + BattleMenuWatcher battle_menu(BattleType::STARTER, COLOR_YELLOW); + EndBattleWatcher end_battle; - // Detect move-learning arrows. - SelectionArrowFinder learn_move(stream.overlay(), {0.50, 0.62, 0.40, 0.18}, COLOR_YELLOW); + while (true) { + context.wait_for_all_requests(); - int ret = run_until(stream, - context, - [](ProControllerContext& context){ - // Continuously clear: - // EXP gain - // level up - // faint text - // send-out text - // battle narration - pbf_mash_button(context, BUTTON_B, 120000ms); - }, + // Phase 1: wait for either battle menu or battle end. + int ret = run_until( + stream, context, + [](ProControllerContext& ctx) { + pbf_mash_button(ctx, BUTTON_B, 120000ms); + }, { {battle_menu}, - battle_menu_seen ? PeriodicInferenceCallback{end_battle} : PeriodicInferenceCallback{}, - {learn_move}, - }); - - switch (ret){ - - // ------------------------------------------------- - // Battle menu detected. - // ------------------------------------------------- - case 0: { - stream.log("[AutoStory] Battle menu detected.", COLOR_BLUE); + {end_battle}, + } + ); - battle_menu_seen = true; + switch (ret) { + case 0: + { + stream.log("[AutoStory] Battle menu detected. Selecting move...", COLOR_BLUE); - select_natalie_move_three(stream, context); + // Select Fight, then move slot 1. + pbf_press_button(context, BUTTON_A, 80ms, 500ms); + pbf_press_button(context, BUTTON_A, 80ms, 3000ms); + // Loop back - mash B through animations until next menu or end. + break; + } + case 1: + { + stream.log("[AutoStory] Starly battle complete.", COLOR_GREEN); + + // Mash through XP / level-up / dialog screens back to overworld. + OverworldWatcher overworld; + int cleanup_ret = run_until( + stream, context, + [](ProControllerContext& ctx) { + pbf_mash_button(ctx, BUTTON_B, 120000ms); + }, + { {overworld} } + ); + if (cleanup_ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Starly battle: Overworld cleanup timed out.", stream); + } + return; + } + default: + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Starly battle timed out.", stream); + } + } + } - break; - } + // --------------------------------------------------------------------------- + // Tristan battle + // --------------------------------------------------------------------------- + // Two grass rows at 240 ms each, with room to spare: the wall stops us. + static constexpr Milliseconds CATCH_GRASS_WALK = 900ms; - // ------------------------------------------------- - // Battle finished. - // ------------------------------------------------- - case 1: { - stream.log("[AutoStory] Natalie battle complete.", COLOR_GREEN); + static void encounter_one_wild_pokemon(VideoStream& stream, ProControllerContext& context) { + context.wait_for_all_requests(); + pbf_wait(context, 1000ms); + + bool encounter_found = false; + const auto deadline = std::chrono::steady_clock::now() + 180s; + + // Walk up and down the catch grass until a battle starts. Its three rows + // sit between tree walls, so each walk ends at a wall, on the same tile + // every time, after two new grass tiles. Short sideways steps drift and + // can rock across one tile edge without entering new grass. + while (!encounter_found && std::chrono::steady_clock::now() < deadline) { + BlackScreenWatcher battle_start; + BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + + route_turn(stream, context, Pokemon::Direction::NORTH); + pbf_move_left_joystick(context, {0, 1}, CATCH_GRASS_WALK, 200ms); + context.wait_for_all_requests(); - pbf_mash_button(context, BUTTON_B, 5000ms); + int ret = wait_until( + stream, + context, + 1500ms, + { + {battle_start}, + {battle_menu}, + {battle_dialog}, + } + ); - return; - } + if (ret >= 0) { + encounter_found = true; + break; + } - // ------------------------------------------------- - // Move learning detected. - // ------------------------------------------------- - case 2: { - stream.log("[AutoStory] Learn move detected.", COLOR_ORANGE); + route_turn(stream, context, Pokemon::Direction::SOUTH); + pbf_move_left_joystick(context, {0, -1}, CATCH_GRASS_WALK, 200ms); + context.wait_for_all_requests(); - // Always refuse move learning. - pbf_move_right_joystick(context, {0, -1}, 160ms, 840ms); + ret = wait_until( + stream, + context, + 1500ms, + { + {battle_start}, + {battle_menu}, + {battle_dialog}, + } + ); - pbf_press_button(context, BUTTON_A, 160ms, 840ms); + if (ret >= 0) { + encounter_found = true; + break; + } + } - break; - } + if (!encounter_found) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Wild encounter: walking up and down the grass found no battle within three minutes.", stream); + stream.log("[AutoStory] Wild battle detected!", COLOR_GREEN); - // ------------------------------------------------- - // Timeout. - // ------------------------------------------------- - default: { - stream.log("[AutoStory] Natalie battle timed out.", COLOR_RED); + // Wait until the actual battle menu appears. + BattleMenuWatcher confirm_menu(BattleType::STANDARD); - pbf_mash_button(context, BUTTON_B, 5000ms); + int menu_ret = wait_until( + stream, + context, + 15s, + { + {confirm_menu}, + } + ); - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "[AutoStory] Natalie battle timed out.", - stream); - } - } - } -} + if (menu_ret < 0) { + stream.log( + "[AutoStory] Failed to detect battle menu.", + COLOR_RED + ); + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "[AutoStory] Failed to detect battle menu.", stream); + } -// --------------------------------------------------------------------------- -// Starly battle -// --------------------------------------------------------------------------- - -void fight_starly(VideoStream& stream, ProControllerContext& context){ - stream.log("[AutoStory] Starting Starly battle...", COLOR_BLUE); - - // Declare OUTSIDE the loop so accumulated detector state - // (trigger counts, black-screen transitions) is preserved across frames. - // STARTER type: player has no Pokeballs, so ball-count areas won't - // be white. STARTER skips that check and only verifies status bars - // and the Fight button color. - BattleMenuWatcher battle_menu(BattleType::STARTER, COLOR_YELLOW); - EndBattleWatcher end_battle; - - while (true){ - context.wait_for_all_requests(); - - // Phase 1: wait for either battle menu or battle end. - int ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - { - {battle_menu}, - {end_battle}, - }); + stream.log("[AutoStory] Battle menu detected.", COLOR_GREEN); - switch (ret){ - case 0: { - stream.log("[AutoStory] Battle menu detected. Selecting move...", COLOR_BLUE); + context.wait_for_all_requests(); + pbf_wait(context, 500ms); - // Select Fight, then move slot 1. - pbf_press_button(context, BUTTON_A, 80ms, 500ms); - pbf_press_button(context, BUTTON_A, 80ms, 3000ms); - // Loop back - mash B through animations until next menu or end. - break; - } - case 1: { - stream.log("[AutoStory] Starly battle complete.", COLOR_GREEN); - - // Mash through XP / level-up / dialog screens back to overworld. - OverworldWatcher overworld; - int cleanup_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - {{overworld}}); - if (cleanup_ret != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Starly battle: Overworld cleanup timed out.", - stream); } - return; - } - default: - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Starly battle timed out.", - stream); - } - } -} -// --------------------------------------------------------------------------- -// Tristan battle -// --------------------------------------------------------------------------- -static void encounter_one_wild_pokemon(VideoStream& stream, ProControllerContext& context){ - context.wait_for_all_requests(); - pbf_wait(context, 1000ms); - - bool encounter_found = false; - const auto deadline = std::chrono::steady_clock::now() + 180s; - - // Wiggle left/right until battle starts. - while (!encounter_found && std::chrono::steady_clock::now() < deadline){ - BlackScreenWatcher battle_start; - BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); - BattleDialogWatcher battle_dialog(COLOR_YELLOW); - - // Turn left. - pbf_move_left_joystick(context, {-1, 0}, 120ms, 200ms); - context.wait_for_all_requests(); - - int ret = wait_until(stream, - context, - 1500ms, - { - {battle_start}, - {battle_menu}, - {battle_dialog}, - }); + static void confirm_battle_overworld(VideoStream& stream, ProControllerContext& context, + const std::string& label) { + OverworldDetector overworld; + ShortDialogDetector dialogue(COLOR_YELLOW); + BlackScreenDetector black; + VideoOverlaySet overlays(stream.overlay()); + overworld.make_overlays(overlays); + dialogue.make_overlays(overlays); + black.make_overlays(overlays); + const auto deadline = std::chrono::steady_clock::now() + 180s; + auto quiet_since = std::chrono::steady_clock::now(); + bool finished = false; + while (std::chrono::steady_clock::now() < deadline) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No video frame.", stream); + const auto now = std::chrono::steady_clock::now(); + if (dialogue.detect(frame) || black.detect(frame) || !overworld.detect(frame)) { + quiet_since = now; + } else if (now - quiet_since >= 4s) { + finished = true; + break; + } + pbf_mash_button(context, BUTTON_B, 100ms); + context.wait_for_all_requests(); + } + if (!finished) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Dialogue-free overworld not confirmed for four seconds.", stream); + context.wait_for_all_requests(); + } - if (ret >= 0){ - encounter_found = true; - break; - } + // Our HP bar in the battle's bottom-left panel, as a fraction; -1 if + // no bar is there. Measured on 1920x1080 frames: the bar runs from + // x 88 to 466 on rows 961 to 965. + static double battle_player_hp(const ImageViewRGB32& frame) { + const ImageViewRGB32 bar = extract_box_reference(frame, ImageFloatBox{0.046, 0.890, 0.197, 0.004}); + size_t filled = 0; + for (size_t x = 0; x < bar.width(); x++) { + size_t color = 0; + for (size_t y = 0; y < bar.height(); y++) { + const uint32_t p = bar.pixel(x, y); + const int r = (p >> 16) & 255, g = (p >> 8) & 255, b = p & 255; + const bool green = g > r + 30 && g > b + 20; + const bool yellow = r > 150 && g > 120 && b < 100; + const bool red = r > 150 && r > g + 50 && r > b + 50; + color += green || yellow || red; + } + filled += color > bar.height() / 2; + } + return bar.width() && filled ? (double)filled / bar.width() : -1; + } - // Turn right. - pbf_move_left_joystick(context, {1, 0}, 120ms, 200ms); - context.wait_for_all_requests(); + enum class BattleHeal { NOT_NEEDED, USED, NONE_LEFT }; - ret = wait_until(stream, - context, - 1500ms, - { - {battle_start}, - {battle_menu}, - {battle_dialog}, - }); + // At the battle menu: if our HP is under `heal_below`, use a Potion + // from the Bag on the lead, which takes the turn. Otherwise the + // battle menu is left up. + static BattleHeal battle_heal_if_low(VideoStream& stream, ProControllerContext& context, + BattleType battle_type, double heal_below, const std::string& label) { + context.wait_for_all_requests(); + double hp = -1; + for (size_t check = 0; check < 5 && hp < 0; check++) { + const auto frame = stream.video().snapshot(); + if (frame) hp = battle_player_hp(*frame.frame); + if (hp < 0) pbf_wait(context, 150ms); + context.wait_for_all_requests(); + } + stream.log("[" + label + "] Our HP bar reads " + std::to_string((int)(hp * 100)) + "%."); + if (hp < 0 || hp >= heal_below) return BattleHeal::NOT_NEEDED; + BattleMenuDetector commands(battle_type); + bool on_bag = false; + for (size_t check = 0; check < 30 && !on_bag; check++) { + context.wait_for_all_requests(); + const auto frame = stream.video().snapshot(); + const BattleMenuOption selected = frame ? commands.selected_option(frame) : BattleMenuOption::UNKNOWN; + if (selected == BattleMenuOption::BAG) on_bag = true; + else if (selected == BattleMenuOption::UNKNOWN) pbf_wait(context, 150ms); + else pbf_press_dpad(context, (int)selected < (int)BattleMenuOption::BAG ? DPAD_DOWN : DPAD_UP, 80ms, 300ms); + } + if (!on_bag) { + stream.log("[" + label + "] Could not reach the Bag command; fighting on.", COLOR_ORANGE); + return BattleHeal::NONE_LEFT; + } + pbf_press_button(context, BUTTON_A, 80ms, 2000ms); + if (!find_bag_item_in_open_bag(stream, context, "potion", 1)) { + stream.log("[" + label + "] No Potion left; fighting on.", COLOR_ORANGE); + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + pbf_press_button(context, BUTTON_B, 80ms, 1500ms); + context.wait_for_all_requests(); + return BattleHeal::NONE_LEFT; + } + // Potion, then the lead in the party list, then Use. + stream.log("[" + label + "] Using a Potion on the lead.", COLOR_BLUE); + pbf_press_button(context, BUTTON_A, 80ms, 1500ms); + pbf_press_button(context, BUTTON_A, 80ms, 1500ms); + pbf_press_button(context, BUTTON_A, 80ms, 2500ms); + context.wait_for_all_requests(); + return BattleHeal::USED; + } - if (ret >= 0){ - encounter_found = true; - break; - } - } + static void fight_using_move_list( + VideoStream& stream, ProControllerContext& context, + BattleType battle_type, const std::vector& move_slots, + const std::string& label, bool confirm_default_target, bool post_battle_dialogue_cleanup, + bool doubles = false, bool switch_on_faint = false + ) { + if (move_slots.empty() || std::any_of(move_slots.begin(),move_slots.end(), + [](size_t slot) { return slot>4; })) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Supply a nonempty move list containing only positions 0 (best) through 4.",stream); + } + size_t move_index=0; + // The first Fight menu starts at move 1. Later turns retain + // this Pokemon's previous selection; a new battle resets it. + size_t current_move=1; + stream.log( + "[AutoStory] Starting " + label + " battle...", + COLOR_BLUE + ); - if (!encounter_found){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Wild encounter: Left/right wiggle did not find a battle within three minutes.", - stream); - } - stream.log("[AutoStory] Wild battle detected!", COLOR_GREEN); - - // Wait until the actual battle menu appears. - BattleMenuWatcher confirm_menu(BattleType::STANDARD); - - int menu_ret = wait_until(stream, - context, - 15s, - { - {confirm_menu}, - }); - - if (menu_ret < 0){ - stream.log("[AutoStory] Failed to detect battle menu.", COLOR_RED); - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "[AutoStory] Failed to detect battle menu.", - stream); - } + // Scripted battles wait on the menu detector below, so no + // extra fixed delay is needed after a caller confirms it. + // Preserve the existing early-game transition delay. + if (!confirm_default_target) pbf_wait(context,5000ms); - stream.log("[AutoStory] Battle menu detected.", COLOR_GREEN); + bool battle_menu_seen = false; + bool out_of_potions = false; + size_t potions_used = 0; + size_t turns = 0; + size_t faint_switches = 0; + const bool watch_learn_move = confirm_default_target || doubles || switch_on_faint; + EndBattleWatcher end_battle; - context.wait_for_all_requests(); - pbf_wait(context, 500ms); -} + while (true) { -static void confirm_battle_overworld(VideoStream& stream, ProControllerContext& context, const std::string& label){ - OverworldDetector overworld; - ShortDialogDetector dialogue(COLOR_YELLOW); - BlackScreenDetector black; - VideoOverlaySet overlays(stream.overlay()); - overworld.make_overlays(overlays); - dialogue.make_overlays(overlays); - black.make_overlays(overlays); - const auto deadline = std::chrono::steady_clock::now() + 180s; - auto quiet_since = std::chrono::steady_clock::now(); - bool finished = false; - while (std::chrono::steady_clock::now() < deadline){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": No video frame.", - stream); - } - const auto now = std::chrono::steady_clock::now(); - if (dialogue.detect(frame) || black.detect(frame) || !overworld.detect(frame)){ - quiet_since = now; - }else if (now - quiet_since >= 4s){ - finished = true; - break; - } - pbf_mash_button(context, BUTTON_B, 100ms); - context.wait_for_all_requests(); - } - if (!finished){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Dialogue-free overworld not confirmed for four seconds.", - stream); - } - context.wait_for_all_requests(); -} + context.wait_for_all_requests(); -static void fight_using_move_list(VideoStream& stream, - ProControllerContext& context, - BattleType battle_type, - const std::vector& move_slots, - const std::string& label, - bool confirm_default_target, - bool post_battle_dialogue_cleanup){ - if (move_slots.empty() || std::any_of(move_slots.begin(), move_slots.end(), [](size_t slot){ - return slot < 1 || slot > 4; - })){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Supply a nonempty move list containing only positions 1 through 4.", - stream); - } - size_t move_index = 0; - // The first Fight menu starts at move 1. Later turns retain - // this Pokemon's previous selection; a new battle resets it. - size_t current_move = 1; - stream.log("[AutoStory] Starting " + label + " battle...", COLOR_BLUE); - - // Scripted battles wait on the menu detector below, so no - // extra fixed delay is needed after a caller confirms it. - // Preserve the existing early-game transition delay. - if (!confirm_default_target){ - pbf_wait(context, 5000ms); - } + BattleMenuWatcher battle_menu(battle_type); + // Reuse the farming handlers' move-learning prompt recovery. + SelectionArrowFinder learn_move(stream.overlay(),{0.50,0.62,0.40,0.18},COLOR_YELLOW); + + int ret = run_until( + stream, + context, + [&](ProControllerContext& context) { + // Clear narration with B while watching for commands. + // A here can open Fight before selection begins. + // With faint switching, a quiet 30 s means the party + // screen after a faint, which B cannot leave. + pbf_mash_button(context,BUTTON_B,switch_on_faint ? 30000ms : 120000ms); + }, + { + {battle_menu}, + battle_menu_seen + ? PeriodicInferenceCallback{end_battle} + : PeriodicInferenceCallback{}, + watch_learn_move ? PeriodicInferenceCallback{learn_move} : PeriodicInferenceCallback{}, + } + ); + + switch (ret) { + + // ------------------------------------------------- + // Battle menu detected. + // ------------------------------------------------- + case 0: + battle_menu_seen = true; + // Early battles take a few turns; this many means the chosen + // move is not being used (no PP, a misread menu). + if (++turns > 25) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": still at the battle menu after 25 turns.", stream); + } - bool battle_menu_seen = false; - EndBattleWatcher end_battle; + stream.log( + "[AutoStory] Battle menu detected.", + COLOR_BLUE + ); + + // A trainer battle we may lose: a Potion when HP is low. + if (battle_type == BattleType::TRAINER && !out_of_potions && potions_used < 4) { + const BattleHeal heal = battle_heal_if_low(stream, context, battle_type, 0.35, label); + if (heal == BattleHeal::USED) { + potions_used++; + break; + } + out_of_potions = heal == BattleHeal::NONE_LEFT; + } + stream.log("["+label+"] Move list position "+std::to_string(move_index+1)+ + ": use move "+std::to_string(move_slots[move_index])+"."); + select_battle_move_slot(stream, context, battle_type, move_slots[move_index], label, + confirm_default_target,current_move); + current_move=move_slots[move_index]; + // A double battle asks for the target next; B would + // back out of it. The default target is an opponent. + if (doubles) { + pbf_wait(context,1200ms); + pbf_press_button(context,BUTTON_A,80ms,1500ms); + } + // Advance once per move selection; retain the final entry. + if (move_index+1(stream, context, + [](ProControllerContext& ctx) { + pbf_mash_button(ctx, BUTTON_B, 120000ms); + }, {{overworld}}); + if (cleanup_ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Post-battle cleanup timed out.", stream); + } + context.wait_for_all_requests(); + confirm_battle_overworld(stream, context, label + " cleanup"); + return; + } - BattleMenuWatcher battle_menu(battle_type); - // Reuse the farming handlers' move-learning prompt recovery. - SelectionArrowFinder learn_move(stream.overlay(), {0.50, 0.62, 0.40, 0.18}, COLOR_YELLOW); + case 2: + stream.log("["+label+"] Declining move learning; preserving scripted move positions."); + pbf_move_right_joystick(context,{0,-1},160ms,840ms); + pbf_press_button(context,BUTTON_ZL,160ms,840ms); + break; - int ret = run_until(stream, - context, - [](ProControllerContext& context){ - // Clear narration with B while watching for commands. - // A here can open Fight before selection begins. - pbf_mash_button(context, BUTTON_B, 120000ms); - }, - { - {battle_menu}, - battle_menu_seen ? PeriodicInferenceCallback{end_battle} : PeriodicInferenceCallback{}, - confirm_default_target ? PeriodicInferenceCallback{learn_move} : PeriodicInferenceCallback{}, - }); - - switch (ret){ - - // ------------------------------------------------- - // Battle menu detected. - // ------------------------------------------------- - case 0: - battle_menu_seen = true; - - stream.log("[AutoStory] Battle menu detected.", COLOR_BLUE); - - stream.log("[" + label + "] Move list position " + std::to_string(move_index + 1) + ": use move " + - std::to_string(move_slots[move_index]) + "."); - select_battle_move_slot(stream, - context, - battle_type, - move_slots[move_index], - label, - confirm_default_target, - current_move); - current_move = move_slots[move_index]; - // Advance once per move selection; retain the final entry. - if (move_index + 1 < move_slots.size()){ - move_index++; - } + default: + // After a faint the game shows the party: Down to the + // next Pokemon, A, then A on "Send out" (by hand, + // 2026-10-29 against Roark). + if (switch_on_faint && faint_switches < 5) { + faint_switches++; + stream.log("[" + label + "] No battle menu for 30 s: a faint; sending out the next Pokemon.", COLOR_ORANGE); + pbf_press_dpad(context, DPAD_DOWN, 80ms, 500ms); + pbf_press_button(context, BUTTON_A, 80ms, 1200ms); + pbf_press_button(context, BUTTON_A, 80ms, 2000ms); + break; + } + stream.log( + "[AutoStory] " + label + " battle timeout.", + COLOR_RED + ); - break; + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "[AutoStory] " + label + " battle timeout.", stream); + } + } + } - // ------------------------------------------------- - // End battle detected. - // ------------------------------------------------- - case 1: { - stream.log("[AutoStory] " + label + " battle complete.", COLOR_GREEN); + // Preserve the existing early-game callers and their B text cleanup. + static void fight_using_move_slot(VideoStream& stream, ProControllerContext& context, + BattleType battle_type, size_t move_slot, const std::string& label) { + fight_using_move_list(stream,context,battle_type,{move_slot},label,false,true); + } - // EndBattleWatcher has confirmed the completed exit transition. - // Ordinary trainers need no scene-wide dialogue-free gate. - if (!post_battle_dialogue_cleanup){ + void clear_post_battle_dialogue(VideoStream& stream, ProControllerContext& context, + Button button, Milliseconds quiet_period, + const char* instruction_card) { context.wait_for_all_requests(); - return; - } - // Story NPCs and gym leaders opt into the existing cleanup. - OverworldWatcher overworld; - int cleanup_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - {{overworld}}); - if (cleanup_ret != 0){ + std::unique_ptr card; + if (instruction_card) card = std::make_unique( + std::make_shared(RESOURCE_PATH()+instruction_card), + ImageFloatBox{.04,.32,.92,.34},30.0); + ShortDialogDetector dialogue(COLOR_YELLOW); + BlackScreenDetector black; + VideoOverlaySet overlays(stream.overlay()); + dialogue.make_overlays(overlays); + black.make_overlays(overlays); + const auto deadline = std::chrono::steady_clock::now() + 120s; + auto absent_since = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() < deadline) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Post-battle dialogue: No video frame.", stream); + const auto now = std::chrono::steady_clock::now(); + const bool dialogue_visible = dialogue.detect(*frame.frame); + const bool card_visible = card && card->detect(*frame.frame); + if (dialogue_visible || card_visible || black.detect(*frame.frame)) absent_since = now; + else if (now - absent_since >= quiet_period) { + stream.log("[Post-battle dialogue] " + std::to_string(quiet_period.count()) + + "ms without dialogue confirmed."); + return; + } + // A can interact again once dialogue disappears. Only confirm + // visible prompts; B retains the existing post-battle behavior. + if (card_visible) pbf_press_button(context, BUTTON_B, 80ms, 100ms); + else if (button == BUTTON_A && !dialogue_visible) pbf_wait(context, 100ms); + else pbf_mash_button(context, button, 100ms); + context.wait_for_all_requests(); + } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Post-battle cleanup timed out.", - stream); + "Post-battle dialogue: Three clear seconds not confirmed.", stream); } - context.wait_for_all_requests(); - confirm_battle_overworld(stream, context, label + " cleanup"); - return; - } - - case 2: - stream.log("[" + label + "] Declining move learning; preserving scripted move positions."); - pbf_move_right_joystick(context, {0, -1}, 160ms, 840ms); - pbf_press_button(context, BUTTON_ZL, 160ms, 840ms); - break; - - default: - stream.log("[AutoStory] " + label + " battle timeout.", COLOR_RED); - - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "[AutoStory] " + label + " battle timeout.", - stream); - } - } -} - -// Preserve the existing early-game callers and their B text cleanup. -static void fight_using_move_slot(VideoStream& stream, - ProControllerContext& context, - BattleType battle_type, - size_t move_slot, - const std::string& label){ - fight_using_move_list(stream, context, battle_type, {move_slot}, label, false, true); -} - -void clear_post_battle_dialogue(VideoStream& stream, - ProControllerContext& context, - Button button, - Milliseconds quiet_period, - const char* instruction_card){ - context.wait_for_all_requests(); - std::unique_ptr card; - if (instruction_card){ - card = std::make_unique(std::make_shared(RESOURCE_PATH() + instruction_card), - ImageFloatBox{.04, .32, .92, .34}, - 30.0); - } - ShortDialogDetector dialogue(COLOR_YELLOW); - BlackScreenDetector black; - VideoOverlaySet overlays(stream.overlay()); - dialogue.make_overlays(overlays); - black.make_overlays(overlays); - const auto deadline = std::chrono::steady_clock::now() + 120s; - auto absent_since = std::chrono::steady_clock::now(); - while (std::chrono::steady_clock::now() < deadline){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Post-battle dialogue: No video frame.", - stream); - } - const auto now = std::chrono::steady_clock::now(); - const bool dialogue_visible = dialogue.detect(*frame.frame); - const bool card_visible = card && card->detect(*frame.frame); - if (dialogue_visible || card_visible || black.detect(*frame.frame)){ - absent_since = now; - }else if (now - absent_since >= quiet_period){ - stream.log( - "[Post-battle dialogue] " + std::to_string(quiet_period.count()) + "ms without dialogue confirmed."); - return; - } - // A can interact again once dialogue disappears. Only confirm - // visible prompts; B retains the existing post-battle behavior. - if (card_visible){ - pbf_press_button(context, BUTTON_B, 80ms, 100ms); - }else if (button == BUTTON_A && !dialogue_visible){ - pbf_wait(context, 100ms); - }else{ - pbf_mash_button(context, button, 100ms); - } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Post-battle dialogue: Three clear seconds not confirmed.", - stream); -} -void align_to_overworld_landmarks(VideoStream& stream, - ProControllerContext& context, - OverworldMatchResult (*matcher)(const ImageRGB32&), - const std::string& label, - bool allow_story_start, - bool horizontal_first, - bool search_down_first, - OverworldMatchResult (*entry_matcher)(const ImageRGB32&), - bool* entry_detected, - double arrival_tolerance_x, - double arrival_tolerance_y, - double moderate_entry_confidence, - bool* rock_acquisition_timed_out){ - if (rock_acquisition_timed_out){ - *rock_acquisition_timed_out = false; - } - auto missing_rocks_since = std::chrono::steady_clock::time_point::max(); - if (entry_detected){ - *entry_detected = false; - } - context.wait_for_all_requests(); - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - ShortDialogDetector dialogue(COLOR_YELLOW); - BattleMenuDetector battle(BattleType::TRAINER, COLOR_GREEN); - size_t confirmations = 0, missing = 0; - bool ace_bypass = false; - const bool route215_renewal = matcher == match_route215_bridge_renewal || matcher == match_route215_ace_renewal || - matcher == match_route215_derek_stairs_renewal || matcher == match_route215_derek_stair_top_renewal; - Milliseconds correction_movement = 0ms; - for (size_t attempt = 0; attempt < 80; attempt++){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": No video frame.", - stream); - } - if (entry_matcher){ - BlackScreenDetector transition; - if (entry_matcher(*frame.frame).located || transition.detect(*frame.frame)){ - BlackScreenWatcher visible(COLOR_RED, - {0.1, 0.1, 0.8, 0.8}, - 100, - 10, - BlackScreenWatcher::FinderType::GONE, - 500ms); - if (wait_until(stream, context, 30s, {{visible}}) != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Building transition did not finish.", - stream); - } - const auto inside = stream.video().snapshot(); - if (!inside || !entry_matcher(*inside.frame).located){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Expected interior not confirmed after transition.", - stream); - } - if (entry_detected){ - *entry_detected = true; - } - stream.log("[" + label + "] Expected interior confirmed; ending outdoor alignment.", COLOR_GREEN); - return; - } - } - // The story can start before the exact reference point. Hand off - // to battle preparation instead of steering during dialogue. - if (dialogue.detect(*frame.frame) || battle.detect(*frame.frame)){ - if (allow_story_start){ - stream.log("[" + label + "] Story dialogue/battle started; handing off without further movement."); - return; - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Unexpected dialogue/battle during alignment.", - stream); - } - const auto result = matcher(*frame.frame); - if (result.located){ - missing_rocks_since = std::chrono::steady_clock::time_point::max(); - } - VideoOverlaySet overlays(stream.overlay()); - for (const auto& landmark : result.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); - } - stream.log("[" + label + "] " + landmark.name + " score=" + std::to_string(landmark.score) + - ", margin=" + std::to_string(landmark.margin) + ", offset=(" + std::to_string(landmark.dx) + "," + - std::to_string(landmark.dy) + ")"); - } - stream.log("[" + label + "] located=" + std::to_string(result.located) + - ", coarse=" + std::to_string(result.coarse) + ", at_target=" + std::to_string(result.at_target) + - ", consensus offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")"); - // The blue-haired NPC patrols the narrow eastern row. Shift - // one tile north on the clear road before crossing west; - // return to the original row only after clearing his column. - if (matcher == match_route215_ace_renewal && result.located){ - if (result.dx < -1.0 / 12){ - ace_bypass = true; - }else if (result.dx >= -1.0 / 32){ - ace_bypass = false; - } - } - const bool ace_row_recovery = ace_bypass && std::abs(result.dy - 70.0 / 1080) > 1.0 / 108; - const bool ace_central_row = matcher == match_route215_ace_renewal && !ace_bypass && result.coarse && - std::abs(result.dx) <= 1.0 / 12 && std::abs(result.dy) > 1.0 / 54; - const double entry_confidence = moderate_entry_confidence > 0 ? overworld_entry_confidence(result) : 0; - const bool moderate_arrival = moderate_entry_confidence > 0 && entry_confidence >= moderate_entry_confidence; - if (moderate_entry_confidence > 0){ - stream.log("[" + label + "] Exterior entry confidence=" + std::to_string(entry_confidence) + - ", moderate threshold=" + std::to_string(moderate_entry_confidence) + - ", strict target=" + std::to_string(result.at_target)); - } - // Explicit loose bounds are for non-interacting route handoffs. - // Default callers still require the matcher's precise arrival. - const bool rough_arrival = arrival_tolerance_x > 0 && arrival_tolerance_y > 0 && result.located && - std::abs(result.dx) <= arrival_tolerance_x && std::abs(result.dy) <= arrival_tolerance_y; - if (result.at_target || rough_arrival || moderate_arrival){ - if (++confirmations >= 3){ - stream.log("[" + label + "] Position confirmed."); - return; - } - pbf_wait(context, 250ms); - }else if (result.located){ - confirmations = 0; - missing = 0; - // Wait at an aligned pose while strict visual support - // recovers, rather than stepping away under a cloud. - if ((matcher == match_galactic_hq_jupiter || matcher == match_route208_hannah_departure || - matcher == match_route208_stair_top) && - result.coarse && std::abs(result.dx) <= 1.0 / 128 && std::abs(result.dy) <= 1.0 / 54){ + void align_to_overworld_landmarks(VideoStream& stream, ProControllerContext& context, + OverworldMatchResult (*matcher)(const ImageRGB32&), const std::string& label, bool allow_story_start, bool horizontal_first, + bool search_down_first, + OverworldMatchResult (*entry_matcher)(const ImageRGB32&), + bool* entry_detected, + double arrival_tolerance_x, double arrival_tolerance_y, + double moderate_entry_confidence, bool* rock_acquisition_timed_out) { + if (rock_acquisition_timed_out) *rock_acquisition_timed_out=false; + auto missing_rocks_since=std::chrono::steady_clock::time_point::max(); + if (entry_detected) *entry_detected=false; + context.wait_for_all_requests(); pbf_wait(context, 500ms); context.wait_for_all_requests(); - continue; - } - // A coarse match cannot confirm position, but two accepted - // anchors can establish a safe horizontal direction when both - // offsets are well outside the uncertainty in their spacing. - bool coarse_horizontal = false; - if (horizontal_first && result.coarse && result.landmarks.size() == 2){ - const auto& a = result.landmarks[0]; - const auto& b = result.landmarks[1]; - const double nearest = std::min(std::abs(a.dx), std::abs(b.dx)); - coarse_horizontal = a.accepted && b.accepted && a.dx * b.dx > 0 && nearest > 1.0 / 64 && - nearest > 2 * std::abs(a.dx - b.dx); - } - const bool plaza_anchor_horizontal = matcher == match_jubilife_plaza_approach && result.coarse && - std::abs(result.dx) > 1.0 / 50 && std::abs(result.dy) <= 1.0 / 54; - // A uniquely matched Eterna ball may correct the door - // column while the sign/bush enter the frame. Arrival - // still requires the matcher's full three-anchor check. - const bool eterna_anchor_horizontal = - (matcher == match_eterna_center_entrance || matcher == match_eterna_underground_house || - matcher == match_eterna_underground_man) && - result.coarse && std::abs(result.dx) > 1.0 / 128; - // Recover the gym's doorway row before moving right if - // the south approach overshot toward the fence/cliff. - const bool gym_anchor_horizontal = matcher == match_eterna_gym_entrance && result.coarse && - std::abs(result.dx) > 1.0 / 128 && std::abs(result.dy) <= .06; - const bool gym_row_recovery = - matcher == match_eterna_gym_entrance && result.coarse && std::abs(result.dy) > .06; - // Reach the row north of Jupiter before entering her - // column. From above that row, align sideways before - // moving south so the NPC cannot block the correction. - const bool jupiter_row_recovery = matcher == match_galactic_hq_jupiter && result.dy < -1.0 / 54; - const bool jupiter_anchor_horizontal = matcher == match_galactic_hq_jupiter && !jupiter_row_recovery && - result.coarse && std::abs(result.dx) > 1.0 / 128; - // Stay south of the Contest Hall wall/sign before crossing to Fantina. - const bool contest_row_recovery = matcher == match_hearthome_contest_fantina && result.dy > 1.0 / 54; - // Keep the stair approach horizontal-first until close to - // its column. Recorded fine poses otherwise spent repeated - // 16ms sideways turns while a larger vertical error waited. - // Fine correction changes axis priority, never arrival gates. - const bool stair_fine_correction = - matcher == match_route208_stair_top && !result.coarse && std::abs(result.dx) <= 1.0 / 64; - const bool horizontal = !ace_row_recovery && !ace_central_row && - (ace_bypass || - (!contest_row_recovery && !gym_row_recovery && !jupiter_row_recovery && - (jupiter_anchor_horizontal || coarse_horizontal || plaza_anchor_horizontal || - eterna_anchor_horizontal || gym_anchor_horizontal || - (!result.coarse && - ((horizontal_first && !stair_fine_correction && std::abs(result.dx) > 1.0 / 128) || - std::abs(result.dx) >= std::abs(result.dy) * 9.0 / 16))))); - if (stair_fine_correction && horizontal_first && !horizontal && std::abs(result.dx) > 1.0 / 128){ - stream.log("[" + label + "] Near stair column; correcting the larger vertical error first."); - } - if (ace_row_recovery){ - stream.log("[" + label + "] Clearing the NPC row before crossing west."); - }else if (ace_central_row){ - stream.log("[" + label + "] Both road bends support restoring the arrival row."); - }else if (coarse_horizontal && horizontal){ - stream.log("[" + label + - "] Both landmarks agree on a large horizontal offset; correcting the street lane first."); - } - const double error = horizontal ? result.dx : (result.dy - (ace_bypass ? 70.0 / 1080 : 0.0)) * 9.0 / 16; - auto duration = std::abs(error) > 1.0 / 24 ? 80ms : std::abs(error) > 1.0 / 64 ? 40ms : 16ms; - // A 16ms turn may not complete a step. Use a bounded - // full correction while reacquiring the second plaza anchor. - if (matcher == match_jubilife_plaza_approach && result.coarse){ - duration = std::max(duration, 60ms); - } - if (matcher == match_galactic_hq_jupiter){ - for (const auto& landmark : result.landmarks){ - // The player stands around y=.74. Do not steer - // sideways/north when the stair opening is close - // to that row; a southward correction moves away. - if (landmark.name == "StairBoundary" && landmark.accepted && - landmark.found.y + landmark.found.height > .60 && (horizontal || result.dy < 0)){ + ShortDialogDetector dialogue(COLOR_YELLOW); + BattleMenuDetector battle(BattleType::TRAINER, COLOR_GREEN); + size_t confirmations = 0, missing = 0; + for (size_t attempt = 0; attempt < 80; attempt++) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": No video frame.", stream); + if (entry_matcher){ + BlackScreenDetector transition; + if (entry_matcher(*frame.frame).located || transition.detect(*frame.frame)){ + BlackScreenWatcher visible(COLOR_RED,{0.1,0.1,0.8,0.8}, + 100,10,BlackScreenWatcher::FinderType::GONE,500ms); + if (wait_until(stream,context,30s,{{visible}})!=0) + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Building transition did not finish.",stream); + const auto inside=stream.video().snapshot(); + if (!inside || !entry_matcher(*inside.frame).located) + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Expected interior not confirmed after transition.",stream); + if(entry_detected)*entry_detected=true; + stream.log("["+label+"] Expected interior confirmed; ending outdoor alignment.",COLOR_GREEN); + return; + } + } + // The story can start before the exact reference point. Hand off + // to battle preparation instead of steering during dialogue. + if (dialogue.detect(*frame.frame) || battle.detect(*frame.frame)) { + if (allow_story_start) { + stream.log("[" + label + "] Story dialogue/battle started; handing off without further movement."); + return; + } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Correction would approach the stair boundary.", - stream); + label + ": Unexpected dialogue/battle during alignment.", stream); } - } - } - if (route215_renewal){ - if (correction_movement + duration > 1500ms){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Renewal alignment movement budget exhausted.", - stream); - } - correction_movement += duration; - } - pbf_move_left_joystick(context, - {horizontal ? (result.dx > 0 ? 1.0 : -1.0) : 0.0, horizontal ? 0.0 : (error < 0 ? 1.0 : -1.0)}, - duration, - 0ms); - pbf_wait(context, 500ms); - }else{ - confirmations = 0; - if (matcher == match_hearthome_center_entrance || matcher == match_hearthome_contest_fantina || - matcher == match_veilstone_city_passage || matcher == match_veilstone_dawn_stairs || route215_renewal){ - // Do not sweep into a different doorway or around the Hall's - // side, or onto a Veilstone terrace, while required anchors are hidden. - if (++missing > 20){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Scene anchors missing; stationary reacquisition exhausted.", - stream); - } - // Recorded expiry endpoints can be north of the passage. - // Acquire its buildings along the clear south-only strip; - // cap total search motion, then remain stationary. - if (matcher == match_veilstone_city_passage && search_down_first && correction_movement < 1800ms){ - pbf_move_left_joystick(context, {0.0, -1.0}, 100ms, 0ms); - correction_movement += 100ms; - } - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - continue; - } - if (matcher == match_galactic_hq_jupiter || matcher == match_route208_stair_top){ - // Keep generic missing-anchor behavior unchanged. Route208's - // explicit caller can instead request its recorded recovery. - const bool stair_rocks = matcher == match_route208_stair_top; - if (stair_rocks && rock_acquisition_timed_out){ - const auto now = std::chrono::steady_clock::now(); - if (missing_rocks_since == std::chrono::steady_clock::time_point::max()){ - missing_rocks_since = now; + const auto result = matcher(*frame.frame); + if (result.located) + missing_rocks_since=std::chrono::steady_clock::time_point::max(); + VideoOverlaySet overlays(stream.overlay()); + for (const auto& landmark : result.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[" + label + "] " + landmark.name + " score=" + + std::to_string(landmark.score) + ", margin=" + std::to_string(landmark.margin) + + ", offset=(" + std::to_string(landmark.dx) + "," + std::to_string(landmark.dy) + ")"); } - // Matcher processing can take seconds per observation; - // measure real elapsed time rather than counting waits. - if (now - missing_rocks_since >= 10s){ - context.wait_for_all_requests(); - *rock_acquisition_timed_out = true; - stream.log("[" + label + - "] Rock acquisition timed out after 10s; handing off to the requested cliff recovery."); - return; + stream.log("[" + label + "] located=" + std::to_string(result.located) + + ", coarse=" + std::to_string(result.coarse) + ", at_target=" + std::to_string(result.at_target) + + ", consensus offset=(" + std::to_string(result.dx) + "," + std::to_string(result.dy) + ")"); + const double entry_confidence=moderate_entry_confidence>0 ? overworld_entry_confidence(result) : 0; + const bool moderate_arrival=moderate_entry_confidence>0 && entry_confidence>=moderate_entry_confidence; + if(moderate_entry_confidence>0) stream.log("["+label+"] Exterior entry confidence="+ + std::to_string(entry_confidence)+", moderate threshold="+std::to_string(moderate_entry_confidence)+ + ", strict target="+std::to_string(result.at_target)); + // Explicit loose bounds are for non-interacting route handoffs. + // Default callers still require the matcher's precise arrival. + const bool rough_arrival=arrival_tolerance_x>0 && arrival_tolerance_y>0 && + result.located && std::abs(result.dx)<=arrival_tolerance_x && + std::abs(result.dy)<=arrival_tolerance_y; + if (result.at_target || rough_arrival || moderate_arrival) { + if (++confirmations >= 3) { stream.log("[" + label + "] Position confirmed."); return; } + pbf_wait(context, 250ms); + } else if (result.located) { + confirmations = 0; + missing = 0; + // Wait at an aligned pose while strict visual support + // recovers, rather than stepping away under a cloud. + if ((matcher==match_galactic_hq_jupiter || + matcher==match_route208_hannah_departure || matcher==match_route208_stair_top) && result.coarse && + std::abs(result.dx)<=1.0/128 && std::abs(result.dy)<=1.0/54){ + pbf_wait(context,500ms); + context.wait_for_all_requests(); + continue; + } + // A coarse match cannot confirm position, but two accepted + // anchors can establish a safe horizontal direction when both + // offsets are well outside the uncertainty in their spacing. + bool coarse_horizontal = false; + if (horizontal_first && result.coarse && result.landmarks.size() == 2) { + const auto& a = result.landmarks[0]; + const auto& b = result.landmarks[1]; + const double nearest = std::min(std::abs(a.dx), std::abs(b.dx)); + coarse_horizontal = a.accepted && b.accepted && a.dx*b.dx > 0 && + nearest > 1.0/64 && nearest > 2*std::abs(a.dx-b.dx); + } + const bool plaza_anchor_horizontal = matcher == match_jubilife_plaza_approach && + result.coarse && std::abs(result.dx)>1.0/50 && std::abs(result.dy)<=1.0/54; + // A uniquely matched Eterna ball may correct the door + // column while the sign/bush enter the frame. Arrival + // still requires the matcher's full three-anchor check. + const bool eterna_anchor_horizontal = (matcher == match_eterna_center_entrance || + matcher == match_eterna_underground_house || matcher == match_eterna_underground_man) && + result.coarse && std::abs(result.dx)>1.0/128; + // Recover the gym's doorway row before moving right if + // the south approach overshot toward the fence/cliff. + const bool gym_anchor_horizontal=matcher==match_eterna_gym_entrance && + result.coarse && std::abs(result.dx)>1.0/128 && std::abs(result.dy)<=.06; + const bool gym_row_recovery=matcher==match_eterna_gym_entrance && + result.coarse && std::abs(result.dy)>.06; + // Reach the row north of Jupiter before entering her + // column. From above that row, align sideways before + // moving south so the NPC cannot block the correction. + const bool jupiter_row_recovery=matcher==match_galactic_hq_jupiter && + result.dy < -1.0/54; + const bool jupiter_anchor_horizontal=matcher==match_galactic_hq_jupiter && + !jupiter_row_recovery && result.coarse && std::abs(result.dx)>1.0/128; + // Stay south of the Contest Hall wall/sign before crossing to Fantina. + const bool contest_row_recovery=matcher==match_hearthome_contest_fantina && result.dy>1.0/54; + // Keep the stair approach horizontal-first until close to + // its column. Recorded fine poses otherwise spent repeated + // 16ms sideways turns while a larger vertical error waited. + // Fine correction changes axis priority, never arrival gates. + const bool stair_fine_correction=matcher==match_route208_stair_top && + !result.coarse && std::abs(result.dx)<=1.0/64; + const bool horizontal = !contest_row_recovery && !gym_row_recovery && !jupiter_row_recovery && (jupiter_anchor_horizontal || coarse_horizontal || plaza_anchor_horizontal || eterna_anchor_horizontal || gym_anchor_horizontal || (!result.coarse && + ((horizontal_first && !stair_fine_correction && std::abs(result.dx)>1.0/128) || + std::abs(result.dx)>=std::abs(result.dy)*9.0/16))); + if (stair_fine_correction && horizontal_first && !horizontal && std::abs(result.dx)>1.0/128) + stream.log("["+label+"] Near stair column; correcting the larger vertical error first."); + if (coarse_horizontal) stream.log("[" + label + + "] Both landmarks agree on a large horizontal offset; correcting the street lane first."); + const double error = horizontal ? result.dx : result.dy*9.0/16; + auto duration = std::abs(error) > 1.0/24 ? 80ms : std::abs(error) > 1.0/64 ? 40ms : 16ms; + // A 16ms turn may not complete a step. Use a bounded + // full correction while reacquiring the second plaza anchor. + if (matcher == match_jubilife_plaza_approach && result.coarse) + duration = std::max(duration, 60ms); + if (matcher==match_galactic_hq_jupiter){ + for (const auto& landmark:result.landmarks){ + // The player stands around y=.74. Do not steer + // sideways/north when the stair opening is close + // to that row; a southward correction moves away. + if (landmark.name=="StairBoundary" && landmark.accepted && + landmark.found.y+landmark.found.height>.60 && + (horizontal || result.dy<0)) + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Correction would approach the stair boundary.",stream); + } + } + pbf_move_left_joystick(context, + {horizontal ? (result.dx > 0 ? 1.0 : -1.0) : 0.0, + horizontal ? 0.0 : (result.dy < 0 ? 1.0 : -1.0)}, duration, 0ms); + pbf_wait(context, 500ms); + } else { + confirmations = 0; + if (matcher==match_hearthome_center_entrance || matcher==match_hearthome_contest_fantina){ + // Do not sweep into a different doorway or around the Hall's + // side when a cloud, NPC or fade hides the required anchors. + if (++missing>20) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+": Scene anchors missing; stationary reacquisition exhausted.",stream); + pbf_wait(context,500ms); + context.wait_for_all_requests(); + continue; + } + if (matcher==match_galactic_hq_jupiter || matcher==match_route208_stair_top){ + // Keep generic missing-anchor behavior unchanged. Route208's + // explicit caller can instead request its recorded recovery. + const bool stair_rocks=matcher==match_route208_stair_top; + if (stair_rocks && rock_acquisition_timed_out) { + const auto now=std::chrono::steady_clock::now(); + if (missing_rocks_since==std::chrono::steady_clock::time_point::max()) + missing_rocks_since=now; + // Matcher processing can take seconds per observation; + // measure real elapsed time rather than counting waits. + if (now-missing_rocks_since>=10s) { + context.wait_for_all_requests(); + *rock_acquisition_timed_out=true; + stream.log("["+label+"] Rock acquisition timed out after 10s; handing off to the requested cliff recovery."); + return; + } + } else if (++missing>(stair_rocks ? 20 : 6)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label+(stair_rocks ? ": Rock anchors missing; no blind search near stairs." : + ": Chair/stair anchors missing; no blind search near stairs."),stream); + } + pbf_wait(context,500ms); + context.wait_for_all_requests(); + continue; + } + // Search right first, then across the starting point to the + // left and back. Each complete sweep returns to its origin. + // Try that sweep at the initial height, above it, and below it; + // no missing/weak landmark ever confirms the destination. + if (++missing > (search_down_first ? 78 : 74)) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": Landmarks missing during bounded bidirectional search.", stream); + if (search_down_first && missing <= 4) { + stream.log("[" + label + "] Post-battle acquisition: moving down before lateral search."); + pbf_move_left_joystick(context, {0.0, -1.0}, 100ms, 0ms); + } else { + const size_t search_step = missing - 1 - (search_down_first ? 4 : 0); + const size_t level = search_step / 25; + const size_t slot = search_step % 25; + const bool lateral = slot < 24; + const double direction = slot < 6 || slot >= 18 ? 1.0 : -1.0; + const auto search_duration = lateral ? 100ms : level == 0 ? 600ms : 1200ms; + stream.log("[" + label + "] Acquisition search step=" + std::to_string(missing) + + ", direction=" + (lateral ? (direction>0 ? "right" : "left") : (level==0 ? "up" : "down"))); + // On the street, a northward search from the east + // edge can stop at the building/NPC collision row. + // Step west around it before changing height, then + // return to the same lane for the next bounded sweep. + const bool street_height_search=!lateral && matcher==match_jubilife_street_approach; + if (street_height_search) pbf_move_left_joystick(context,{-1.0,0.0},150ms,0ms); + pbf_move_left_joystick(context, + {lateral ? direction : 0.0, lateral ? 0.0 : (level == 0 ? 1.0 : -1.0)}, search_duration, 0ms); + if (street_height_search) pbf_move_left_joystick(context,{1.0,0.0},150ms,0ms); + } + pbf_wait(context, 500ms); } - }else if (++missing > (stair_rocks ? 20 : 6)){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + - (stair_rocks ? ": Rock anchors missing; no blind search near stairs." - : ": Chair/stair anchors missing; no blind search near stairs."), - stream); + context.wait_for_all_requests(); } - pbf_wait(context, 500ms); - context.wait_for_all_requests(); - continue; - } - // Search right first, then across the starting point to the - // left and back. Each complete sweep returns to its origin. - // Try that sweep at the initial height, above it, and below it; - // no missing/weak landmark ever confirms the destination. - if (++missing > (search_down_first ? 78 : 74)){ OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Landmarks missing during bounded bidirectional search.", - stream); + label + ": Could not confirm the reference position.", stream); } - if (search_down_first && missing <= 4){ - stream.log("[" + label + "] Post-battle acquisition: moving down before lateral search."); - pbf_move_left_joystick(context, {0.0, -1.0}, 100ms, 0ms); - }else{ - const size_t search_step = missing - 1 - (search_down_first ? 4 : 0); - const size_t level = search_step / 25; - const size_t slot = search_step % 25; - const bool lateral = slot < 24; - const double direction = slot < 6 || slot >= 18 ? 1.0 : -1.0; - const auto search_duration = lateral ? 100ms : level == 0 ? 600ms : 1200ms; - stream.log("[" + label + "] Acquisition search step=" + std::to_string(missing) + - ", direction=" + (lateral ? (direction > 0 ? "right" : "left") : (level == 0 ? "up" : "down"))); - // On the street, a northward search from the east - // edge can stop at the building/NPC collision row. - // Step west around it before changing height, then - // return to the same lane for the next bounded sweep. - const bool street_height_search = !lateral && matcher == match_jubilife_street_approach; - if (street_height_search){ - pbf_move_left_joystick(context, {-1.0, 0.0}, 150ms, 0ms); - } - pbf_move_left_joystick(context, - {lateral ? direction : 0.0, lateral ? 0.0 : (level == 0 ? 1.0 : -1.0)}, - search_duration, - 0ms); - if (street_height_search){ - pbf_move_left_joystick(context, {1.0, 0.0}, 150ms, 0ms); - } - } - pbf_wait(context, 500ms); - } - context.wait_for_all_requests(); - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": Could not confirm the reference position.", - stream); -} -void fight_trainer_using_moves(VideoStream& stream, - ProControllerContext& context, - const std::vector& moves, - const std::string& label, - bool post_battle_dialogue_cleanup){ - fight_using_move_list(stream, context, BattleType::TRAINER, moves, label, true, post_battle_dialogue_cleanup); -} - -void fight_trainer_using_moves(VideoStream& stream, - ProControllerContext& context, - size_t move, - const std::string& label, - bool post_battle_dialogue_cleanup){ - fight_trainer_using_moves(stream, context, std::vector{move}, label, post_battle_dialogue_cleanup); -} + void fight_trainer_using_moves(VideoStream& stream, ProControllerContext& context, + const std::vector& moves, const std::string& label, bool post_battle_dialogue_cleanup) { + fight_using_move_list(stream,context,BattleType::TRAINER,moves,label,true,post_battle_dialogue_cleanup); + } -void fight_trainer_using_move_slot(VideoStream& stream, - ProControllerContext& context, - size_t move_slot, - const std::string& label){ - fight_using_move_slot(stream, context, BattleType::TRAINER, move_slot, label); -} + void fight_trainer_using_moves(VideoStream& stream, ProControllerContext& context, + size_t move, const std::string& label, bool post_battle_dialogue_cleanup) { + fight_trainer_using_moves(stream,context,std::vector{move},label,post_battle_dialogue_cleanup); + } -void fight_tristan(VideoStream& stream, ProControllerContext& context){ - // All starters use move slot 3. Confirm its cursor position - // visually without reading the move name; fail if slot 3 is absent. - fight_trainer_using_move_slot(stream, context, 3, "Tristan (move slot 3)"); -} + void fight_trainer_using_move_slot(VideoStream& stream, ProControllerContext& context, + size_t move_slot, const std::string& label) { + fight_using_move_slot(stream,context,BattleType::TRAINER,move_slot,label); + } -void battle_1_pokemon(VideoStream& stream, ProControllerContext& context){ - stream.log("[Training] Finding one wild Pokemon using the catch encounter wiggle.", COLOR_BLUE); - encounter_one_wild_pokemon(stream, context); - // Use the same battle loop as Tristan with move slot 1. - fight_using_move_slot(stream, context, BattleType::STANDARD, 1, "Training (move slot 1)"); - stream.log("[Training] Fight and level-up cleanup complete; verifying Potion healing."); - use_potion_first_pokemon(stream, context); - context.wait_for_all_requests(); - stream.log("[Training] One wild battle completed; slot 1 healing verified.", COLOR_GREEN); -} + void fight_story_battle(VideoStream& stream, ProControllerContext& context, const std::string& label, bool doubles) { + // The best move each turn; the next Pokemon after a faint; no new moves. + fight_using_move_list(stream, context, BattleType::TRAINER, {0}, label, false, true, doubles, true); + } -// --------------------------------------------------------------------------- -// Catch 1 Pokemon -// --------------------------------------------------------------------------- - -void catch_1_pokemon(VideoStream& stream, ProControllerContext& context){ - stream.log("[AutoStory] Starting catch_1_pokemon helper...", COLOR_BLUE); - - encounter_one_wild_pokemon(stream, context); - - // Catch the pokemon. - stream.log("[AutoStory] Throwing Poke Ball (manual bag open)...", COLOR_BLUE); - ReceivePokemonDetector received_pokemon; - // Preserve exit-transition state from the ball outcome through - // receipt/experience cleanup; do not recreate it between phases. - EndBattleWatcher battle_ended; - bool caught = false; - bool exit_seen = false; - for (int throw_attempt = 0; throw_attempt < 10; throw_attempt++){ - // Open bag from battle menu (X), confirm first slot (ZL). - pbf_press_button(context, BUTTON_X, 160ms, 840ms); - context.wait_for_all_requests(); - pbf_press_button(context, BUTTON_ZL, 160ms, 1000ms); - context.wait_for_all_requests(); - - // Wait to see if the battle menu comes back (ball failed) - // or if the battle ends (caught or fainted). - BattleMenuWatcher menu_back(BattleType::STANDARD, COLOR_YELLOW); - int throw_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - {{menu_back}, {received_pokemon}, {battle_ended}}); - - if (throw_ret == 1 || (throw_ret == 2 && received_pokemon.received())){ - caught = true; - exit_seen = throw_ret == 2; - stream.log("[AutoStory] Pokemon receipt verified.", COLOR_GREEN); - break; - } - if (throw_ret < 0 || throw_ret == 2){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Catch failed: No verified Pokemon receipt before battle ended or timed out.", - stream); - } - stream.log("[AutoStory] Ball failed, retrying (attempt " + std::to_string(throw_attempt + 2) + ")...", - COLOR_ORANGE); - } + void fight_trainer(VideoStream& stream, ProControllerContext& context, const std::string& label) { + // Move 0: the best move on the menu each turn, whatever the starter. + fight_using_move_slot(stream, context, BattleType::TRAINER, 0, label); + } - context.wait_for_all_requests(); + void battle_1_pokemon(VideoStream& stream, ProControllerContext& context) { + stream.log("[Training] Finding one wild Pokemon using the catch encounter wiggle.", COLOR_BLUE); + encounter_one_wild_pokemon(stream, context); + // The best move on the menu, whatever the starter. + fight_using_move_slot(stream, context, BattleType::STANDARD, 0, "Training"); + context.wait_for_all_requests(); + stream.log("[Training] One wild battle completed.", COLOR_GREEN); + } - if (!caught){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Catch failed: Exhausted ten ball attempts.", - stream); - } - // Receipt alone is not an overworld return: clear experience, - // automatic move learning, dex registration and nickname screens - // until the same completed-black-transition watcher used in battles fires. - if (!exit_seen){ - stream.log("[Catch cleanup] Receipt confirmed; waiting for battle exit transition."); - const int cleanup_ret = run_until(stream, - context, - [](ProControllerContext& ctx){ - pbf_mash_button(ctx, BUTTON_B, 120000ms); - }, - {{battle_ended}}); - if (cleanup_ret != 0){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Catch cleanup: Battle exit transition did not finish.", - stream); - } - } - context.wait_for_all_requests(); - stream.log("[Catch cleanup] Battle exit transition confirmed; checking stable overworld."); - confirm_battle_overworld(stream, context, "Catch cleanup"); - stream.log("[Catch cleanup] Verified receipt, completed battle exit, and four seconds of clear overworld.", - COLOR_GREEN); -} + // --------------------------------------------------------------------------- + // Catch 1 Pokemon + // --------------------------------------------------------------------------- + + void catch_1_pokemon( + VideoStream& stream, + ProControllerContext& context + ) { + stream.log("[AutoStory] Starting catch_1_pokemon helper...", COLOR_BLUE); + + encounter_one_wild_pokemon(stream, context); + + // Catch the pokemon. + stream.log("[AutoStory] Throwing Poke Ball (manual bag open)...", COLOR_BLUE); + ReceivePokemonDetector received_pokemon; + // Preserve exit-transition state from the ball outcome through + // receipt/experience cleanup; do not recreate it between phases. + EndBattleWatcher battle_ended; + bool caught = false; + bool exit_seen = false; + for (int throw_attempt = 0; throw_attempt < 10; throw_attempt++) { + // Open bag from battle menu (X), confirm first slot (ZL). + pbf_press_button(context, BUTTON_X, 160ms, 840ms); + context.wait_for_all_requests(); + pbf_press_button(context, BUTTON_ZL, 160ms, 1000ms); + context.wait_for_all_requests(); -// --------------------------------------------------------------------------- -// catch_1_pokemon_v2 - burst wiggle variant for testing. -// Uses WIGGLES_PER_BURST consecutive left/right moves before checking for a -// battle, reducing total wait time compared to checking after every move. -// Commented out - enable for testing when ready. -// --------------------------------------------------------------------------- -// void catch_1_pokemon_v2( -// SingleSwitchProgramEnvironment& env, -// VideoStream& stream, -// ProControllerContext& context -// ) { -// stream.log("[AutoStory] Starting catch_1_pokemon_v2 helper...", COLOR_BLUE); -// -// // Save once before attempting to catch. On failure the game -// // resets back to this save, so the save lives outside the loop. -// stream.log("[AutoStory] Saving game before catch attempt...", COLOR_BLUE); -// { -// static constexpr int SAVE_MAX_RETRIES = 3; -// bool save_succeeded = false; -// for (int save_attempt = 0; save_attempt < SAVE_MAX_RETRIES; save_attempt++) { -// try { -// if (save_attempt > 0) { -// stream.log( -// "[AutoStory] Retrying save (attempt " + std::to_string(save_attempt + 1) + ")...", -// COLOR_ORANGE -// ); -// pbf_mash_button(context, BUTTON_B, 1000ms); -// context.wait_for_all_requests(); -// } -// save_game(stream, context); -// save_succeeded = true; -// break; -// } -// catch (OperationFailedException& e) { -// stream.log( -// "[AutoStory] Save attempt " + std::to_string(save_attempt + 1) + " failed: " + e.message(), -// COLOR_RED -// ); -// context.wait_for(1000ms); -// } -// } -// if (!save_succeeded) { -// stream.log("[AutoStory] All save attempts failed. Using blind fallback save.", COLOR_ORANGE); -// pbf_mash_button(context, BUTTON_B, 1000ms); -// save_game(context); -// pbf_mash_button(context, BUTTON_B, 2000ms); -// } -// } -// context.wait_for_all_requests(); -// pbf_wait(context, 1000ms); -// -// // ------------------------------------------------------- -// // Mini-checkpoint loop: retry from the save above if we -// // run out of balls or our Pokemon faints. -// // ------------------------------------------------------- -// while (true) { -// -// BlackScreenWatcher battle_start; -// BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); -// BattleDialogWatcher battle_dialog(COLOR_YELLOW); -// -// bool encounter_found = false; -// -// // Wiggle left/right in bursts, checking for a battle after each burst. -// // Doing multiple moves before checking avoids waiting after every step. -// static constexpr int WIGGLES_PER_BURST = 3; -// while (!encounter_found) { -// for (int i = 0; i < WIGGLES_PER_BURST; i++) { -// pbf_move_left_joystick(context, {-1, 0}, 120ms, 80ms); -// pbf_move_left_joystick(context, { 1, 0}, 120ms, 80ms); -// } -// context.wait_for_all_requests(); -// -// int ret = wait_until( -// stream, -// context, -// 1500ms, -// { -// {battle_start}, -// {battle_menu}, -// {battle_dialog}, -// } -// ); -// -// if (ret >= 0) { -// encounter_found = true; -// } -// } -// -// stream.log("[AutoStory] Wild battle detected!", COLOR_GREEN); -// -// // Wait until the actual battle menu appears. -// BattleMenuWatcher confirm_menu(BattleType::STANDARD); -// -// int menu_ret = wait_until( -// stream, -// context, -// 15s, -// { -// {confirm_menu}, -// } -// ); -// -// if (menu_ret < 0) { -// stream.log( -// "[AutoStory] Failed to detect battle menu. Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// } -// -// stream.log("[AutoStory] Battle menu detected.", COLOR_GREEN); -// -// context.wait_for_all_requests(); -// pbf_wait(context, 500ms); -// -// // --- Throw balls --- -// CatchResults result = basic_catcher( -// stream, -// context, -// Language::English, -// "poke-ball", -// 10 -// ); -// -// switch (result.result) { -// case CatchResult::POKEMON_CAUGHT: -// stream.log( -// "[AutoStory] Pokemon successfully caught after " + -// std::to_string(result.balls_used) + " ball(s).", -// COLOR_GREEN -// ); -// { -// OverworldWatcher overworld; -// run_until( -// stream, context, -// [](ProControllerContext& ctx) { -// pbf_mash_button(ctx, BUTTON_B, 120000ms); -// }, -// {{overworld}} -// ); -// } -// return; -// -// case CatchResult::POKEMON_FAINTED: -// stream.log("[AutoStory] Wild Pokemon fainted. Exiting battle.", COLOR_ORANGE); -// { -// OverworldWatcher overworld; -// run_until( -// stream, context, -// [](ProControllerContext& ctx) { -// pbf_mash_button(ctx, BUTTON_B, 120000ms); -// }, -// {{overworld}} -// ); -// } -// return; -// -// case CatchResult::OUT_OF_BALLS: -// stream.log( -// "[AutoStory] Ran out of Poke Balls after " + -// std::to_string(result.balls_used) + " throw(s). Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// -// case CatchResult::OWN_FAINTED: -// stream.log( -// "[AutoStory] Own Pokemon fainted. Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// -// case CatchResult::BALL_LIMIT_REACHED: -// stream.log( -// "[AutoStory] Ball limit reached (" + -// std::to_string(result.balls_used) + " throw(s)). Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// -// case CatchResult::CANNOT_THROW_BALL: -// stream.log( -// "[AutoStory] Cannot throw ball (menu timing issue). Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// -// default: -// stream.log( -// "[AutoStory] Catch timed out after " + -// std::to_string(result.balls_used) + " ball(s). Resetting to checkpoint...", -// COLOR_RED -// ); -// reset_game_from_home(env, env.console, context, true); -// continue; -// } -// -// } // end while(true) -// } - -// --------------------------------------------------------------------------- -// Movement Helpers -// --------------------------------------------------------------------------- - -bool detect_black_screen_after_movement(VideoStream& stream, ProControllerContext& context, Milliseconds timeout){ - BlackScreenWatcher black_screen; - return wait_until(stream, context, timeout, {{black_screen}}) == 0; -} + // Wait to see if the battle menu comes back (ball failed) + // or if the battle ends (caught or fainted). + BattleMenuWatcher menu_back(BattleType::STANDARD, COLOR_YELLOW); + int throw_ret = run_until( + stream, context, + [](ProControllerContext& ctx) { + pbf_mash_button(ctx, BUTTON_B, 120000ms); + }, + {{menu_back}, {received_pokemon}, {battle_ended}} + ); + + if (throw_ret == 1 || (throw_ret == 2 && received_pokemon.received())) { + caught = true; + exit_seen = throw_ret == 2; + stream.log("[AutoStory] Pokemon receipt verified.", COLOR_GREEN); + break; + } + if (throw_ret < 0 || throw_ret == 2) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Catch failed: No verified Pokemon receipt before battle ended or timed out.", stream); + } + stream.log( + "[AutoStory] Ball failed, retrying (attempt " + std::to_string(throw_attempt + 2) + ")...", + COLOR_ORANGE + ); + } -bool try_right_down_exit_search(VideoStream& stream, ProControllerContext& context, size_t iterations){ - for (size_t i = 0; i < iterations; i++){ - pbf_move_left_joystick(context, {1.0, 0.0}, 80ms, 0ms); - pbf_move_left_joystick(context, {0.0, -1.0}, 220ms, 0ms); + context.wait_for_all_requests(); - if (detect_black_screen_after_movement(stream, context, 1000ms)){ - return true; - } - } - return false; -} + if (!caught) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Catch failed: Exhausted ten ball attempts.", stream); + } + // Receipt alone is not an overworld return: clear experience, + // automatic move learning, dex registration and nickname screens + // until the same completed-black-transition watcher used in battles fires. + if (!exit_seen) { + stream.log("[Catch cleanup] Receipt confirmed; waiting for battle exit transition."); + const int cleanup_ret = run_until(stream, context, + [](ProControllerContext& ctx) { pbf_mash_button(ctx, BUTTON_B, 120000ms); }, + {{battle_ended}}); + if (cleanup_ret != 0) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Catch cleanup: Battle exit transition did not finish.", stream); + } + } + context.wait_for_all_requests(); + stream.log("[Catch cleanup] Battle exit transition confirmed; checking stable overworld."); + confirm_battle_overworld(stream, context, "Catch cleanup"); + stream.log("[Catch cleanup] Verified receipt, completed battle exit, and four seconds of clear overworld.", COLOR_GREEN); + } -void leave_pokemon_center_before_barry_1(VideoStream& stream, ProControllerContext& context){ - // Original down movement to leave Pokemon Center. - pbf_move_left_joystick(context, {0.0, -1.0}, 814ms, 0ms); + // --------------------------------------------------------------------------- + // catch_1_pokemon_v2 - burst wiggle variant for testing. + // Uses WIGGLES_PER_BURST consecutive left/right moves before checking for a + // battle, reducing total wait time compared to checking after every move. + // Commented out - enable for testing when ready. + // --------------------------------------------------------------------------- + // void catch_1_pokemon_v2( + // SingleSwitchProgramEnvironment& env, + // VideoStream& stream, + // ProControllerContext& context + // ) { + // stream.log("[AutoStory] Starting catch_1_pokemon_v2 helper...", COLOR_BLUE); + // + // // Save once before attempting to catch. On failure the game + // // resets back to this save, so the save lives outside the loop. + // stream.log("[AutoStory] Saving game before catch attempt...", COLOR_BLUE); + // { + // static constexpr int SAVE_MAX_RETRIES = 3; + // bool save_succeeded = false; + // for (int save_attempt = 0; save_attempt < SAVE_MAX_RETRIES; save_attempt++) { + // try { + // if (save_attempt > 0) { + // stream.log( + // "[AutoStory] Retrying save (attempt " + std::to_string(save_attempt + 1) + ")...", + // COLOR_ORANGE + // ); + // pbf_mash_button(context, BUTTON_B, 1000ms); + // context.wait_for_all_requests(); + // } + // save_game(stream, context); + // save_succeeded = true; + // break; + // } + // catch (OperationFailedException& e) { + // stream.log( + // "[AutoStory] Save attempt " + std::to_string(save_attempt + 1) + " failed: " + e.message(), + // COLOR_RED + // ); + // context.wait_for(1000ms); + // } + // } + // if (!save_succeeded) { + // stream.log("[AutoStory] All save attempts failed. Using blind fallback save.", COLOR_ORANGE); + // pbf_mash_button(context, BUTTON_B, 1000ms); + // save_game(context); + // pbf_mash_button(context, BUTTON_B, 2000ms); + // } + // } + // context.wait_for_all_requests(); + // pbf_wait(context, 1000ms); + // + // // ------------------------------------------------------- + // // Mini-checkpoint loop: retry from the save above if we + // // run out of balls or our Pokemon faints. + // // ------------------------------------------------------- + // while (true) { + // + // BlackScreenWatcher battle_start; + // BattleMenuWatcher battle_menu(BattleType::STANDARD, COLOR_YELLOW); + // BattleDialogWatcher battle_dialog(COLOR_YELLOW); + // + // bool encounter_found = false; + // + // // Wiggle left/right in bursts, checking for a battle after each burst. + // // Doing multiple moves before checking avoids waiting after every step. + // static constexpr int WIGGLES_PER_BURST = 3; + // while (!encounter_found) { + // for (int i = 0; i < WIGGLES_PER_BURST; i++) { + // pbf_move_left_joystick(context, {-1, 0}, 120ms, 80ms); + // pbf_move_left_joystick(context, { 1, 0}, 120ms, 80ms); + // } + // context.wait_for_all_requests(); + // + // int ret = wait_until( + // stream, + // context, + // 1500ms, + // { + // {battle_start}, + // {battle_menu}, + // {battle_dialog}, + // } + // ); + // + // if (ret >= 0) { + // encounter_found = true; + // } + // } + // + // stream.log("[AutoStory] Wild battle detected!", COLOR_GREEN); + // + // // Wait until the actual battle menu appears. + // BattleMenuWatcher confirm_menu(BattleType::STANDARD); + // + // int menu_ret = wait_until( + // stream, + // context, + // 15s, + // { + // {confirm_menu}, + // } + // ); + // + // if (menu_ret < 0) { + // stream.log( + // "[AutoStory] Failed to detect battle menu. Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // } + // + // stream.log("[AutoStory] Battle menu detected.", COLOR_GREEN); + // + // context.wait_for_all_requests(); + // pbf_wait(context, 500ms); + // + // // --- Throw balls --- + // CatchResults result = basic_catcher( + // stream, + // context, + // Language::English, + // "poke-ball", + // 10 + // ); + // + // switch (result.result) { + // case CatchResult::POKEMON_CAUGHT: + // stream.log( + // "[AutoStory] Pokemon successfully caught after " + + // std::to_string(result.balls_used) + " ball(s).", + // COLOR_GREEN + // ); + // { + // OverworldWatcher overworld; + // run_until( + // stream, context, + // [](ProControllerContext& ctx) { + // pbf_mash_button(ctx, BUTTON_B, 120000ms); + // }, + // {{overworld}} + // ); + // } + // return; + // + // case CatchResult::POKEMON_FAINTED: + // stream.log("[AutoStory] Wild Pokemon fainted. Exiting battle.", COLOR_ORANGE); + // { + // OverworldWatcher overworld; + // run_until( + // stream, context, + // [](ProControllerContext& ctx) { + // pbf_mash_button(ctx, BUTTON_B, 120000ms); + // }, + // {{overworld}} + // ); + // } + // return; + // + // case CatchResult::OUT_OF_BALLS: + // stream.log( + // "[AutoStory] Ran out of Poke Balls after " + + // std::to_string(result.balls_used) + " throw(s). Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // + // case CatchResult::OWN_FAINTED: + // stream.log( + // "[AutoStory] Own Pokemon fainted. Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // + // case CatchResult::BALL_LIMIT_REACHED: + // stream.log( + // "[AutoStory] Ball limit reached (" + + // std::to_string(result.balls_used) + " throw(s)). Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // + // case CatchResult::CANNOT_THROW_BALL: + // stream.log( + // "[AutoStory] Cannot throw ball (menu timing issue). Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // + // default: + // stream.log( + // "[AutoStory] Catch timed out after " + + // std::to_string(result.balls_used) + " ball(s). Resetting to checkpoint...", + // COLOR_RED + // ); + // reset_game_from_home(env, env.console, context, true); + // continue; + // } + // + // } // end while(true) + // } + + // --------------------------------------------------------------------------- + // Movement Helpers + // --------------------------------------------------------------------------- + + bool detect_black_screen_after_movement( + VideoStream& stream, + ProControllerContext& context, + Milliseconds timeout + ) { + BlackScreenWatcher black_screen; + return wait_until(stream, context, timeout, {{black_screen}}) == 0; + } - if (detect_black_screen_after_movement(stream, context, 1200ms)){ - pbf_wait(context, 2340ms); - return; - } + bool try_right_down_exit_search( + VideoStream& stream, + ProControllerContext& context, + size_t iterations + ) { + for (size_t i = 0; i < iterations; i++) { + pbf_move_left_joystick(context, { 1.0, 0.0 }, 80ms, 0ms); + pbf_move_left_joystick(context, { 0.0, -1.0 }, 220ms, 0ms); + + if (detect_black_screen_after_movement(stream, context, 1000ms)) { + return true; + } + } + return false; + } - stream.log("[AutoStory] Pokemon Center exit black screen not detected. Searching with right/down movement.", - COLOR_ORANGE); + void leave_pokemon_center_before_barry_1( + VideoStream& stream, + ProControllerContext& context + ) { + // Original down movement to leave Pokemon Center. + pbf_move_left_joystick(context, { 0.0, -1.0 }, 814ms, 0ms); - if (try_right_down_exit_search(stream, context, 20)){ - pbf_wait(context, 2340ms); - return; - } + if (detect_black_screen_after_movement(stream, context, 1200ms)) { + pbf_wait(context, 2340ms); + return; + } - stream.log("[AutoStory] First exit search failed. Repositioning left and retrying.", COLOR_ORANGE); - pbf_move_left_joystick(context, {-1.0, 0.0}, 5000ms, 0ms); + stream.log("[AutoStory] Pokemon Center exit black screen not detected. Searching with right/down movement.", COLOR_ORANGE); - if (try_right_down_exit_search(stream, context, 30)){ - pbf_wait(context, 2340ms); - return; - } + if (try_right_down_exit_search(stream, context, 20)) { + pbf_wait(context, 2340ms); + return; + } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Failed to find Pokemon Center exit black screen before Barry 1. Triggering checkpoint retry.", - stream); -} + stream.log("[AutoStory] First exit search failed. Repositioning left and retrying.", COLOR_ORANGE); + pbf_move_left_joystick(context, { -1.0, 0.0 }, 5000ms, 0ms); -// --------------------------------------------------------------------------- -// Generic Movement Helper - Walk through grass in specified direction for -// specified steps, attempting to run from any battles that are encountered. -// --------------------------------------------------------------------------- -void walk_through_grass(VideoStream& stream, - ProControllerContext& context, - int8_t x, - int8_t y, - size_t steps, - const std::string& direction_name, - BattleType battle_type, - Milliseconds movement_duration, - OverworldMatchResult (*destination)(const ImageRGB32&)){ - size_t movements = 0, confirmations = 0; - while (true){ - context.wait_for_all_requests(); - // Destination checks happen before input and after each step/escape. - // With no matcher, retain the original fixed-step behavior. - VideoOverlaySet overlays(stream.overlay()); - OverworldMatchResult location; - if (destination){ - const auto frame = stream.video().snapshot(); - if (!frame){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Grass destination: No video frame.", - stream); - } - location = destination(*frame.frame); - for (const auto& landmark : location.landmarks){ - overlays.add(COLOR_CYAN, landmark.search); - if (landmark.accepted){ - overlays.add(COLOR_GREEN, landmark.found); - } - stream.log("[Grass destination] " + direction_name + " " + landmark.name + - " score=" + std::to_string(landmark.score) + " margin=" + std::to_string(landmark.margin) + - " accepted=" + std::to_string(landmark.accepted)); - } - stream.log("[Grass destination] located=" + std::to_string(location.located) + - " at_target=" + std::to_string(location.at_target) + " screen-relative offset=(" + - std::to_string(location.dx) + "," + std::to_string(location.dy) + ")"); - if (location.at_target){ - if (++confirmations >= 3){ - stream.log("[Grass] Destination confirmed; stopped " + direction_name + " after " + - std::to_string(movements) + "/" + std::to_string(steps) + " movements.", - COLOR_GREEN); + if (try_right_down_exit_search(stream, context, 30)) { + pbf_wait(context, 2340ms); return; } - pbf_wait(context, 250ms); - continue; + + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "Failed to find Pokemon Center exit black screen before Barry 1. Triggering checkpoint retry.", + stream + ); } - confirmations = 0; - } - if (movements >= steps){ - if (destination){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Grass destination not confirmed within movement limit: " + direction_name, - stream); + + // --------------------------------------------------------------------------- + // Generic Movement Helper - Walk through grass in specified direction for + // specified steps, attempting to run from any battles that are encountered. + // --------------------------------------------------------------------------- + void walk_through_grass( + VideoStream& stream, ProControllerContext& context, + int8_t x, int8_t y, size_t steps, const std::string& direction_name, + BattleType battle_type, Milliseconds movement_duration, + OverworldMatchResult (*destination)(const ImageRGB32&) + ) { + size_t movements = 0, confirmations = 0; + while (true) { + context.wait_for_all_requests(); + // Destination checks happen before input and after each step/escape. + // With no matcher, retain the original fixed-step behavior. + VideoOverlaySet overlays(stream.overlay()); + OverworldMatchResult location; + if (destination) { + const auto frame = stream.video().snapshot(); + if (!frame) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Grass destination: No video frame.", stream); + location = destination(*frame.frame); + for (const auto& landmark : location.landmarks) { + overlays.add(COLOR_CYAN, landmark.search); + if (landmark.accepted) overlays.add(COLOR_GREEN, landmark.found); + stream.log("[Grass destination] " + direction_name + " " + landmark.name + + " score=" + std::to_string(landmark.score) + + " margin=" + std::to_string(landmark.margin) + + " accepted=" + std::to_string(landmark.accepted)); + } + stream.log("[Grass destination] located=" + std::to_string(location.located) + + " at_target=" + std::to_string(location.at_target) + + " screen-relative offset=(" + std::to_string(location.dx) + + "," + std::to_string(location.dy) + ")"); + if (location.at_target) { + if (++confirmations >= 3) { + stream.log("[Grass] Destination confirmed; stopped " + direction_name + + " after " + std::to_string(movements) + "/" + std::to_string(steps) + " movements.", COLOR_GREEN); + return; + } + pbf_wait(context, 250ms); + continue; + } + confirmations = 0; + } + if (movements >= steps) { + if (destination) OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Grass destination not confirmed within movement limit: " + direction_name, stream); + stream.log("[Grass] Completed " + direction_name + " (" + std::to_string(steps) + " steps).", COLOR_GREEN); + return; + } + int8_t move_x=x, move_y=y; + Milliseconds duration=movement_duration; + if (destination && location.located && !location.coarse && + std::abs(location.dx)<=0.10 && std::abs(location.dy)<=0.12) { + // Small corrections prevent stepping past a narrow stop target. + const bool horizontal=std::abs(location.dx)>=std::abs(location.dy)*9.0/16; + const double error=horizontal ? location.dx : location.dy*9.0/16; + duration=std::min(duration, std::abs(error)>0.03125 ? 80ms : + std::abs(error)>0.015625 ? 40ms : 16ms); + move_x=horizontal ? (location.dx>0 ? 1 : -1) : 0; + move_y=horizontal ? 0 : (location.dy<0 ? 1 : -1); + stream.log("[Grass destination] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + + " axis for " + std::to_string(duration.count()) + "ms."); + } + // Each movement gets fresh watcher state. A completed white + // transition from a prior encounter must not trigger again. + BlackScreenWatcher battle_start; + WhiteScreenOverWatcher white_screen(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); + BattleMenuWatcher battle_menu(battle_type, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + pbf_move_left_joystick(context, {(double)move_x, (double)move_y}, duration, 0ms); + movements++; + context.wait_for_all_requests(); + int ret = wait_until(stream, context, 2500ms, + {{white_screen}, {battle_start}, {battle_dialog}, {battle_menu}}); + if (ret >= 0) { + confirmations = 0; + stream.log("[Grass] " + direction_name + " step " + std::to_string(movements) + + "/" + std::to_string(steps) + ": encounter trigger=" + std::to_string(ret) + + "; using shared confirmed-Run escape."); + escape_wild_battle(stream, context, battle_type); + context.wait_for_all_requests(); + stream.log("[Grass] Escape confirmed; resuming " + direction_name + "."); + } + } } - stream.log("[Grass] Completed " + direction_name + " (" + std::to_string(steps) + " steps).", COLOR_GREEN); - return; - } - int8_t move_x = x, move_y = y; - Milliseconds duration = movement_duration; - if (destination && location.located && !location.coarse && std::abs(location.dx) <= 0.10 && - std::abs(location.dy) <= 0.12){ - // Small corrections prevent stepping past a narrow stop target. - const bool horizontal = std::abs(location.dx) >= std::abs(location.dy) * 9.0 / 16; - const double error = horizontal ? location.dx : location.dy * 9.0 / 16; - duration = std::min(duration, std::abs(error) > 0.03125 ? 80ms : std::abs(error) > 0.015625 ? 40ms : 16ms); - move_x = horizontal ? (location.dx > 0 ? 1 : -1) : 0; - move_y = horizontal ? 0 : (location.dy < 0 ? 1 : -1); - stream.log("[Grass destination] Correcting " + std::string(horizontal ? "horizontal" : "vertical") + - " axis for " + std::to_string(duration.count()) + "ms."); - } - // Each movement gets fresh watcher state. A completed white - // transition from a prior encounter must not trigger again. - BlackScreenWatcher battle_start; - WhiteScreenOverWatcher white_screen(COLOR_YELLOW, {0.1, 0.1, 0.8, 0.8}); - BattleMenuWatcher battle_menu(battle_type, COLOR_YELLOW); - BattleDialogWatcher battle_dialog(COLOR_YELLOW); - pbf_move_left_joystick(context, {(double)move_x, (double)move_y}, duration, 0ms); - movements++; - context.wait_for_all_requests(); - int ret = wait_until(stream, context, 2500ms, {{white_screen}, {battle_start}, {battle_dialog}, {battle_menu}}); - if (ret >= 0){ - confirmations = 0; - stream.log("[Grass] " + direction_name + " step " + std::to_string(movements) + "/" + - std::to_string(steps) + ": encounter trigger=" + std::to_string(ret) + - "; using shared confirmed-Run escape."); - escape_wild_battle(stream, context, battle_type); - context.wait_for_all_requests(); - stream.log("[Grass] Escape confirmed; resuming " + direction_name + "."); + } } } - -} -} -} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h index 47a1de720e..351ed5ab5b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Segment_00_tools.h @@ -1,139 +1,161 @@ #pragma once +#include "PokemonBDSP_OverworldMatchers.h" + +#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" +#include "../StorySegments/PokemonBDSP_AutoStory_Segment_00.h" #include "CommonTools/Async/InferenceRoutines.h" #include "NintendoSwitch/Programs/DateManip/NintendoSwitch_DateManip.h" -#include "PokemonBDSP/Inference/Battles/PokemonBDSP_BattleMenuDetector.h" #include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" -#include "PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.h" - -#include "PokemonBDSP_OverworldMatchers.h" namespace PokemonAutomation { -namespace NintendoSwitch { -namespace PokemonBDSP { - -// --------------------------------------------------------------------------- -// Utility Watchers -// --------------------------------------------------------------------------- - -class BattleDialogWatcher : public DetectorToFinder { -public: - BattleDialogWatcher(Color color = COLOR_RED); -}; - -// --------------------------------------------------------------------------- -// Time / setup helpers -// --------------------------------------------------------------------------- - -// Shared visual alignment and dialogue cleanup used by story routes. -void clear_post_battle_dialogue(VideoStream& stream, - ProControllerContext& context, - Button button = BUTTON_B, - Milliseconds quiet_period = Milliseconds(3000), - const char* instruction_card = nullptr); -void align_to_overworld_landmarks(VideoStream& stream, - ProControllerContext& context, - OverworldMatchResult (*matcher)(const ImageRGB32&), - const std::string& label, - bool allow_story_start = false, - bool horizontal_first = false, - bool search_down_first = false, - OverworldMatchResult (*entry_matcher)(const ImageRGB32&) = nullptr, - bool* entry_detected = nullptr, - double arrival_tolerance_x = 0, - double arrival_tolerance_y = 0, - double moderate_entry_confidence = 0, - bool* rock_acquisition_timed_out = nullptr); - -void verify_tutorial_settings(VideoStream& stream, ProControllerContext& context); - -void set_time_fast(SingleSwitchProgramEnvironment& env, - ProControllerContext& context, - const DateTime& target, - bool preserve_date = false // Keep current year/month/day; set only target time. -); - -// --------------------------------------------------------------------------- -// Battles -// --------------------------------------------------------------------------- - -void fight_starly(VideoStream& stream, ProControllerContext& context); - -// Select the requested 1-based move slot using visual cursor confirmation. -void fight_trainer_using_move_slot(VideoStream& stream, - ProControllerContext& context, - size_t move_slot, - const std::string& label); - -void fight_tristan(VideoStream& stream, ProControllerContext& context); - -void fight_Natalie(VideoStream& stream, ProControllerContext& context); - -// --------------------------------------------------------------------------- -// Catching / healing -// --------------------------------------------------------------------------- - -void catch_1_pokemon(VideoStream& stream, ProControllerContext& context); - -// One wild encounter using move slot 1, followed by verified Potion healing. -void battle_1_pokemon(VideoStream& stream, ProControllerContext& context); - -// Burst wiggle variant of catch_1_pokemon for testing. -// Commented out in the .cpp - uncomment both to enable. -// void catch_1_pokemon_v2( -// SingleSwitchProgramEnvironment& env, -// VideoStream& stream, -// ProControllerContext& context -// ); - -void use_potion_first_pokemon(VideoStream& stream, ProControllerContext& context); - -// --------------------------------------------------------------------------- -// Grass movement helpers -// --------------------------------------------------------------------------- - -void walk_through_grass(VideoStream& stream, - ProControllerContext& context, - int8_t x, - int8_t y, - size_t steps, - const std::string& direction_name, - BattleType battle_type = BattleType::STANDARD, - Milliseconds movement_duration = Milliseconds(200), - OverworldMatchResult (*destination)(const ImageRGB32&) = nullptr); - -bool detect_black_screen_after_movement(VideoStream& stream, ProControllerContext& context, Milliseconds timeout); - -bool try_right_down_exit_search(VideoStream& stream, ProControllerContext& context, size_t iterations); - -void leave_pokemon_center_before_barry_1(VideoStream& stream, ProControllerContext& context); -// --------------------------------------------------------------------------- -// Helper function to navigate the poketech challenge clown dialogue. -// Example usage -// Move up and talk to clown. -// clown_dialog_helper( -// stream, -// context, -// DPAD_UP -// ); - -// Move left and talk to clown. -// clown_dialog_helper( -// stream, -// context, -// DPAD_LEFT -// ); - -// Move right and talk to clown. -// clown_dialog_helper( -// stream, -// context, -// DPAD_RIGHT -// ); -// ---------------------------------------------------------------------------- -void clown_dialog_helper(VideoStream& stream, ProControllerContext& context, DpadPosition direction); -} -} + namespace NintendoSwitch { + namespace PokemonBDSP { + + // --------------------------------------------------------------------------- + // Utility Watchers + // --------------------------------------------------------------------------- + + class BattleDialogWatcher : public DetectorToFinder { + public: + BattleDialogWatcher(Color color = COLOR_RED); + }; + + // --------------------------------------------------------------------------- + // Time / setup helpers + // --------------------------------------------------------------------------- + + // Shared visual alignment and dialogue cleanup used by story routes. + void clear_post_battle_dialogue(VideoStream& stream, ProControllerContext& context, + Button button = BUTTON_B, Milliseconds quiet_period = Milliseconds(3000), + const char* instruction_card = nullptr); + void align_to_overworld_landmarks(VideoStream& stream, ProControllerContext& context, + OverworldMatchResult (*matcher)(const ImageRGB32&), const std::string& label, bool allow_story_start = false, bool horizontal_first = false, + bool search_down_first = false, + OverworldMatchResult (*entry_matcher)(const ImageRGB32&) = nullptr, + bool* entry_detected = nullptr, + double arrival_tolerance_x = 0, double arrival_tolerance_y = 0, + double moderate_entry_confidence = 0, + bool* rock_acquisition_timed_out = nullptr); + + void verify_tutorial_settings(VideoStream& stream, ProControllerContext& context); + + void set_time_fast( + SingleSwitchProgramEnvironment& env, + ProControllerContext& context, + const DateTime& target, + bool preserve_date = false // Keep current year/month/day; set only target time. + ); + + // --------------------------------------------------------------------------- + // Battles + // --------------------------------------------------------------------------- + + void fight_starly( + VideoStream& stream, + ProControllerContext& context + ); + + // Select the requested 1-based move slot using visual cursor confirmation. + void fight_trainer_using_move_slot(VideoStream& stream, ProControllerContext& context, + size_t move_slot, const std::string& label); + + // Any trainer battle, whatever the starter: uses move slot 1 until the + // battle ends. Slot 1 is a damaging move (Tackle, Scratch or Pound) for + // every starter. + void fight_trainer(VideoStream& stream, ProControllerContext& context, const std::string& label); + + // A story battle that may go badly: the best move each turn, Potions + // under 35 %, the next Pokemon when one faints, and "Keep old moves" + // on level-up prompts. `doubles` confirms the default target each + // turn (tag and twin battles). Returns once the battle has ended, + // won or lost; after a loss we stand in the last Pokemon Center. + void fight_story_battle(VideoStream& stream, ProControllerContext& context, const std::string& label, bool doubles = false); + + // --------------------------------------------------------------------------- + // Catching / healing + // --------------------------------------------------------------------------- + + void catch_1_pokemon( + VideoStream& stream, + ProControllerContext& context + ); + + // Finds one wild Pokemon in the grass by stepping left and right, and + // fights it with the best move on the menu. Does not heal. + void battle_1_pokemon(VideoStream& stream, ProControllerContext& context); + + // Burst wiggle variant of catch_1_pokemon for testing. + // Commented out in the .cpp - uncomment both to enable. + // void catch_1_pokemon_v2( + // SingleSwitchProgramEnvironment& env, + // VideoStream& stream, + // ProControllerContext& context + // ); + + // --------------------------------------------------------------------------- + // Grass movement helpers + // --------------------------------------------------------------------------- + + void walk_through_grass( + VideoStream& stream, + ProControllerContext& context, + int8_t x, + int8_t y, + size_t steps, + const std::string& direction_name, + BattleType battle_type = BattleType::STANDARD, + Milliseconds movement_duration = Milliseconds(200), + OverworldMatchResult (*destination)(const ImageRGB32&) = nullptr + ); + + bool detect_black_screen_after_movement( + VideoStream& stream, + ProControllerContext& context, + Milliseconds timeout + ); + + bool try_right_down_exit_search( + VideoStream& stream, + ProControllerContext& context, + size_t iterations + ); + + void leave_pokemon_center_before_barry_1( + VideoStream& stream, + ProControllerContext& context + ); + // --------------------------------------------------------------------------- + // Helper function to navigate the poketech challenge clown dialogue. + // Example usage + // Move up and talk to clown. + // clown_dialog_helper( + // stream, + // context, + // DPAD_UP + // ); + + // Move left and talk to clown. + // clown_dialog_helper( + // stream, + // context, + // DPAD_LEFT + // ); + + // Move right and talk to clown. + // clown_dialog_helper( + // stream, + // context, + // DPAD_RIGHT + // ); + // ---------------------------------------------------------------------------- + void clown_dialog_helper( + VideoStream& stream, + ProControllerContext& context, + DpadPosition direction + ); + } + } } \ No newline at end of file diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index e1e58ce156..8046c60215 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -335,6 +335,8 @@ file(GLOB CORE_LIBRARY_SOURCES Source/NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController_from_Keyboard.h Source/NintendoSwitch/Controllers/Procon/NintendoSwitch_ProControllerState.cpp Source/NintendoSwitch/Controllers/Procon/NintendoSwitch_ProControllerState.h + Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.cpp + Source/NintendoSwitch/Inference/NintendoSwitch_SystemKeyboardDetector.h Source/NintendoSwitch/NintendoSwitch_Settings.cpp Source/NintendoSwitch/NintendoSwitch_Settings.h Source/NintendoSwitch/Options/NintendoSwitch_CodeEntrySettingsOption.cpp @@ -1436,7 +1438,11 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/Navigation/Pokemon_LegWalker.cpp Source/Pokemon/Navigation/Pokemon_LegWalker.h Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp + Source/Pokemon/Navigation/Pokemon_MapSet.cpp + Source/Pokemon/Navigation/Pokemon_MapSet.h Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h + Source/Pokemon/Navigation/Pokemon_TileGrid.cpp + Source/Pokemon/Navigation/Pokemon_TileGrid.h Source/Pokemon/Options/Pokemon_BoxSortingTable.cpp Source/Pokemon/Options/Pokemon_BoxSortingTable.h Source/Pokemon/Options/Pokemon_EncounterBotOptions.h @@ -1499,6 +1505,34 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/Resources/Pokemon_PokemonNames.h Source/Pokemon/Resources/Pokemon_PokemonSlugs.cpp Source/Pokemon/Resources/Pokemon_PokemonSlugs.h + Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.cpp + Source/PokemonBDSP/Inference/PokemonBDSP_NameListDetector.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_JubilifeCity.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_OreburghMine.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_RavagedPath.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route204.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp + Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp + Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Walk.h Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.cpp Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_OverworldMatchers.h Source/PokemonBDSP/Programs/TestPrograms/PokemonBDSP_OverworldLightingTest.cpp From 985de47c1fe6d90b3cd1317ba7140f42e0a2f519 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 21:20:00 -0700 Subject: [PATCH 06/19] BDSP AutoStory 00.04: find Mom's row by talking, not by counted taps Two d-pad taps north from the house door were meant to put the player level with Mom; on r108b one tap did not move and the A press talked to nobody. The door fixes the column, so face east and press A one row at a time going north until Mom answers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonBDSP_AutoStory_Segment_00.cpp | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index ca2faf0ebf..9ff08f037d 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -437,6 +437,31 @@ namespace PokemonAutomation { // and send the starter to pokemon HOME. // --------------------------------------------------------------------------- + // Inside the player's house, just through the door: Mom watches TV a + // column east of the door and two rows north of the mat. The door fixes + // our column, but counted taps north lost a row (r108b stood a row below + // her). So find her row: face east and press A, and step one tile north + // at a time until she answers. A press from a row off talks to nobody, + // and the TV stand stops us a row above her. + static void talk_to_mom_at_home(VideoStream& stream, ProControllerContext& context) { + for (size_t row = 0; row < 4; row++) { + route_turn(stream, context, Pokemon::Direction::EAST); + pbf_press_button(context, BUTTON_A, 80ms, 0ms); + context.wait_for_all_requests(); + ShortDialogWatcher dialog(COLOR_CYAN); + if (wait_until(stream, context, 1500ms, {{dialog}}) == 0) { + stream.log("[00.04] Mom answered after " + std::to_string(row) + " step(s) north."); + return; + } + // Facing east, a short tap north only turns; facing north, it is one tile. + route_turn(stream, context, Pokemon::Direction::NORTH); + pbf_press_dpad(context, DPAD_UP, 80ms, 400ms); + context.wait_for_all_requests(); + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Home: Mom did not answer from any of four rows north of the door.", stream); + } + static void tutorial_part_3_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { // Arm exit detection before movement and finish the fade before // starting the dialogue watcher, which cancels queued commands. @@ -466,12 +491,7 @@ namespace PokemonAutomation { pbf_wait(context, 1655ms); pbf_move_left_joystick(context, { 0.000000, 1.000000 }, 640ms, 0ms); pbf_wait(context, 3128ms); - pbf_press_dpad(context, DPAD_UP, 180ms, 0ms); - pbf_wait(context, 472ms); - pbf_press_dpad(context, DPAD_UP, 130ms, 0ms); - pbf_wait(context, 1621ms); - pbf_press_dpad(context, DPAD_RIGHT, 130ms, 0ms); - pbf_press_button(context, BUTTON_A, 180ms, 0ms); + talk_to_mom_at_home(stream, context); wait_for_dialogue(stream, context, "Phase 15 (Mom conversation to accept parcel)"); From d744cb27c3cf72529cd3710aa8b5dc3830dbda39 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 22:27:16 -0700 Subject: [PATCH 07/19] BDSP AutoStory: match the location banner despite OCR slips r109 failed entering Sandgem Town because the banner OCR read "andgemltown". Accept a read within a quarter of the name's letters (edit distance), with the digits matching exactly so one route is never taken for another. Unit test covers the r109 read and negative cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 35 ++++++++++++++++++ .../Utils/PokemonBDSP_AutoStory_RouteWalk.cpp | 37 ++++++++++++++++++- .../Utils/PokemonBDSP_AutoStory_RouteWalk.h | 7 ++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index 378242a8a9..9fd2f1a647 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -17,6 +17,7 @@ #include "Data/PokemonBDSP_AutoStory_SandgemTown.h" #include "Data/PokemonBDSP_AutoStory_StoryMaps.h" #include "Utils/PokemonBDSP_AutoStory_NewGame.h" +#include "Utils/PokemonBDSP_AutoStory_RouteWalk.h" #include "PokemonBDSP_AutoStory_Tests.h" namespace PokemonAutomation{ @@ -588,11 +589,45 @@ class Test_IntroDecisions : public UnitTest{ } +class Test_LocationBanner : public UnitTest{ +public: + Test_LocationBanner() + : UnitTest("PokemonBDSP::AutoStory - location banner match") + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + struct Case{ + const char* read; + const char* expected; + bool match; + }; + const Case cases[] = { + {"sandgemtown", "Sandgem Town", true}, + {"andgemltown", "Sandgem Town", true}, // r109's OCR + {"Route 202", "Route 202", true}, + {"route2o2", "Route 202", false}, // a digit read as a letter is not trusted + {"route201", "Route 202", false}, + {"jubilifecity", "Sandgem Town", false}, + {"twinleaftown", "Sandgem Town", false}, + {"", "Sandgem Town", false}, + }; + for (const Case& c : cases){ + if (location_banner_matches(c.read, c.expected) != c.match){ + return std::string("Banner \"") + c.read + "\" for " + c.expected + + (c.match ? " should match." : " should not match."); + } + } + return true; + } +}; + + void add_tests_AutoStory(UnitTestDatabase& database){ database.add(); database.add(); database.add(); database.add(); + database.add(); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp index c4befcac87..8738c4d21d 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -123,6 +123,41 @@ std::string map_of(const MapSet& maps, const std::vector& tiles){ } +bool location_banner_matches(const std::string& read, const std::string& expected){ + const std::string a = normalize_name(read); + const std::string b = normalize_name(expected); + if (a.empty() || b.empty()){ + return false; + } + auto digits = [](const std::string& text){ + std::string out; + for (char ch : text){ + if (std::isdigit((unsigned char)ch)){ + out.push_back(ch); + } + } + return out; + }; + if (digits(a) != digits(b)){ + return false; + } + // Levenshtein distance. + std::vector row(b.size() + 1); + for (size_t j = 0; j <= b.size(); j++){ + row[j] = j; + } + for (size_t i = 1; i <= a.size(); i++){ + size_t diagonal = row[0]; + row[0] = i; + for (size_t j = 1; j <= b.size(); j++){ + const size_t above = row[j]; + row[j] = std::min({row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] == b[j - 1] ? 0 : 1)}); + diagonal = above; + } + } + return row[b.size()] <= std::max(1, b.size() / 4); +} + void route_status(VideoStream& stream, const std::string& text){ stream.log("[Route] " + text, COLOR_BLUE); add_wrapped_log(stream.overlay(), text, COLOR_WHITE); @@ -289,7 +324,7 @@ std::vector walk_route( // the walk did not end where the plan says. const std::string map_before = map_of(maps, from); const std::string map_after = map_of(maps, planned.after); - if (!map_after.empty() && map_after != map_before && banner != normalize_name(map_after)){ + if (!map_after.empty() && map_after != map_before && !location_banner_matches(banner, map_after)){ OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, leg_label + ": expected to enter " + map_after + ", but the location banner read \"" + banner + "\".", stream); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h index 3a0d4d6b8c..9f0b702acb 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -45,6 +45,13 @@ constexpr Milliseconds ROUTE_TURN_TAP = Milliseconds(100); // faces another way: one already facing it may take a step. void route_turn(VideoStream& stream, ProControllerContext& context, Pokemon::Direction direction); +// Whether the location banner's OCR `read` names the map `expected`. Both +// are compared as lower-case letters and digits. OCR drops or swaps letters +// ("andgemltown" for Sandgem Town on r109), so up to a quarter of the name's +// letters may differ; the digits must match exactly, so Route 201 is never +// read as Route 202. +bool location_banner_matches(const std::string& read, const std::string& expected); + // Shows what the route walk is doing in the log and the video overlay. void route_status(VideoStream& stream, const std::string& text); From d7d99dc29c63c04aacd2cc459971a1a5d0a8b1f6 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 23:05:30 -0700 Subject: [PATCH 08/19] BDSP AutoStory 00.04: find the Poke Mart lady by talking r113a stopped two tiles west of the counted tile (44, 3), facing away from her, and talked to nobody. Add talk_to_adjacent_npc(), which turns each given way, presses A and keeps the direction a dialogue opens from. Probe east, west, north and south for her, and tell apart "nobody answered" from "she talked but no Potion arrived" (she gives them once). Mom's row search uses the same helper, with only east. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonBDSP_AutoStory_Segment_00.cpp | 24 +++++++++++++------ .../Utils/PokemonBDSP_AutoStory_RouteWalk.cpp | 19 +++++++++++++++ .../Utils/PokemonBDSP_AutoStory_RouteWalk.h | 12 ++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 9ff08f037d..403e46d627 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -445,11 +445,7 @@ namespace PokemonAutomation { // and the TV stand stops us a row above her. static void talk_to_mom_at_home(VideoStream& stream, ProControllerContext& context) { for (size_t row = 0; row < 4; row++) { - route_turn(stream, context, Pokemon::Direction::EAST); - pbf_press_button(context, BUTTON_A, 80ms, 0ms); - context.wait_for_all_requests(); - ShortDialogWatcher dialog(COLOR_CYAN); - if (wait_until(stream, context, 1500ms, {{dialog}}) == 0) { + if (talk_to_adjacent_npc(stream, context, {Pokemon::Direction::EAST}, "Home: Mom")) { stream.log("[00.04] Mom answered after " + std::to_string(row) + " step(s) north."); return; } @@ -698,14 +694,28 @@ namespace PokemonAutomation { const TilePos talk_from = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_TALK_FROM); walk_route(stream, context, maps, {catch_spot}, [&](const TilePos& pos) { return pos == talk_from; }, trainers, fight, "To the Poke Mart lady"); + // The counted tile can be off: r113a stopped two tiles west of + // (44, 3), with her to the east. So find her by talking. The + // walk comes from the east and never ends facing east, so + // turning east first is a turn, not a step. + if (!talk_to_adjacent_npc(stream, context, { + Pokemon::Direction::EAST, Pokemon::Direction::WEST, + Pokemon::Direction::NORTH, Pokemon::Direction::SOUTH, + }, "Poke Mart lady", 2000ms)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart lady: nobody answered on any side of where the walk stopped.", stream); + } // Her lines take a while to print: one A per line. - for (size_t line = 0; line < 9; line++) { + for (size_t line = 0; line < 8; line++) { pbf_press_button(context, BUTTON_A, 80ms, 1500ms); } context.wait_for_all_requests(); if (!bag_has_item(stream, context, "potion", 1)) { + // She gives the Potions once: on a save that already got + // (and used) them, she only talks. OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: no Potion in the Bag after talking to her.", stream); + "Poke Mart lady: she talked, but no Potion is in the Bag. " + "Has this save already had her Potions?", stream); } walk_route(stream, context, maps, {talk_from}, [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp index 8738c4d21d..0bbc9369d5 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -176,6 +176,25 @@ void route_turn(VideoStream& stream, ProControllerContext& context, Direction di context.wait_for_all_requests(); } +std::optional talk_to_adjacent_npc( + VideoStream& stream, ProControllerContext& context, + const std::vector& directions, + const std::string& label, Milliseconds wait +){ + for (Direction direction : directions){ + route_turn(stream, context, direction); + pbf_press_button(context, BUTTON_A, 80ms, 0ms); + context.wait_for_all_requests(); + ShortDialogWatcher dialog(COLOR_CYAN); + if (wait_until(stream, context, wait, {{dialog}}) == 0){ + stream.log("[Route] " + label + ": answered from the " + direction_name(direction) + "."); + return direction; + } + } + stream.log("[Route] " + label + ": nobody answered next to us."); + return std::nullopt; +} + std::vector walk_route( VideoStream& stream, ProControllerContext& context, diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h index 9f0b702acb..ff0724187f 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -12,6 +12,7 @@ #define PokemonAutomation_PokemonBDSP_AutoStory_RouteWalk_H #include +#include #include #include #include "Common/Cpp/Time.h" @@ -45,6 +46,17 @@ constexpr Milliseconds ROUTE_TURN_TAP = Milliseconds(100); // faces another way: one already facing it may take a step. void route_turn(VideoStream& stream, ProControllerContext& context, Pokemon::Direction direction); +// Talks to someone on a tile next to ours, without trusting the tile we +// counted ourselves onto: for each of `directions` in order, turns that way, +// presses A and waits up to `wait` for a dialogue. Returns the direction that +// answered, its dialogue open, or none if nobody did. Turns are taps (see +// route_turn()), so do not list the way the player already faces first. +std::optional talk_to_adjacent_npc( + VideoStream& stream, ProControllerContext& context, + const std::vector& directions, + const std::string& label, Milliseconds wait = Milliseconds(1500) +); + // Whether the location banner's OCR `read` names the map `expected`. Both // are compared as lower-case letters and digits. OCR drops or swaps letters // ("andgemltown" for Sandgem Town on r109), so up to a quarter of the name's From 059e6c05a2d7fcfc6dbab98041b5e3edfa7bd4d7 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 23:17:31 -0700 Subject: [PATCH 09/19] BDSP AutoStory 00.04: meet the Poke Mart lady from her row's corner She does not stay on (43, 3): r113a and r114a walked west along row 3 to its west end through her tile, and found her two tiles east in the row or one tile east and one row up. Take her off the grid, walk to the corner that pins the row (42, 3), then walk east pressing A until she blocks the walk and answers, as done by hand. If nobody answers along the row, try the other sides of where the walk stopped. The walk back plans from the whole row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Data/PokemonBDSP_AutoStory_Route201.cpp | 16 +++++----- .../Data/PokemonBDSP_AutoStory_Route201.h | 11 +++---- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 11 +++---- .../PokemonBDSP_AutoStory_Segment_00.cpp | 25 ++++++++-------- .../Utils/PokemonBDSP_AutoStory_RouteWalk.cpp | 29 +++++++++++++++++++ .../Utils/PokemonBDSP_AutoStory_RouteWalk.h | 10 +++++++ 6 files changed, 68 insertions(+), 34 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp index 9fa493693b..8348c3a1a4 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp @@ -26,7 +26,7 @@ using namespace Pokemon; // than it looks on the DP map, as on Route 202; the west one is unchecked. West edge, rows 9..12: towards Lake Verity. South gap // (14..17, 19): Twinleaf Town. East edge, rows 3..6: Sandgem Town. // Route 201 has no trainers. Its other NPCs wander, so they are left off: -// a planned wall must stay put. The Potion giver only turns. +// a planned wall must stay put. The Potion giver moves too (see below). const TileGrid& grid(){ // 0 1 2 3 4 5 6 // 0123456789012345678901234567890123456789012345678901234567890123 @@ -34,7 +34,7 @@ const TileGrid& grid(){ R"(################################################################)", // 0 R"(################################################################)", // 1 R"(################################################################)", // 2 - R"(##########################################"N"""""""""...........)", // 3 + R"(##########################################"""""""""""...........)", // 3 R"(##########################################"""""""""""..S........)", // 4 R"(##########################################"""""""""##...........)", // 5 R"(##..####..####....######..##....##..####.."""""""""##...........)", // 6 @@ -56,12 +56,12 @@ const TileGrid& grid(){ } -// On the DP map she is at (44,3), pixel (718,57). On the console (by hand, -// then through the Lab Console) she stands at (43,3), in the top grass row, -// and only turns: walking west along row 3 from the trees at its east end -// stops at (44,3), facing her. -const TilePos POTION_GIVER{43, 3}; -const TilePos POTION_GIVER_TALK_FROM{44, 3}; +// On the DP map she is at (44,3), pixel (718,57), and she was once met at +// (43,3) on the console. She does not stay there: r113a and r114a walked +// west along row 3 to its west end (42,3) through her tile, and found her +// one tile east and one row up (r114a), or two tiles east in the row +// (r113a). So she is left off the grid like the other NPCs, and is found by +// walking east from the row's west end until she answers. const TilePos POTION_GIVER_ROW_EAST{53, 3}; const TilePos POTION_GIVER_ROW_WEST_END{42, 3}; const TilePos POTION_GIVER_ROW_EAST_END{52, 3}; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h index a2fdc38dfd..497ab599d3 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h @@ -19,15 +19,12 @@ namespace Route201{ const Pokemon::TileGrid& grid(); -// The girl who gives Potions, in the top row of the north-east tall grass, -// (42..52, 3), one tile from its west end. -extern const Pokemon::TilePos POTION_GIVER; -// East of her, where a walk west along her row stops: talk to her from here, -// facing west. -extern const Pokemon::TilePos POTION_GIVER_TALK_FROM; +// The girl who gives Potions stands around the top row of the north-east +// tall grass, (42..52, 3), near its west end; she is not on the grid. // The tile east of her row, against the trees: walk west from here to meet her. extern const Pokemon::TilePos POTION_GIVER_ROW_EAST; -// The first and last tiles of her row. +// The first and last tiles of her row. The first is the corner that pins +// the row: trees west of it and north of it. Walk east from it to meet her. extern const Pokemon::TilePos POTION_GIVER_ROW_WEST_END; extern const Pokemon::TilePos POTION_GIVER_ROW_EAST_END; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index 9fd2f1a647..2c00040423 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -116,9 +116,6 @@ class Test_TileGridParse : public UnitTest{ TileType type; }; const Named named[] = { - {MAP_ROUTE_201, Route201::POTION_GIVER, TileType::NPC}, - {MAP_ROUTE_201, Route201::POTION_GIVER.next(Direction::SOUTH), TileType::TALL_GRASS}, - {MAP_ROUTE_201, Route201::POTION_GIVER_TALK_FROM, TileType::TALL_GRASS}, {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST, TileType::WALKABLE}, {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END, TileType::TALL_GRASS}, {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END, TileType::TALL_GRASS}, @@ -406,14 +403,14 @@ class Test_TileGridPlans : public UnitTest{ return error; } // To where she is talked to, and back to the catch grass to save. - const TilePos talk_from = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_TALK_FROM); - error = check_robust_plan(logger, "Catch spot to the Potion giver", maps, {catch_spot}, - [&](const TilePos& pos){ return pos == talk_from; }); + const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); + error = check_robust_plan(logger, "Catch spot to the Potion giver's corner", maps, {catch_spot}, + [&](const TilePos& pos){ return pos == corner; }); if (!error.empty()){ return error; } const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::POTION_SAVE_SPOT); - error = check_robust_plan(logger, "Potion giver to the save spot", maps, {talk_from}, + error = check_robust_plan(logger, "Potion giver to the save spot", maps, story_potion_giver_area(), [&](const TilePos& pos){ return pos == save_spot; }); if (!error.empty()){ return error; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 403e46d627..3ea78ee2ab 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -691,19 +691,19 @@ namespace PokemonAutomation { stream.log("[00.04] The Bag has Potions; not visiting the Poke Mart lady."); } else { const TilePos catch_spot = maps.to_world(MAP_ROUTE_202, Route202::CATCH_SPOT); - const TilePos talk_from = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_TALK_FROM); + // She moves about near the west end of her row (r113a, r114a), + // so go to the corner that pins the row, (42, 3): trees to its + // west and north. Then walk east until she blocks the walk and + // answers, as done by hand. + const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); + const TilePos row_east_end = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); walk_route(stream, context, maps, {catch_spot}, - [&](const TilePos& pos) { return pos == talk_from; }, trainers, fight, "To the Poke Mart lady"); - // The counted tile can be off: r113a stopped two tiles west of - // (44, 3), with her to the east. So find her by talking. The - // walk comes from the east and never ends facing east, so - // turning east first is a turn, not a step. - if (!talk_to_adjacent_npc(stream, context, { - Pokemon::Direction::EAST, Pokemon::Direction::WEST, - Pokemon::Direction::NORTH, Pokemon::Direction::SOUTH, - }, "Poke Mart lady", 2000ms)) { + [&](const TilePos& pos) { return pos == corner; }, trainers, fight, "To the Poke Mart lady"); + if (!walk_along_row_to_talk(stream, context, Pokemon::Direction::EAST, + (size_t)(row_east_end.x - corner.x), "Poke Mart lady")) { OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: nobody answered on any side of where the walk stopped.", stream); + "Poke Mart lady: nobody answered walking east along her row from (42, 3), " + "nor on any side of where the walk stopped.", stream); } // Her lines take a while to print: one A per line. for (size_t line = 0; line < 8; line++) { @@ -717,7 +717,8 @@ namespace PokemonAutomation { "Poke Mart lady: she talked, but no Potion is in the Bag. " "Has this save already had her Potions?", stream); } - walk_route(stream, context, maps, {talk_from}, + // Somewhere along her row: plan from all of it. + walk_route(stream, context, maps, story_potion_giver_area(), [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); save_game(env, context); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp index 0bbc9369d5..8ba9e47f82 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -195,6 +195,35 @@ std::optional talk_to_adjacent_npc( return std::nullopt; } +bool walk_along_row_to_talk( + VideoStream& stream, ProControllerContext& context, + Direction direction, size_t tiles, const std::string& label +){ + double x, y; + stick_for(direction, x, y); + route_status(stream, label + ": walking " + direction_name(direction) + " until someone answers"); + const Milliseconds limit = ROUTE_START_DELAY + ms_per_tile(direction) * tiles; + const WalkResult walk = walk_to_talk(stream, context, x, y, limit, label); + if (walk.stop == WalkStop::DIALOG){ + stream.log("[Route] " + label + ": answered after walking " + direction_name(direction) + + " for " + std::to_string(walk.walked.count()) + "ms."); + return true; + } + if (walk.stop == WalkStop::TRANSITION){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": left the map while walking " + direction_name(direction) + " along the row.", stream); + } + // The walk faces `direction` and A was pressed that way: try the others. + std::vector others; + for (Direction side : {Direction::NORTH, Direction::SOUTH, Direction::EAST, Direction::WEST}){ + if (side != direction){ + others.push_back(side); + } + } + stream.log("[Route] " + label + ": nobody in the row; trying the other sides."); + return talk_to_adjacent_npc(stream, context, others, label).has_value(); +} + std::vector walk_route( VideoStream& stream, ProControllerContext& context, diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h index ff0724187f..8a2421af45 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -57,6 +57,16 @@ std::optional talk_to_adjacent_npc( const std::string& label, Milliseconds wait = Milliseconds(1500) ); +// Talks to someone standing somewhere along a row: walks `direction` for +// up to `tiles` tiles, pressing A as it goes (walk_to_talk()), so the walk +// ends facing whoever blocks it. Start from a tile a wall pins to the row. +// If nobody answered by the end, tries the other sides of where the walk +// stopped (talk_to_adjacent_npc()). Returns whether a dialogue is open. +bool walk_along_row_to_talk( + VideoStream& stream, ProControllerContext& context, + Pokemon::Direction direction, size_t tiles, const std::string& label +); + // Whether the location banner's OCR `read` names the map `expected`. Both // are compared as lower-case letters and digits. OCR drops or swaps letters // ("andgemltown" for Sandgem Town on r109), so up to a quarter of the name's From 5f8d3fcb34433490a46670dd73d7bb7c99d589d8 Mon Sep 17 00:00:00 2001 From: dayfine Date: Thu, 29 Oct 2026 23:43:09 -0700 Subject: [PATCH 10/19] BDSP AutoStory: finish a talk when it ends, not after N presses r115a: 8 A presses left the Poke Mart lady's Potions talk open and the menu open failed. finish_dialogue() now takes a button instead of an A count: it presses it only while a dialogue box shows and returns once the overworld has shown with no box for 1.5 s (the box blinks off between lines and during "obtained" jingles), failing after 40 s. A where a talk asks Yes/No or gives an item, B elsewhere. The Poke Mart lady uses it instead of her fixed 8 A presses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonBDSP_AutoStory_Segment_00.cpp | 21 ++++----- .../PokemonBDSP_AutoStory_Segment_01.cpp | 25 ++++++----- .../Utils/PokemonBDSP_AutoStory_RouteWalk.cpp | 43 +++++++++++++------ .../Utils/PokemonBDSP_AutoStory_RouteWalk.h | 13 +++--- 4 files changed, 60 insertions(+), 42 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 3ea78ee2ab..58903acf5c 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -705,11 +705,8 @@ namespace PokemonAutomation { "Poke Mart lady: nobody answered walking east along her row from (42, 3), " "nor on any side of where the walk stopped.", stream); } - // Her lines take a while to print: one A per line. - for (size_t line = 0; line < 8; line++) { - pbf_press_button(context, BUTTON_A, 80ms, 1500ms); - } - context.wait_for_all_requests(); + // Her talk gives 10 Potions ("obtained" lines): A to the end. + finish_dialogue(stream, context, BUTTON_A, "Poke Mart lady"); if (!bag_has_item(stream, context, "potion", 1)) { // She gives the Potions once: on a save that already got // (and used) them, she only talks. @@ -980,7 +977,7 @@ namespace PokemonAutomation { auto world = [&](const TilePos& pos) { return maps.to_world(MAP_JUBILIFE_CITY, pos); }; // Jubilife has no trainers; someone in the way only talks. std::vector trainers; - auto in_the_way = [&](const std::string&) { finish_dialogue(stream, context, 0, "Someone in the way"); }; + auto in_the_way = [&](const std::string&) { finish_dialogue(stream, context, BUTTON_B, "Someone in the way"); }; std::vector at; for (const TilePos& pos : JubilifeCity::after_dawn()) { at.push_back(world(pos)); @@ -1012,7 +1009,7 @@ namespace PokemonAutomation { } } // His scene ends with "You obtained a Town Map!", which A closes. - finish_dialogue(stream, context, 16, "Barry"); + finish_dialogue(stream, context, BUTTON_A, "Barry"); // Out: south to the front wall, a tile east (the door's column), out. walk_until(stream, context, 0, -1, 4000ms, "to the school's front wall"); route_turn(stream, context, Pokemon::Direction::EAST); @@ -1030,7 +1027,7 @@ namespace PokemonAutomation { const TilePos president = world(JubilifeCity::POKETCH_PRESIDENT); talk_to_npc(stream, context, maps, at, president, {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH}, trainers, in_the_way, "Poketch president"); - finish_dialogue(stream, context, 0, "Poketch president"); + finish_dialogue(stream, context, BUTTON_B, "Poketch president"); step(3); // Each clown asks a question whose default answer, Yes, is right, @@ -1038,20 +1035,20 @@ namespace PokemonAutomation { talk_to_npc(stream, context, maps, at, world(JubilifeCity::CLOWN_MART), {Pokemon::Direction::SOUTH, Pokemon::Direction::WEST, Pokemon::Direction::EAST}, trainers, in_the_way, "Clown by the Poke Mart"); - finish_dialogue(stream, context, 8, "Clown by the Poke Mart"); + finish_dialogue(stream, context, BUTTON_A, "Clown by the Poke Mart"); talk_to_npc(stream, context, maps, at, world(JubilifeCity::CLOWN_TV_STATION), {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH, Pokemon::Direction::WEST}, trainers, in_the_way, "Clown by the TV station"); - finish_dialogue(stream, context, 8, "Clown by the TV station"); + finish_dialogue(stream, context, BUTTON_A, "Clown by the TV station"); talk_to_npc(stream, context, maps, at, world(JubilifeCity::CLOWN_POKETCH_COMPANY), {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH, Pokemon::Direction::WEST}, trainers, in_the_way, "Clown by the Poketch Company"); - finish_dialogue(stream, context, 8, "Clown by the Poketch Company"); + finish_dialogue(stream, context, BUTTON_A, "Clown by the Poketch Company"); step(4); talk_to_npc(stream, context, maps, at, president, {Pokemon::Direction::EAST, Pokemon::Direction::SOUTH}, trainers, in_the_way, "Poketch president"); - finish_dialogue(stream, context, 0, "Poketch president"); + finish_dialogue(stream, context, BUTTON_B, "Poketch president"); step(5); const TilePos center = world(JubilifeCity::POKEMON_CENTER_DOOR.next(Pokemon::Direction::SOUTH)); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp index a4db9550d9..4c6bcc37de 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_01.cpp @@ -157,13 +157,12 @@ void jubilife_to_barry(VideoStream& stream, ProControllerContext& context){ if (walk_to_talk(stream, context, 0, 1, 3000ms, "to the nurse").stop != WalkStop::DIALOG){ fail(stream, "Jubilife Pokemon Center: the nurse did not answer."); } - // Two lines, then Yes to "rest your Pokemon?" (B would answer No), and - // one more A for "I'll take your Pokemon"; B for the rest. - finish_dialogue(stream, context, 4, "Nurse"); + // A throughout: Yes to "rest your Pokemon?" (B would answer No). + finish_dialogue(stream, context, BUTTON_A, "Nurse"); // Healing shows no text box for a few seconds, which reads as the end; // her "Thank you for waiting" lines follow it. if (dialogue_opens(stream, context, 6000ms)){ - finish_dialogue(stream, context, 0, "Nurse, after healing"); + finish_dialogue(stream, context, BUTTON_B, "Nurse, after healing"); } // South through the door until a girl standing below it stops us. The // door's fade may or may not read as a transition. @@ -296,7 +295,7 @@ void route203_to_oreburgh(VideoStream& stream, ProControllerContext& context){ if (to_hiker.stop != WalkStop::DIALOG && !dialogue_opens(stream, context, 2000ms)){ fail(stream, "Oreburgh Gate: the Hiker did not stop us."); } - finish_dialogue(stream, context, 0, "Hiker"); + finish_dialogue(stream, context, BUTTON_B, "Hiker"); } // Then a "Hidden Moves" tutorial card, closed with B. It is no dialogue // box, and it can come up a moment after his last line. @@ -338,9 +337,9 @@ void route203_to_oreburgh(VideoStream& stream, ProControllerContext& context){ step(4); // He walks us to the Pokemon Center between his lines, and Barry joins // there: more lines after each walk. - finish_dialogue(stream, context, 0, "Man at Oreburgh's entrance"); + finish_dialogue(stream, context, BUTTON_B, "Man at Oreburgh's entrance"); for (size_t scene = 0; scene < 6 && dialogue_opens(stream, context, 6000ms); scene++){ - finish_dialogue(stream, context, 0, "Oreburgh scene"); + finish_dialogue(stream, context, BUTTON_B, "Oreburgh scene"); } context.wait_for_all_requests(); } @@ -385,7 +384,7 @@ void oreburgh_to_roark(VideoStream& stream, ProControllerContext& context){ if (!dialogue_opens(stream, context, 3000ms)){ fail(stream, "Oreburgh: Barry did not talk at the gym door."); } - finish_dialogue(stream, context, 0, "Barry at the gym door"); + finish_dialogue(stream, context, BUTTON_B, "Barry at the gym door"); if (walk_dir(stream, context, Direction::SOUTH, 8000ms, "south to the stone ledge").stop != WalkStop::BLOCKED){ fail(stream, "Oreburgh: walking south did not stop at the stone ledge."); } @@ -527,7 +526,7 @@ void oreburgh_to_roark(VideoStream& stream, ProControllerContext& context){ if (!talked){ fail(stream, "Oreburgh Mine: no talk opened below Roark."); } - finish_dialogue(stream, context, 0, "Roark"); + finish_dialogue(stream, context, BUTTON_B, "Roark"); // His scene ends with him smashing his rock and leaving: his tile is // free now. Step onto it and back, or fail if he is still there. if (walk_dir(stream, context, Direction::NORTH, 2000ms, "onto Roark's tile").walked <= 800ms){ @@ -591,7 +590,7 @@ bool in_pokemon_center(VideoStream& stream){ // out through the door. The door is straight south of the nurse in most // Centers; in Oreburgh's it was two tiles east of where we stood (by hand). void walk_out_of_center(VideoStream& stream, ProControllerContext& context, const std::string& label){ - finish_dialogue(stream, context, 0, label + ": the nurse"); + finish_dialogue(stream, context, BUTTON_B, label + ": the nurse"); const int shifts[] = {0, 2, -4}; for (int shift : shifts){ if (shift != 0){ @@ -674,7 +673,7 @@ bool oreburgh_gym_battles(VideoStream& stream, ProControllerContext& context){ if (in_pokemon_center(stream)){ return false; } - finish_dialogue(stream, context, 0, "Roark after the battle"); + finish_dialogue(stream, context, BUTTON_B, "Roark after the battle"); return true; } continue; @@ -728,7 +727,7 @@ void oreburgh_gym(VideoStream& stream, ProControllerContext& context){ return; } if (down.stop == WalkStop::DIALOG){ - finish_dialogue(stream, context, 0, "Someone in the gym"); + finish_dialogue(stream, context, BUTTON_B, "Someone in the gym"); } } fail(stream, "Oreburgh Gym: did not leave by its door."); @@ -835,7 +834,7 @@ void oreburgh_to_floaroma(VideoStream& stream, ProControllerContext& context){ if (in_pokemon_center(stream)){ fail(stream, "Jubilife: lost the tag battle with Dawn."); } - finish_dialogue(stream, context, 0, "Rowan and Dawn after the battle"); + finish_dialogue(stream, context, BUTTON_B, "Rowan and Dawn after the battle"); step(4); // Route 204's south half: a trainer just past the sign, then the cave in diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp index 8ba9e47f82..ed62539709 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -518,29 +518,48 @@ void talk_to_npc( void finish_dialogue( VideoStream& stream, ProControllerContext& context, - size_t a_presses, const std::string& label + Button button, const std::string& label, Milliseconds timeout ){ - for (size_t press = 0; press < a_presses; press++){ - pbf_press_button(context, BUTTON_A, 80ms, 1500ms); - } + // No line counts: talks differ in length (r115a: 8 A left the Poke Mart + // lady's Potions talk open). The box blinks off between lines and during + // "obtained" jingles, so only a stable absence ends the talk. + const WallClock none = WallClock::max(); ShortDialogDetector dialog; OverworldDetector overworld; - size_t quiet = 0; - for (size_t press = 0; press < 30; press++){ + const WallClock deadline = current_time() + timeout; + WallClock quiet_since = none; + WallClock unknown_since = none; + while (current_time() < deadline){ context.wait_for_all_requests(); const auto frame = stream.video().snapshot(); - if (frame && !dialog.detect(frame) && overworld.detect(frame)){ - if (++quiet >= 2){ + const WallClock now = current_time(); + const bool talking = frame && dialog.detect(frame); + if (frame && !talking && overworld.detect(frame)){ + unknown_since = none; + if (quiet_since == none){ + quiet_since = now; + }else if (now - quiet_since >= 1500ms){ return; } - context.wait_for(500ms); + context.wait_for(200ms); + continue; + } + quiet_since = none; + // Press only on a box (an A on the overworld talks again), or on a + // screen that is neither for 3 s. + if (talking || (unknown_since != none && now - unknown_since >= 3000ms)){ + unknown_since = none; + pbf_press_button(context, button, 80ms, 1200ms); continue; } - quiet = 0; - pbf_press_button(context, BUTTON_B, 80ms, 1200ms); + if (unknown_since == none){ + unknown_since = now; + } + context.wait_for(200ms); } OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": the dialogue did not end.", stream); + label + ": the dialogue did not end within " + + std::to_string(std::chrono::duration_cast(timeout).count()) + " s.", stream); } void enter_door_north(VideoStream& stream, ProControllerContext& context, const std::string& label){ diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h index 8a2421af45..6a67a0b3e9 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -113,13 +113,16 @@ void talk_to_npc( const std::string& label ); -// Finishes a dialogue that has opened: `a_presses` presses of A, 1.5 s -// apart (A answers Yes and closes "obtained" messages), then B until the -// overworld is back with no dialogue. B never starts a new talk, while an A -// after the last line talks to the NPC again. +// Finishes a dialogue that has opened, however many lines it has: presses +// `button` while a dialogue box shows, and returns once the overworld has +// shown with no box for 1.5 s (the box blinks off between lines and during +// "obtained" jingles). Use A where the talk asks Yes/No or gives an item (A +// answers Yes and closes "obtained" lines; B would answer No), B elsewhere +// (B never starts a new talk). Fails with a screenshot after `timeout`. void finish_dialogue( VideoStream& stream, ProControllerContext& context, - size_t a_presses, const std::string& label + Button button, const std::string& label, + Milliseconds timeout = std::chrono::seconds(40) ); // For a player on the tile below a door: walks north through it. If a wall From f16dc3246ee1010433c577dafbae0778fa82cbd2 Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 30 Oct 2026 00:36:04 -0700 Subject: [PATCH 11/19] BDSP AutoStory 00.04: walk back from the Poke Mart lady round her rows r116a: the walk back planned east along her row from where she answered. She stood next to us, east; the tall grass kept the screen changing, so "blocked after 4000ms" read as a walk to the far wall, and the next legs (one ending on its time limit) went from a pose that was never there. The player ended at the bottom of the route, not at Sandgem. The map was not at fault: the leg screenshots fit the Route 201 grid. Now walk west along her row to its corner, away from her, then plan with her row and the row below as walls (walk_route()'s new `avoid`): south out of the grass and round by column 57, so every Route 201 leg ends on a wall out of the grass, where a stop can be read. The location banner check accepts any banner seen during the leg, as a wild battle or a second walk to the wall may come after it. The offline plans test checks the walk back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Data/PokemonBDSP_AutoStory_StoryMaps.cpp | 19 +++++-- .../Data/PokemonBDSP_AutoStory_StoryMaps.h | 13 +++-- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 40 +++++++++++++- .../PokemonBDSP_AutoStory_Segment_00.cpp | 13 ++++- .../Utils/PokemonBDSP_AutoStory_RouteWalk.cpp | 55 ++++++++++++++----- .../Utils/PokemonBDSP_AutoStory_RouteWalk.h | 14 ++++- 6 files changed, 124 insertions(+), 30 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp index 862839fca9..c0e1f7cc56 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp @@ -99,13 +99,24 @@ bool story_at_jubilife_exit(const TilePos& world){ return tile && tile->map == MAP_ROUTE_202 && tile->pos.y <= 3; } -std::vector story_potion_giver_area(){ +std::vector story_potion_giver_row(){ const MapSet& maps = story_maps(); - std::vector area; const TilePos west = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - const TilePos east = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST); + const TilePos east = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); + std::vector row; + for (int x = west.x; x <= east.x; x++){ + row.push_back({x, west.y}); + } + return row; +} + +std::vector story_potion_giver_wander_area(){ + const MapSet& maps = story_maps(); + const TilePos west = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); + const TilePos east = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); + std::vector area; for (int y = west.y; y <= west.y + 1; y++){ - for (int x = west.x; x <= east.x; x++){ + for (int x = west.x + 1; x <= east.x; x++){ if (maps.world().standable({x, y})){ area.push_back({x, y}); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h index 2b08afb0c0..300c1bd7ac 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h @@ -48,10 +48,15 @@ bool story_map_plannable(const std::string& map); // any of them enters Jubilife City. bool story_at_jubilife_exit(const Pokemon::TilePos& world); -// Where we may stand after talking to the Potion giver and the training -// battle next to her: the standable tiles of the top two rows of her grass -// and the tile east of them, in world tiles. -std::vector story_potion_giver_area(); +// Where we may stand after talking to the Potion giver: her row, from the +// corner that pins it to its east end, in world tiles. The walk to her goes +// east along it from the corner, so it never leaves the row. +std::vector story_potion_giver_row(); + +// Where the Potion giver may stand: her row and the row below, east of the +// corner, in world tiles. She wanders there (r113a, r114a); a walk planned +// through it may walk into her, which tall grass hides from the walk. +std::vector story_potion_giver_wander_area(); // Where we may stand after training in the catch grass, in world tiles: the // training walks up and down the catch spot's column, between the tree walls diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index 2c00040423..b307b86dc7 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -264,6 +264,43 @@ std::string check_robust_plan( return ""; } +// The walk back from the Potion giver (00.04): west along her row to its +// corner, away from her, then round her rows to the save spot. Every leg on +// Route 201 after the corner must end on a wall the walk can read, out of the +// tall grass, so that no leg there that runs out of time is taken as an anchor. +std::string check_potion_giver_walk_back(Logger& logger){ + const Pokemon::MapSet& maps = story_maps(); + const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); + const auto to_corner = Pokemon::plan_robust_route(maps.world(), story_potion_giver_row(), + [&](const TilePos& pos){ return pos == corner; }); + if (!to_corner || to_corner->size() != 1 || to_corner->front().leg.direction != Direction::WEST){ + return "Potion giver walk back: her row should reach its corner in one leg west."; + } + TileGrid grid = maps.world(); + const std::vector wander = story_potion_giver_wander_area(); + for (const TilePos& pos : wander){ + grid.set(pos, TileType::BLOCKED); + } + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::POTION_SAVE_SPOT); + const auto legs = Pokemon::plan_robust_route(grid, {corner}, [&](const TilePos& pos){ return pos == save_spot; }); + if (!legs || legs->empty()){ + return "Potion giver walk back: no plan from the corner round her rows."; + } + std::string text = "Potion giver corner to the save spot, round her rows:"; + for (const Pokemon::PlannedLeg& leg : *legs){ + text += "\n " + Pokemon::to_string(leg.leg) + " to"; + for (const TilePos& pos : leg.after){ + text += " " + maps.describe(pos); + } + const std::optional tile = maps.to_local(leg.after.front()); + if (tile && tile->map == MAP_ROUTE_201 && (!leg.leg.ends_at_wall || !route_wall_readable(grid, leg.after))){ + return "Potion giver walk back: " + Pokemon::to_string(leg.leg) + " ends on Route 201 where a stop cannot be read."; + } + } + logger.log(text); + return legs->back().after == std::vector{save_spot} ? "" : "Potion giver walk back: may not end at the save spot."; +} + // Checks a plan from `from` to `to`: every leg can be walked, ledges are // crossed only southward, and each leg's wall flag matches the grid. // With `maps`, legs are printed with the map tile they end on, and with @@ -410,8 +447,7 @@ class Test_TileGridPlans : public UnitTest{ return error; } const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::POTION_SAVE_SPOT); - error = check_robust_plan(logger, "Potion giver to the save spot", maps, story_potion_giver_area(), - [&](const TilePos& pos){ return pos == save_spot; }); + error = check_potion_giver_walk_back(logger); if (!error.empty()){ return error; } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 58903acf5c..3e687021e0 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -714,9 +714,16 @@ namespace PokemonAutomation { "Poke Mart lady: she talked, but no Potion is in the Bag. " "Has this save already had her Potions?", stream); } - // Somewhere along her row: plan from all of it. - walk_route(stream, context, maps, story_potion_giver_area(), - [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); + // We stand in her row with her next to us, east. r116a planned + // east along the row from here: she stood in the way, the tall + // grass hid that the walk had stopped, and every later leg was + // off. So go back west to the corner, away from her, then plan + // with her rows as walls: south out of the grass and round. + const std::vector at_corner = walk_route(stream, context, maps, story_potion_giver_row(), + [&](const TilePos& pos) { return pos == corner; }, trainers, fight, "Back to her row's corner"); + walk_route(stream, context, maps, at_corner, + [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202", + story_potion_giver_wander_area()); stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); save_game(env, context); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp index ed62539709..3e201759eb 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -123,6 +123,12 @@ std::string map_of(const MapSet& maps, const std::vector& tiles){ } +bool route_wall_readable(const TileGrid& grid, const std::vector& after){ + return std::none_of(after.begin(), after.end(), [&](const TilePos& pos){ + return grid.at(pos) == TileType::TALL_GRASS; + }); +} + bool location_banner_matches(const std::string& read, const std::string& expected){ const std::string a = normalize_name(read); const std::string b = normalize_name(expected); @@ -232,10 +238,16 @@ std::vector walk_route( const std::function& goal, std::vector& trainers, const RouteTrainerFight& fight, - const std::string& label + const std::string& label, + const std::vector& avoid ){ // Our own copy, so things found in the way can be added to it. TileGrid grid = maps.world(); + for (const TilePos& pos : avoid){ + if (std::find(from.begin(), from.end(), pos) == from.end()){ + grid.set(pos, TileType::BLOCKED); + } + } // Which way the player faces, once a leg has set it. std::optional facing; for (size_t plans = 0; plans < 8; plans++){ @@ -262,11 +274,23 @@ std::vector walk_route( stick_for(leg.direction, x, y); const std::string leg_label = label + ": " + to_string(leg); - // Note any location banner the walk passes, to check map changes. - std::string banner; + // Note the location banners the walk passes, to check map changes. + // A wild battle or a second walk to the wall may follow the + // banner: any banner seen during the leg counts. + const std::string map_before = map_of(maps, from); + const std::string map_after = map_of(maps, planned.after); + std::vector banners; + auto banner_ok = [&]{ + return std::any_of(banners.begin(), banners.end(), [&](const std::string& read){ + return location_banner_matches(read, map_after); + }); + }; auto watch_banner = [&](const ImageViewRGB32& screen){ - if (banner.empty()){ - banner = read_location_banner(screen); + if (banners.empty() || (!map_after.empty() && !banner_ok() && banners.size() < 8)){ + const std::string read = read_location_banner(screen); + if (!read.empty() && (banners.empty() || banners.back() != read)){ + banners.push_back(read); + } } return false; }; @@ -362,22 +386,26 @@ std::vector walk_route( } } facing = leg.direction; - if (banner.empty()){ + { context.wait_for_all_requests(); if (VideoSnapshot screen = stream.video().snapshot()){ watch_banner(*screen.frame); } } + std::string banner_text; + for (const std::string& read : banners){ + banner_text += (banner_text.empty() ? "\"" : ", \"") + read + "\""; + } // Entering another map shows its name; a missing or wrong name means - // the walk did not end where the plan says. - const std::string map_before = map_of(maps, from); - const std::string map_after = map_of(maps, planned.after); - if (!map_after.empty() && map_after != map_before && !location_banner_matches(banner, map_after)){ + // the walk did not end where the plan says. From tiles on several + // maps (a leg not confirmed) the banner may already have shown. + if (!map_before.empty() && !map_after.empty() && map_after != map_before && !banner_ok()){ OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - leg_label + ": expected to enter " + map_after + ", but the location banner read \"" + banner + "\".", stream); + leg_label + ": expected to enter " + map_after + ", but the location banner read " + + (banner_text.empty() ? "nothing" : banner_text) + ".", stream); } - if (!banner.empty()){ - stream.log("[Route] " + leg_label + ": location banner \"" + banner + "\"."); + if (!banner_text.empty()){ + stream.log("[Route] " + leg_label + ": location banner " + banner_text + "."); } // Where each leg ended, to compare with the plan after a run. if (VideoSnapshot screen = stream.video().snapshot()){ @@ -389,7 +417,6 @@ std::vector walk_route( OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, leg_label + ": unexpected transition.", stream); } - // A trainer who sees us freezes the player and walks over; the // dialogue may open after the walk has ended. const std::vector> paths = [&]{ diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h index 6a67a0b3e9..00125b0a86 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -74,6 +74,11 @@ bool walk_along_row_to_talk( // read as Route 202. bool location_banner_matches(const std::string& read, const std::string& expected); +// Whether a walk to the wall that ends on any of `after` can tell it has +// stopped: not in tall grass, whose sway keeps the screen changing at a +// wall. Elsewhere, a walk to the wall that stops confirms where it ended. +bool route_wall_readable(const Pokemon::TileGrid& grid, const std::vector& after); + // Shows what the route walk is doing in the log and the video overlay. void route_status(VideoStream& stream, const std::string& text); @@ -85,8 +90,10 @@ using RouteTrainerFight = std::function; // Walks from any of the world tiles `from` of `maps` until every tile the // player may be on is `goal`, and returns those tiles. Trainers in // `trainers` who see the player are fought with `fight` and taken off the -// list. Fails with a screenshot on a transition, a dialogue no trainer -// explains, a wild battle during an open leg, or when no plan exists. +// list. Tiles in `avoid` (where someone wanders) are planned as walls. +// Fails with a screenshot +// on a transition, a dialogue no trainer explains, a wild battle during an +// open leg, or when no plan exists. std::vector walk_route( VideoStream& stream, ProControllerContext& context, const Pokemon::MapSet& maps, @@ -94,7 +101,8 @@ std::vector walk_route( const std::function& goal, std::vector& trainers, const RouteTrainerFight& fight, - const std::string& label + const std::string& label, + const std::vector& avoid = {} ); // Walks next to the NPC on world tile `npc`, faces them and presses A, trying From 5b112d3e98e2cbb5a50153f7f0be988eace2a275 Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 30 Oct 2026 01:18:45 -0700 Subject: [PATCH 12/19] BDSP AutoStory 00.04: meet the Poke Mart lady from the open strip, heal at Sandgem's Center Three runs (r113a..r117a) failed after the Poke Mart lady's talk: the walk back went round her rows through the tall grass, where a walk cannot read that it has stopped, and once she stood in the way every later leg was off. Her row is the top row of the north-east grass (the DP map and the r117a screenshots show her and the player in the tufts right under the tree trunks); east of the grass it is open as far as Sandgem's fence. So the walk now comes to (56,3) on that open strip, which the trees and the sign's row pin, walks west along the row until she answers, and leaves east along the same row to its end at (63,3), requiring the stop to be read there (out of the grass) and waiting for her to move when a walk only runs out of time. From there the open east edge leads south into Sandgem. The planner is left the rest, with a test that no leg on Route 201 ends where a stop cannot be read. After training, the party heals at Sandgem Town's Pokemon Center instead of with the Bag's Potions, keeping them for the Route 202 trainers; the walk to the tile below the Center's sign is the one r75 made. The post-catch half is split into free functions, one per step; the save spot loses its "Potion" name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Data/PokemonBDSP_AutoStory_Route201.cpp | 30 +- .../Data/PokemonBDSP_AutoStory_Route201.h | 12 +- .../Data/PokemonBDSP_AutoStory_Route202.cpp | 13 +- .../Data/PokemonBDSP_AutoStory_Route202.h | 6 +- .../Data/PokemonBDSP_AutoStory_StoryMaps.cpp | 26 +- .../Data/PokemonBDSP_AutoStory_StoryMaps.h | 19 +- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 165 +++++----- .../PokemonBDSP_AutoStory_Segment_00.cpp | 284 +++++++++++------- 8 files changed, 334 insertions(+), 221 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp index 8348c3a1a4..46a058ade2 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp @@ -23,10 +23,12 @@ using namespace Pokemon; // Trees follow the Route 202 rules: tiles under trunks or mostly under a // canopy are blocked. Ledges: row 10 west of the tall grass, and (53..56,7) // in the north-east. On the console the north-east ledge is one row higher -// than it looks on the DP map, as on Route 202; the west one is unchecked. West edge, rows 9..12: towards Lake Verity. South gap -// (14..17, 19): Twinleaf Town. East edge, rows 3..6: Sandgem Town. -// Route 201 has no trainers. Its other NPCs wander, so they are left off: -// a planned wall must stay put. The Potion giver moves too (see below). +// than it looks on the DP map, as on Route 202; the west one is unchecked. +// West edge, rows 9..12: towards Lake Verity. South gap (14..17, 19): +// Twinleaf Town. East edge, rows 3..6: Sandgem Town. +// Route 201 has no trainers. Its NPCs wander, so they are left off: a +// planned wall must stay put. The Poke Mart lady, who gives 10 Potions, is +// one of them (see below). const TileGrid& grid(){ // 0 1 2 3 4 5 6 // 0123456789012345678901234567890123456789012345678901234567890123 @@ -56,15 +58,19 @@ const TileGrid& grid(){ } -// On the DP map she is at (44,3), pixel (718,57), and she was once met at -// (43,3) on the console. She does not stay there: r113a and r114a walked -// west along row 3 to its west end (42,3) through her tile, and found her -// one tile east and one row up (r114a), or two tiles east in the row -// (r113a). So she is left off the grid like the other NPCs, and is found by -// walking east from the row's west end until she answers. -const TilePos POTION_GIVER_ROW_EAST{53, 3}; +// The lady stands in the top row of the north-east tall grass, (42..52, 3), +// right under the trees: on the DP map she is at (44,3), pixel (718,57), and +// on the console she was met at (43,3) and (44,3) from the east (r113a..r116a +// screenshots show her and the player in the tufts under the trunks). She +// wanders about the grass, so she is not on the grid. East of the grass her +// row is open to Sandgem's fence at (64,6). const TilePos POTION_GIVER_ROW_WEST_END{42, 3}; -const TilePos POTION_GIVER_ROW_EAST_END{52, 3}; +// North of the tile east of the sign at (55,4): the trees north and the +// sign's row pin it. Walk west from here along row 3 to meet her. +const TilePos POTION_GIVER_APPROACH{56, 3}; +// Against Sandgem's fence: walking east along her row stops here, out of +// the grass, where a stop can be read. +const TilePos POTION_GIVER_ROW_EAST_END{63, 3}; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h index 497ab599d3..32fec8952b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h @@ -19,13 +19,13 @@ namespace Route201{ const Pokemon::TileGrid& grid(); -// The girl who gives Potions stands around the top row of the north-east -// tall grass, (42..52, 3), near its west end; she is not on the grid. -// The tile east of her row, against the trees: walk west from here to meet her. -extern const Pokemon::TilePos POTION_GIVER_ROW_EAST; -// The first and last tiles of her row. The first is the corner that pins -// the row: trees west of it and north of it. Walk east from it to meet her. +// The Poke Mart lady's row, (42..63, 3): she wanders its tall-grass part, +// (42..52, 3), and is not on the grid. The row's west end is a corner with +// trees west and north of it. extern const Pokemon::TilePos POTION_GIVER_ROW_WEST_END; +// The open tile east of the grass to walk west from, to meet her. +extern const Pokemon::TilePos POTION_GIVER_APPROACH; +// The row's east end, against Sandgem Town's fence. extern const Pokemon::TilePos POTION_GIVER_ROW_EAST_END; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp index 945b743a56..91243166f9 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp @@ -66,12 +66,15 @@ const TileGrid& grid(){ } -// Where the post-catch save stands: the bottom row of the tall-grass patch at -// (15..20, 20..22), nearest the Sandgem Town entrance at the bottom right. -// On the console the sign at (24,20) is 5 columns right and 2 rows up, and -// walking east stops at the trees after 11 tiles. +// Where 00.03's save after the catch stands: the bottom row of the tall-grass +// patch at (15..20, 20..22), nearest the Sandgem Town entrance at the bottom +// right. On the console the sign at (24,20) is 5 columns right and 2 rows up, +// and walking east stops at the trees after 11 tiles. const TilePos CATCH_SPOT{19, 22}; -const TilePos POTION_SAVE_SPOT{30, 19}; +// Where the console's 00.04 save stands, made by the runs that fetched +// Potions from Route 201 (r113a..r117a): under the south ledge, against the +// east trees. +const TilePos SAVE_SPOT{30, 19}; // Blue-cap trainer, feet at about (128,186), facing south. const TilePos TRAINER_A{7, 11}; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h index 52e7219d0f..f4fd37fac3 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.h @@ -22,9 +22,9 @@ const Pokemon::TileGrid& grid(); // Where the player stands in the tall grass after the catching tutorial. extern const Pokemon::TilePos CATCH_SPOT; -// Under the south ledge, against the east trees: where 00.04 saves once it -// has the Potions. Two walls pin it. -extern const Pokemon::TilePos POTION_SAVE_SPOT; +// Under the south ledge, against the east trees: where the console's 00.04 +// save stands. Two walls pin it. +extern const Pokemon::TilePos SAVE_SPOT; // The three trainers, south to north. Which one is Tristan, Natalie or // Logan is not known from the map. diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp index c0e1f7cc56..93f18fc730 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp @@ -99,10 +99,18 @@ bool story_at_jubilife_exit(const TilePos& world){ return tile && tile->map == MAP_ROUTE_202 && tile->pos.y <= 3; } +std::vector story_save_spots(){ + const MapSet& maps = story_maps(); + return { + maps.to_world(MAP_ROUTE_202, Route202::CATCH_SPOT), + maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT), + }; +} + std::vector story_potion_giver_row(){ const MapSet& maps = story_maps(); const TilePos west = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - const TilePos east = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); + const TilePos east = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH); std::vector row; for (int x = west.x; x <= east.x; x++){ row.push_back({x, west.y}); @@ -110,19 +118,9 @@ std::vector story_potion_giver_row(){ return row; } -std::vector story_potion_giver_wander_area(){ - const MapSet& maps = story_maps(); - const TilePos west = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - const TilePos east = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); - std::vector area; - for (int y = west.y; y <= west.y + 1; y++){ - for (int x = west.x + 1; x <= east.x; x++){ - if (maps.world().standable({x, y})){ - area.push_back({x, y}); - } - } - } - return area; +std::vector story_door_front(const char* map, const TilePos& door){ + const TilePos below = story_maps().to_world(map, door.next(Direction::SOUTH)); + return {below.next(Direction::WEST), below, below.next(Direction::EAST)}; } std::vector story_after_training_area(){ diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h index 300c1bd7ac..089969b188 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h @@ -48,15 +48,20 @@ bool story_map_plannable(const std::string& map); // any of them enters Jubilife City. bool story_at_jubilife_exit(const Pokemon::TilePos& world); -// Where we may stand after talking to the Potion giver: her row, from the -// corner that pins it to its east end, in world tiles. The walk to her goes -// east along it from the corner, so it never leaves the row. +// Where the post-catch half of 00.04 may start, in world tiles: the catch +// spot (00.03 saves there after the catch) and the save spot under the +// ledge (the console's save from the runs that fetched Potions). +std::vector story_save_spots(); + +// Where we may stand after talking to the Potion giver, in world tiles: her +// row from its west corner to the approach tile. The walk to her goes west +// along the row from the approach tile, so it never leaves the row. std::vector story_potion_giver_row(); -// Where the Potion giver may stand: her row and the row below, east of the -// corner, in world tiles. She wanders there (r113a, r114a); a walk planned -// through it may walk into her, which tall grass hides from the walk. -std::vector story_potion_giver_wander_area(); +// Where a walk out of the door `door` of `map` may leave us, in world tiles: +// the tile below it and its neighbours either side, since the door opening +// is not lined up with one tile (Jubilife's Center, r85). +std::vector story_door_front(const char* map, const Pokemon::TilePos& door); // Where we may stand after training in the catch grass, in world tiles: the // training walks up and down the catch spot's column, between the tree walls diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index b307b86dc7..2bae792f15 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -116,13 +116,15 @@ class Test_TileGridParse : public UnitTest{ TileType type; }; const Named named[] = { - {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST, TileType::WALKABLE}, {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END, TileType::TALL_GRASS}, - {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END, TileType::TALL_GRASS}, + {MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH, TileType::WALKABLE}, + {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END, TileType::WALKABLE}, {MAP_SANDGEM_TOWN, SandgemTown::LAB_DOOR, TileType::DOOR}, {MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR, TileType::DOOR}, + {MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN, TileType::SIGN}, {MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR, TileType::DOOR}, {MAP_ROUTE_202, Route202::CATCH_SPOT, TileType::TALL_GRASS}, + {MAP_ROUTE_202, Route202::SAVE_SPOT, TileType::WALKABLE}, {MAP_ROUTE_202, Route202::TRAINER_A, TileType::NPC}, {MAP_ROUTE_202, Route202::TRAINER_B, TileType::NPC}, {MAP_ROUTE_202, Route202::TRAINER_C, TileType::NPC}, @@ -141,8 +143,23 @@ class Test_TileGridParse : public UnitTest{ return std::string(c.map) + " tile " + Pokemon::to_string(c.pos) + " does not map back."; } } - if (Route201::POTION_GIVER_ROW_EAST.next(Direction::WEST) != Route201::POTION_GIVER_ROW_EAST_END){ - return "The Potion giver's row does not end next to its east tile."; + // The Poke Mart lady's row: all of row 3 from the corner to the fence, + // and the approach tile pinned by the sign's row and the trees. + { + const TilePos corner = world.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); + const TilePos approach = world.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH); + const TilePos row_end = world.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); + if (corner.y != approach.y || approach.y != row_end.y || !(corner.x < approach.x && approach.x < row_end.x)){ + return "The Poke Mart lady's row is not one row from its corner to the fence."; + } + if (story_potion_giver_row().size() != (size_t)(approach.x - corner.x + 1)){ + return "story_potion_giver_row() is not the row from the corner to the approach tile."; + } + if (world.world().at(approach.next(Direction::NORTH)) != TileType::BLOCKED || + world.world().at(approach.next(Direction::SOUTH).next(Direction::WEST)) != TileType::SIGN + ){ + return "The approach tile is not under the trees, north-east of the sign."; + } } // The maps join where their exits meet. @@ -221,7 +238,7 @@ class Test_TileGridWallHit : public UnitTest{ {route, {12, 8}, Direction::SOUTH, {12, 22}, 14, true}, // hops both ledges {route, Route202::EXIT_JUBILIFE, Direction::NORTH, Route202::EXIT_JUBILIFE, 0, false}, }; - for (const Case& c : cases){ + auto check = [](const Case& c) -> std::string{ const Pokemon::WallHit hit = Pokemon::walk_to_wall(c.grid, c.from, c.direction); if (hit.stop != c.stop || hit.tiles != c.tiles || hit.anchored != c.anchored){ return "Walking " + std::string(Pokemon::direction_name(c.direction)) + " from " + @@ -229,6 +246,36 @@ class Test_TileGridWallHit : public UnitTest{ std::to_string(hit.tiles) + " tiles" + (hit.anchored ? "" : " (not anchored)") + ", expected " + Pokemon::to_string(c.stop) + " after " + std::to_string(c.tiles) + "."; } + return ""; + }; + for (const Case& c : cases){ + const std::string error = check(c); + if (!error.empty()){ + return error; + } + } + // The Poke Mart lady's row on the joined maps (00.04): west along it + // from the approach tile to the corner, east from the corner to + // Sandgem's fence, then south down the open east edge, and the way + // to the approach tile: west along the sign's row and one north. + const Pokemon::MapSet& maps = story_maps(); + const TileGrid& world = maps.world(); + auto r201 = [&](const TilePos& pos){ return maps.to_world(MAP_ROUTE_201, pos); }; + const TilePos corner = r201(Route201::POTION_GIVER_ROW_WEST_END); + const TilePos approach = r201(Route201::POTION_GIVER_APPROACH); + const TilePos row_end = r201(Route201::POTION_GIVER_ROW_EAST_END); + const Case lady[] = { + {world, approach, Direction::WEST, corner, 14, true}, + {world, corner, Direction::EAST, row_end, 21, true}, + {world, row_end, Direction::SOUTH, r201({63, 6}), 3, true}, + {world, r201({63, 4}), Direction::WEST, approach.next(Direction::SOUTH), 7, true}, + {world, approach.next(Direction::SOUTH), Direction::NORTH, approach, 1, true}, + }; + for (const Case& c : lady){ + const std::string error = check(c); + if (!error.empty()){ + return error; + } } return true; } @@ -264,41 +311,43 @@ std::string check_robust_plan( return ""; } -// The walk back from the Potion giver (00.04): west along her row to its -// corner, away from her, then round her rows to the save spot. Every leg on -// Route 201 after the corner must end on a wall the walk can read, out of the -// tall grass, so that no leg there that runs out of time is taken as an anchor. -std::string check_potion_giver_walk_back(Logger& logger){ +// The Poke Mart lady's talk (00.04): the way to the tile east of her row from +// the save spots, and the way back from her row's east end to the save spot. +// Every leg on Route 201 must end on a wall the walk can read, out of the +// tall grass, so that no leg there that runs out of time is taken as an +// anchor; the row itself is walked by hand, east to the fence. +std::string check_poke_mart_lady_walks(Logger& logger){ const Pokemon::MapSet& maps = story_maps(); - const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - const auto to_corner = Pokemon::plan_robust_route(maps.world(), story_potion_giver_row(), - [&](const TilePos& pos){ return pos == corner; }); - if (!to_corner || to_corner->size() != 1 || to_corner->front().leg.direction != Direction::WEST){ - return "Potion giver walk back: her row should reach its corner in one leg west."; - } - TileGrid grid = maps.world(); - const std::vector wander = story_potion_giver_wander_area(); - for (const TilePos& pos : wander){ - grid.set(pos, TileType::BLOCKED); - } - const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::POTION_SAVE_SPOT); - const auto legs = Pokemon::plan_robust_route(grid, {corner}, [&](const TilePos& pos){ return pos == save_spot; }); - if (!legs || legs->empty()){ - return "Potion giver walk back: no plan from the corner round her rows."; - } - std::string text = "Potion giver corner to the save spot, round her rows:"; - for (const Pokemon::PlannedLeg& leg : *legs){ - text += "\n " + Pokemon::to_string(leg.leg) + " to"; - for (const TilePos& pos : leg.after){ - text += " " + maps.describe(pos); + const TilePos approach = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH); + const TilePos row_end = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); + const struct{ const char* name; std::vector from; TilePos to; } walks[] = { + {"Save spots to the tile east of the Poke Mart lady's row", story_save_spots(), approach}, + {"Poke Mart lady's row end to the save spot", {row_end}, save_spot}, + }; + for (const auto& walk : walks){ + const TilePos to = walk.to; + const auto legs = Pokemon::plan_robust_route(maps.world(), walk.from, [&](const TilePos& pos){ return pos == to; }); + if (!legs || legs->empty()){ + return std::string(walk.name) + ": no robust plan."; + } + std::string text = std::string(walk.name) + " (robust):"; + for (const Pokemon::PlannedLeg& leg : *legs){ + text += "\n " + Pokemon::to_string(leg.leg) + " to"; + for (const TilePos& pos : leg.after){ + text += " " + maps.describe(pos); + } + const std::optional tile = maps.to_local(leg.after.front()); + if (tile && tile->map == MAP_ROUTE_201 && (!leg.leg.ends_at_wall || !route_wall_readable(maps.world(), leg.after))){ + return std::string(walk.name) + ": " + Pokemon::to_string(leg.leg) + " ends on Route 201 where a stop cannot be read."; + } } - const std::optional tile = maps.to_local(leg.after.front()); - if (tile && tile->map == MAP_ROUTE_201 && (!leg.leg.ends_at_wall || !route_wall_readable(grid, leg.after))){ - return "Potion giver walk back: " + Pokemon::to_string(leg.leg) + " ends on Route 201 where a stop cannot be read."; + logger.log(text); + if (legs->back().after != std::vector{to}){ + return std::string(walk.name) + ": may not end at " + maps.describe(to) + "."; } } - logger.log(text); - return legs->back().after == std::vector{save_spot} ? "" : "Potion giver walk back: may not end at the save spot."; + return ""; } // Checks a plan from `from` to `to`: every leg can be walked, ledges are @@ -406,54 +455,34 @@ class Test_TileGridPlans : public UnitTest{ return "A route north over a ledge was found."; } + // 00.04 after the catch: every leg it walks, in order. const Pokemon::MapSet& maps = story_maps(); const std::vector trainers = story_trainer_sights(); const TilePos catch_spot = maps.to_world(MAP_ROUTE_202, Route202::CATCH_SPOT); - const TilePos potion = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST); const TilePos jubilife = maps.to_world(MAP_ROUTE_202, Route202::EXIT_JUBILIFE); - error = check_plan(logger, "Catch spot to Potion giver", maps.world(), catch_spot, potion, &maps, trainers); + error = check_plan(logger, "Catch spot to Jubilife", maps.world(), catch_spot, jubilife, &maps, trainers); if (!error.empty()){ return error; } - error = check_plan(logger, "Potion giver to Jubilife", maps.world(), potion, jubilife, &maps, trainers); + error = check_poke_mart_lady_walks(logger); if (!error.empty()){ return error; } - const TilePos below_sign = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN.next(Direction::SOUTH)); - error = check_robust_plan(logger, "After training to the Pokemon Center", maps, story_after_training_area(), - [&](const TilePos& pos){ return pos == below_sign; }); - if (!error.empty()){ - return error; - } - const TilePos below_door = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR.next(Direction::SOUTH)); - error = check_robust_plan(logger, "Pokemon Center to Jubilife", maps, {below_door}, story_at_jubilife_exit); - if (!error.empty()){ - return error; - } - error = check_robust_plan(logger, "After training to Jubilife", maps, story_after_training_area(), story_at_jubilife_exit); - if (!error.empty()){ - return error; - } - error = check_robust_plan(logger, "Catch spot to Potion giver", maps, {catch_spot}, - [&](const TilePos& pos){ return pos == potion; }); - if (!error.empty()){ - return error; - } - // To where she is talked to, and back to the catch grass to save. - const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - error = check_robust_plan(logger, "Catch spot to the Potion giver's corner", maps, {catch_spot}, - [&](const TilePos& pos){ return pos == corner; }); + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); + const std::vector grass = story_after_training_area(); + error = check_robust_plan(logger, "Save spot to the catch grass", maps, {save_spot}, + [&](const TilePos& pos){ return std::find(grass.begin(), grass.end(), pos) != grass.end(); }); if (!error.empty()){ return error; } - const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::POTION_SAVE_SPOT); - error = check_potion_giver_walk_back(logger); + const TilePos below_sign = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN.next(Direction::SOUTH)); + error = check_robust_plan(logger, "After training to the Pokemon Center", maps, story_after_training_area(), + [&](const TilePos& pos){ return pos == below_sign; }); if (!error.empty()){ return error; } - const std::vector grass = story_after_training_area(); - error = check_robust_plan(logger, "Save spot to the catch grass", maps, {save_spot}, - [&](const TilePos& pos){ return std::find(grass.begin(), grass.end(), pos) != grass.end(); }); + error = check_robust_plan(logger, "Pokemon Center to Jubilife", maps, + story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR), story_at_jubilife_exit); if (!error.empty()){ return error; } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 3e687021e0..02855d5a08 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -625,130 +625,174 @@ namespace PokemonAutomation { align_to_tutorial_landmarks(stream, context, match_sandgem_junction, "Sandgem junction"); } - // Resumes from the save made after catching the second Pokemon. - static void tutorial_part_3_after_catch_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { - // A retry reloads the save at whatever time the clock has reached. - // Pin it again so the landmark references see the same lighting. - set_time_fast(env, context, DateTime{ 2026, 1, 1, 0, 0, 50 }); + // What the post-catch half of 00.04 does, logged up front and as each + // step starts. A Lv5 Piplup fainted to Route 202's trainers (r72), so: + // Potions (they also saved the Barry fight in r85), training, and a + // heal at the Center between Route 202's two halves. + static const char* const AFTER_CATCH_STEPS[] = { + "Get 10 Potions from the Poke Mart lady on Route 201, walk back to Route 202's save spot and save (skipped if the Bag has Potions)", + "Train: fight 3 wild Pokemon in the catch grass, with the best move", + "Walk south into Sandgem Town and heal at its Pokemon Center", + "Walk north up Route 202 to the Jubilife exit, fighting trainers who see us; after each, use a Potion if HP is under half", + "Enter Jubilife and get through Dawn's scene", + }; + constexpr size_t AFTER_CATCH_STEP_COUNT = sizeof(AFTER_CATCH_STEPS) / sizeof(AFTER_CATCH_STEPS[0]); + + static void log_after_catch_steps(VideoStream& stream) { + std::string text = "[00.04] After the catch, the steps are:"; + for (size_t i = 0; i < AFTER_CATCH_STEP_COUNT; i++) { + text += "\n " + std::to_string(i + 1) + ". " + AFTER_CATCH_STEPS[i]; + } + stream.log(text, COLOR_BLUE); + } - // What this part of 00.04 does, logged up front and as each step starts. - // The Potion on Route 201 is optional, and the starter outclasses the - // Route 202 trainers, so we go straight north. - static const char* const STEPS[] = { - "Get 10 Potions from the Poke Mart lady on Route 201, walk back to Route 202 and save (skipped if the Bag has Potions)", - "Train: fight 3 wild Pokemon in the catch grass, with the best move", - "Heal to full with Potions from the Bag", - "Walk north up Route 202 to the Jubilife exit, fighting trainers who see us; after each, use a Potion if HP is under half", - "Enter Jubilife and get through Dawn's scene", - }; - constexpr size_t STEP_COUNT = sizeof(STEPS) / sizeof(STEPS[0]); - { - std::string text = "[00.04] After the catch, the steps are:"; - for (size_t i = 0; i < STEP_COUNT; i++) { - text += "\n " + std::to_string(i + 1) + ". " + STEPS[i]; - } - stream.log(text, COLOR_BLUE); + static void after_catch_step(VideoStream& stream, size_t number) { + const std::string count = std::to_string(number) + "/" + std::to_string(AFTER_CATCH_STEP_COUNT); + std::string text = "[00.04 step " + count + "] " + AFTER_CATCH_STEPS[number - 1]; + text += number < AFTER_CATCH_STEP_COUNT ? std::string(" -- next: ") + AFTER_CATCH_STEPS[number] : std::string(" -- last step"); + stream.log(text, COLOR_BLUE); + add_wrapped_log(stream.overlay(), "00.04 step " + count + ": " + AFTER_CATCH_STEPS[number - 1], COLOR_WHITE); + if (number < AFTER_CATCH_STEP_COUNT) { + add_wrapped_log(stream.overlay(), "Next: " + std::string(AFTER_CATCH_STEPS[number]), COLOR_GRAY); } - auto step = [&](size_t number) { - std::string text = "[00.04 step " + std::to_string(number) + "/" + std::to_string(STEP_COUNT) + "] " + STEPS[number - 1]; - text += number < STEP_COUNT ? std::string(" -- next: ") + STEPS[number] : std::string(" -- last step"); - stream.log(text, COLOR_BLUE); - add_wrapped_log(stream.overlay(), "00.04 step " + std::to_string(number) + "/" + std::to_string(STEP_COUNT) + ": " + STEPS[number - 1], COLOR_WHITE); - if (number < STEP_COUNT) { - add_wrapped_log(stream.overlay(), "Next: " + std::string(STEPS[number]), COLOR_GRAY); - } - }; + } - const MapSet& maps = story_maps(); - std::vector trainers = story_trainer_sights(); - // Fight a trainer whose dialogue has opened, with whatever starter we - // have. Someone who only talks leaves no battle to fight. - auto fight = [&](const std::string& name) { - pbf_mash_button(context, BUTTON_B, 5000ms); + // Fights a trainer whose dialogue has opened, with whatever starter we + // have. Someone who only talks leaves no battle to fight. + static void fight_route_trainer(VideoStream& stream, ProControllerContext& context, const std::string& name) { + pbf_mash_button(context, BUTTON_B, 5000ms); + context.wait_for_all_requests(); + BattleMenuWatcher battle_menu(BattleType::TRAINER, COLOR_YELLOW); + BattleDialogWatcher battle_dialog(COLOR_YELLOW); + if (wait_until(stream, context, 8s, {{battle_menu}, {battle_dialog}}) < 0) { + stream.log("[00.04] " + name + ": no battle followed the talk."); + pbf_mash_button(context, BUTTON_B, 2000ms); context.wait_for_all_requests(); - BattleMenuWatcher battle_menu(BattleType::TRAINER, COLOR_YELLOW); - BattleDialogWatcher battle_dialog(COLOR_YELLOW); - if (wait_until(stream, context, 8s, {{battle_menu}, {battle_dialog}}) < 0) { - stream.log("[00.04] " + name + ": no battle followed the talk."); - pbf_mash_button(context, BUTTON_B, 2000ms); - context.wait_for_all_requests(); + return; + } + fight_trainer(stream, context, name); + context.wait_for_all_requests(); + // The next trainer may be a few tiles on: check HP now. With no + // Potion in the Bag this only looks. + use_potion_first_pokemon(stream, context, 0.5); + } + + // To the Poke Mart lady and her talk. She stands in the top row of Route + // 201's north-east grass, under the trees, and wanders about it (r113a, + // r114a). Come along the open strip east of the grass to the tile on + // her row that the trees north and the sign's row pin, and walk west + // along the row until she blocks the walk and answers. + static void meet_poke_mart_lady( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + const TilePos approach = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH); + const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); + walk_route(stream, context, maps, story_save_spots(), + [&](const TilePos& pos) { return pos == approach; }, trainers, fight, "To the Poke Mart lady"); + if (!walk_along_row_to_talk(stream, context, Pokemon::Direction::WEST, + (size_t)(approach.x - corner.x), "Poke Mart lady")) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart lady: nobody answered walking west along her row from (56, 3), " + "nor on any side of where the walk stopped.", stream); + } + // Her talk gives 10 Potions ("obtained" lines): A to the end. + finish_dialogue(stream, context, BUTTON_A, "Poke Mart lady"); + if (!bag_has_item(stream, context, "potion", 1)) { + // She gives the Potions once: on a save that already got + // (and used) them, she only talks. + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart lady: she talked, but no Potion is in the Bag. " + "Has this save already had her Potions?", stream); + } + } + + // East along the Poke Mart lady's row to its end at Sandgem's fence, out + // of the grass, where a stop can be read. The grass hides a stop + // (r116a: she stood in the way east, and every later leg was off), so a + // walk that only runs out of time has not got there: wait for her to + // move, and walk east again. + static void walk_to_poke_mart_lady_row_end(VideoStream& stream, ProControllerContext& context, size_t tiles) { + const Milliseconds limit = ROUTE_START_DELAY + ROUTE_MS_PER_TILE_ACROSS * tiles * 5 / 4 + 1000ms; + for (size_t tries = 0; tries < 5; tries++) { + route_status(stream, "Poke Mart lady: east along her row to its end"); + const WalkResult walk = walk_until(stream, context, 1, 0, limit, "east along the Poke Mart lady's row"); + if (walk.stop == WalkStop::BLOCKED) { return; } - fight_trainer(stream, context, name); - context.wait_for_all_requests(); - // The next trainer may be a few tiles on: check HP now. - use_potion_first_pokemon(stream, context, 0.5); - }; - - pbf_wait(context, 1500ms); - step(1); - // Potions let us heal where we stand, instead of walking back to the - // Pokemon Center. A save made after getting them starts at the save - // spot with Potions in the Bag; the post-catch save starts at the - // catch spot without. - const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::POTION_SAVE_SPOT); - if (bag_has_item(stream, context, "potion", 1)) { - stream.log("[00.04] The Bag has Potions; not visiting the Poke Mart lady."); - } else { - const TilePos catch_spot = maps.to_world(MAP_ROUTE_202, Route202::CATCH_SPOT); - // She moves about near the west end of her row (r113a, r114a), - // so go to the corner that pins the row, (42, 3): trees to its - // west and north. Then walk east until she blocks the walk and - // answers, as done by hand. - const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - const TilePos row_east_end = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); - walk_route(stream, context, maps, {catch_spot}, - [&](const TilePos& pos) { return pos == corner; }, trainers, fight, "To the Poke Mart lady"); - if (!walk_along_row_to_talk(stream, context, Pokemon::Direction::EAST, - (size_t)(row_east_end.x - corner.x), "Poke Mart lady")) { - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: nobody answered walking east along her row from (42, 3), " - "nor on any side of where the walk stopped.", stream); + if (walk.stop == WalkStop::DIALOG) { + finish_dialogue(stream, context, BUTTON_B, "Poke Mart lady again"); } - // Her talk gives 10 Potions ("obtained" lines): A to the end. - finish_dialogue(stream, context, BUTTON_A, "Poke Mart lady"); - if (!bag_has_item(stream, context, "potion", 1)) { - // She gives the Potions once: on a save that already got - // (and used) them, she only talks. + if (walk.stop == WalkStop::TRANSITION) { OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: she talked, but no Potion is in the Bag. " - "Has this save already had her Potions?", stream); + "Poke Mart lady: left the map walking east along her row.", stream); + } + if (walk.wild_battles == 0) { + stream.log("[00.04] Poke Mart lady: the walk east reached no wall it could read; waiting for her to move.", COLOR_ORANGE); + pbf_wait(context, 3000ms); + context.wait_for_all_requests(); } - // We stand in her row with her next to us, east. r116a planned - // east along the row from here: she stood in the way, the tall - // grass hid that the walk had stopped, and every later leg was - // off. So go back west to the corner, away from her, then plan - // with her rows as walls: south out of the grass and round. - const std::vector at_corner = walk_route(stream, context, maps, story_potion_giver_row(), - [&](const TilePos& pos) { return pos == corner; }, trainers, fight, "Back to her row's corner"); - walk_route(stream, context, maps, at_corner, - [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202", - story_potion_giver_wander_area()); - stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); - save_game(env, context); } - walk_route(stream, context, maps, {save_spot}, - [&](const TilePos& pos) { - const std::vector grass = story_after_training_area(); - return std::find(grass.begin(), grass.end(), pos) != grass.end(); - }, trainers, fight, "To the catch grass"); + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart lady: her row's east end was not reached in five walks east.", stream); + } - step(2); - // The starter starts around level 5 with only a Normal move; the - // Route 202 trainers need a level or two more. + // Potions let us heal where we stand. A save made after getting them + // starts at the save spot with Potions in the Bag; the post-catch save + // starts at the catch spot without. From her row's east end the open + // east edge of Route 201 leads south into Sandgem and on to Route 202. + static void get_potions_from_poke_mart_lady( + SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + if (bag_has_item(stream, context, "potion", 1)) { + stream.log("[00.04] The Bag has Potions; not visiting the Poke Mart lady."); + return; + } + meet_poke_mart_lady(stream, context, maps, trainers, fight); + const TilePos row_end = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); + const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); + walk_to_poke_mart_lady_row_end(stream, context, (size_t)(row_end.x - corner.x)); + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); + walk_route(stream, context, maps, {row_end}, + [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); + stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); + save_game(env, context); + } + + // The starter starts around level 5 with only a Normal move; the Route + // 202 trainers need a level or two more. + static void train_in_catch_grass( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); + const std::vector grass = story_after_training_area(); + walk_route(stream, context, maps, {save_spot}, + [&](const TilePos& pos) { return std::find(grass.begin(), grass.end(), pos) != grass.end(); }, + trainers, fight, "To the catch grass"); for (size_t battle = 0; battle < 3; battle++) { route_status(stream, "Training battle " + std::to_string(battle + 1) + "/3"); battle_1_pokemon(stream, context); } + } - step(3); - use_potion_first_pokemon(stream, context); - - step(4); + // South into Sandgem Town, to the tile below the Pokemon Center's sign, + // which the house west of its column and the sign pin (r75 walked this + // and healed). The Center's door is three tiles west; the heal lines up + // with it by sight. Returns where we stand after: below the door. + static std::vector heal_at_sandgem_center( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + const TilePos below_sign = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN.next(Pokemon::Direction::SOUTH)); walk_route(stream, context, maps, story_after_training_area(), - story_at_jubilife_exit, trainers, fight, "To Jubilife"); + [&](const TilePos& pos) { return pos == below_sign; }, trainers, fight, "To the Pokemon Center"); + heal_at_pokemon_center(stream, context, "Sandgem Town Pokemon Center"); + return story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR); + } - step(5); + static void meet_dawn_in_jubilife(VideoStream& stream, ProControllerContext& context) { // Jubilife's south edge is a wall with stairs in it, west of where the // route may leave us (r78 stood at its east end). Entering starts a // scene: Dawn walks over while we stand still. So walk north; if the @@ -778,6 +822,34 @@ namespace PokemonAutomation { pbf_mash_button(context, BUTTON_B, 5000ms); } + + // Resumes from the save made after catching the second Pokemon. + static void tutorial_part_3_after_catch_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { + // A retry reloads the save at whatever time the clock has reached. + // Pin it again so the landmark references see the same lighting. + set_time_fast(env, context, DateTime{ 2026, 1, 1, 0, 0, 50 }); + log_after_catch_steps(stream); + + const MapSet& maps = story_maps(); + std::vector trainers = story_trainer_sights(); + const RouteTrainerFight fight = [&](const std::string& name) { fight_route_trainer(stream, context, name); }; + pbf_wait(context, 1500ms); + + after_catch_step(stream, 1); + get_potions_from_poke_mart_lady(env, stream, context, maps, trainers, fight); + + after_catch_step(stream, 2); + train_in_catch_grass(stream, context, maps, trainers, fight); + + after_catch_step(stream, 3); + const std::vector at = heal_at_sandgem_center(stream, context, maps, trainers, fight); + + after_catch_step(stream, 4); + walk_route(stream, context, maps, at, story_at_jubilife_exit, trainers, fight, "To Jubilife"); + + after_catch_step(stream, 5); + meet_dawn_in_jubilife(stream, context); + } // --------------------------------------------------------------------------- // End of Segment 00 - 04 // Standing in Jubilife after speaking to dawn. From 010ac3d477155631f0f22d5a4f69c47ea2b64db8 Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 30 Oct 2026 01:37:47 -0700 Subject: [PATCH 13/19] BDSP AutoStory 00.04: meet the Poke Mart lady from the tile east of her grass r118a: the approach tile (56,3) has no wall to stop on, so the plan went east to Sandgem's fence and back in, and failed on the Sandgem banner. Approach from (53,3) instead: west along row 5 or 6 to the trees, then north onto her row under the trees, and walk west from there. The plan test now checks the route stays on Route 201 once there and ends with the step north. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Data/PokemonBDSP_AutoStory_Route201.cpp | 7 +++-- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 31 ++++++++++++++----- .../PokemonBDSP_AutoStory_Segment_00.cpp | 8 ++--- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp index 46a058ade2..4596aff0d1 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp @@ -65,9 +65,10 @@ const TileGrid& grid(){ // wanders about the grass, so she is not on the grid. East of the grass her // row is open to Sandgem's fence at (64,6). const TilePos POTION_GIVER_ROW_WEST_END{42, 3}; -// North of the tile east of the sign at (55,4): the trees north and the -// sign's row pin it. Walk west from here along row 3 to meet her. -const TilePos POTION_GIVER_APPROACH{56, 3}; +// Just east of her grass: coming from Sandgem, walk west past the sign +// along row 5 or 6 to the trees at column 52, then north to the trees +// above row 3. Walk west from here along row 3 to meet her. +const TilePos POTION_GIVER_APPROACH{53, 3}; // Against Sandgem's fence: walking east along her row stops here, out of // the grass, where a stop can be read. const TilePos POTION_GIVER_ROW_EAST_END{63, 3}; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index 2bae792f15..6f0189ea9a 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -144,7 +144,8 @@ class Test_TileGridParse : public UnitTest{ } } // The Poke Mart lady's row: all of row 3 from the corner to the fence, - // and the approach tile pinned by the sign's row and the trees. + // and the approach tile just east of her grass, under the trees and + // north of the trees that end rows 5 and 6. { const TilePos corner = world.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); const TilePos approach = world.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH); @@ -155,10 +156,13 @@ class Test_TileGridParse : public UnitTest{ if (story_potion_giver_row().size() != (size_t)(approach.x - corner.x + 1)){ return "story_potion_giver_row() is not the row from the corner to the approach tile."; } + const TilePos below = approach.next(Direction::SOUTH).next(Direction::SOUTH); if (world.world().at(approach.next(Direction::NORTH)) != TileType::BLOCKED || - world.world().at(approach.next(Direction::SOUTH).next(Direction::WEST)) != TileType::SIGN + world.world().at(approach.next(Direction::WEST)) != TileType::TALL_GRASS || + world.world().at(below.next(Direction::WEST)) != TileType::BLOCKED || + world.world().at(below.next(Direction::SOUTH).next(Direction::WEST)) != TileType::BLOCKED ){ - return "The approach tile is not under the trees, north-east of the sign."; + return "The approach tile is not under the trees, east of the grass and above the trees ending rows 5 and 6."; } } @@ -257,7 +261,7 @@ class Test_TileGridWallHit : public UnitTest{ // The Poke Mart lady's row on the joined maps (00.04): west along it // from the approach tile to the corner, east from the corner to // Sandgem's fence, then south down the open east edge, and the way - // to the approach tile: west along the sign's row and one north. + // to the approach tile: west along row 5 or 6 to the trees and north. const Pokemon::MapSet& maps = story_maps(); const TileGrid& world = maps.world(); auto r201 = [&](const TilePos& pos){ return maps.to_world(MAP_ROUTE_201, pos); }; @@ -265,11 +269,13 @@ class Test_TileGridWallHit : public UnitTest{ const TilePos approach = r201(Route201::POTION_GIVER_APPROACH); const TilePos row_end = r201(Route201::POTION_GIVER_ROW_EAST_END); const Case lady[] = { - {world, approach, Direction::WEST, corner, 14, true}, + {world, approach, Direction::WEST, corner, 11, true}, {world, corner, Direction::EAST, row_end, 21, true}, {world, row_end, Direction::SOUTH, r201({63, 6}), 3, true}, - {world, r201({63, 4}), Direction::WEST, approach.next(Direction::SOUTH), 7, true}, - {world, approach.next(Direction::SOUTH), Direction::NORTH, approach, 1, true}, + {world, r201({63, 6}), Direction::WEST, r201({53, 6}), 10, true}, + {world, r201({63, 5}), Direction::WEST, r201({53, 5}), 10, true}, + {world, r201({53, 6}), Direction::NORTH, approach, 3, true}, + {world, r201({53, 5}), Direction::NORTH, approach, 2, true}, }; for (const Case& c : lady){ const std::string error = check(c); @@ -332,12 +338,19 @@ std::string check_poke_mart_lady_walks(Logger& logger){ return std::string(walk.name) + ": no robust plan."; } std::string text = std::string(walk.name) + " (robust):"; + bool on_route_201 = false; for (const Pokemon::PlannedLeg& leg : *legs){ text += "\n " + Pokemon::to_string(leg.leg) + " to"; for (const TilePos& pos : leg.after){ text += " " + maps.describe(pos); } const std::optional tile = maps.to_local(leg.after.front()); + // r118a: a plan that went back to Sandgem to come round failed on + // its banner. Once on Route 201, the way to her stays there. + if (to == approach && tile && tile->map == MAP_SANDGEM_TOWN && on_route_201){ + return std::string(walk.name) + ": " + Pokemon::to_string(leg.leg) + " goes back to Sandgem Town."; + } + on_route_201 = on_route_201 || (tile && tile->map == MAP_ROUTE_201); if (tile && tile->map == MAP_ROUTE_201 && (!leg.leg.ends_at_wall || !route_wall_readable(maps.world(), leg.after))){ return std::string(walk.name) + ": " + Pokemon::to_string(leg.leg) + " ends on Route 201 where a stop cannot be read."; } @@ -346,6 +359,10 @@ std::string check_poke_mart_lady_walks(Logger& logger){ if (legs->back().after != std::vector{to}){ return std::string(walk.name) + ": may not end at " + maps.describe(to) + "."; } + if (to == approach && legs->back().leg.direction != Direction::NORTH){ + return std::string(walk.name) + ": ends with " + Pokemon::to_string(legs->back().leg) + + ", expected the step north onto her row."; + } } return ""; } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 02855d5a08..e752b7582c 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -679,9 +679,9 @@ namespace PokemonAutomation { // To the Poke Mart lady and her talk. She stands in the top row of Route // 201's north-east grass, under the trees, and wanders about it (r113a, - // r114a). Come along the open strip east of the grass to the tile on - // her row that the trees north and the sign's row pin, and walk west - // along the row until she blocks the walk and answers. + // r114a). Come west past the sign to the trees east of the grass, step + // north onto her row under the trees, and walk west along the row + // until she blocks the walk and answers. static void meet_poke_mart_lady( VideoStream& stream, ProControllerContext& context, const MapSet& maps, std::vector& trainers, const RouteTrainerFight& fight @@ -693,7 +693,7 @@ namespace PokemonAutomation { if (!walk_along_row_to_talk(stream, context, Pokemon::Direction::WEST, (size_t)(approach.x - corner.x), "Poke Mart lady")) { OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: nobody answered walking west along her row from (56, 3), " + "Poke Mart lady: nobody answered walking west along her row from (53, 3), " "nor on any side of where the walk stopped.", stream); } // Her talk gives 10 Potions ("obtained" lines): A to the end. From bc7a87bd173eec4ff75f0de970f76ec3c155328b Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 30 Oct 2026 01:46:09 -0700 Subject: [PATCH 14/19] BDSP AutoStory: probe each tile of row 3 for the Poke Mart lady She stands on the open ground between the tree trunks above her grass, diagonal to the row, so walking west along row 3 never meets her (r114a, r119a). From the approach tile, face north and west and press A at each tile, stepping one tile west at a time, and fail after the row's west end. Drop walk_along_row_to_talk(), which nothing else uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonBDSP_AutoStory_Segment_00.cpp | 40 ++++++++++++++----- .../Utils/PokemonBDSP_AutoStory_RouteWalk.cpp | 30 -------------- .../Utils/PokemonBDSP_AutoStory_RouteWalk.h | 10 ----- 3 files changed, 31 insertions(+), 49 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index e752b7582c..0642a97720 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -677,11 +677,35 @@ namespace PokemonAutomation { use_potion_first_pokemon(stream, context, 0.5); } - // To the Poke Mart lady and her talk. She stands in the top row of Route - // 201's north-east grass, under the trees, and wanders about it (r113a, - // r114a). Come west past the sign to the trees east of the grass, step - // north onto her row under the trees, and walk west along the row - // until she blocks the walk and answers. + // Looks for the Poke Mart lady from each tile of her row, from `tiles` + // tiles east of its west end to the end. She wanders the open ground + // between the tree trunks just above the grass (r114a, r119a), a row + // the walk along row 3 never meets, so at each tile: face north and + // press A, then west (in case she stands in the row), and if nobody + // answers step one tile west. Facing north, a short tap west only + // turns; facing west, a short hold is one tile (as for Mom at home). + // Returns whether her dialogue is open. + static bool probe_row_for_poke_mart_lady(VideoStream& stream, ProControllerContext& context, size_t tiles) { + const std::vector sides = {Pokemon::Direction::NORTH, Pokemon::Direction::WEST}; + for (size_t step = 0; ; step++) { + route_status(stream, "Poke Mart lady: probing north and west, " + + std::to_string(step) + "/" + std::to_string(tiles) + " tiles west of the approach"); + if (talk_to_adjacent_npc(stream, context, sides, "Poke Mart lady")) { + stream.log("[00.04] Poke Mart lady answered after " + std::to_string(step) + " tile(s) west."); + return true; + } + if (step >= tiles) { + return false; + } + // Facing west now. The grass may start a wild battle: run. + walk_through_grass(stream, context, -1, 0, 1, "west along the Poke Mart lady's row", + BattleType::STANDARD, route_open_hold(Pokemon::Direction::WEST, 1)); + } + } + + // To the Poke Mart lady and her talk. Come west past the sign to the + // trees east of her grass, step north onto row 3 under the trees, and + // probe tile by tile west along it until she answers. static void meet_poke_mart_lady( VideoStream& stream, ProControllerContext& context, const MapSet& maps, std::vector& trainers, const RouteTrainerFight& fight @@ -690,11 +714,9 @@ namespace PokemonAutomation { const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); walk_route(stream, context, maps, story_save_spots(), [&](const TilePos& pos) { return pos == approach; }, trainers, fight, "To the Poke Mart lady"); - if (!walk_along_row_to_talk(stream, context, Pokemon::Direction::WEST, - (size_t)(approach.x - corner.x), "Poke Mart lady")) { + if (!probe_row_for_poke_mart_lady(stream, context, (size_t)(approach.x - corner.x))) { OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: nobody answered walking west along her row from (53, 3), " - "nor on any side of where the walk stopped.", stream); + "Poke Mart lady: no answer from north or west at any of row 3's tiles 53..42.", stream); } // Her talk gives 10 Potions ("obtained" lines): A to the end. finish_dialogue(stream, context, BUTTON_A, "Poke Mart lady"); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp index 3e201759eb..1485ae48e1 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -201,36 +201,6 @@ std::optional talk_to_adjacent_npc( return std::nullopt; } -bool walk_along_row_to_talk( - VideoStream& stream, ProControllerContext& context, - Direction direction, size_t tiles, const std::string& label -){ - double x, y; - stick_for(direction, x, y); - route_status(stream, label + ": walking " + direction_name(direction) + " until someone answers"); - const Milliseconds limit = ROUTE_START_DELAY + ms_per_tile(direction) * tiles; - const WalkResult walk = walk_to_talk(stream, context, x, y, limit, label); - if (walk.stop == WalkStop::DIALOG){ - stream.log("[Route] " + label + ": answered after walking " + direction_name(direction) + - " for " + std::to_string(walk.walked.count()) + "ms."); - return true; - } - if (walk.stop == WalkStop::TRANSITION){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": left the map while walking " + direction_name(direction) + " along the row.", stream); - } - // The walk faces `direction` and A was pressed that way: try the others. - std::vector others; - for (Direction side : {Direction::NORTH, Direction::SOUTH, Direction::EAST, Direction::WEST}){ - if (side != direction){ - others.push_back(side); - } - } - stream.log("[Route] " + label + ": nobody in the row; trying the other sides."); - return talk_to_adjacent_npc(stream, context, others, label).has_value(); -} - - std::vector walk_route( VideoStream& stream, ProControllerContext& context, const MapSet& maps, diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h index 00125b0a86..b58dfbd7ae 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -57,16 +57,6 @@ std::optional talk_to_adjacent_npc( const std::string& label, Milliseconds wait = Milliseconds(1500) ); -// Talks to someone standing somewhere along a row: walks `direction` for -// up to `tiles` tiles, pressing A as it goes (walk_to_talk()), so the walk -// ends facing whoever blocks it. Start from a tile a wall pins to the row. -// If nobody answered by the end, tries the other sides of where the walk -// stopped (talk_to_adjacent_npc()). Returns whether a dialogue is open. -bool walk_along_row_to_talk( - VideoStream& stream, ProControllerContext& context, - Pokemon::Direction direction, size_t tiles, const std::string& label -); - // Whether the location banner's OCR `read` names the map `expected`. Both // are compared as lower-case letters and digits. OCR drops or swaps letters // ("andgemltown" for Sandgem Town on r109), so up to a quarter of the name's From 6fa4f75f68258712ca6bb0c6919d3dd81da6f706 Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 30 Oct 2026 02:23:52 -0700 Subject: [PATCH 15/19] BDSP AutoStory 00.04: buy Potions at Sandgem's Poke Mart instead of meeting the lady The Poke Mart lady on Route 201 wanders between the top grass row and the open ground under the trees above it, and moves while we probe: eight runs (r113a..r120a) never got her talk or the walk back. Drop her and buy the Potions next door to Sandgem's Center instead. New order after the catch: train x3 in the catch grass (from either save), heal at the Center, walk to the Mart's door, buy 5 Potions with buy_items() and check the Bag has them, walk back to Route 202's save spot and save, then the Route 202 trainers and Dawn as before. Potions in the Bag at the start mean step 4's save, so a retry from it goes straight to the trainers. The walk to the save spot first anchors at the trees east of the Mart (SandgemTown::FRONT_ROW_EAST_END): planned from the door's three tiles no leg north is robust and the plan loops round the south of town. The Center's exit walk becomes leave_door_south() so the Mart shares it. Remove the lady's Route 201 anchors, story_potion_giver_row() and her tests; the plan tests now cover the Center-to-Mart, Mart-to-trees and trees-to-save-spot walks, and check those never stop in tall grass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../Data/PokemonBDSP_AutoStory_Route201.cpp | 20 +- .../Data/PokemonBDSP_AutoStory_Route201.h | 9 - .../Data/PokemonBDSP_AutoStory_Route202.cpp | 6 +- .../PokemonBDSP_AutoStory_SandgemTown.cpp | 2 + .../Data/PokemonBDSP_AutoStory_SandgemTown.h | 4 + .../Data/PokemonBDSP_AutoStory_StoryMaps.cpp | 11 - .../Data/PokemonBDSP_AutoStory_StoryMaps.h | 7 +- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 169 ++++++------- .../PokemonBDSP_AutoStory_Segment_00.cpp | 228 ++++++++---------- .../PokemonBDSP_AutoStory_PokemonCenter.cpp | 23 +- .../Utils/PokemonBDSP_AutoStory_RouteWalk.cpp | 25 ++ .../Utils/PokemonBDSP_AutoStory_RouteWalk.h | 6 + 12 files changed, 220 insertions(+), 290 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp index 4596aff0d1..ec3468b4c6 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.cpp @@ -28,7 +28,9 @@ using namespace Pokemon; // Twinleaf Town. East edge, rows 3..6: Sandgem Town. // Route 201 has no trainers. Its NPCs wander, so they are left off: a // planned wall must stay put. The Poke Mart lady, who gives 10 Potions, is -// one of them (see below). +// one of them: she roams the north-east tall grass (42..52, 3) and the open +// ground under the trees above it, and eight runs (r113a..r120a) never got +// her talk, so 00.04 buys its Potions in Sandgem instead. const TileGrid& grid(){ // 0 1 2 3 4 5 6 // 0123456789012345678901234567890123456789012345678901234567890123 @@ -58,22 +60,6 @@ const TileGrid& grid(){ } -// The lady stands in the top row of the north-east tall grass, (42..52, 3), -// right under the trees: on the DP map she is at (44,3), pixel (718,57), and -// on the console she was met at (43,3) and (44,3) from the east (r113a..r116a -// screenshots show her and the player in the tufts under the trunks). She -// wanders about the grass, so she is not on the grid. East of the grass her -// row is open to Sandgem's fence at (64,6). -const TilePos POTION_GIVER_ROW_WEST_END{42, 3}; -// Just east of her grass: coming from Sandgem, walk west past the sign -// along row 5 or 6 to the trees at column 52, then north to the trees -// above row 3. Walk west from here along row 3 to meet her. -const TilePos POTION_GIVER_APPROACH{53, 3}; -// Against Sandgem's fence: walking east along her row stops here, out of -// the grass, where a stop can be read. -const TilePos POTION_GIVER_ROW_EAST_END{63, 3}; - - } } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h index 32fec8952b..607defdb1b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route201.h @@ -19,15 +19,6 @@ namespace Route201{ const Pokemon::TileGrid& grid(); -// The Poke Mart lady's row, (42..63, 3): she wanders its tall-grass part, -// (42..52, 3), and is not on the grid. The row's west end is a corner with -// trees west and north of it. -extern const Pokemon::TilePos POTION_GIVER_ROW_WEST_END; -// The open tile east of the grass to walk west from, to meet her. -extern const Pokemon::TilePos POTION_GIVER_APPROACH; -// The row's east end, against Sandgem Town's fence. -extern const Pokemon::TilePos POTION_GIVER_ROW_EAST_END; - } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp index 91243166f9..b404a62c4b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_Route202.cpp @@ -71,9 +71,9 @@ const TileGrid& grid(){ // right. On the console the sign at (24,20) is 5 columns right and 2 rows up, // and walking east stops at the trees after 11 tiles. const TilePos CATCH_SPOT{19, 22}; -// Where the console's 00.04 save stands, made by the runs that fetched -// Potions from Route 201 (r113a..r117a): under the south ledge, against the -// east trees. +// Where 00.04 saves once it has Potions, before Route 202's trainers (the +// console's saves from r113a..r117a stand here too): under the south ledge, +// against the east trees. const TilePos SAVE_SPOT{30, 19}; // Blue-cap trainer, feet at about (128,186), facing south. diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp index 646d9e9ecc..b60797e667 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp @@ -68,6 +68,8 @@ const TilePos POKEMON_CENTER_DOOR{17, 6}; const TilePos POKEMON_CENTER_SIGN{20, 6}; // Pixel (448,104): the Poke Mart's door. const TilePos POKE_MART_DOOR{27, 6}; +// Walking east along the doors' front row stops here, against the trees. +const TilePos FRONT_ROW_EAST_END{29, 7}; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h index 6e4b21f43e..5d995d74a3 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h @@ -25,6 +25,10 @@ extern const Pokemon::TilePos POKEMON_CENTER_DOOR; // its column stops on the tile below it. extern const Pokemon::TilePos POKEMON_CENTER_SIGN; extern const Pokemon::TilePos POKE_MART_DOOR; +// The east end of the row below the Center's and the Mart's doors, at the +// trees: one known tile to plan the way north to Route 202 from. From the +// Mart's door, a tile either side of its column, no plan north is robust. +extern const Pokemon::TilePos FRONT_ROW_EAST_END; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp index 93f18fc730..f259226ea9 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.cpp @@ -107,17 +107,6 @@ std::vector story_save_spots(){ }; } -std::vector story_potion_giver_row(){ - const MapSet& maps = story_maps(); - const TilePos west = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - const TilePos east = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH); - std::vector row; - for (int x = west.x; x <= east.x; x++){ - row.push_back({x, west.y}); - } - return row; -} - std::vector story_door_front(const char* map, const TilePos& door){ const TilePos below = story_maps().to_world(map, door.next(Direction::SOUTH)); return {below.next(Direction::WEST), below, below.next(Direction::EAST)}; diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h index 089969b188..71779441f1 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h @@ -50,14 +50,9 @@ bool story_at_jubilife_exit(const Pokemon::TilePos& world); // Where the post-catch half of 00.04 may start, in world tiles: the catch // spot (00.03 saves there after the catch) and the save spot under the -// ledge (the console's save from the runs that fetched Potions). +// ledge (00.04 saves there once it has Potions, before Route 202's trainers). std::vector story_save_spots(); -// Where we may stand after talking to the Potion giver, in world tiles: her -// row from its west corner to the approach tile. The walk to her goes west -// along the row from the approach tile, so it never leaves the row. -std::vector story_potion_giver_row(); - // Where a walk out of the door `door` of `map` may leave us, in world tiles: // the tile below it and its neighbours either side, since the door opening // is not lined up with one tile (Jubilife's Center, r85). diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index 6f0189ea9a..73c4bfd0b0 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -116,13 +116,11 @@ class Test_TileGridParse : public UnitTest{ TileType type; }; const Named named[] = { - {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END, TileType::TALL_GRASS}, - {MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH, TileType::WALKABLE}, - {MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END, TileType::WALKABLE}, {MAP_SANDGEM_TOWN, SandgemTown::LAB_DOOR, TileType::DOOR}, {MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR, TileType::DOOR}, {MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN, TileType::SIGN}, {MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR, TileType::DOOR}, + {MAP_SANDGEM_TOWN, SandgemTown::FRONT_ROW_EAST_END, TileType::WALKABLE}, {MAP_ROUTE_202, Route202::CATCH_SPOT, TileType::TALL_GRASS}, {MAP_ROUTE_202, Route202::SAVE_SPOT, TileType::WALKABLE}, {MAP_ROUTE_202, Route202::TRAINER_A, TileType::NPC}, @@ -143,26 +141,18 @@ class Test_TileGridParse : public UnitTest{ return std::string(c.map) + " tile " + Pokemon::to_string(c.pos) + " does not map back."; } } - // The Poke Mart lady's row: all of row 3 from the corner to the fence, - // and the approach tile just east of her grass, under the trees and - // north of the trees that end rows 5 and 6. + // Sandgem's Poke Mart is next door east of its Center, on the same + // front row: 00.04 walks from the one door to the other. { - const TilePos corner = world.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - const TilePos approach = world.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH); - const TilePos row_end = world.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); - if (corner.y != approach.y || approach.y != row_end.y || !(corner.x < approach.x && approach.x < row_end.x)){ - return "The Poke Mart lady's row is not one row from its corner to the fence."; + const TilePos center = world.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR); + const TilePos mart = world.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); + if (center.y != mart.y || mart.x <= center.x){ + return "The Poke Mart's door is not east of the Pokemon Center's on the same row."; } - if (story_potion_giver_row().size() != (size_t)(approach.x - corner.x + 1)){ - return "story_potion_giver_row() is not the row from the corner to the approach tile."; - } - const TilePos below = approach.next(Direction::SOUTH).next(Direction::SOUTH); - if (world.world().at(approach.next(Direction::NORTH)) != TileType::BLOCKED || - world.world().at(approach.next(Direction::WEST)) != TileType::TALL_GRASS || - world.world().at(below.next(Direction::WEST)) != TileType::BLOCKED || - world.world().at(below.next(Direction::SOUTH).next(Direction::WEST)) != TileType::BLOCKED - ){ - return "The approach tile is not under the trees, east of the grass and above the trees ending rows 5 and 6."; + for (int x = center.x; x <= mart.x; x++){ + if (!world.world().standable({x, mart.y + 1})){ + return "The row below the Center's and the Mart's doors is not open at " + world.describe({x, mart.y + 1}) + "."; + } } } @@ -258,26 +248,21 @@ class Test_TileGridWallHit : public UnitTest{ return error; } } - // The Poke Mart lady's row on the joined maps (00.04): west along it - // from the approach tile to the corner, east from the corner to - // Sandgem's fence, then south down the open east edge, and the way - // to the approach tile: west along row 5 or 6 to the trees and north. + // Sandgem Town on the joined maps (00.04): the front row of the + // Center and the Mart runs east to the trees past the Mart, and the + // Mart's door is a warp, not a wall. const Pokemon::MapSet& maps = story_maps(); const TileGrid& world = maps.world(); - auto r201 = [&](const TilePos& pos){ return maps.to_world(MAP_ROUTE_201, pos); }; - const TilePos corner = r201(Route201::POTION_GIVER_ROW_WEST_END); - const TilePos approach = r201(Route201::POTION_GIVER_APPROACH); - const TilePos row_end = r201(Route201::POTION_GIVER_ROW_EAST_END); - const Case lady[] = { - {world, approach, Direction::WEST, corner, 11, true}, - {world, corner, Direction::EAST, row_end, 21, true}, - {world, row_end, Direction::SOUTH, r201({63, 6}), 3, true}, - {world, r201({63, 6}), Direction::WEST, r201({53, 6}), 10, true}, - {world, r201({63, 5}), Direction::WEST, r201({53, 5}), 10, true}, - {world, r201({53, 6}), Direction::NORTH, approach, 3, true}, - {world, r201({53, 5}), Direction::NORTH, approach, 2, true}, + auto town = [&](const TilePos& pos){ return maps.to_world(MAP_SANDGEM_TOWN, pos); }; + const TilePos below_mart = town(SandgemTown::POKE_MART_DOOR.next(Direction::SOUTH)); + const TilePos row_end = town(SandgemTown::FRONT_ROW_EAST_END); + const Case sandgem[] = { + {world, town(SandgemTown::POKEMON_CENTER_DOOR.next(Direction::SOUTH)), Direction::EAST, row_end, 12, true}, + {world, below_mart, Direction::EAST, row_end, 2, true}, + {world, below_mart, Direction::NORTH, below_mart, 0, false}, + {world, row_end, Direction::NORTH, row_end, 0, true}, }; - for (const Case& c : lady){ + for (const Case& c : sandgem){ const std::string error = check(c); if (!error.empty()){ return error; @@ -317,51 +302,21 @@ std::string check_robust_plan( return ""; } -// The Poke Mart lady's talk (00.04): the way to the tile east of her row from -// the save spots, and the way back from her row's east end to the save spot. -// Every leg on Route 201 must end on a wall the walk can read, out of the -// tall grass, so that no leg there that runs out of time is taken as an -// anchor; the row itself is walked by hand, east to the fence. -std::string check_poke_mart_lady_walks(Logger& logger){ - const Pokemon::MapSet& maps = story_maps(); - const TilePos approach = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH); - const TilePos row_end = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); - const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); - const struct{ const char* name; std::vector from; TilePos to; } walks[] = { - {"Save spots to the tile east of the Poke Mart lady's row", story_save_spots(), approach}, - {"Poke Mart lady's row end to the save spot", {row_end}, save_spot}, - }; - for (const auto& walk : walks){ - const TilePos to = walk.to; - const auto legs = Pokemon::plan_robust_route(maps.world(), walk.from, [&](const TilePos& pos){ return pos == to; }); - if (!legs || legs->empty()){ - return std::string(walk.name) + ": no robust plan."; - } - std::string text = std::string(walk.name) + " (robust):"; - bool on_route_201 = false; - for (const Pokemon::PlannedLeg& leg : *legs){ - text += "\n " + Pokemon::to_string(leg.leg) + " to"; - for (const TilePos& pos : leg.after){ - text += " " + maps.describe(pos); - } - const std::optional tile = maps.to_local(leg.after.front()); - // r118a: a plan that went back to Sandgem to come round failed on - // its banner. Once on Route 201, the way to her stays there. - if (to == approach && tile && tile->map == MAP_SANDGEM_TOWN && on_route_201){ - return std::string(walk.name) + ": " + Pokemon::to_string(leg.leg) + " goes back to Sandgem Town."; - } - on_route_201 = on_route_201 || (tile && tile->map == MAP_ROUTE_201); - if (tile && tile->map == MAP_ROUTE_201 && (!leg.leg.ends_at_wall || !route_wall_readable(maps.world(), leg.after))){ - return std::string(walk.name) + ": " + Pokemon::to_string(leg.leg) + " ends on Route 201 where a stop cannot be read."; - } - } - logger.log(text); - if (legs->back().after != std::vector{to}){ - return std::string(walk.name) + ": may not end at " + maps.describe(to) + "."; - } - if (to == approach && legs->back().leg.direction != Direction::NORTH){ - return std::string(walk.name) + ": ends with " + Pokemon::to_string(legs->back().leg) + - ", expected the step north onto her row."; +// Checks that no leg of a robust plan from `from` to `goal` can stop in tall +// grass, where a walk cannot read that it has stopped (the sway keeps the +// screen changing): a leg that only runs out of time there would be taken +// as an anchor. +std::string check_plan_stops_readable( + const std::string& name, const Pokemon::MapSet& maps, + const std::vector& from, const std::function& goal +){ + const auto legs = Pokemon::plan_robust_route(maps.world(), from, goal); + if (!legs){ + return name + ": no robust route found."; + } + for (const Pokemon::PlannedLeg& leg : *legs){ + if (leg.leg.ends_at_wall && !route_wall_readable(maps.world(), leg.after)){ + return name + ": " + Pokemon::to_string(leg.leg) + " ends on a wall in tall grass, where a stop cannot be read."; } } return ""; @@ -481,27 +436,39 @@ class Test_TileGridPlans : public UnitTest{ if (!error.empty()){ return error; } - error = check_poke_mart_lady_walks(logger); - if (!error.empty()){ - return error; - } + // 00.04's walks in order, from where the step before may leave us. const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); const std::vector grass = story_after_training_area(); - error = check_robust_plan(logger, "Save spot to the catch grass", maps, {save_spot}, - [&](const TilePos& pos){ return std::find(grass.begin(), grass.end(), pos) != grass.end(); }); - if (!error.empty()){ - return error; - } const TilePos below_sign = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN.next(Direction::SOUTH)); - error = check_robust_plan(logger, "After training to the Pokemon Center", maps, story_after_training_area(), - [&](const TilePos& pos){ return pos == below_sign; }); - if (!error.empty()){ - return error; - } - error = check_robust_plan(logger, "Pokemon Center to Jubilife", maps, - story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR), story_at_jubilife_exit); - if (!error.empty()){ - return error; + const TilePos below_mart = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR.next(Direction::SOUTH)); + const TilePos row_end = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::FRONT_ROW_EAST_END); + // The walks that start or end in the catch grass must stop in it; + // the Sandgem walks (`readable`) never need to. + const struct{ const char* name; std::vector from; std::function goal; bool readable; } walks[] = { + {"Save spots to the catch grass", story_save_spots(), + [&](const TilePos& pos){ return std::find(grass.begin(), grass.end(), pos) != grass.end(); }, false}, + {"After training to the Pokemon Center", story_after_training_area(), + [&](const TilePos& pos){ return pos == below_sign; }, false}, + // The Mart's door takes a tile either side: enter_door_north() steps across. + {"Pokemon Center to the Poke Mart", story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR), + [&](const TilePos& pos){ return pos.y == below_mart.y && std::abs(pos.x - below_mart.x) <= 1; }, true}, + {"Poke Mart to the trees east of it", story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR), + [&](const TilePos& pos){ return pos == row_end; }, true}, + {"Trees east of the Poke Mart to the save spot", {row_end}, + [&](const TilePos& pos){ return pos == save_spot; }, true}, + {"Save spots to Jubilife", story_save_spots(), story_at_jubilife_exit, false}, + }; + for (const auto& walk : walks){ + error = check_robust_plan(logger, walk.name, maps, walk.from, walk.goal); + if (!error.empty()){ + return error; + } + if (walk.readable){ + error = check_plan_stops_readable(walk.name, maps, walk.from, walk.goal); + if (!error.empty()){ + return error; + } + } } // 00.05 in Jubilife: every leg the segment walks, from where the one diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 0642a97720..141550caf0 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -8,7 +8,6 @@ #include "CommonFramework/GlobalAutoPaths.h" #include "PokemonBDSP_AutoStory_Segment_00.h" #include "../Data/PokemonBDSP_AutoStory_JubilifeCity.h" -#include "../Data/PokemonBDSP_AutoStory_Route201.h" #include "../Data/PokemonBDSP_AutoStory_Route202.h" #include "../Data/PokemonBDSP_AutoStory_SandgemTown.h" #include "../Data/PokemonBDSP_AutoStory_StoryMaps.h" @@ -627,12 +626,16 @@ namespace PokemonAutomation { // What the post-catch half of 00.04 does, logged up front and as each // step starts. A Lv5 Piplup fainted to Route 202's trainers (r72), so: - // Potions (they also saved the Barry fight in r85), training, and a - // heal at the Center between Route 202's two halves. + // training, a heal at the Center, and Potions (they also saved the + // Barry fight in r85) before Route 202's trainers. The Poke Mart lady + // on Route 201 gives 10 for free, but she wanders between her grass + // row and the open ground above it, and eight runs (r113a..r120a) + // never got her talk; the Mart's clerk stays put. static const char* const AFTER_CATCH_STEPS[] = { - "Get 10 Potions from the Poke Mart lady on Route 201, walk back to Route 202's save spot and save (skipped if the Bag has Potions)", "Train: fight 3 wild Pokemon in the catch grass, with the best move", "Walk south into Sandgem Town and heal at its Pokemon Center", + "Buy 5 Potions at the Poke Mart next door, and check the Bag has them", + "Walk back to Route 202's save spot and save", "Walk north up Route 202 to the Jubilife exit, fighting trainers who see us; after each, use a Potion if HP is under half", "Enter Jubilife and get through Dawn's scene", }; @@ -677,120 +680,16 @@ namespace PokemonAutomation { use_potion_first_pokemon(stream, context, 0.5); } - // Looks for the Poke Mart lady from each tile of her row, from `tiles` - // tiles east of its west end to the end. She wanders the open ground - // between the tree trunks just above the grass (r114a, r119a), a row - // the walk along row 3 never meets, so at each tile: face north and - // press A, then west (in case she stands in the row), and if nobody - // answers step one tile west. Facing north, a short tap west only - // turns; facing west, a short hold is one tile (as for Mom at home). - // Returns whether her dialogue is open. - static bool probe_row_for_poke_mart_lady(VideoStream& stream, ProControllerContext& context, size_t tiles) { - const std::vector sides = {Pokemon::Direction::NORTH, Pokemon::Direction::WEST}; - for (size_t step = 0; ; step++) { - route_status(stream, "Poke Mart lady: probing north and west, " + - std::to_string(step) + "/" + std::to_string(tiles) + " tiles west of the approach"); - if (talk_to_adjacent_npc(stream, context, sides, "Poke Mart lady")) { - stream.log("[00.04] Poke Mart lady answered after " + std::to_string(step) + " tile(s) west."); - return true; - } - if (step >= tiles) { - return false; - } - // Facing west now. The grass may start a wild battle: run. - walk_through_grass(stream, context, -1, 0, 1, "west along the Poke Mart lady's row", - BattleType::STANDARD, route_open_hold(Pokemon::Direction::WEST, 1)); - } - } - - // To the Poke Mart lady and her talk. Come west past the sign to the - // trees east of her grass, step north onto row 3 under the trees, and - // probe tile by tile west along it until she answers. - static void meet_poke_mart_lady( - VideoStream& stream, ProControllerContext& context, const MapSet& maps, - std::vector& trainers, const RouteTrainerFight& fight - ) { - const TilePos approach = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_APPROACH); - const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - walk_route(stream, context, maps, story_save_spots(), - [&](const TilePos& pos) { return pos == approach; }, trainers, fight, "To the Poke Mart lady"); - if (!probe_row_for_poke_mart_lady(stream, context, (size_t)(approach.x - corner.x))) { - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: no answer from north or west at any of row 3's tiles 53..42.", stream); - } - // Her talk gives 10 Potions ("obtained" lines): A to the end. - finish_dialogue(stream, context, BUTTON_A, "Poke Mart lady"); - if (!bag_has_item(stream, context, "potion", 1)) { - // She gives the Potions once: on a save that already got - // (and used) them, she only talks. - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: she talked, but no Potion is in the Bag. " - "Has this save already had her Potions?", stream); - } - } - - // East along the Poke Mart lady's row to its end at Sandgem's fence, out - // of the grass, where a stop can be read. The grass hides a stop - // (r116a: she stood in the way east, and every later leg was off), so a - // walk that only runs out of time has not got there: wait for her to - // move, and walk east again. - static void walk_to_poke_mart_lady_row_end(VideoStream& stream, ProControllerContext& context, size_t tiles) { - const Milliseconds limit = ROUTE_START_DELAY + ROUTE_MS_PER_TILE_ACROSS * tiles * 5 / 4 + 1000ms; - for (size_t tries = 0; tries < 5; tries++) { - route_status(stream, "Poke Mart lady: east along her row to its end"); - const WalkResult walk = walk_until(stream, context, 1, 0, limit, "east along the Poke Mart lady's row"); - if (walk.stop == WalkStop::BLOCKED) { - return; - } - if (walk.stop == WalkStop::DIALOG) { - finish_dialogue(stream, context, BUTTON_B, "Poke Mart lady again"); - } - if (walk.stop == WalkStop::TRANSITION) { - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: left the map walking east along her row.", stream); - } - if (walk.wild_battles == 0) { - stream.log("[00.04] Poke Mart lady: the walk east reached no wall it could read; waiting for her to move.", COLOR_ORANGE); - pbf_wait(context, 3000ms); - context.wait_for_all_requests(); - } - } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - "Poke Mart lady: her row's east end was not reached in five walks east.", stream); - } - - // Potions let us heal where we stand. A save made after getting them - // starts at the save spot with Potions in the Bag; the post-catch save - // starts at the catch spot without. From her row's east end the open - // east edge of Route 201 leads south into Sandgem and on to Route 202. - static void get_potions_from_poke_mart_lady( - SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, const MapSet& maps, - std::vector& trainers, const RouteTrainerFight& fight - ) { - if (bag_has_item(stream, context, "potion", 1)) { - stream.log("[00.04] The Bag has Potions; not visiting the Poke Mart lady."); - return; - } - meet_poke_mart_lady(stream, context, maps, trainers, fight); - const TilePos row_end = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_EAST_END); - const TilePos corner = maps.to_world(MAP_ROUTE_201, Route201::POTION_GIVER_ROW_WEST_END); - walk_to_poke_mart_lady_row_end(stream, context, (size_t)(row_end.x - corner.x)); - const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); - walk_route(stream, context, maps, {row_end}, - [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); - stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); - save_game(env, context); - } - // The starter starts around level 5 with only a Normal move; the Route - // 202 trainers need a level or two more. + // 202 trainers need a level or two more. Starts from either save: the + // catch spot (00.03's save, in the grass) or the save spot under the + // ledge. static void train_in_catch_grass( VideoStream& stream, ProControllerContext& context, const MapSet& maps, std::vector& trainers, const RouteTrainerFight& fight ) { - const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); const std::vector grass = story_after_training_area(); - walk_route(stream, context, maps, {save_spot}, + walk_route(stream, context, maps, story_save_spots(), [&](const TilePos& pos) { return std::find(grass.begin(), grass.end(), pos) != grass.end(); }, trainers, fight, "To the catch grass"); for (size_t battle = 0; battle < 3; battle++) { @@ -814,6 +713,83 @@ namespace PokemonAutomation { return story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR); } + // How many Potions to buy. A Potion is 300 and a new game starts with + // 3000; the money on screen cannot be read yet. Five heal the starter + // through Route 202's four trainers and leave half the money. + constexpr int POTIONS_TO_BUY = 5; + + // From below the Center's door to below the Poke Mart's, ten tiles + // east along the same row, and in. The last leg is open, so we may be + // a tile off the door's column: enter_door_north() tries either side. + static void enter_sandgem_poke_mart( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + const TilePos front = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR.next(Pokemon::Direction::SOUTH)); + walk_route(stream, context, maps, story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR), + [&](const TilePos& pos) { return pos.y == front.y && std::abs(pos.x - front.x) <= 1; }, + trainers, fight, "To the Poke Mart"); + enter_door_north(stream, context, "Sandgem Poke Mart: through the door"); + } + + // Inside the Mart, the clerk's counter is straight ahead of the door + // (every Mart is laid out so; Oreburgh's and Eterna's are walked the + // same way). Walk up to it, buy the Potions, and walk back out. + // buy_items() opens the talk and finds the Potion row by OCR of its + // name and price; the quantity is counted out blind. + static void buy_potions_at_the_counter(VideoStream& stream, ProControllerContext& context) { + route_status(stream, "Poke Mart: to the clerk."); + const WalkResult walk = walk_until(stream, context, 0, 1, 6000ms, "Poke Mart: to the clerk"); + if (walk.stop != WalkStop::BLOCKED && walk.stop != WalkStop::DIALOG) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart: the counter was not reached (" + std::string(walk_stop_name(walk.stop)) + ").", stream); + } + route_status(stream, "Poke Mart: buying " + std::to_string(POTIONS_TO_BUY) + " Potions."); + if (!buy_items(stream, context, {{ShopItem::Potion, POTIONS_TO_BUY}})) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart: could not buy " + std::to_string(POTIONS_TO_BUY) + " Potions.", stream); + } + leave_door_south(stream, context, "Poke Mart: out the door"); + } + + // Potions let us heal where we stand. Sandgem's Poke Mart is next + // door east of its Center. The purchase is checked in the Bag after, + // since buy_items() counts the quantity out without reading it. + // Returns where we stand after: below the Mart's door. + static std::vector buy_potions_in_sandgem( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + enter_sandgem_poke_mart(stream, context, maps, trainers, fight); + buy_potions_at_the_counter(stream, context); + if (!bag_has_item(stream, context, "potion", 1)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Poke Mart: the purchase went through, but no Potion is in the Bag.", stream); + } + return story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); + } + + // Back up to Route 202's save spot under the ledge and save there: it + // is where a retry may start (story_save_spots()), its walls pin it, + // and the trainers are north of it. A save in Sandgem would need the + // training walk to plan from town as well. First east to the trees + // that end the doors' row, so the way north starts from one known + // tile: planned from the door's three, it goes round the south of + // town instead. + static void save_at_route_202_save_spot( + SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector at, std::vector& trainers, const RouteTrainerFight& fight + ) { + const TilePos row_end = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::FRONT_ROW_EAST_END); + const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); + at = walk_route(stream, context, maps, std::move(at), + [&](const TilePos& pos) { return pos == row_end; }, trainers, fight, "To the trees east of the Poke Mart"); + walk_route(stream, context, maps, std::move(at), + [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); + stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); + save_game(env, context); + } + static void meet_dawn_in_jubilife(VideoStream& stream, ProControllerContext& context) { // Jubilife's south edge is a wall with stairs in it, west of where the // route may leave us (r78 stood at its east end). Entering starts a @@ -845,7 +821,10 @@ namespace PokemonAutomation { pbf_mash_button(context, BUTTON_B, 5000ms); } - // Resumes from the save made after catching the second Pokemon. + // Resumes from the save made after catching the second Pokemon, or + // from step 4's save at Route 202's save spot. The catch save has no + // Potions; step 4's has them, with the starter trained and healed, so + // Potions in the Bag mean steps 1 to 4 are done. static void tutorial_part_3_after_catch_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { // A retry reloads the save at whatever time the clock has reached. // Pin it again so the landmark references see the same lighting. @@ -857,19 +836,26 @@ namespace PokemonAutomation { const RouteTrainerFight fight = [&](const std::string& name) { fight_route_trainer(stream, context, name); }; pbf_wait(context, 1500ms); - after_catch_step(stream, 1); - get_potions_from_poke_mart_lady(env, stream, context, maps, trainers, fight); + if (bag_has_item(stream, context, "potion", 1)) { + stream.log("[00.04] The Bag has Potions: this is step 4's save; on to Route 202's trainers.", COLOR_BLUE); + } else { + after_catch_step(stream, 1); + train_in_catch_grass(stream, context, maps, trainers, fight); - after_catch_step(stream, 2); - train_in_catch_grass(stream, context, maps, trainers, fight); + after_catch_step(stream, 2); + heal_at_sandgem_center(stream, context, maps, trainers, fight); - after_catch_step(stream, 3); - const std::vector at = heal_at_sandgem_center(stream, context, maps, trainers, fight); + after_catch_step(stream, 3); + const std::vector at = buy_potions_in_sandgem(stream, context, maps, trainers, fight); - after_catch_step(stream, 4); - walk_route(stream, context, maps, at, story_at_jubilife_exit, trainers, fight, "To Jubilife"); + after_catch_step(stream, 4); + save_at_route_202_save_spot(env, stream, context, maps, at, trainers, fight); + } after_catch_step(stream, 5); + walk_route(stream, context, maps, story_save_spots(), story_at_jubilife_exit, trainers, fight, "To Jubilife"); + + after_catch_step(stream, 6); meet_dawn_in_jubilife(stream, context); } // --------------------------------------------------------------------------- diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp index d238089cc7..de80ebd61e 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp @@ -159,28 +159,7 @@ void heal_at_pokemon_center( context.wait_for_all_requests(); route_status(stream, label + ": healed; walking out."); - // Her last line may still be up, and it would stop the walk. - pbf_mash_button(context, BUTTON_B, 2000ms); - context.wait_for_all_requests(); - VideoSnapshot counter = stream.video().snapshot(); - bool faded_out = false; - bool scene_changed = false; - for (size_t tries = 0; tries < 3 && !faded_out && !scene_changed; tries++){ - const WalkResult walk = walk_until(stream, context, 0, -1, 6000ms, label + ": out the door"); - faded_out = walk.stop == WalkStop::TRANSITION; - if (walk.stop == WalkStop::DIALOG){ - pbf_mash_button(context, BUTTON_B, 2000ms); - } - context.wait_for_all_requests(); - VideoSnapshot now = stream.video().snapshot(); - scene_changed = counter && now && walk_screen_change(*counter.frame, *now.frame) > WALK_SCENE_CHANGE; - } - if (!faded_out && !scene_changed){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": did not get back out.", stream); - } - pbf_wait(context, 1500ms); - context.wait_for_all_requests(); + leave_door_south(stream, context, label + ": out the door"); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp index 1485ae48e1..e6aed7f768 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -583,6 +583,31 @@ void enter_door_north(VideoStream& stream, ProControllerContext& context, const label + ": could not go through the door.", stream); } +void leave_door_south(VideoStream& stream, ProControllerContext& context, const std::string& label){ + // A last line of talk may still be up, and it would stop the walk. + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + VideoSnapshot inside = stream.video().snapshot(); + bool faded_out = false; + bool scene_changed = false; + for (size_t tries = 0; tries < 3 && !faded_out && !scene_changed; tries++){ + const WalkResult walk = walk_until(stream, context, 0, -1, 6000ms, label); + faded_out = walk.stop == WalkStop::TRANSITION; + if (walk.stop == WalkStop::DIALOG){ + pbf_mash_button(context, BUTTON_B, 2000ms); + } + context.wait_for_all_requests(); + VideoSnapshot now = stream.video().snapshot(); + scene_changed = inside && now && walk_screen_change(*inside.frame, *now.frame) > WALK_SCENE_CHANGE; + } + if (!faded_out && !scene_changed){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": did not get back out.", stream); + } + pbf_wait(context, 1500ms); + context.wait_for_all_requests(); +} + } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h index b58dfbd7ae..d0974e9861 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -128,6 +128,12 @@ void finish_dialogue( // then west, and tries again. Fails with a screenshot if no step goes in. void enter_door_north(VideoStream& stream, ProControllerContext& context, const std::string& label); +// For a player inside a building whose door is straight south: closes any +// talk still up and walks south out of it. The door's fade is not always +// black enough to read as a transition, so a whole new scene also counts. +// Fails with a screenshot if three walks south get nowhere. +void leave_door_south(VideoStream& stream, ProControllerContext& context, const std::string& label); + } From ab38e89de6e912f3148fbb2835c2d7cc32793014 Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 30 Oct 2026 02:37:01 -0700 Subject: [PATCH 16/19] BDSP AutoStory: enter Sandgem's Poke Mart by its lit doorway r121a reached the Mart but enter_door_north()'s whole-tile side steps never lined up with the door: movement is not locked to tiles. Pull the Pokemon Center's sight-based door entry out as enter_lit_door() and use it for the Mart too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 2 +- .../PokemonBDSP_AutoStory_Segment_00.cpp | 8 +- .../PokemonBDSP_AutoStory_PokemonCenter.cpp | 77 +++++++++++++------ .../PokemonBDSP_AutoStory_PokemonCenter.h | 10 +++ 4 files changed, 68 insertions(+), 29 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index 73c4bfd0b0..2ef9262990 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -449,7 +449,7 @@ class Test_TileGridPlans : public UnitTest{ [&](const TilePos& pos){ return std::find(grass.begin(), grass.end(), pos) != grass.end(); }, false}, {"After training to the Pokemon Center", story_after_training_area(), [&](const TilePos& pos){ return pos == below_sign; }, false}, - // The Mart's door takes a tile either side: enter_door_north() steps across. + // The Mart's door takes a tile either side: enter_lit_door() lines up by sight. {"Pokemon Center to the Poke Mart", story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR), [&](const TilePos& pos){ return pos.y == below_mart.y && std::abs(pos.x - below_mart.x) <= 1; }, true}, {"Poke Mart to the trees east of it", story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR), diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 141550caf0..702b6d7c2c 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -719,8 +719,10 @@ namespace PokemonAutomation { constexpr int POTIONS_TO_BUY = 5; // From below the Center's door to below the Poke Mart's, ten tiles - // east along the same row, and in. The last leg is open, so we may be - // a tile off the door's column: enter_door_north() tries either side. + // east along the same row, and in. The last leg is open and movement + // is not locked to tiles, so we may stand part of a tile off the door: + // whole-tile steps either side missed it in r121a. The Mart's doorway + // is lit like the Center's, so it is lined up with by sight the same way. static void enter_sandgem_poke_mart( VideoStream& stream, ProControllerContext& context, const MapSet& maps, std::vector& trainers, const RouteTrainerFight& fight @@ -729,7 +731,7 @@ namespace PokemonAutomation { walk_route(stream, context, maps, story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR), [&](const TilePos& pos) { return pos.y == front.y && std::abs(pos.x - front.x) <= 1; }, trainers, fight, "To the Poke Mart"); - enter_door_north(stream, context, "Sandgem Poke Mart: through the door"); + enter_lit_door(stream, context, "Sandgem Poke Mart: through the door"); } // Inside the Mart, the clerk's counter is straight ahead of the door diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp index de80ebd61e..d8b35ec3f5 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp @@ -75,10 +75,46 @@ std::optional lit_doorway_x(const ImageViewRGB32& screen){ return best; } +// Whether the whole view is a different scene from "before". +bool scene_changed_since(VideoStream& stream, ProControllerContext& context, const VideoSnapshot& before){ + context.wait_for_all_requests(); + VideoSnapshot now = stream.video().snapshot(); + return before && now && walk_screen_change(*before.frame, *now.frame) > WALK_SCENE_CHANGE; } +// Moves across by "tiles", east if positive, west if negative. +void step_across(VideoStream& stream, ProControllerContext& context, double tiles){ + const Direction side = tiles > 0 ? Direction::EAST : Direction::WEST; + route_turn(stream, context, side); + const Milliseconds hold = std::max(Milliseconds(40), + Milliseconds((int64_t)(std::abs(tiles) * ROUTE_MS_PER_TILE_ACROSS.count()))); + pbf_move_left_joystick(context, {tiles > 0 ? 1.0 : -1.0, 0}, hold, 300ms); +} -void heal_at_pokemon_center( +// Walks north into the door straight ahead. Returns whether we are inside. +bool walk_into_door( + VideoStream& stream, ProControllerContext& context, + const VideoSnapshot& outside, const std::string& label +){ + if (walk_until(stream, context, 0, 1, 1500ms, label + ": into the door").stop == WalkStop::TRANSITION){ + pbf_wait(context, 2000ms); + return true; + } + // The door slides open before it takes us in, and its fade is not always + // black enough to read as a transition, and may finish after the walk; a + // whole new scene says we are in. + pbf_wait(context, 2000ms); + if (!scene_changed_since(stream, context, outside)){ + return false; + } + stream.log("[Route] " + label + ": the scene changed; inside."); + return true; +} + +} + + +void enter_lit_door( VideoStream& stream, ProControllerContext& context, const std::string& label ){ @@ -89,11 +125,6 @@ void heal_at_pokemon_center( route_status(stream, label + ": lining up with the door."); bool inside = false; VideoSnapshot last_outside; - auto scene_changed_since = [&](const VideoSnapshot& before){ - context.wait_for_all_requests(); - VideoSnapshot now = stream.video().snapshot(); - return before && now && walk_screen_change(*before.frame, *now.frame) > WALK_SCENE_CHANGE; - }; for (size_t tries = 0; tries < 12 && !inside; tries++){ context.wait_for_all_requests(); VideoSnapshot outside = stream.video().snapshot(); @@ -104,39 +135,35 @@ void heal_at_pokemon_center( if (!doorway){ // The door may have taken us in after all, more slowly than we watched. pbf_wait(context, 1500ms); - if (scene_changed_since(last_outside)){ - stream.log("[Route] " + label + ": the scene changed; inside."); - inside = true; - break; + if (!scene_changed_since(stream, context, last_outside)){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": no lit doorway on screen.", stream); } - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": no lit doorway on screen.", stream); + stream.log("[Route] " + label + ": the scene changed; inside."); + inside = true; + break; } last_outside = outside; const double tiles = (*doorway - 0.5) * SCREEN_WIDTH_TILES; stream.log("[Route] " + label + ": doorway " + std::to_string(tiles) + " tiles across."); if (std::abs(tiles) > 0.25){ - const Direction side = tiles > 0 ? Direction::EAST : Direction::WEST; - route_turn(stream, context, side); - const Milliseconds hold = std::max(Milliseconds(40), - Milliseconds((int64_t)(std::abs(tiles) * ROUTE_MS_PER_TILE_ACROSS.count()))); - pbf_move_left_joystick(context, {tiles > 0 ? 1.0 : -1.0, 0}, hold, 300ms); + step_across(stream, context, tiles); continue; } - inside = walk_until(stream, context, 0, 1, 1500ms, label + ": into the door").stop == WalkStop::TRANSITION; - // The door's fade is not always black enough to read as a transition, - // and may finish after the walk; a whole new scene says we are in. - pbf_wait(context, 2000ms); - if (!inside && scene_changed_since(outside)){ - stream.log("[Route] " + label + ": the scene changed; inside."); - inside = true; - } + inside = walk_into_door(stream, context, outside, label); } if (!inside){ OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, label + ": door not found.", stream); } pbf_wait(context, 1500ms); +} + +void heal_at_pokemon_center( + VideoStream& stream, ProControllerContext& context, + const std::string& label +){ + enter_lit_door(stream, context, label); // The nurse's counter is straight ahead of the door. route_status(stream, label + ": talking to the nurse."); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h index bbd593e63b..6935e49ca0 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h @@ -16,6 +16,16 @@ namespace NintendoSwitch{ namespace PokemonBDSP{ +// Goes in through a building's door (a Pokemon Center's or a Poke Mart's). +// Starts in front of it with its lit doorway on screen, within a few tiles: +// lines up with the doorway by sight and walks in. Whole-tile steps sideways +// miss it, since movement is not locked to tiles, and its fade may not read +// as black: a new scene also counts as inside. +void enter_lit_door( + VideoStream& stream, ProControllerContext& context, + const std::string& label +); + // Heals the party at a Pokemon Center. Starts in front of the Center with its // lit doorway on screen, within a few tiles of it: lines up with the doorway // by sight, walks in, talks to the nurse, accepts, and walks back out. Ends From c6a9f74839abd3e4f8afd4c715f7dd9be6116d74 Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 30 Oct 2026 03:04:54 -0700 Subject: [PATCH 17/19] BDSP AutoStory: find a lit doorway anywhere up the screen, and walk again when short of it r122a's walk to Sandgem's Poke Mart counted itself to the door's row but stood three rows south of it (someone wandering the dirt stops an open leg short), and enter_lit_door() then failed with "no lit doorway on screen": it only looked at the band just above the player (y 324..628 of 1080, tuned for the Center's door straight ahead), and its colours were the Center's yellower door (255, 255, 185), not the Mart's (235, 243, 205). Look for the doorway over the upper two thirds of the screen, as the pale yellow-white columns at least 32 px tall (the roof bands are 12 to 20) and the biggest such run near the middle, and give its position as tiles across and rows up from the player, whom the camera keeps at the centre. enter_lit_door() steps across by the tiles and walks north for the rows, looking again after every move, and says so after 12 moves. The Mart step reads the doorway after its walk: shown further than straight ahead, the walk ended short, and it is made once more from the tiles the offset puts us on (give or take one) before lining up. Tests: the two failure screenshots (in CommandLineTests under PokemonBDSP/AutoStory) give the door 0.4 tiles east and straight ahead (r121a) and 0.8 tiles west, three to four rows up (r122a); every block of tiles south of the Mart has a robust plan to its front row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 100 ++++++++ .../PokemonBDSP_AutoStory_Segment_00.cpp | 18 +- .../PokemonBDSP_AutoStory_PokemonCenter.cpp | 223 +++++++++++++----- .../PokemonBDSP_AutoStory_PokemonCenter.h | 38 ++- 4 files changed, 316 insertions(+), 63 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index 2ef9262990..93677b694f 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -6,7 +6,10 @@ #include #include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Filesystem/Filesystem.h" #include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" #include "Pokemon/Navigation/Pokemon_TileGrid.h" #include "Data/PokemonBDSP_AutoStory_JubilifeCity.h" #include "Data/PokemonBDSP_AutoStory_OreburghMine.h" @@ -17,6 +20,7 @@ #include "Data/PokemonBDSP_AutoStory_SandgemTown.h" #include "Data/PokemonBDSP_AutoStory_StoryMaps.h" #include "Utils/PokemonBDSP_AutoStory_NewGame.h" +#include "Utils/PokemonBDSP_AutoStory_PokemonCenter.h" #include "Utils/PokemonBDSP_AutoStory_RouteWalk.h" #include "PokemonBDSP_AutoStory_Tests.h" @@ -668,12 +672,108 @@ class Test_LocationBanner : public UnitTest{ }; +// The walk to the Poke Mart may be stopped short by someone in the way, and +// is then made again from the tiles the doorway on screen puts us on, give +// or take one each way: from every such block of tiles south of the Mart, +// a robust plan to the row below its door must exist. +class Test_PokeMartShortWalks : public UnitTest{ +public: + Test_PokeMartShortWalks() + : UnitTest("PokemonBDSP::AutoStory - walks short of the Poke Mart") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + const Pokemon::MapSet& maps = story_maps(); + const TileGrid& grid = maps.world(); + const TilePos front = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR.next(Direction::SOUTH)); + const auto at_front = [&](const TilePos& pos){ return pos.y == front.y && std::abs(pos.x - front.x) <= 1; }; + // The open ground south of the Mart, to the trees east of town and + // the fence rows below. + for (int y = front.y + 1; y <= front.y + 7; y++){ + for (int x = front.x - 6; x <= front.x + 2; x++){ + std::vector block; + for (int dy = -1; dy <= 1; dy++){ + for (int dx = -1; dx <= 1; dx++){ + if (grid.standable({x + dx, y + dy})){ + block.push_back({x + dx, y + dy}); + } + } + } + if (block.empty()){ + continue; + } + const std::string error = check_robust_plan(logger, "Short of the Poke Mart about " + maps.describe({x, y}), + maps, block, at_front); + if (!error.empty()){ + return error; + } + } + } + return true; + } +}; + + +// The Mart's doorway on the failure screenshots of r121a and r122a, kept in +// the unit test resources (the CommandLineTests checkout): where it is +// relative to the player, who is in the middle of the screen. +class Test_LitDoorwayScreenshots : public UnitTest{ +public: + Test_LitDoorwayScreenshots() + : UnitTest("PokemonBDSP::AutoStory - lit doorway screenshots") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + struct Case{ + const char* file; + double tiles_across_min; + double tiles_across_max; + double rows_up_min; + double rows_up_max; + }; + const Case cases[] = { + // r121a: below-left of the door, which is part of a tile east, straight ahead. + {"PokemonBDSP/AutoStory/SandgemMartDoorUpRight-20261030-023443.png", 0.2, 0.7, 0.5, 1.5}, + // r122a: a tile west of the door and three or four rows south of its front. + {"PokemonBDSP/AutoStory/SandgemMartDoorFarLeft-20261030-024648.png", -1.3, -0.5, 2.5, 4.5}, + }; + for (const Case& c : cases){ + const std::string path = UNIT_TEST_RESOURCE_PATH() + c.file; + if (!Filesystem::is_regular_file(path)){ + return UnitTestResult(UnitTestResult::SKIPPED, "Screenshot not found: " + path); + } + const ImageRGB32 screen(path); + const std::optional doorway = find_lit_doorway(screen); + if (!doorway){ + return std::string(c.file) + ": no lit doorway found."; + } + logger.log(std::string(c.file) + ": doorway at " + std::to_string(doorway->x) + ", " + + std::to_string(doorway->y) + ": " + std::to_string(doorway->tiles_across) + " tiles across, " + + std::to_string(doorway->rows_up) + " rows up."); + if (doorway->tiles_across < c.tiles_across_min || doorway->tiles_across > c.tiles_across_max){ + return std::string(c.file) + ": doorway " + std::to_string(doorway->tiles_across) + + " tiles across, expected " + std::to_string(c.tiles_across_min) + " to " + + std::to_string(c.tiles_across_max) + "."; + } + if (doorway->rows_up < c.rows_up_min || doorway->rows_up > c.rows_up_max){ + return std::string(c.file) + ": doorway " + std::to_string(doorway->rows_up) + + " rows up, expected " + std::to_string(c.rows_up_min) + " to " + + std::to_string(c.rows_up_max) + "."; + } + } + return true; + } +}; + + void add_tests_AutoStory(UnitTestDatabase& database){ database.add(); database.add(); database.add(); database.add(); + database.add(); database.add(); + database.add(); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 702b6d7c2c..6918c59ab6 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -722,15 +722,25 @@ namespace PokemonAutomation { // east along the same row, and in. The last leg is open and movement // is not locked to tiles, so we may stand part of a tile off the door: // whole-tile steps either side missed it in r121a. The Mart's doorway - // is lit like the Center's, so it is lined up with by sight the same way. + // is lit like the Center's, so it is lined up with by sight the same + // way. Two townspeople wander the dirt south of the Mart and stop a + // leg short without the walk knowing (r122a counted to the door's + // row from three rows south of it): where the doorway shows on screen + // says so, and the walk is made once more from there. static void enter_sandgem_poke_mart( VideoStream& stream, ProControllerContext& context, const MapSet& maps, std::vector& trainers, const RouteTrainerFight& fight ) { - const TilePos front = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR.next(Pokemon::Direction::SOUTH)); + const TilePos door = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); + const TilePos front = door.next(Pokemon::Direction::SOUTH); + const auto at_front = [&](const TilePos& pos) { return pos.y == front.y && std::abs(pos.x - front.x) <= 1; }; walk_route(stream, context, maps, story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_DOOR), - [&](const TilePos& pos) { return pos.y == front.y && std::abs(pos.x - front.x) <= 1; }, - trainers, fight, "To the Poke Mart"); + at_front, trainers, fight, "To the Poke Mart"); + const std::vector short_of_door = tiles_short_of_door(stream, context, maps.world(), door, "To the Poke Mart"); + if (!short_of_door.empty()) { + route_status(stream, "To the Poke Mart: stopped short of the door; walking again."); + walk_route(stream, context, maps, short_of_door, at_front, trainers, fight, "To the Poke Mart again"); + } enter_lit_door(stream, context, "Sandgem Poke Mart: through the door"); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp index d8b35ec3f5..f8a49d210a 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp @@ -29,48 +29,108 @@ namespace{ // How many tiles the 1080p view spans across: a tile is about 125 px. constexpr double SCREEN_WIDTH_TILES = 1920.0 / 125; +// The camera keeps the player in the middle of the screen across, and the +// centre of his tile a little above the middle down (r121a and r122a: his +// feet at y = 590 of 1080). +constexpr double PLAYER_X = 0.5; +constexpr double PLAYER_Y = 0.505; +// How much of the screen's height one row of tiles takes near the player: +// the doorway straight ahead of him (r121a) is centred this far above his +// tile's centre. Rows further north look shorter, so this undercounts them. +constexpr double SCREEN_ROW_HEIGHT = 0.12; +// Doorways are looked for down to here, 0 to 1: a door the player can walk +// into is north of him. +constexpr double DOORWAY_SEARCH_BOTTOM = 0.67; +// A doorway found within this many rows of straight ahead is at the front +// row; further, the walk to the door ended short. +constexpr double DOORWAY_FRONT_ROWS = 1.5; -// Where a building's lit doorway is across the screen, 0 to 1: the warm -// white columns in the band where doors stand, taking the run of them -// nearest the middle (a second building's door may show further off). -// Nothing if no doorway shows. -std::optional lit_doorway_x(const ImageViewRGB32& screen){ - const ImageRGB32 small = screen.scale_to(480, 270); - const size_t width = small.width(); - std::vector counts(width, 0); - for (size_t y = 81; y < 157; y++){ - for (size_t x = 0; x < width; x++){ - const uint32_t pixel = small.pixel(x, y); - const int r = (pixel >> 16) & 0xff, g = (pixel >> 8) & 0xff, b = pixel & 0xff; - if (r > 235 && g > 215 && b > 120 && b < 215 && r - b > 40){ - counts[x]++; +// The doorway is looked for on a view this size: 4 px of 1080p per pixel. +constexpr size_t SMALL_WIDTH = 480; +constexpr size_t SMALL_HEIGHT = 270; +// A doorway's lit columns run at least this many small rows (the Mart's 12, +// the Center's 15); the buildings' lit roof bands 3 to 5. +constexpr size_t DOORWAY_COLUMN_ROWS = 8; +// A doorway has this many lit small pixels (the Mart's 260 to 350); a roof +// band's run 60 to 75. +constexpr double DOORWAY_MIN_MASS = 80; + +// Whether a pixel is the pale yellow-white of a lit doorway: the Mart's is +// (235, 243, 205), the Center's (255, 255, 185). The buildings' white roof +// bands are bluer, their orange bands and the light they throw on the +// ground outside are darker. +bool lit_doorway_pixel(uint32_t pixel){ + const int r = (pixel >> 16) & 0xff; + const int g = (pixel >> 8) & 0xff; + const int b = pixel & 0xff; + return r >= 215 && g >= 215 && b >= 150 && b <= g - 15 && r >= g - 25; +} + +// Each column of the small view: how many lit doorway pixels it has in the +// search band, and the sum of their rows. +struct LitColumns{ + std::vector count; + std::vector row_sum; +}; +LitColumns lit_columns(const ImageViewRGB32& screen){ + const ImageRGB32 small = screen.scale_to(SMALL_WIDTH, SMALL_HEIGHT); + LitColumns columns{std::vector(small.width(), 0), std::vector(small.width(), 0)}; + const size_t bottom = (size_t)(DOORWAY_SEARCH_BOTTOM * (double)small.height()); + for (size_t y = 0; y < bottom; y++){ + for (size_t x = 0; x < small.width(); x++){ + if (lit_doorway_pixel(small.pixel(x, y))){ + columns.count[x]++; + columns.row_sum[x] += (double)y; } } } - std::optional best; - double best_distance = 0; - for (size_t x = 0; x < width; ){ - if (counts[x] < 4){ - x++; + return columns; +} + +// A run of lit columns: its lit pixels, and the sums of their columns and rows. +struct LitRun{ + double mass = 0; + double column_sum = 0; + double row_sum = 0; +}; +// The run of columns with at least DOORWAY_COLUMN_ROWS lit that starts at +// `x`, allowing gaps of up to 3 columns (the door's frame). Moves `x` past it. +LitRun lit_run(const LitColumns& columns, size_t& x){ + LitRun run; + size_t gap = 0; + for (; x < columns.count.size() && gap <= 3; x++){ + if (columns.count[x] < DOORWAY_COLUMN_ROWS){ + gap++; continue; } - double mass = 0, moment = 0; - size_t gap = 0; - for (; x < width && gap <= 3; x++){ - if (counts[x] >= 4){ - mass += (double)counts[x]; - moment += (double)counts[x] * (double)x; - gap = 0; - }else{ - gap++; - } + run.mass += (double)columns.count[x]; + run.column_sum += (double)columns.count[x] * (double)x; + run.row_sum += columns.row_sum[x]; + gap = 0; + } + return run; +} + +// Where a building's lit doorway is across and down the screen, 0 to 1: the +// biggest run of lit columns within 0.35 of the middle (a second building's +// door may show further off). Nothing if no doorway shows. +std::optional> lit_doorway_position(const ImageViewRGB32& screen){ + const LitColumns columns = lit_columns(screen); + const double width = (double)columns.count.size(); + std::optional> best; + double best_mass = 0; + for (size_t x = 0; x < columns.count.size(); ){ + if (columns.count[x] < DOORWAY_COLUMN_ROWS){ + x++; + continue; } - const double center = moment / mass / (double)width; - const double distance = std::abs(center - 0.5); - if (distance < 0.35 && (!best || distance < best_distance)){ - best = center; - best_distance = distance; + const LitRun run = lit_run(columns, x); + const double center = run.column_sum / run.mass / width; + if (run.mass < DOORWAY_MIN_MASS || std::abs(center - PLAYER_X) >= 0.35 || run.mass <= best_mass){ + continue; } + best = {center, run.row_sum / run.mass / (double)SMALL_HEIGHT}; + best_mass = run.mass; } return best; } @@ -91,29 +151,84 @@ void step_across(VideoStream& stream, ProControllerContext& context, double tile pbf_move_left_joystick(context, {tiles > 0 ? 1.0 : -1.0, 0}, hold, 300ms); } -// Walks north into the door straight ahead. Returns whether we are inside. +// Walks north into the door straight ahead, `doorway` away. Returns whether +// we are inside. bool walk_into_door( VideoStream& stream, ProControllerContext& context, - const VideoSnapshot& outside, const std::string& label + const VideoSnapshot& outside, const LitDoorway& doorway, const std::string& label ){ - if (walk_until(stream, context, 0, 1, 1500ms, label + ": into the door").stop == WalkStop::TRANSITION){ + // Time for the rows to the door (none when it is straight ahead), the + // door opening, and a start delay. + const double rows = std::max(0.0, doorway.rows_up - 1); + const Milliseconds limit = 1500ms + Milliseconds((int64_t)(rows * ROUTE_MS_PER_TILE_UP_DOWN.count())); + if (walk_until(stream, context, 0, 1, limit, label + ": into the door").stop == WalkStop::TRANSITION){ pbf_wait(context, 2000ms); return true; } // The door slides open before it takes us in, and its fade is not always // black enough to read as a transition, and may finish after the walk; a - // whole new scene says we are in. + // whole new scene says we are in. Not after a walk of several rows, which + // scrolls the view: the next look decides. pbf_wait(context, 2000ms); - if (!scene_changed_since(stream, context, outside)){ + if (doorway.rows_up > DOORWAY_FRONT_ROWS || !scene_changed_since(stream, context, outside)){ return false; } stream.log("[Route] " + label + ": the scene changed; inside."); return true; } +// Where the doorway is on screen, in the log. +void log_doorway(VideoStream& stream, const std::string& label, const LitDoorway& doorway){ + stream.log("[Route] " + label + ": doorway " + std::to_string(doorway.tiles_across) + + " tiles across, " + std::to_string(doorway.rows_up) + " rows up (at " + + std::to_string(doorway.x) + ", " + std::to_string(doorway.y) + " of the screen)."); +} + } +std::optional find_lit_doorway(const ImageViewRGB32& screen){ + const std::optional> position = lit_doorway_position(screen); + if (!position){ + return std::nullopt; + } + return LitDoorway{ + position->first, + position->second, + (position->first - PLAYER_X) * SCREEN_WIDTH_TILES, + (PLAYER_Y - position->second) / SCREEN_ROW_HEIGHT, + }; +} + +std::vector tiles_short_of_door( + VideoStream& stream, ProControllerContext& context, + const TileGrid& grid, const TilePos& door, const std::string& label +){ + context.wait_for_all_requests(); + VideoSnapshot screen = stream.video().snapshot(); + const std::optional doorway = screen ? find_lit_doorway(*screen.frame) : std::nullopt; + if (!doorway){ + stream.log("[Route] " + label + ": no lit doorway on screen; not walking again."); + return {}; + } + log_doorway(stream, label, *doorway); + if (doorway->rows_up <= DOORWAY_FRONT_ROWS){ + return {}; + } + // The doorway's offset says which tile we stand on, give or take one + // each way: the rows are a rough count. + const TilePos guess{door.x - (int)std::lround(doorway->tiles_across), door.y + (int)std::lround(doorway->rows_up)}; + std::vector tiles; + for (int y = guess.y - 1; y <= guess.y + 1; y++){ + for (int x = guess.x - 1; x <= guess.x + 1; x++){ + if (grid.standable(TilePos{x, y})){ + tiles.push_back(TilePos{x, y}); + } + } + } + return tiles; +} + void enter_lit_door( VideoStream& stream, ProControllerContext& context, const std::string& label @@ -121,17 +236,16 @@ void enter_lit_door( // Movement is not locked to tiles, and the door takes us in only when we // are lined up with it. Its doorway is lit: find it on screen, where the // camera keeps the player in the middle, move across by its offset, and - // walk in once it is straight ahead. + // walk in once it is straight ahead, looking again after every move. route_status(stream, label + ": lining up with the door."); - bool inside = false; VideoSnapshot last_outside; - for (size_t tries = 0; tries < 12 && !inside; tries++){ + for (size_t tries = 0; tries < 12; tries++){ context.wait_for_all_requests(); VideoSnapshot outside = stream.video().snapshot(); if (!outside){ continue; } - const std::optional doorway = lit_doorway_x(*outside.frame); + const std::optional doorway = find_lit_doorway(*outside.frame); if (!doorway){ // The door may have taken us in after all, more slowly than we watched. pbf_wait(context, 1500ms); @@ -140,23 +254,22 @@ void enter_lit_door( label + ": no lit doorway on screen.", stream); } stream.log("[Route] " + label + ": the scene changed; inside."); - inside = true; - break; + pbf_wait(context, 1500ms); + return; } last_outside = outside; - const double tiles = (*doorway - 0.5) * SCREEN_WIDTH_TILES; - stream.log("[Route] " + label + ": doorway " + std::to_string(tiles) + " tiles across."); - if (std::abs(tiles) > 0.25){ - step_across(stream, context, tiles); + log_doorway(stream, label, *doorway); + if (std::abs(doorway->tiles_across) > 0.25){ + step_across(stream, context, doorway->tiles_across); continue; } - inside = walk_into_door(stream, context, outside, label); - } - if (!inside){ - OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, - label + ": door not found.", stream); + if (walk_into_door(stream, context, outside, *doorway, label)){ + pbf_wait(context, 1500ms); + return; + } } - pbf_wait(context, 1500ms); + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": not through the door after 12 tries.", stream); } void heal_at_pokemon_center( diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h index 6935e49ca0..e1fef11dc9 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h @@ -7,20 +7,50 @@ #ifndef PokemonAutomation_PokemonBDSP_AutoStory_PokemonCenter_H #define PokemonAutomation_PokemonBDSP_AutoStory_PokemonCenter_H +#include #include +#include +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" #include "CommonFramework/Tools/VideoStream.h" #include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "Pokemon/Navigation/Pokemon_TileGrid.h" namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonBDSP{ +// A building's lit doorway seen on the overworld, relative to the player, +// whom the camera keeps in the middle of the screen. +struct LitDoorway{ + double x; // centre across the screen, 0 to 1 + double y; // centre down the screen, 0 to 1 + double tiles_across; // east of the player if positive, west if negative + double rows_up; // rows north of the player's tile, roughly: 1 straight ahead +}; + +// Finds a lit doorway (a Pokemon Center's or a Poke Mart's) in the upper +// two thirds of `screen`, the pale yellow-white of its open door. Nothing if +// none shows there. +std::optional find_lit_doorway(const ImageViewRGB32& screen); + +// For a player who walked to the row below the door on world tile `door`: +// if its lit doorway shows further up the screen than straight ahead, the +// walk ended short (someone in the way stops an open leg, and the walk +// cannot tell), and the tiles he may stand on instead, read off the +// doorway's offset, are returned to walk from again. Empty when he is at +// the front row, or no doorway shows. +std::vector tiles_short_of_door( + VideoStream& stream, ProControllerContext& context, + const Pokemon::TileGrid& grid, const Pokemon::TilePos& door, const std::string& label +); + // Goes in through a building's door (a Pokemon Center's or a Poke Mart's). -// Starts in front of it with its lit doorway on screen, within a few tiles: -// lines up with the doorway by sight and walks in. Whole-tile steps sideways -// miss it, since movement is not locked to tiles, and its fade may not read -// as black: a new scene also counts as inside. +// Starts with its lit doorway on screen, within a few tiles: lines up with +// the doorway by sight and walks in, looking again after every move. Whole- +// tile steps sideways miss it, since movement is not locked to tiles, and +// its fade may not read as black: a new scene also counts as inside. Fails +// with a screenshot when no doorway shows, or after 12 moves. void enter_lit_door( VideoStream& stream, ProControllerContext& context, const std::string& label From 4f6a00b057f5aca7c00277d7bfb099b3ebb65f32 Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 30 Oct 2026 03:31:37 -0700 Subject: [PATCH 18/19] BDSP AutoStory: Sandgem's south half from the game's collision; north to Route 202 through the gap between the signs r123a walked from the trees east of the Poke Mart into a house. The Sandgem grid, read from Bulbapedia's map image, squeezed rows 7..18 into 7..15: the houses' doors sat on row 15 instead of 18, and the east house's wall sat on rows 10..14, where the game has open ground. The plan went south, then west "to" that wall, ran on past it, and walked north into the west house's door (7, 18). Rows 7 on now come from Platinum's land data for Sandgem (pret/pokeplatinum map_data_007.bin collision bits) with signs and doors from its event file. Step 4 now walks six tiles west from the trees and north through the gap between the Center's and the Mart's signs, nudging a tile west if a sign or the Mart stops it, instead of the planner's detour round the south of town where townspeople wander. Tests: the 00.04 walks are checked for legs that could walk into a door, even with open legs running long, and north from every column the west leg may reach. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../PokemonBDSP_AutoStory_SandgemTown.cpp | 50 ++++++++++----- .../Data/PokemonBDSP_AutoStory_SandgemTown.h | 7 +++ .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 63 ++++++++++++++++++- .../PokemonBDSP_AutoStory_Segment_00.cpp | 46 +++++++++++++- 4 files changed, 147 insertions(+), 19 deletions(-) diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp index b60797e667..6c5a135a49 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.cpp @@ -21,8 +21,19 @@ using namespace Pokemon; // Buildings are blocked; each door is the tile the player walks into, on // the building's front row. Top left: Professor Rowan's lab, with a fence // running west along row 6 to the map edge. Top middle: Pokemon Center. -// Top right: Poke Mart. Exits: west, rows 7..9 (Route 201); north, -// (20..29, 0) (Route 202); south, (20..25, 22) (the beach). +// Top right: Poke Mart. Exits: west, rows 7..11 (Route 201); north, +// (20..29, 0) (Route 202); south, (20..25, 27) (the beach). +// +// Rows 7 and on are the game's own collision, not the map image: Platinum's +// land data for Sandgem (pret/pokeplatinum res/field/maps/data/ +// map_data_007.bin, its permissions' collision bit; grid row = the map's +// row - 4, column = the map's column), with the signs and doors from +// res/field/events/events_sandgem_town.json. The image squeezed rows 7..18 +// into 7..15: it put both houses' doors on row 15, where the game has them +// on row 18, and the east house's wall at rows 10..14 where the game has +// open ground. r123a planned a west leg to that wall on row ~11, ran on +// past it and entered the west house's door going north. The townspeople +// south of the Mart (a youngster and two breeders) wander: not marked. // // Checked on the console: walking south from Route 202 (30,22) stops at // (29,2) above the Poke Mart, and walking west from there stops at (20,2). @@ -42,19 +53,24 @@ const TileGrid& grid(){ R"(.....S........................####)", // 7 R"(..............................####)", // 8 R"(..............................####)", // 9 - R"(######.........#####..........####)", // 10 - R"(######.........#####..........####)", // 11 - R"(##########.....#####..........####)", // 12 - R"(##########.....#####..........####)", // 13 - R"(##########.....#####..........####)", // 14 - R"(#######D##....S#D###..S.......####)", // 15 - R"(######........................####)", // 16 - R"(######........................####)", // 17 - R"(######....................########)", // 18 - R"(####################......########)", // 19 - R"(####################......########)", // 20 - R"(####################......########)", // 21 - R"(####################......########)", // 22 + R"(..............................####)", // 10 + R"(..............................####)", // 11 + R"(######........................####)", // 12 + R"(######........................####)", // 13 + R"(######.........#####..........####)", // 14 + R"(##########.....#####..........####)", // 15 + R"(##########.....#####..........####)", // 16 + R"(##########.....#####..........####)", // 17 + R"(#######D##....S#D###..S.......####)", // 18 + R"(######........................####)", // 19 + R"(######........................####)", // 20 + R"(######........................####)", // 21 + R"(######....................########)", // 22 + R"(####################......########)", // 23 + R"(####################......########)", // 24 + R"(####################......########)", // 25 + R"(####################......########)", // 26 + R"(####################......########)", // 27 }); return grid; } @@ -71,6 +87,10 @@ const TilePos POKE_MART_DOOR{27, 6}; // Walking east along the doors' front row stops here, against the trees. const TilePos FRONT_ROW_EAST_END{29, 7}; +std::vector below_sign_gap(){ + return {{21, 7}, {22, 7}, {23, 7}, {24, 7}}; +} + } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h index 5d995d74a3..7d34f0b783 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_SandgemTown.h @@ -9,6 +9,7 @@ #ifndef PokemonAutomation_PokemonBDSP_AutoStory_SandgemTown_H #define PokemonAutomation_PokemonBDSP_AutoStory_SandgemTown_H +#include #include "Pokemon/Navigation/Pokemon_TileGrid.h" namespace PokemonAutomation{ @@ -29,6 +30,12 @@ extern const Pokemon::TilePos POKE_MART_DOOR; // trees: one known tile to plan the way north to Route 202 from. From the // Mart's door, a tile either side of its column, no plan north is robust. extern const Pokemon::TilePos FRONT_ROW_EAST_END; +// The doors' front row below the gap between the Center's sign and the +// Mart's, columns 21..24: the only way north from that row that passes no +// door. Walking north from any of them leaves town for Route 202. Walking +// north from anywhere else on the row between columns 18 and 26 stops at a +// sign or a wall; the Center's door is at 17, the Mart's at 27. +std::vector below_sign_gap(); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index 93677b694f..7e0ed23ea6 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -105,7 +105,7 @@ class Test_TileGridParse : public UnitTest{ }; const Map maps[] = { {MAP_ROUTE_201, Route201::grid(), 64, 20}, - {MAP_SANDGEM_TOWN, SandgemTown::grid(), 34, 23}, + {MAP_SANDGEM_TOWN, SandgemTown::grid(), 34, 28}, {MAP_ROUTE_202, Route202::grid(), 36, 26}, }; for (const Map& map : maps){ @@ -326,6 +326,41 @@ std::string check_plan_stops_readable( return ""; } +// Checks that no leg of a robust plan from `from` to `goal` can walk into a +// door, from any tile the leg before may have left us on, even if an open +// leg runs on to twice its length plus two: r123a's legs in Sandgem ran +// past where the grid said and the last one entered a house. +std::string check_plan_avoids_doors( + const std::string& name, const Pokemon::MapSet& maps, + const std::vector& from, const std::function& goal +){ + const TileGrid& grid = maps.world(); + const auto legs = Pokemon::plan_robust_route(grid, from, goal); + if (!legs){ + return name + ": no robust route found."; + } + std::vector starts = from; + for (const Pokemon::PlannedLeg& planned : *legs){ + const TileLeg& leg = planned.leg; + const size_t reach = leg.ends_at_wall ? (size_t)grid.width() + grid.height() : leg.tiles * 2 + 2; + for (TilePos pos : starts){ + for (size_t i = 0; i < reach; i++){ + const TilePos next = pos.next(leg.direction); + if (grid.at(next) == TileType::DOOR){ + return name + ": " + Pokemon::to_string(leg) + " may walk into the door at " + maps.describe(next) + "."; + } + const std::optional step = grid.step(pos, leg.direction); + if (!step){ + break; + } + pos = *step; + } + } + starts = planned.after; + } + return ""; +} + // Checks a plan from `from` to `to`: every leg can be walked, ledges are // crossed only southward, and each leg's wall flag matches the grid. // With `maps`, legs are printed with the map tile they end on, and with @@ -446,6 +481,26 @@ class Test_TileGridPlans : public UnitTest{ const TilePos below_sign = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKEMON_CENTER_SIGN.next(Direction::SOUTH)); const TilePos below_mart = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR.next(Direction::SOUTH)); const TilePos row_end = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::FRONT_ROW_EAST_END); + // From the trees east of the Mart, 00.04 walks six tiles west, three + // off at most, and north (north_through_sign_gap()): from every + // column it may reach, north passes no door, and only the gap + // between the signs leads out of town. + std::vector above_sign_gap; + for (int x = row_end.x - 11; x <= row_end.x - 3; x++){ + const TilePos pos{x, row_end.y}; + const Pokemon::WallHit hit = Pokemon::walk_to_wall(maps.world(), pos, Direction::NORTH); + if (maps.world().at(hit.wall) == TileType::DOOR){ + return "North from " + maps.describe(pos) + " walks into a door."; + } + const std::vector gap = SandgemTown::below_sign_gap(); + const bool in_gap = std::find(gap.begin(), gap.end(), maps.to_local(pos)->pos) != gap.end(); + if (in_gap != (hit.tiles > 2)){ + return "North from " + maps.describe(pos) + " goes " + std::to_string(hit.tiles) + " tiles."; + } + if (in_gap){ + above_sign_gap.push_back(hit.stop); + } + } // The walks that start or end in the catch grass must stop in it; // the Sandgem walks (`readable`) never need to. const struct{ const char* name; std::vector from; std::function goal; bool readable; } walks[] = { @@ -458,7 +513,7 @@ class Test_TileGridPlans : public UnitTest{ [&](const TilePos& pos){ return pos.y == below_mart.y && std::abs(pos.x - below_mart.x) <= 1; }, true}, {"Poke Mart to the trees east of it", story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR), [&](const TilePos& pos){ return pos == row_end; }, true}, - {"Trees east of the Poke Mart to the save spot", {row_end}, + {"North of the gap between the signs to the save spot", above_sign_gap, [&](const TilePos& pos){ return pos == save_spot; }, true}, {"Save spots to Jubilife", story_save_spots(), story_at_jubilife_exit, false}, }; @@ -467,6 +522,10 @@ class Test_TileGridPlans : public UnitTest{ if (!error.empty()){ return error; } + error = check_plan_avoids_doors(walk.name, maps, walk.from, walk.goal); + if (!error.empty()){ + return error; + } if (walk.readable){ error = check_plan_stops_readable(walk.name, maps, walk.from, walk.goal); if (!error.empty()){ diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 6918c59ab6..15b603b5c5 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -781,13 +781,54 @@ namespace PokemonAutomation { return story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); } + // From the trees at the east end of the doors' row, west into the gap + // between the Center's sign and the Mart's (below_sign_gap()) and + // north through it until a wall stops us. Six tiles west from column + // 29 ends on 23, and on 20 to 26 if two tiles off: north from all of + // those passes no door, and only the gap lets us through. A walk + // north that goes nowhere stood below a sign or the Mart: a tile + // west, twice at most, never reaches the Center's door at 17. The + // planner would rather go round the south of town on walls, where + // townspeople wander. Returns where the walk north may have ended. + static std::vector north_through_sign_gap(VideoStream& stream, ProControllerContext& context, const MapSet& maps){ + using Pokemon::Direction; + route_status(stream, "To the gap between the Center's and the Mart's signs: west 6."); + route_turn(stream, context, Direction::WEST); + walk_for(stream, context, -1, 0, route_open_hold(Direction::WEST, 6), "To the gap between the signs"); + for (size_t nudges = 0;; nudges++){ + const WalkResult north = walk_until(stream, context, 0, 1, 8000ms, "North through the gap"); + if (north.stop == WalkStop::TRANSITION || north.stop == WalkStop::DIALOG || north.wild_battles > 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "North through the gap: " + std::string(walk_stop_name(north.stop)) + " instead of a wall.", stream); + } + // Blocked at once: the wall reads after about 800ms still. + if (north.walked > ROUTE_START_DELAY + 800ms + 2 * ROUTE_MS_PER_TILE_UP_DOWN){ + break; + } + if (nudges == 2){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "North through the gap: still blocked after two tiles west.", stream); + } + route_status(stream, "North through the gap: blocked at once, below a sign or the Mart; a tile west."); + route_turn(stream, context, Direction::WEST); + walk_for(stream, context, -1, 0, route_open_hold(Direction::WEST, 1), "A tile west"); + route_turn(stream, context, Direction::NORTH); + } + std::vector ends; + for (const TilePos& pos : SandgemTown::below_sign_gap()){ + ends.push_back(Pokemon::walk_to_wall(maps.world(), maps.to_world(MAP_SANDGEM_TOWN, pos), Direction::NORTH).stop); + } + return ends; + } + // Back up to Route 202's save spot under the ledge and save there: it // is where a retry may start (story_save_spots()), its walls pin it, // and the trainers are north of it. A save in Sandgem would need the // training walk to plan from town as well. First east to the trees // that end the doors' row, so the way north starts from one known - // tile: planned from the door's three, it goes round the south of - // town instead. + // tile, then north through the gap between the signs: r123a, planned + // round the south of town on a map that had it wrong, walked into a + // house. static void save_at_route_202_save_spot( SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, const MapSet& maps, std::vector at, std::vector& trainers, const RouteTrainerFight& fight @@ -796,6 +837,7 @@ namespace PokemonAutomation { const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); at = walk_route(stream, context, maps, std::move(at), [&](const TilePos& pos) { return pos == row_end; }, trainers, fight, "To the trees east of the Poke Mart"); + at = north_through_sign_gap(stream, context, maps); walk_route(stream, context, maps, std::move(at), [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); From cf61bf1a4035b63f8f23918f89c0249569965143 Mon Sep 17 00:00:00 2001 From: dayfine Date: Fri, 30 Oct 2026 04:18:15 -0700 Subject: [PATCH 19/19] BDSP AutoStory: read where the save screen says we are, and stop a door walk at its fade r124a's 00.04 saved beside Sandgem's Poke Mart with a log that said "arrived at Route 202 (30, 19)". leave_door_south() held the stick until a wall: the Mart's fade did not read as black, so the walk went on about ten rows south of the door, and every plan after started from the door's front row. Both "to wall" legs then ended on walls, as walls always do, and the save had no check of its own. The Center's exit overshot the same way (its doorway correction hid it). - leave_door_south() stops as soon as a look differs from the last by more than walking can (the fade reads 121 to 192, a Center's scroll 102 at most), then waits for the fade-in. - save_game() takes an expected location: the save card's name (OCR on its salmon text, made black on white) must match it, or the save is backed out of and the step fails, "About to save in sandgemtown, expected Route 202". Step 4 uses it. The location reading lives in a Location module, with the banner reading and matching moved from RouteWalk. - walk_route() requires the banner of the map it ends on when the walk changes map, over the whole walk, not just legs whose ends sit on one map. north_through_sign_gap() watches for Route 202's banner and keeps only the ends on the map it saw. - With Potions in the Bag, 00.04 reads the save screen's location before planning: Route 202 goes on as before; Sandgem Town walks east to the trees and north to the Mart's row, places itself by Route 202's banner or the Mart's lit doorway, and walks back to the save spot without saving. Tests: the r124a save prompt (CommandLineTests PokemonBDSP/AutoStory) reads "sandgemtown"; the other AutoStory tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../AutoStory/PokemonBDSP_AutoStoryTools.cpp | 67 +++++-- .../AutoStory/PokemonBDSP_AutoStoryTools.h | 12 +- .../AutoStory/PokemonBDSP_AutoStory_Tests.cpp | 29 +++ .../PokemonBDSP_AutoStory_Segment_00.cpp | 189 +++++++++++++++--- .../Utils/PokemonBDSP_AutoStory_Location.cpp | 105 ++++++++++ .../Utils/PokemonBDSP_AutoStory_Location.h | 45 +++++ .../PokemonBDSP_AutoStory_PokemonCenter.cpp | 8 +- .../PokemonBDSP_AutoStory_PokemonCenter.h | 7 + .../Utils/PokemonBDSP_AutoStory_RouteWalk.cpp | 118 ++++------- .../Utils/PokemonBDSP_AutoStory_RouteWalk.h | 12 +- SerialPrograms/cmake/SourceFiles.cmake | 2 + 11 files changed, 460 insertions(+), 134 deletions(-) create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp create mode 100644 SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp index c0257e3e8f..29f1a82c89 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.cpp @@ -46,6 +46,7 @@ #include "PokemonBDSP/Programs/PokemonBDSP_GameNavigation.h" #include "Detect/PokemonBDSP_AutoStory_OverworldDetector.h" +#include "Utils/PokemonBDSP_AutoStory_Location.h" #include "PokemonBDSP_AutoStoryTools.h" @@ -132,13 +133,53 @@ void add_wrapped_log(VideoOverlay& overlay, const std::string& text, Color color } } +// Opens the save prompt from the overworld and keeps a screenshot of it: it +// shows the play time and badges, a record of where each checkpoint save +// stands. Returns the map its card names. +static std::string open_save_prompt(VideoStream& stream, ProControllerContext& context){ + overworld_to_menu(stream, context); + pbf_press_button(context, BUTTON_R, 80ms, 2000ms); + context.wait_for_all_requests(); + VideoSnapshot screen = stream.video().snapshot(); + if (!screen){ + return ""; + } + const std::string folder = SCREENSHOTS_PATH() + "AutoStory/saves/"; + Filesystem::create_directories(Filesystem::Path(folder)); + const std::string path = folder + now_to_filestring() + ".png"; + screen.frame->save(path); + stream.log("Save prompt screenshot: " + path); + const std::string location = read_save_screen_location(*screen.frame); + stream.log("Save screen location: \"" + location + "\"."); + return location; +} + +// Backs out of the save prompt and its menu to the overworld. +static void leave_save_prompt(VideoStream& stream, ProControllerContext& context){ + pbf_mash_button(context, BUTTON_B, 2000ms); + context.wait_for_all_requests(); + OverworldWatcher overworld; + if (wait_until(stream, context, std::chrono::seconds(15), {overworld}) < 0){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Failed to detect the overworld after leaving the save prompt.", stream); + } +} + +std::string read_location_from_save_screen(VideoStream& stream, ProControllerContext& context){ + const std::string location = open_save_prompt(stream, context); + leave_save_prompt(stream, context); + return location; +} + bool save_game( SingleSwitchProgramEnvironment& env, - ProControllerContext& context + ProControllerContext& context, + const std::string& expected_location ) { static constexpr size_t MAX_RETRIES = 3; - for (size_t attempt = 0; attempt < MAX_RETRIES; attempt++){ + std::string wrong_place; + for (size_t attempt = 0; attempt < MAX_RETRIES && wrong_place.empty(); attempt++){ try { env.console.log("Checkpoint save attempt: " + std::to_string(attempt + 1)); @@ -149,17 +190,13 @@ bool save_game( } // The feedback-based save of save_game(VideoStream&, ...), split at the - // save prompt to keep a screenshot of it: it shows the play time and - // badges, a record of where each checkpoint save stands. - overworld_to_menu(env.console, context); - pbf_press_button(context, BUTTON_R, 80ms, 2000ms); - context.wait_for_all_requests(); - if (VideoSnapshot screen = env.console.video().snapshot()){ - const std::string folder = SCREENSHOTS_PATH() + "AutoStory/saves/"; - Filesystem::create_directories(Filesystem::Path(folder)); - const std::string path = folder + now_to_filestring() + ".png"; - screen.frame->save(path); - env.console.log("Save prompt screenshot: " + path); + // save prompt to check where it stands. + const std::string location = open_save_prompt(env.console, context); + if (!expected_location.empty() && !location_banner_matches(location, expected_location)){ + wrong_place = "About to save in \"" + location + "\", expected " + expected_location + "; not saving."; + env.console.log(wrong_place, COLOR_RED); + leave_save_prompt(env.console, context); + break; } pbf_press_button(context, BUTTON_ZL, 80ms, 5000ms); @@ -187,6 +224,10 @@ bool save_game( context.wait_for(1000ms); } } + // No blind fallback for the wrong place: it would save there. + if (!wrong_place.empty()){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, wrong_place, env.console); + } // Blind fallback: reuse the non-feedback save_game(context) from GameNavigation // rather than duplicating the button sequence manually. diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h index 8ef5f6f908..7a5e6e1c20 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h @@ -181,11 +181,21 @@ class AutoStory_Checkpoint { // enough for the log box, which draws each line on one row and cuts it off. void add_wrapped_log(VideoOverlay& overlay, const std::string& text, Color color); +// With `expected_location`, the map named on the save screen's card must be +// it (compared as a location banner is), or the save is backed out of and +// the function fails with a screenshot: r124a saved in Sandgem Town after a +// walk that was sure it stood on Route 202. bool save_game( SingleSwitchProgramEnvironment& env, - ProControllerContext& context + ProControllerContext& context, + const std::string& expected_location = "" ); +// Opens the save screen from the overworld, reads where the game says we +// are (read_save_screen_location() in the Location module), and backs out +// without saving. +std::string read_location_from_save_screen(VideoStream& stream, ProControllerContext& context); + // Fake save game to be replaced with save_game when program runs smoothly void checkpoint_save(SingleSwitchProgramEnvironment& env, ProControllerContext& context); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp index 7e0ed23ea6..55cb21e74b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp @@ -19,6 +19,7 @@ #include "Data/PokemonBDSP_AutoStory_Route202.h" #include "Data/PokemonBDSP_AutoStory_SandgemTown.h" #include "Data/PokemonBDSP_AutoStory_StoryMaps.h" +#include "Utils/PokemonBDSP_AutoStory_Location.h" #include "Utils/PokemonBDSP_AutoStory_NewGame.h" #include "Utils/PokemonBDSP_AutoStory_PokemonCenter.h" #include "Utils/PokemonBDSP_AutoStory_RouteWalk.h" @@ -825,6 +826,33 @@ class Test_LitDoorwayScreenshots : public UnitTest{ }; +// The save prompt r124a took beside Sandgem's Poke Mart, sure it stood on +// Route 202 (in CommandLineTests under PokemonBDSP/AutoStory): its card +// names Sandgem Town, which the save must read before confirming. +class Test_SaveScreenLocation : public UnitTest{ +public: + Test_SaveScreenLocation() + : UnitTest("PokemonBDSP::AutoStory - save screen location") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + const std::string path = UNIT_TEST_RESOURCE_PATH() + "PokemonBDSP/AutoStory/SaveScreenSandgemTown-20261030-034444.png"; + if (!Filesystem::is_regular_file(path)){ + return UnitTestResult(UnitTestResult::SKIPPED, "Screenshot not found: " + path); + } + const std::string read = read_save_screen_location(ImageRGB32(path)); + logger.log("Save screen location read: \"" + read + "\"."); + if (!location_banner_matches(read, MAP_SANDGEM_TOWN)){ + return "Save screen location \"" + read + "\" should match Sandgem Town."; + } + if (location_banner_matches(read, MAP_ROUTE_202)){ + return "Save screen location \"" + read + "\" should not match Route 202."; + } + return true; + } +}; + + void add_tests_AutoStory(UnitTestDatabase& database){ database.add(); database.add(); @@ -833,6 +861,7 @@ void add_tests_AutoStory(UnitTestDatabase& database){ database.add(); database.add(); database.add(); + database.add(); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp index 15b603b5c5..5f379a14a9 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/StorySegments/PokemonBDSP_AutoStory_Segment_00.cpp @@ -11,6 +11,7 @@ #include "../Data/PokemonBDSP_AutoStory_Route202.h" #include "../Data/PokemonBDSP_AutoStory_SandgemTown.h" #include "../Data/PokemonBDSP_AutoStory_StoryMaps.h" +#include "../Utils/PokemonBDSP_AutoStory_Location.h" #include "../Utils/PokemonBDSP_AutoStory_PokemonCenter.h" #include "../Utils/PokemonBDSP_AutoStory_RouteWalk.h" #include "../Utils/PokemonBDSP_AutoStory_Segment_00_tools.h" @@ -781,22 +782,39 @@ namespace PokemonAutomation { return story_door_front(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); } - // From the trees at the east end of the doors' row, west into the gap - // between the Center's sign and the Mart's (below_sign_gap()) and - // north through it until a wall stops us. Six tiles west from column - // 29 ends on 23, and on 20 to 26 if two tiles off: north from all of - // those passes no door, and only the gap lets us through. A walk - // north that goes nowhere stood below a sign or the Mart: a tile - // west, twice at most, never reaches the Center's door at 17. The - // planner would rather go round the south of town on walls, where - // townspeople wander. Returns where the walk north may have ended. - static std::vector north_through_sign_gap(VideoStream& stream, ProControllerContext& context, const MapSet& maps){ + // A walk_until() callback that keeps every location banner it sees. + static std::function banner_keeper(std::vector& banners) { + return [&banners](const ImageViewRGB32& screen) { + const std::string read = read_location_banner(screen); + if (!read.empty() && (banners.empty() || banners.back() != read)) { + banners.push_back(read); + } + return false; + }; + } + + static bool any_banner_names(const std::vector& banners, const char* map) { + return std::any_of(banners.begin(), banners.end(), + [&](const std::string& read) { return location_banner_matches(read, map); }); + } + + // Whether all of `tiles` are on `map`. + static bool all_on_map(const MapSet& maps, const std::vector& tiles, const char* map) { + return !tiles.empty() && std::all_of(tiles.begin(), tiles.end(), [&](const TilePos& pos) { + const std::optional tile = maps.to_local(pos); + return tile && tile->map == map; + }); + } + + // North from below the gap between the signs until a wall stops us, + // nudging a tile west, twice at most, when blocked at once (below a + // sign or the Mart). Returns whether Route 202's banner showed: only + // one of the gap's columns leads out of town before a wall. + static bool walk_north_through_gap(VideoStream& stream, ProControllerContext& context) { using Pokemon::Direction; - route_status(stream, "To the gap between the Center's and the Mart's signs: west 6."); - route_turn(stream, context, Direction::WEST); - walk_for(stream, context, -1, 0, route_open_hold(Direction::WEST, 6), "To the gap between the signs"); + std::vector banners; for (size_t nudges = 0;; nudges++){ - const WalkResult north = walk_until(stream, context, 0, 1, 8000ms, "North through the gap"); + const WalkResult north = walk_until(stream, context, 0, 1, 8000ms, "North through the gap", banner_keeper(banners)); if (north.stop == WalkStop::TRANSITION || north.stop == WalkStop::DIALOG || north.wild_battles > 0){ OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, "North through the gap: " + std::string(walk_stop_name(north.stop)) + " instead of a wall.", stream); @@ -814,34 +832,138 @@ namespace PokemonAutomation { walk_for(stream, context, -1, 0, route_open_hold(Direction::WEST, 1), "A tile west"); route_turn(stream, context, Direction::NORTH); } + context.wait_for_all_requests(); + if (VideoSnapshot screen = stream.video().snapshot()){ + banner_keeper(banners)(*screen.frame); + } + return any_banner_names(banners, MAP_ROUTE_202); + } + + // From the trees at the east end of the doors' row, west into the gap + // between the Center's sign and the Mart's (below_sign_gap()) and + // north through it until a wall stops us. Six tiles west from column + // 29 ends on 23, and on 20 to 26 if two tiles off: north from all of + // those passes no door, and only the gap lets us through. A walk + // north that goes nowhere stood below a sign or the Mart: a tile + // west, twice at most, never reaches the Center's door at 17. The + // planner would rather go round the south of town on walls, where + // townspeople wander. Returns where the walk north may have ended: + // the gap's columns end on Sandgem's top row, bar one that runs on to + // Route 202, and its banner tells the two apart. + static std::vector north_through_sign_gap(VideoStream& stream, ProControllerContext& context, const MapSet& maps){ + using Pokemon::Direction; + route_status(stream, "To the gap between the Center's and the Mart's signs: west 6."); + route_turn(stream, context, Direction::WEST); + walk_for(stream, context, -1, 0, route_open_hold(Direction::WEST, 6), "To the gap between the signs"); + const bool on_route = walk_north_through_gap(stream, context); std::vector ends; for (const TilePos& pos : SandgemTown::below_sign_gap()){ - ends.push_back(Pokemon::walk_to_wall(maps.world(), maps.to_world(MAP_SANDGEM_TOWN, pos), Direction::NORTH).stop); + const TilePos end = Pokemon::walk_to_wall(maps.world(), maps.to_world(MAP_SANDGEM_TOWN, pos), Direction::NORTH).stop; + if (all_on_map(maps, {end}, MAP_ROUTE_202) == on_route){ + ends.push_back(end); + } } + route_status(stream, std::string("North through the gap: Route 202's banner ") + + (on_route ? "showed; on Route 202." : "did not show; on Sandgem Town's top row.")); return ends; } - // Back up to Route 202's save spot under the ledge and save there: it - // is where a retry may start (story_save_spots()), its walls pin it, - // and the trainers are north of it. A save in Sandgem would need the - // training walk to plan from town as well. First east to the trees - // that end the doors' row, so the way north starts from one known - // tile, then north through the gap between the signs: r123a, planned - // round the south of town on a map that had it wrong, walked into a - // house. - static void save_at_route_202_save_spot( - SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, const MapSet& maps, + // Back up to Route 202's save spot under the ledge: it is where a + // retry may start (story_save_spots()), its walls pin it, and the + // trainers are north of it. First east to the trees that end the + // doors' row, so the way north starts from one known tile, then north + // through the gap between the signs: r123a, planned round the south + // of town on a map that had it wrong, walked into a house. + static void walk_to_route_202_save_spot( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, std::vector at, std::vector& trainers, const RouteTrainerFight& fight ) { const TilePos row_end = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::FRONT_ROW_EAST_END); const TilePos save_spot = maps.to_world(MAP_ROUTE_202, Route202::SAVE_SPOT); - at = walk_route(stream, context, maps, std::move(at), - [&](const TilePos& pos) { return pos == row_end; }, trainers, fight, "To the trees east of the Poke Mart"); - at = north_through_sign_gap(stream, context, maps); + if (!all_on_map(maps, at, MAP_ROUTE_202)) { + at = walk_route(stream, context, maps, std::move(at), + [&](const TilePos& pos) { return pos == row_end; }, trainers, fight, "To the trees east of the Poke Mart"); + at = north_through_sign_gap(stream, context, maps); + } walk_route(stream, context, maps, std::move(at), [&](const TilePos& pos) { return pos == save_spot; }, trainers, fight, "Back to Route 202"); + } + + // Step 4: the walk above, then the save, which the save screen's own + // location must confirm (r124a saved beside the Mart, sure it stood + // on Route 202). A save in Sandgem would need the training walk to + // plan from town as well. + static void save_at_route_202_save_spot( + SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector at, std::vector& trainers, const RouteTrainerFight& fight + ) { + walk_to_route_202_save_spot(stream, context, maps, std::move(at), trainers, fight); stream.log("[00.04] Saving with the Potions, at the save spot on Route 202."); - save_game(env, context); + save_game(env, context, MAP_ROUTE_202); + } + + // From anywhere on Sandgem Town's open ground, to tiles the map can + // confirm: east until the trees or a wall stop us, then north until a + // wall. The columns under the north exit run on to Route 202, whose + // banner then says so; the others end on the Mart's row, where its + // lit doorway says which tile (r124a's save stands beside its door). + static std::vector anchor_in_sandgem(VideoStream& stream, ProControllerContext& context, const MapSet& maps) { + using Pokemon::Direction; + route_status(stream, "Sandgem Town: east to the trees, then north to the Mart's row."); + std::vector banners; + const WalkResult east = walk_until(stream, context, 1, 0, 6000ms, "Sandgem: east to the trees"); + const WalkResult north = walk_until(stream, context, 0, 1, 9000ms, "Sandgem: north to the Mart's row", banner_keeper(banners)); + for (const WalkResult& walk : {east, north}) { + if (walk.stop == WalkStop::TRANSITION || walk.stop == WalkStop::DIALOG) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Sandgem Town: " + std::string(walk_stop_name(walk.stop)) + " instead of a wall.", stream); + } + } + context.wait_for_all_requests(); + VideoSnapshot screen = stream.video().snapshot(); + if (!screen) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, "Sandgem Town: no video frame.", stream); + } + banner_keeper(banners)(*screen.frame); + if (any_banner_names(banners, MAP_ROUTE_202)) { + std::vector ends; + for (int x : {25, 29}) { + ends.push_back(Pokemon::walk_to_wall(maps.world(), maps.to_world(MAP_SANDGEM_TOWN, {x, 0}), Direction::NORTH).stop); + } + route_status(stream, "Sandgem Town: Route 202's banner showed on the way north; on Route 202."); + return ends; + } + const TilePos door = maps.to_world(MAP_SANDGEM_TOWN, SandgemTown::POKE_MART_DOOR); + const std::optional doorway = find_lit_doorway(*screen.frame); + const std::vector at = doorway ? tiles_by_lit_doorway(*doorway, maps.world(), door) : std::vector{}; + if (at.empty()) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "Sandgem Town: no Route 202 banner and no lit doorway in sight; cannot place the player.", stream); + } + route_status(stream, "Sandgem Town: the Mart's doorway is " + std::to_string(doorway->tiles_across) + + " tiles across and " + std::to_string(doorway->rows_up) + " rows up."); + return at; + } + + // With Potions in the Bag, steps 1 to 4 are done, but where the save + // stands is the save screen's to say: step 4's at Route 202's save + // spot, or r124a's in Sandgem Town. From town, walk back to the save + // spot first, without saving again. + static void resume_with_potions( + VideoStream& stream, ProControllerContext& context, const MapSet& maps, + std::vector& trainers, const RouteTrainerFight& fight + ) { + const std::string where = read_location_from_save_screen(stream, context); + if (location_banner_matches(where, MAP_ROUTE_202)) { + stream.log("[00.04] The Bag has Potions and the save is on Route 202: step 4's save; on to Route 202's trainers.", COLOR_BLUE); + return; + } + if (!location_banner_matches(where, MAP_SANDGEM_TOWN)) { + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + "The Bag has Potions, but the save screen reads \"" + where + "\": neither Route 202 nor Sandgem Town.", stream); + } + stream.log("[00.04] The Bag has Potions, but the save is in Sandgem Town: back to Route 202's save spot first, without saving.", COLOR_BLUE); + walk_to_route_202_save_spot(stream, context, maps, anchor_in_sandgem(stream, context, maps), trainers, fight); } static void meet_dawn_in_jubilife(VideoStream& stream, ProControllerContext& context) { @@ -876,9 +998,10 @@ namespace PokemonAutomation { } // Resumes from the save made after catching the second Pokemon, or - // from step 4's save at Route 202's save spot. The catch save has no - // Potions; step 4's has them, with the starter trained and healed, so - // Potions in the Bag mean steps 1 to 4 are done. + // from step 4's save. The catch save has no Potions; step 4's has + // them, with the starter trained and healed, so Potions in the Bag + // mean steps 1 to 4 are done; where that save stands is read off the + // save screen (resume_with_potions()). static void tutorial_part_3_after_catch_navigation(SingleSwitchProgramEnvironment& env, VideoStream& stream, ProControllerContext& context) { // A retry reloads the save at whatever time the clock has reached. // Pin it again so the landmark references see the same lighting. @@ -891,7 +1014,7 @@ namespace PokemonAutomation { pbf_wait(context, 1500ms); if (bag_has_item(stream, context, "potion", 1)) { - stream.log("[00.04] The Bag has Potions: this is step 4's save; on to Route 202's trainers.", COLOR_BLUE); + resume_with_potions(stream, context, maps, trainers, fight); } else { after_catch_step(stream, 1); train_in_catch_grass(stream, context, maps, trainers, fight); diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp new file mode 100644 index 0000000000..c29b2c0e4a --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp @@ -0,0 +1,105 @@ +/* BDSP AutoStory - Location + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/Language.h" +#include "CommonTools/Images/ImageFilter.h" +#include "CommonTools/OCR/OCR_RawTesseractOCR.h" +#include "PokemonBDSP_AutoStory_Location.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +namespace{ + +// The location banner: a white box at the top left that names a map for a +// few seconds after the player enters it. Left of the centred name it is plain +// white (251 on console B, against 27-75 for the trees there otherwise). +const ImageFloatBox LOCATION_BANNER_WHITE{0.035, 0.035, 0.085, 0.050}; +const ImageFloatBox LOCATION_BANNER_TEXT{0.100, 0.025, 0.300, 0.080}; + +// The save card's header: the map's name in salmon (255, 152, 140) on near +// white (245), top left, right of the card's red border. Too faint for OCR +// as it is: the reddish pixels are made black on white first. +const ImageFloatBox SAVE_SCREEN_LOCATION{0.036, 0.045, 0.274, 0.070}; +const uint32_t SAVE_SCREEN_TEXT_MIN = 0xff960000; +const uint32_t SAVE_SCREEN_TEXT_MAX = 0xffffa9a9; + +std::string digits_of(const std::string& text){ + std::string out; + for (char ch : text){ + if (std::isdigit((unsigned char)ch)){ + out.push_back(ch); + } + } + return out; +} + +} + + +std::string normalize_location_name(const std::string& text){ + std::string out; + for (unsigned char ch : text){ + if (std::isalnum(ch)){ + out.push_back((char)std::tolower(ch)); + } + } + return out; +} + +std::string read_location_banner(const ImageViewRGB32& screen){ + const ImageStats white = image_stats(extract_box_reference(screen, LOCATION_BANNER_WHITE)); + if (white.average.sum() < 690 || white.stddev.sum() > 30){ + return ""; + } + return normalize_location_name(OCR::tesseract_ocr_read(Language::English, + extract_box_reference(screen, LOCATION_BANNER_TEXT))); +} + +std::string read_save_screen_location(const ImageViewRGB32& screen){ + const ImageRGB32 text = to_blackwhite_rgb32_range( + extract_box_reference(screen, SAVE_SCREEN_LOCATION), true, SAVE_SCREEN_TEXT_MIN, SAVE_SCREEN_TEXT_MAX); + return normalize_location_name(OCR::tesseract_ocr_read(Language::English, text)); +} + +bool location_banner_matches(const std::string& read, const std::string& expected){ + const std::string a = normalize_location_name(read); + const std::string b = normalize_location_name(expected); + if (a.empty() || b.empty()){ + return false; + } + if (digits_of(a) != digits_of(b)){ + return false; + } + // Levenshtein distance. + std::vector row(b.size() + 1); + for (size_t j = 0; j <= b.size(); j++){ + row[j] = j; + } + for (size_t i = 1; i <= a.size(); i++){ + size_t diagonal = row[0]; + row[0] = i; + for (size_t j = 1; j <= b.size(); j++){ + const size_t above = row[j]; + row[j] = std::min({row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] == b[j - 1] ? 0 : 1)}); + diagonal = above; + } + } + return row[b.size()] <= std::max(1, b.size() / 4); +} + + +} +} +} diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h new file mode 100644 index 0000000000..a17dd863d6 --- /dev/null +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h @@ -0,0 +1,45 @@ +/* BDSP AutoStory - Location + * + * From: https://github.com/PokemonAutomation/ + * + * Where the game says the player is: the location banner shown on entering + * a map, and the map named on the save screen's card. + * + */ + +#ifndef PokemonAutomation_PokemonBDSP_AutoStory_Location_H +#define PokemonAutomation_PokemonBDSP_AutoStory_Location_H + +#include +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonBDSP{ + + +// Lower-case letters and digits only: "Route 202" and "ROUTE 2O2 " compare +// by "route202". +std::string normalize_location_name(const std::string& text); + +// The location banner (the white box at the top left that names a map for a +// few seconds after the player enters it), read by OCR and normalized; +// empty when no banner shows. +std::string read_location_banner(const ImageViewRGB32& screen); + +// The map named at the top left of the save screen's card ("Sandgem Town", +// "Route 202"), read by OCR and normalized; empty if nothing reads. +std::string read_save_screen_location(const ImageViewRGB32& screen); + +// Whether the location banner's OCR `read` names the map `expected`. Both +// are compared as lower-case letters and digits. OCR drops or swaps letters +// ("andgemltown" for Sandgem Town on r109), so up to a quarter of the name's +// letters may differ; the digits must match exactly, so Route 201 is never +// read as Route 202. +bool location_banner_matches(const std::string& read, const std::string& expected); + + +} +} +} +#endif diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp index f8a49d210a..25ae5762a8 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp @@ -215,9 +215,11 @@ std::vector tiles_short_of_door( if (doorway->rows_up <= DOORWAY_FRONT_ROWS){ return {}; } - // The doorway's offset says which tile we stand on, give or take one - // each way: the rows are a rough count. - const TilePos guess{door.x - (int)std::lround(doorway->tiles_across), door.y + (int)std::lround(doorway->rows_up)}; + return tiles_by_lit_doorway(*doorway, grid, door); +} + +std::vector tiles_by_lit_doorway(const LitDoorway& doorway, const TileGrid& grid, const TilePos& door){ + const TilePos guess{door.x - (int)std::lround(doorway.tiles_across), door.y + (int)std::lround(doorway.rows_up)}; std::vector tiles; for (int y = guess.y - 1; y <= guess.y + 1; y++){ for (int x = guess.x - 1; x <= guess.x + 1; x++){ diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h index e1fef11dc9..c49c21f16d 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.h @@ -34,6 +34,13 @@ struct LitDoorway{ // none shows there. std::optional find_lit_doorway(const ImageViewRGB32& screen); +// The tiles a player who sees `doorway` (the door on world tile `door`) may +// stand on: the one its offset says, give or take one each way, since the +// rows are a rough count. Only standable tiles of `grid`. +std::vector tiles_by_lit_doorway( + const LitDoorway& doorway, const Pokemon::TileGrid& grid, const Pokemon::TilePos& door +); + // For a player who walked to the row below the door on world tile `door`: // if its lit doorway shows further up the screen than straight ahead, the // walk ended short (someone in the way stops an open leg, and the walk diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp index e6aed7f768..e2ead0b6c8 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.cpp @@ -11,15 +11,13 @@ #include "CommonFramework/GlobalAutoPaths.h" #include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" #include "CommonFramework/ImageTypes/ImageRGB32.h" -#include "CommonFramework/ImageTools/ImageBoxes.h" -#include "CommonFramework/ImageTools/ImageStats.h" #include "CommonFramework/VideoPipeline/VideoFeed.h" -#include "CommonTools/OCR/OCR_RawTesseractOCR.h" #include "CommonTools/Async/InferenceRoutines.h" #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "PokemonBDSP/Inference/PokemonBDSP_DialogDetector.h" #include "PokemonBDSP/Programs/AutoStory/Detect/PokemonBDSP_AutoStory_OverworldDetector.h" #include "PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStoryTools.h" +#include "PokemonBDSP_AutoStory_Location.h" #include "PokemonBDSP_AutoStory_Walk.h" #include "PokemonBDSP_AutoStory_RouteWalk.h" @@ -79,34 +77,6 @@ std::vector leg_path(const TileGrid& grid, TilePos from, Direction dire return path; } -// Lower-case letters and digits only: "Route 202" and "ROUTE 2O2 " compare -// by "route202". -std::string normalize_name(const std::string& text){ - std::string out; - for (unsigned char ch : text){ - if (std::isalnum(ch)){ - out.push_back((char)std::tolower(ch)); - } - } - return out; -} - -// The location banner: a white box at the top left that names a map for a -// few seconds after the player enters it. Left of the centred name it is plain -// white (251 on console B, against 27-75 for the trees there otherwise). -const ImageFloatBox LOCATION_BANNER_WHITE{0.035, 0.035, 0.085, 0.050}; -const ImageFloatBox LOCATION_BANNER_TEXT{0.100, 0.025, 0.300, 0.080}; - -// The banner's map name, normalized, or empty when no banner shows. -std::string read_location_banner(const ImageViewRGB32& screen){ - const ImageStats white = image_stats(extract_box_reference(screen, LOCATION_BANNER_WHITE)); - if (white.average.sum() < 690 || white.stddev.sum() > 30){ - return ""; - } - return normalize_name(OCR::tesseract_ocr_read(Language::English, - extract_box_reference(screen, LOCATION_BANNER_TEXT))); -} - // The map all of `tiles` are on, or empty if they are on several or none. std::string map_of(const MapSet& maps, const std::vector& tiles){ std::string name; @@ -129,41 +99,6 @@ bool route_wall_readable(const TileGrid& grid, const std::vector& after }); } -bool location_banner_matches(const std::string& read, const std::string& expected){ - const std::string a = normalize_name(read); - const std::string b = normalize_name(expected); - if (a.empty() || b.empty()){ - return false; - } - auto digits = [](const std::string& text){ - std::string out; - for (char ch : text){ - if (std::isdigit((unsigned char)ch)){ - out.push_back(ch); - } - } - return out; - }; - if (digits(a) != digits(b)){ - return false; - } - // Levenshtein distance. - std::vector row(b.size() + 1); - for (size_t j = 0; j <= b.size(); j++){ - row[j] = j; - } - for (size_t i = 1; i <= a.size(); i++){ - size_t diagonal = row[0]; - row[0] = i; - for (size_t j = 1; j <= b.size(); j++){ - const size_t above = row[j]; - row[j] = std::min({row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] == b[j - 1] ? 0 : 1)}); - diagonal = above; - } - } - return row[b.size()] <= std::max(1, b.size() / 4); -} - void route_status(VideoStream& stream, const std::string& text){ stream.log("[Route] " + text, COLOR_BLUE); add_wrapped_log(stream.overlay(), text, COLOR_WHITE); @@ -220,6 +155,9 @@ std::vector walk_route( } // Which way the player faces, once a leg has set it. std::optional facing; + // Every banner read on the way, to check the map we arrive on against. + const std::string map_start = map_of(maps, from); + std::vector all_banners; for (size_t plans = 0; plans < 8; plans++){ const std::optional> legs = plan_robust_route(grid, from, goal); if (!legs){ @@ -366,6 +304,7 @@ std::vector walk_route( for (const std::string& read : banners){ banner_text += (banner_text.empty() ? "\"" : ", \"") + read + "\""; } + all_banners.insert(all_banners.end(), banners.begin(), banners.end()); // Entering another map shows its name; a missing or wrong name means // the walk did not end where the plan says. From tiles on several // maps (a leg not confirmed) the banner may already have shown. @@ -381,7 +320,7 @@ std::vector walk_route( if (VideoSnapshot screen = stream.video().snapshot()){ const std::string folder = SCREENSHOTS_PATH() + "AutoStory/route/"; Filesystem::create_directories(Filesystem::Path(folder)); - screen.frame->save(folder + now_to_filestring() + "-" + normalize_name(leg_label) + ".png"); + screen.frame->save(folder + now_to_filestring() + "-" + normalize_location_name(leg_label) + ".png"); } if (result.stop == WalkStop::TRANSITION){ OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, @@ -467,6 +406,21 @@ std::vector walk_route( break; } if (!replanned){ + // Legs whose ends span two maps skip the per-leg check; the walk + // as a whole must still have seen the banner of the map it ends on. + const std::string map_end = map_of(maps, from); + const bool changed_map = !map_start.empty() && !map_end.empty() && map_end != map_start; + if (changed_map && std::none_of(all_banners.begin(), all_banners.end(), [&](const std::string& read){ + return location_banner_matches(read, map_end); + })){ + std::string seen; + for (const std::string& read : all_banners){ + seen += (seen.empty() ? "\"" : ", \"") + read + "\""; + } + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, + label + ": arrived at " + describe(maps, from) + " on the map, but " + map_end + + "'s location banner never showed (read " + (seen.empty() ? "nothing" : seen) + ").", stream); + } stream.log("[Route] " + label + ": arrived at " + describe(maps, from) + "."); return from; } @@ -588,24 +542,36 @@ void leave_door_south(VideoStream& stream, ProControllerContext& context, const pbf_mash_button(context, BUTTON_B, 2000ms); context.wait_for_all_requests(); VideoSnapshot inside = stream.video().snapshot(); - bool faded_out = false; - bool scene_changed = false; - for (size_t tries = 0; tries < 3 && !faded_out && !scene_changed; tries++){ - const WalkResult walk = walk_until(stream, context, 0, -1, 6000ms, label); - faded_out = walk.stop == WalkStop::TRANSITION; + // The door's fade changes the whole screen between two looks, as walking + // never does (16 to 102 per look inside a Center; the fades read 121 to + // 192). Stop the walk there: a stick held through the fade-in walks on + // outside, and r124a stood ten rows south of the Mart's door after + // "walking out" until a wall stopped it. + ImageRGB32 last; + bool have_last = false; + auto fading = [&](const ImageViewRGB32& screen){ + const bool fade = have_last && walk_screen_change(last, screen) > WALK_SCENE_CHANGE; + last = screen.copy(); + have_last = true; + return fade; + }; + bool out = false; + for (size_t tries = 0; tries < 3 && !out; tries++){ + const WalkResult walk = walk_until(stream, context, 0, -1, 6000ms, label, fading); if (walk.stop == WalkStop::DIALOG){ pbf_mash_button(context, BUTTON_B, 2000ms); } + // The fade-in finishes after the walk has stopped. + pbf_wait(context, 2000ms); context.wait_for_all_requests(); VideoSnapshot now = stream.video().snapshot(); - scene_changed = inside && now && walk_screen_change(*inside.frame, *now.frame) > WALK_SCENE_CHANGE; + out = walk.stop == WalkStop::TRANSITION || + (inside && now && walk_screen_change(*inside.frame, *now.frame) > WALK_SCENE_CHANGE); } - if (!faded_out && !scene_changed){ + if (!out){ OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, label + ": did not get back out.", stream); } - pbf_wait(context, 1500ms); - context.wait_for_all_requests(); } diff --git a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h index d0974e9861..d41688408b 100644 --- a/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h +++ b/SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_RouteWalk.h @@ -57,13 +57,6 @@ std::optional talk_to_adjacent_npc( const std::string& label, Milliseconds wait = Milliseconds(1500) ); -// Whether the location banner's OCR `read` names the map `expected`. Both -// are compared as lower-case letters and digits. OCR drops or swaps letters -// ("andgemltown" for Sandgem Town on r109), so up to a quarter of the name's -// letters may differ; the digits must match exactly, so Route 201 is never -// read as Route 202. -bool location_banner_matches(const std::string& read, const std::string& expected); - // Whether a walk to the wall that ends on any of `after` can tell it has // stopped: not in tall grass, whose sway keeps the screen changing at a // wall. Elsewhere, a walk to the wall that stops confirms where it ended. @@ -83,7 +76,10 @@ using RouteTrainerFight = std::function; // list. Tiles in `avoid` (where someone wanders) are planned as walls. // Fails with a screenshot // on a transition, a dialogue no trainer explains, a wild battle during an -// open leg, or when no plan exists. +// open leg, or when no plan exists. A walk from one map to another must see +// the second map's location banner on the way: a walk whose legs all ended +// on walls can still end in the wrong place (r124a saved in Sandgem Town +// after "arriving" on Route 202). std::vector walk_route( VideoStream& stream, ProControllerContext& context, const Pokemon::MapSet& maps, diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 8046c60215..d0bd217b93 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1525,6 +1525,8 @@ file(GLOB LIBRARY_SOURCES Source/PokemonBDSP/Programs/AutoStory/Data/PokemonBDSP_AutoStory_StoryMaps.h Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.cpp Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_Tests.h + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.cpp + Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_Location.h Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.cpp Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_NewGame.h Source/PokemonBDSP/Programs/AutoStory/Utils/PokemonBDSP_AutoStory_PokemonCenter.cpp