diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs index f5fffa6..29d19e0 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs @@ -13,16 +13,29 @@ //! minimum Manhattan distance to any goal. That stays admissible because a step costs one and //! moves one tile. //! -//! **The walkable predicate has a window.** Agent A's [`Walkable::Unknown`] is load-bearing: the -//! tile ids a walkability test needs live in the screen buffer, so only the tiles around the -//! player can be answered at all. An unknown tile is *expensive* to path through rather than -//! forbidden ([`UNKNOWN_STEP`]): a known-walkable way round is always preferred, and the search -//! steps into the unknown only when nothing known gets any closer. That is what a map edge six -//! tiles away needs — it is off the screen by definition, so a search that refused every unknown -//! tile could not plan a single step toward Route 1 from the middle of Pallet Town, which is half -//! of why the fly never took it (`infra/docs/macros-bench.md`, 2026-09-16). The guess is cheap and -//! bounded: [`super::executor`] re-plans after every tile with a per-step check that the player -//! moved, and three failed steps abort as `Blocked`. +//! **It plans over the whole map when the map can be decoded** (`docs/design/macros.md` section +//! 15, the operator 2026-09-22: "the frontier and warp macros need to be map aware: A* over +//! walkable tiles"). [`MacroState::map_grid`] is every tile of the loaded map, walkability and +//! directed walls, decoded from the block and collision tables the cartridge has loaded +//! ([`crate::pokemon_red::mapgrid`]). With it, one plan crosses a town: `GO WARP` routes to its +//! warp tile, `GO OUT` and `GO ROUTE` to their door or connection tile, and the frontier is the +//! nearest unstood ground *anywhere on the map* rather than the nearest on screen. +//! +//! **Without it, the window is still the fallback.** Agent A's [`Walkable::Unknown`] is +//! load-bearing on a frame the grid cannot be decoded — no cartridge behind the seam, a battle or +//! a text box over the map, a header that is not loaded +//! ([`crate::pokemon_red::state::GridRefusal`] says which): the tile ids a walkability test needs +//! live in the screen buffer then, so only the tiles around the player can be answered at all. An +//! unknown tile is *expensive* to path through rather than forbidden ([`UNKNOWN_STEP`]): a +//! known-walkable way round is always preferred, and the search steps into the unknown only when +//! nothing known gets any closer. That is what a map edge six tiles away needed — it is off the +//! screen by definition, so a search that refused every unknown tile could not plan a single step +//! toward Route 1 from the middle of Pallet Town, which was half of why the fly never took it +//! (`infra/docs/macros-bench.md`, 2026-09-16). +//! +//! Either way the walk is re-planned only when the ground says so — a refusal, or the player not +//! where the plan expects it — which is [`super::executor`]'s committed route and not this +//! module's business. //! //! The search still has its second answer for a goal it cannot reach at all: when no goal is //! reachable, it returns the route to the reachable tile that gets closest to one. @@ -34,7 +47,24 @@ use super::cartridge::{ Edge, ExitId, LAST_MAP, MacroState, TalkTarget, Tile, destination_outdoors, outdoors, }; use super::geography; -use super::state::{Facing, Walkable}; +use super::state::{Facing, MapGrid, Walkable}; + +/// Whether the player could stand on a tile of the current map: the grid's answer, or the +/// window's when there is no grid. +/// +/// One place asks the question so that the search, the exit list and the frontier cannot disagree +/// about which reading they are on (`docs/design/macros.md` section 15). +fn walkable_at( + state: &mut dyn MacroState, + grid: Option<&MapGrid>, + x: u8, + y: u8, +) -> Walkable { + match grid { + Some(grid) => grid.walkable(x, y), + None => state.walkable(x, y), + } +} /// What one step onto a tile the walkable predicate cannot answer for costs. /// @@ -156,6 +186,10 @@ pub fn route_avoiding( } let player = state.player()?; let size = state.map_size()?; + // One decode per plan, from the cache the sim loop keeps: with a grid the search is over the + // whole map, without one it is over the ten-by-nine window as it always was. + let grid = state.map_grid(); + let grid = grid.as_deref(); let start = Tile::new(player.x, player.y); if let Some(goal) = goals.iter().position(|tile| *tile == start) { return Some(Route { goal: Some(goal), steps: Vec::new() }); @@ -191,6 +225,12 @@ pub fn route_avoiding( if refused.contains(&(tile, facing)) { continue; } + // A step the *tables* refuse: a tile-pair collision, which is passable ground on both + // sides and a wall between them (`mapgrid::TILE_PAIRS_LAND`). The walk used to learn + // each of these by spending a step on it; with the grid the first plan goes round. + if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) { + continue; + } // **A tile the cartridge pushes the fly off is not a tile to walk through**, either // (row 37 of `infra/docs/macros-traps.md`). Excluding it as a *goal* was half the fix // and the measurement said so: Viridian City's (19, 9) went from 53,266 text-box @@ -200,10 +240,11 @@ pub fn route_avoiding( if next != start && state.pushed_tile(next.x, next.y) { continue; } - let step = match state.walkable(next.x, next.y) { + let step = match walkable_at(state, grid, next.x, next.y) { _ if next == start => 1, Walkable::Yes => 1, - // Off the screen buffer: plausible ground, priced so that anything known beats it. + // Off the screen buffer, or a block the blockset was read short of: plausible + // ground, priced so that anything known beats it. Walkable::Unknown => UNKNOWN_STEP, Walkable::No => continue, }; @@ -334,6 +375,8 @@ pub fn target_at(state: &mut dyn MacroState, tile: Tile) -> Option { pub fn exits(state: &mut dyn MacroState) -> Vec { let Some(size) = state.map_size() else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() }; + let grid = state.map_grid(); + let grid = grid.as_deref(); let here_outdoors = outdoors(player.map); let mut out = Vec::new(); for (index, warp) in state.warps().iter().enumerate() { @@ -373,13 +416,16 @@ pub fn exits(state: &mut dyn MacroState) -> Vec { let way = if here_outdoors { Way::Route } else { Way::Exit }; let into = geography::connected(player.map, edge); for tile in edge_tiles(facing, size.width, size.height) { - // Not `== Yes`: the walkable predicate's window is the screen, so the far edge of an - // outdoor map reads `Unknown` from anywhere but next to it, and filtering on `Yes` - // left a town's connections out of the exit list entirely -- which is half of why - // nothing could ever aim at Route 1 from the middle of Pallet Town. An unknown tile is - // a goal worth walking towards; the route search's own approach answer handles a goal - // it cannot reach yet, and a tile that turns out to be a wall costs one blocked walk. - if state.walkable(tile.x, tile.y) != Walkable::No { + // Not `== Yes`: without a grid the walkable predicate's window is the screen, so the + // far edge of an outdoor map reads `Unknown` from anywhere but next to it, and + // filtering on `Yes` left a town's connections out of the exit list entirely -- which + // is half of why nothing could ever aim at Route 1 from the middle of Pallet Town. An + // unknown tile is a goal worth walking towards; the route search's own approach answer + // handles a goal it cannot reach yet, and a tile that turns out to be a wall costs one + // blocked walk. With a grid the answer is `Yes` or `No` for every edge tile of the map + // and this rejects the walls, which is what lets one plan reach the right end of a + // connection instead of the nearest of twenty tiles along it. + if walkable_at(state, grid, tile.x, tile.y) != Walkable::No { out.push(Exit { id: ExitId::Edge(edge), tile, press: Some(facing), way, into }); } } @@ -396,9 +442,12 @@ pub fn exits(state: &mut dyn MacroState) -> Vec { /// Each answer is a tile to stand on paired with the direction the new ground lies in, which is /// the same shape `GO NPC` and `GO ITEM` use -- and the same press, which in the overworld walks /// onto the tile when it is walkable, so the frontier the fly is looking at becomes ground it has -/// stood on. Both tiles have to be walkable: an unreachable one is not ground, and the walkable -/// predicate's window means the answer is always local to the player, which is what makes the -/// re-plan after every tile do the work of a long walk. +/// stood on. Both tiles have to be walkable: an unreachable one is not ground. +/// +/// **With a grid this is the whole map** (`docs/design/macros.md` section 15): the nearest unstood +/// walkable tile anywhere on it, which is what the operator asked for and what the route search +/// then plans one walk to. Without a grid it is what it always was -- the ten-by-nine window, so +/// the answer is local to the player and the long walk is done by re-planning. /// /// Deduplicated by the tile to stand on, in tile order, so the choice between two equally near /// frontiers does not depend on iteration order. @@ -415,13 +464,15 @@ pub fn exits(state: &mut dyn MacroState) -> Vec { pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { let Some(size) = state.map_size() else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() }; + let grid = state.map_grid(); + let grid = grid.as_deref(); let here = Tile::new(player.x, player.y); let held: Vec = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect(); let mut out: Vec<(Tile, Facing)> = Vec::new(); for y in 0..size.height { for x in 0..size.width { let tile = Tile::new(x, y); - if tile != here && state.walkable(x, y) != Walkable::Yes { + if tile != here && walkable_at(state, grid, x, y) != Walkable::Yes { continue; } if tile != here && held.contains(&tile) { @@ -432,7 +483,12 @@ pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { if next.x >= size.width || next.y >= size.height || next == here { continue; } - if state.walkable(next.x, next.y) != Walkable::Yes { + if walkable_at(state, grid, next.x, next.y) != Walkable::Yes { + continue; + } + // A step the tables refuse is not a way onto that ground, so the tile it leads to + // is not this tile's frontier -- somebody else's, if anything reaches it. + if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) { continue; } if held.contains(&next) { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs index fc4b42c..d8cfd3b 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs @@ -3994,4 +3994,5 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() { ); } +mod map_aware; mod shop_purchase; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs new file mode 100644 index 0000000..1257e99 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs @@ -0,0 +1,344 @@ +//! Walks planned over the whole map, and the same walks with the window as the only reading. +//! +//! `docs/design/macros.md` section 15. [`super::super::path`] has two readings of the ground now +//! and the interesting tests are the ones that tell them apart: a map bigger than the ten-by-nine +//! window, a frontier on the far side of it, and a wall the collision list cannot predict. The +//! fake here is deliberately not [`super::World`] — it implements the seam and nothing else, so a +//! failure is about the search rather than about a script's frames. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState, Tile}; +use crate::pokemon_red::macros::path::{self, Way}; +use crate::pokemon_red::macros::state::{ + BagItem, Battle, Connections, Facing, GameState, MapGrid, MapSize, Mon, Npc, Party, Pc, Player, + Scene, Shop, Sign, StartMenu, TextBox, Walkable, Warp, +}; + +/// A passable tile id and a wall tile id, for a grid built by hand. +const FLOOR: u8 = 0x01; +const WALL: u8 = 0x60; + +/// The ground, and nothing else: the seam's questions about a map and the fly standing on it. +struct Ground { + map: u8, + size: MapSize, + player: Tile, + /// Tiles that are not walkable; everything else inside `size` is. + walls: BTreeSet, + /// Steps the cartridge refuses although both tiles are passable, as the grid records them. + pair_walls: Vec<(Tile, Facing)>, + /// Ground the run has stood on, which is what makes a tile not a frontier. + stood: BTreeSet, + warps: Vec, + connections: Connections, + npcs: Vec, + /// Whether the whole map is decoded, or only the window can answer. + decoded: bool, +} + +impl Ground { + /// A map `wide` by `high` tiles with the fly at `player` and every tile walkable. + fn new(wide: u8, high: u8, player: (u8, u8)) -> Self { + Self { + map: 0, + size: MapSize { width: wide, height: high }, + player: Tile::new(player.0, player.1), + walls: BTreeSet::new(), + pair_walls: Vec::new(), + stood: BTreeSet::new(), + warps: Vec::new(), + connections: Connections::default(), + npcs: Vec::new(), + decoded: true, + } + } + + fn wall(mut self, x: u8, y: u8) -> Self { + self.walls.insert(Tile::new(x, y)); + self + } + + /// A column of wall with one gap in it, which is the shape that tells the two readings apart. + fn wall_column(mut self, x: u8, gap: u8) -> Self { + for y in 0..self.size.height { + if y != gap { + self.walls.insert(Tile::new(x, y)); + } + } + self + } + + /// Everything within `distance` of the fly counts as stood on, which is what a fly that has + /// been walking around one corner of a route has. + fn stood_around(mut self, distance: u32) -> Self { + for y in 0..self.size.height { + for x in 0..self.size.width { + let tile = Tile::new(x, y); + if tile.distance(self.player) <= distance { + self.stood.insert(tile); + } + } + } + self + } + + /// The window reading only: what every walk had before section 15. + fn window_only(mut self) -> Self { + self.decoded = false; + self + } + + fn pair_wall(mut self, from: (u8, u8), facing: Facing) -> Self { + self.pair_walls.push((Tile::new(from.0, from.1), facing)); + self + } + + fn connected(mut self, connections: Connections) -> Self { + self.connections = connections; + self + } + + /// The ten-by-nine window agent A's predicate can answer for, which moves with the fly. + fn in_window(&self, x: u8, y: u8) -> bool { + let dx = i32::from(x) - i32::from(self.player.x); + let dy = i32::from(y) - i32::from(self.player.y); + (-4..=5).contains(&dx) && (-4..=4).contains(&dy) + } + + fn walkable_tile(&self, x: u8, y: u8) -> bool { + x < self.size.width && y < self.size.height && !self.walls.contains(&Tile::new(x, y)) + } +} + +impl GameState for Ground { + fn scene(&mut self) -> Scene { + Scene::Overworld + } + + fn player(&mut self) -> Option { + Some(Player { map: self.map, x: self.player.x, y: self.player.y, facing: Facing::Down }) + } + + fn map_size(&mut self) -> Option { + Some(self.size) + } + + fn party(&mut self) -> Party { + Party { mons: Vec::::new(), active: None } + } + + fn battle(&mut self) -> Option { + None + } + + fn text_box(&mut self) -> TextBox { + TextBox { open: false, waiting: false } + } + + fn start_menu(&mut self) -> Option { + None + } + + fn shop(&mut self) -> Option { + None + } + + fn pc(&mut self) -> Option { + None + } + + fn money(&mut self) -> u32 { + 0 + } + + fn bag(&mut self) -> Vec { + Vec::new() + } + + fn npcs(&mut self) -> Vec { + self.npcs.clone() + } + + fn signs(&mut self) -> Vec { + Vec::new() + } + + /// Agent A's predicate: the window, and `Unknown` outside it. + fn walkable(&mut self, x: u8, y: u8) -> Walkable { + if x >= self.size.width || y >= self.size.height { + return Walkable::No; + } + if !self.in_window(x, y) { + return Walkable::Unknown; + } + if self.walkable_tile(x, y) { Walkable::Yes } else { Walkable::No } + } + + fn warps(&mut self) -> Vec { + self.warps.clone() + } + + fn connections(&mut self) -> Connections { + self.connections + } +} + +impl MacroState for Ground { + fn map_grid(&mut self) -> Option> { + if !self.decoded { + return None; + } + let mut grid = MapGrid::new(self.map, self.size.width, self.size.height); + for y in 0..self.size.height { + for x in 0..self.size.width { + let walkable = self.walkable_tile(x, y); + grid.set( + x, + y, + if walkable { FLOOR } else { WALL }, + if walkable { Walkable::Yes } else { Walkable::No }, + ); + } + } + for (tile, facing) in &self.pair_walls { + grid.wall(tile.x, tile.y, *facing); + } + Some(Arc::new(grid)) + } + + fn tile_visited(&mut self, x: u8, y: u8) -> bool { + self.stood.contains(&Tile::new(x, y)) + } +} + +/// Walk a route's presses and report where they land and whether every tile was walkable. +fn walk(ground: &Ground, from: Tile, steps: &[Facing]) -> (Tile, bool) { + let mut at = from; + let mut clean = true; + for facing in steps { + let Some(next) = at.step(*facing) else { + clean = false; + break; + }; + if !ground.walkable_tile(next.x, next.y) { + clean = false; + } + at = next; + } + (at, clean) +} + +#[test] +fn one_plan_crosses_a_map_larger_than_the_window() { + // Twenty by eighteen — Pallet Town's size — with a wall down the middle and one gap in it, + // which is where a plan has to go and is off the screen from where the fly starts. + let start = (2, 2); + let goal = Tile::new(18, 16); + let mut ground = Ground::new(20, 18, start).wall_column(10, 1); + let route = path::route(&mut ground, &[goal]).expect("a route across the map"); + assert_eq!(route.goal, Some(0), "the goal itself, not an approach to it"); + let (landed, clean) = walk(&ground, Tile::new(start.0, start.1), &route.steps); + assert_eq!(landed, goal); + assert!(clean, "every tile of the plan is walkable ground"); + // The gap is at the top, so the plan is longer than the Manhattan distance and knows it. + assert!(route.steps.len() > Tile::new(start.0, start.1).distance(goal) as usize); + + // The same map with only the window to read: the plan sets off through a wall it cannot see. + let mut blind = Ground::new(20, 18, start).wall_column(10, 1).window_only(); + let route = path::route(&mut blind, &[goal]).expect("a route through the unknown"); + let (_, clean) = walk(&blind, Tile::new(start.0, start.1), &route.steps); + assert!(!clean, "the window cannot see the wall, so the plan walks into it"); +} + +#[test] +fn the_frontier_is_the_nearest_unstood_tile_anywhere_on_the_map() { + // A fly that has covered the ground around it. Nine tiles is the furthest corner of the + // ten-by-nine window (five across and four down), so every tile the window can answer for has + // been stood on and the nearest new ground is the first tile outside it. + let mut ground = Ground::new(20, 18, (3, 3)).stood_around(9); + let frontier = path::frontier(&mut ground); + assert!(!frontier.is_empty(), "the rest of the map is still new ground"); + let (tile, facing) = frontier + .iter() + .copied() + .min_by_key(|(tile, _)| tile.distance(Tile::new(3, 3))) + .expect("a nearest frontier"); + let new_ground = tile.step(facing).expect("the ground it faces"); + assert!(!ground.tile_visited(new_ground.x, new_ground.y)); + assert_eq!(tile.distance(Tile::new(3, 3)), 9, "the near side of the unstood ground"); + // And a route to it, in one plan. + let goals: Vec = frontier.iter().map(|(tile, _)| *tile).collect(); + let route = path::route(&mut ground, &goals).expect("a route to the frontier"); + assert!(route.goal.is_some()); + + // The window reading has nothing to offer here at all, which is the pad the operator saw + // hanging: every tile it can answer for has been stood on. + let mut blind = Ground::new(20, 18, (3, 3)).stood_around(9).window_only(); + assert!(path::frontier(&mut blind).is_empty()); +} + +#[test] +fn a_step_the_tables_refuse_is_planned_around_rather_than_walked_into() { + // A tile-pair collision: both tiles passable, the step between them refused + // (`data/tilesets/pair_collision_tile_ids.asm`). The wall is the only way east on its row, so + // a search that did not know about it would plan straight through. + let goal = Tile::new(3, 0); + let mut ground = Ground::new(4, 3, (1, 0)) + .pair_wall((1, 0), Facing::Right) + .pair_wall((2, 0), Facing::Left); + let route = path::route(&mut ground, &[goal]).expect("a route round the pair wall"); + assert_eq!(route.steps.first(), Some(&Facing::Down), "round it, not through it"); + let (landed, clean) = walk(&ground, Tile::new(1, 0), &route.steps); + assert_eq!(landed, goal); + assert!(clean); + assert!(!route.steps.is_empty()); + + // Without the pair rule the same map is a straight line east. + let mut open = Ground::new(4, 3, (1, 0)); + let route = path::route(&mut open, &[goal]).expect("a route east"); + assert_eq!(route.steps, vec![Facing::Right, Facing::Right]); +} + +#[test] +fn a_connection_on_the_far_edge_is_a_goal_and_the_walls_along_it_are_not() { + // A route's north edge, off the screen from where the fly stands, with two walkable tiles on + // it. With the map decoded the exit list is those two tiles and not the whole row. + let mut ground = Ground::new(20, 18, (3, 16)) + .connected(Connections { north: true, south: false, east: false, west: false }); + for x in 0..20u8 { + if x != 7 && x != 8 { + ground = ground.wall(x, 0); + } + } + let exits = path::exits(&mut ground); + let north: Vec = exits + .iter() + .filter(|exit| exit.id == ExitId::Edge(Edge::North)) + .map(|exit| exit.id) + .collect(); + assert_eq!(north.len(), 2, "the two tiles a step north can be taken from"); + let goals: Vec = exits + .iter() + .filter(|exit| exit.way == Way::Route && exit.id == ExitId::Edge(Edge::North)) + .map(|exit| exit.tile) + .collect(); + assert!(goals.iter().all(|tile| tile.y == 0 && (tile.x == 7 || tile.x == 8))); + let route = path::route(&mut ground, &goals).expect("a route to the connection"); + assert!(route.goal.is_some()); + let (landed, clean) = walk(&ground, Tile::new(3, 16), &route.steps); + assert!(goals.contains(&landed)); + assert!(clean, "one plan, across sixteen tiles of map, every tile of it known ground"); + + // With only the window, every tile of that edge is `Unknown` and so every one of them is an + // exit, walls included: the search's own approach answer is what used to carry the walk. + let mut blind = Ground::new(20, 18, (3, 16)) + .connected(Connections { north: true, south: false, east: false, west: false }) + .window_only(); + let count = path::exits(&mut blind) + .iter() + .filter(|exit| exit.id == ExitId::Edge(Edge::North)) + .count(); + assert_eq!(count, 20); +}